Compare commits

..

38 Commits

Author SHA1 Message Date
MHSanaei 950a647bcc v3.2.6 2026-06-02 04:20:53 +02:00
MHSanaei c8ad42631c fix(migrate): copy composite-key tables without FindInBatches (#4787)
SQLite to Postgres migration aborted with "copy *model.ClientInbound:
primary key required" on installs whose client_inbounds table exceeds
one read batch (500 rows). gorm's FindInBatches pages between batches
using a single PrioritizedPrimaryField, which composite-key tables
(client_id + inbound_id, no surrogate id) do not have, so it returns
ErrPrimaryKeyRequired once a table holds more than one batch.

Replace FindInBatches in copyTable with explicit LIMIT/OFFSET paging
ordered by the model's primary-key columns. This works for every table
including composite-key ones, keeps memory bounded, and changes no
schema.

Add a Postgres-gated regression test covering a >500-row composite-key
table.
2026-06-02 04:20:42 +02:00
MHSanaei 4f597a08c4 perf(clients): batch bulk attach/detach to cut per-item DB work
BulkDetach removed one client per (email x inbound) pair, each with its own
settings rewrite, transaction and full SyncInbound. Add delInboundClients to
remove all targeted clients from an inbound in a single pass and group removals
by inbound, turning O(emails x inbounds) write cycles into O(inbounds).

BulkAttach ran the global getAllEmailSubIDs scan once per target inbound via
checkEmailsExistForClients. Compute that snapshot once per call and thread it
through a new internal addInboundClient; the duplicate check is unaffected
because attach reuses each client's existing identity (same subId).

Covered by bulk_clients_test.go: VLESS round-trip (linkage, settings JSON,
idempotency, record survival), skip-unattached, and Trojan key matching.
2026-06-02 03:59:10 +02:00
MHSanaei d56505004e style: gofmt -s (doc-comment list separator, struct field alignment) 2026-06-02 03:58:58 +02:00
MHSanaei f0e459e51e fix(node): suppress unavoidable InsecureSkipVerify alert for cert pinning
FetchCertFingerprint must accept any certificate by design: it fetches a
not-yet-pinned node's leaf cert (trust-on-first-use) so the admin can pin
it. Disabling verification is inherent to that, so go/disabled-certificate-check
cannot be cleared by code changes. Suppress the finding inline, matching the
existing lgtm convention in custom_geo.go.
2026-06-02 03:58:52 +02:00
MHSanaei 327228d8f3 Remove .svg extension from shields URLs in READMEs
Remove the trailing .svg extension from shields.io badge image URLs to use content-negotiated badge endpoints (recommended by shields.io). Changes applied to README.md and localized files: README.ar_EG.md, README.es_ES.md, README.fa_IR.md, README.ru_RU.md, README.zh_CN.md.
2026-06-02 03:16:54 +02:00
MHSanaei d2dc589f14 fix(node): capture node cert via VerifyConnection for fingerprint fetch
FetchCertFingerprint read the leaf certificate from a bare insecure TLS
handshake, which CodeQL flagged as go/disabled-certificate-check. The
function intentionally accepts any cert (trust-on-first-use, so the admin
can pin a not-yet-trusted node), so verification cannot be enabled.

Capture the leaf cert inside a VerifyConnection callback instead, matching
the existing pattern in nodeHTTPClientFor that already clears the same
query. Behavior is unchanged.
2026-06-02 03:09:33 +02:00
MHSanaei 87f446fe22 docs(readme): revamp README and sync all translations
Rewrite the five translated READMEs (fa, ar, zh, es, ru) to match the overhauled English README: centered badge layout plus Features, Screenshots, Supported Platforms, Database/Docker, Environment Variables, Supported Languages, and Contributing sections. Add Windows to supported platforms and a fallback feature (multiple protocols on one port). Refresh the referenced screenshots.
2026-06-02 03:03:14 +02:00
MHSanaei 49ef1449f1 fix(clients): keep Add Client modal in viewport with internal scroll
Open the modal near the top (top: 20) and let the body scroll internally (maxHeight + overflowY auto, overflowX hidden) so the tall vertical-layout form no longer leaves a large gap above and runs off the bottom.
2026-06-02 03:01:21 +02:00
MHSanaei b9612f1326 fix(xray): clear dirty state after saving unchanged config
Editing an outbound and re-saving it without real changes left the top Save button stuck enabled, and clicking it never cleared it. The form re-normalizes values into deeply-equal config, so react-query keeps the same configQuery.data reference on refetch and the seed effect that resets the dirty baseline never re-runs. Advance the baseline to the persisted value in saveMut.onSuccess instead of relying solely on the refetch.
2026-06-02 02:08:06 +02:00
MHSanaei 7bc31dd194 feat(outbounds): pick dialerProxy from other outbound tags for proxy chaining
Turn the outbound sockopt dialerProxy free-text input into a searchable Select populated with the other outbound tags, so users can build a proxy chain (route one outbound through another) without typing tags by hand. The list excludes the current outbound, so self-reference cycles cannot be selected. A tooltip and placeholder explain the chaining concept. Adds dialerProxyPlaceholder and dialerProxyHint to all 13 locales.

Closes #4446
2026-06-02 01:52:38 +02:00
Mayurifag 8fa248c621 fix(job): skip fail2ban IP limit when disabled (#4581)
Honor XUI_ENABLE_FAIL2BAN before running fail2ban-dependent IP-limit work. This avoids spawning fail2ban-client on disabled Docker installs while preserving the default enabled behavior when the env var is unset.

Co-authored-by: Mayurifag <Mayurifag@users.noreply.github.com>
2026-06-02 01:36:24 +02:00
MHSanaei 01d2ec5061 chore(generated): sync node types/zod with TLS verification fields (#4757)
Regenerated frontend types from the model.Node change in the previous commit (adds tlsVerifyMode and pinnedCertSha256).
2026-06-02 01:25:12 +02:00
MHSanaei 56ec359041 feat(nodes): add per-node TLS verification mode for self-signed certs (#4757)
Adds a per-node TLS verification mode to the Add/Edit Node dialog so the panel can reach nodes that serve HTTPS with a self-signed certificate:

- verify (default): normal CA validation.
- skip: InsecureSkipVerify, with a clear UI warning that it drops MITM protection.
- pin: validates the leaf certificate's SHA-256 (base64 or hex) via VerifyConnection while bypassing the default chain/name check — keeps MITM protection for self-signed certs, the secure alternative to skip.

New Node model fields tlsVerifyMode + pinnedCertSha256 (gorm auto-migrated). Probe() selects the HTTP client per node via nodeHTTPClientFor, keeping the SSRF-guarded dialer. A new POST /panel/api/nodes/certFingerprint endpoint (FetchCertFingerprint) lets the UI fetch and pin the node's current certificate in one click. Endpoint documented in api-docs/openapi; i18n added across all locales. Verified end-to-end in Docker (verify rejects, skip bypasses, fetch matches, pin accepts correct / rejects wrong).
2026-06-02 01:24:27 +02:00
MHSanaei b2e2120eb3 feat(inbounds): support Unix domain socket path in Listen field (#4429)
UDS listen already worked for proxying (the listen string is passed to xray verbatim and port 0 is accepted), and the Go sub/link layer already ignores the bind listen. The only gap was the frontend resolveAddr, which would put a socket-path listen into share/sub links (e.g. vless://uuid@/run/xray/x.sock:0). resolveAddr now treats a path-style listen (starting with / or @) as having no client-reachable address and falls back to hostOverride/hostname. Adds a test and a Listen-field help hint across all locales.
2026-06-02 00:37:20 +02:00
MHSanaei cb17eb8c06 feat(x-ui.sh): support Cloudflare API Token for DNS SSL (menu 20) (#4595)
Menu 20 only exported CF_Key/CF_Email, so a restricted Cloudflare API Token was misread as a Global Key and acme.sh failed with 'invalid domain'. Add a token-or-global-key prompt (default token): an API Token exports CF_Token, the Global Key keeps the previous CF_Key + CF_Email behavior. Also stop echoing the key/token value to the debug log.
2026-06-02 00:22:12 +02:00
MHSanaei 49bec1db0f fix(fallbacks): allow free-form dest entries for external servers (#4748)
Since v3.1.0 every fallback row had to reference a panel inbound via childId, so rows with only a free-form dest (e.g. 8080 or 127.0.0.1:8080 to an external Nginx) were silently dropped at three layers: the frontend save filter, the backend SetByMaster guard, and BuildFallbacksJSON. A row is now valid when it has a child OR an explicit dest; self-references normalize to childId 0, and BuildFallbacksJSON prefers an explicit dest (also fixing rows whose child was deleted). UI gains allowClear on the child picker; help text updated across all locales. Verified end-to-end in Docker: a free-form dest fallback now persists and is injected into the live xray config. Refs #4554, #4639.
2026-06-02 00:17:21 +02:00
MHSanaei 5b6e05a0fc fix(raw): complete the HTTP header section for inbound and outbound
Align both raw (TCP) transport forms with the Xray docs: request {version, method, path, headers} + response {version, status, reason, headers}. The outbound form was missing the request.path input, so panel-created outbounds were stuck on GET / and could not match a custom inbound path; add it with the same comma-separated array handling as the inbound. Also drop a stale inbound comment that claimed xray-core ignores the inbound request object, which contradicts both the code and the docs (request and response must match on both sides).
2026-06-01 23:48:53 +02:00
MHSanaei bcb982aeba fix(x-ui.sh): preserve 2FA on credential reset (#4758)
Go's flag package parses '-resetTwoFactor false' as '-resetTwoFactor=true' with a dangling positional 'false', so two-factor auth was always wiped on username/password reset regardless of the prompt answer. Omit the flag in the preserve branch (default is false) and use '-resetTwoFactor=true' in the disable branch.
2026-06-01 23:36:22 +02:00
MHSanaei ccd0853b6c fix(inbounds): allow port 0 for UDS inbounds (#4783)
Unix Domain Socket inbounds (listen path starting with /) use port 0, which xray-core ignores. Validation was hard-locked to a minimum of 1 in three places: the shared Zod PortSchema, the AntD InputNumber, and the Go Inbound model tag. Adds an InboundPortSchema (min 0) for the inbound form/API schemas, makes the port InputNumber min UDS-aware, and relaxes the Inbound model validate tag to gte=0. PortSchema and the Node model stay min 1.
2026-06-01 23:26:20 +02:00
MHSanaei 3657ed55dc fix(warp): persist client_id so WARP outbound gets reserved bytes (#4781)
RegWarp now stores config.client_id from the Cloudflare registration, and WarpModal sources the reserved bytes from the live config response (falling back to stored creds). Previously reservedFor read an always-missing client_id, producing an empty reserved array.
2026-06-01 23:14:40 +02:00
MHSanaei 47d9b49666 feat(x-ui.sh): add PostgreSQL management menu
Add a self-contained 'PostgreSQL Management' submenu (main-menu option 27) so the panel can be set up and migrated without re-running the remote install script:

- Install PostgreSQL locally (server + client tools + dedicated xui user/db), ported from install.sh so x-ui.sh stays standalone

- Migrate SQLite to PostgreSQL via 'x-ui migrate-db', then write XUI_DB_TYPE/XUI_DB_DSN to the service env file and restart the panel; client tools are ensured first so in-panel backup/restore works for local and external databases

- Service control: status (clusters + port 5432), start, stop, restart, enable autostart, view log, with auto-detected cluster version
2026-06-01 23:00:35 +02:00
MHSanaei 5b9ed34009 fix(nodes): sum client traffic across nodes instead of overwriting
A client shared across multiple nodes has a single email-keyed client_traffics row, but each node reports its cumulative up/down. setRemoteTrafficLocked overwrote the row with one node's cumulative, so non-owning nodes hit the create branch and OnConflict-DoNothing, silently dropping their traffic and under-counting the client.

Make the shared row a pure accumulator (like the local path): a new node_client_traffics(node_id, email) baseline table stores each node's last cumulative; the node path converts cumulative to a per-node delta (clamped to the post-reset value on a negative delta) and does up = up + delta. First observation seeds the baseline and adds 0 so upgrades and newly-shared clients are not double-counted. Create-vs-accumulate now keys off global email existence. Baselines are cleaned in DelClientStat, the node sweeps, and NodeService.Delete.
2026-06-01 22:54:56 +02:00
MHSanaei 588ea86298 fix(hysteria): use pinSHA256 for pinned cert and emit ech in share links
Hysteria links now carry the pinned peer cert under the hysteria2-standard pinSHA256 key instead of pcs (frontend genHysteriaLink + outbound importer round-trip), and the Go subscription generator emits ech from echConfigList. Also drops the dead allowInsecure guard in genHysteriaLink, which read a field that does not exist on TlsClientSettings.
2026-06-01 22:02:37 +02:00
MHSanaei 7f8c79675f fix(sub): source Userinfo total/expiry from client config in multi-node (#4645)
The Subscription-Userinfo header read total/expiry from client_traffics, but in a multi-node setup the master's node sync overwrites those with the node snapshot's zeros, so the header reported total=0; expire=0 even though the panel UI (which reads the clients table) showed the configured limits. AggregateTrafficByEmails now falls back to the clients table for total/expiry when the traffic row is zero, keeping up/down/lastOnline from client_traffics.
2026-06-01 21:27:50 +02:00
MHSanaei 80173b1b1d fix(db): make password-hash migration idempotent to prevent lock-out (#4612)
The UserPasswordHash seeder bcrypt-hashed user.Password unconditionally, assuming plaintext. If it ran on an already-bcrypt value (DB restore, SQLite<->Postgres switch, history_of_seeders inconsistency on upgrade) it double-hashed the password, locking the admin out with both old and new passwords rejected. Skip any password that is already a bcrypt hash.
2026-06-01 20:48:12 +02:00
MHSanaei 6ae1b38607 fix(outbound): add None option to uTLS fingerprint in TLS form (#4760)
Hysteria doesn't use uTLS, but the outbound TLS form's uTLS dropdown only listed concrete fingerprints (chrome, firefox, ...) with no explicit empty entry. Add a None option, matching the inbound TLS form, so the fingerprint can be left empty.
2026-06-01 19:21:37 +02:00
MHSanaei 803e010921 fix(outbound): carry ALPN, fingerprint and UDP mask when importing a Hysteria2 link (#4760)
parseHysteria2Link hardcoded alpn to h3 and never read fp, ech, or the fm (finalmask) param, so importing a Hysteria2 client URL as an outbound dropped the configured ALPN, fingerprint, and salamander UDP mask. Parse alpn (falling back to h3 only when absent), fp, ech, and the pcs pinned-cert key, and restore the UDP mask via applyFinalMaskParam.
2026-06-01 19:21:29 +02:00
MHSanaei b6641439d4 fix(sockopt): rename interfaceName to interface so xray honors it
xray-core reads the bind-interface sockopt as json:"interface", but the schema and forms used interfaceName. Go's JSON unmarshal is case-insensitive, yet interfacename != interface, so the value never reached xray and interface binding silently did nothing. Rename the field across the schema, the inbound/outbound forms, and the golden fixture to match xray-core and the official docs.
2026-06-01 18:21:37 +02:00
MHSanaei d29a17d333 fix(sub): ensure unique Clash proxy names (#4641)
genRemark can return an empty string (remark-less inbound, or a remark model that depends on the email the Clash path drops), which was set verbatim as the proxy name. mihomo rejects the whole config on a duplicate name, so two such proxies made the Clash Verge profile vanish on refresh; a single one was dropped from the PROXY group, collapsing it to DIRECT so Rule mode stopped proxying while Global still worked. Guarantee every proxy carries a non-empty, unique name before assembling the group.
2026-06-01 18:07:01 +02:00
MHSanaei 39b716409a fix(settings): enforce trafficDiff max of 100 in UI (#4769)
The trafficDiff InputNumber and form schema lacked an upper bound, so values above 100 were accepted in the UI but rejected by the backend (gte=0,lte=100), failing the entire settings save with a misleading 'request body failed validation' error. Add max=100 to the input and .max(100) to the schema.
2026-06-01 17:47:24 +02:00
MHSanaei 13c04bb982 fix(outbound): fill encryption and pqv when importing VLESS link
The link-to-JSON importer dropped two VLESS Reality fields:

- pqv (post-quantum ML-DSA-65 verify key) was never parsed; map it back
  to realitySettings.mldsa65Verify, matching the inbound link generator.
- encryption was force-reset to 'none' in the form adapter regardless of
  the parsed value, discarding post-quantum encryption strings.

Add regression tests for both paths.
2026-06-01 17:37:54 +02:00
MHSanaei 28330e60d8 fix(docker): grant NET_ADMIN/NET_RAW so fail2ban IP-limit bans apply
The image bundles fail2ban (enabled by default) to enforce per-client IP
limits via iptables, but docker-compose.yml granted no capabilities. The
job logs the ban and fail2ban reports it as banned, yet the iptables
action fails with "Permission denied (you must be root)" and no rule is
inserted, so the client is never actually blocked. Add cap_add
NET_ADMIN/NET_RAW to the service and document the docker run flags.
2026-06-01 17:17:49 +02:00
MHSanaei 72121784fe test(iplimit): align ban-policy tests with last-IP-wins (#4699)
PR #4699 restored the "keep newest live IP, ban the oldest" policy in
check_client_ip_job.go but left the integration test asserting the old
"protect original, ban newcomer" behavior, so it failed. Update the test
to expect the oldest live IP banned and the newest kept, and fix the now
misleading name/comment on the partitionLiveIps concurrency unit test.
2026-06-01 17:17:43 +02:00
ALOKY 16edb037e7 Fix IP limit enforcement and clarify related comments (#4699)
* fix: keep latest IP for limit enforcement

* chore: clarify IP limit comment

* chore: clarify timestamp sorting comment

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-01 16:34:08 +02:00
xiaoxiyao 2b7c1eeb6a fix(sub): Add Clash subscription profile filename header (#4743) 2026-06-01 16:32:56 +02:00
fgsfds 6b2243a40f chore(ui): remove cards jump on hover (#4755) 2026-06-01 16:32:12 +02:00
ckun52880 f9aa363a63 Replace static label with translation for downlink stats (#4762) 2026-06-01 16:31:45 +02:00
112 changed files with 3084 additions and 295 deletions
+125 -11
View File
@@ -7,29 +7,143 @@
</picture> </picture>
</p> </p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases) <p align="center">
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions) <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#) <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>
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest) <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) <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>
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3) <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>
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3) <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. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة. **3X-UI** هي لوحة تحكم ويب متقدمة ومفتوحة المصدر لإدارة خوادم [Xray-core](https://github.com/XTLS/Xray-core). توفّر واجهة نظيفة ومتعددة اللغات لنشر وتكوين ومراقبة مجموعة واسعة من بروتوكولات الوكيل وVPN — من خادم VPS واحد إلى عمليات النشر متعددة العقد.
تم بناء 3X-UI كنسخة محسّنة (fork) من مشروع X-UI الأصلي، وتضيف دعمًا أوسع للبروتوكولات، واستقرارًا محسّنًا، ومحاسبة للترافيك لكل عميل، والعديد من ميزات تحسين تجربة الاستخدام.
> [!IMPORTANT] > [!IMPORTANT]
> هذا المشروع مخصص للاستخدام الشخصي والاتصال فقط، يرجى عدم استخدامه لأغراض غير قانونية، يرجى عدم استخدامه في بيئة الإنتاج. > هذا المشروع مخصص للاستخدام الشخصي فقط. يرجى عدم استخدامه لأغراض غير قانونية أو في بيئة إنتاجية.
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية. ## الميزات
- **اتصالات واردة متعددة البروتوكولات** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، Hysteria2، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
- **وسائل نقل وأمان حديثة** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، مؤمَّنة بـ TLS و XTLS و REALITY.
- **Fallback** — تقديم عدة بروتوكولات على منفذ واحد (مثل VLESS و Trojan على المنفذ 443) باستخدام ميزة fallback في Xray.
- **إدارة لكل عميل** — حصص الترافيك، تواريخ انتهاء الصلاحية، حدود IP، حالة الاتصال المباشرة، وروابط مشاركة وأكواد QR واشتراكات بنقرة واحدة.
- **إحصائيات الترافيك** — لكل اتصال وارد، ولكل عميل، ولكل اتصال صادر، مع عناصر تحكم لإعادة التعيين.
- **دعم العقد المتعددة** — إدارة وتوسيع عبر عدة خوادم من لوحة واحدة.
- **الاتصالات الصادرة والتوجيه** — WARP، NordVPN، قواعد توجيه مخصصة، موازنات تحميل، وتسلسل الوكلاء الصادرة.
- **خادم اشتراك مدمج** بصيغ إخراج متعددة.
- **روبوت تيليجرام** للمراقبة والإدارة عن بُعد.
- **واجهة RESTful API** مع توثيق Swagger داخل اللوحة.
- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
- **13 لغة لواجهة المستخدم** مع سمات داكنة وفاتحة.
- **تكامل مع Fail2ban** لفرض حدود IP لكل عميل.
## لقطات الشاشة
<details>
<summary>انقر للتوسيع</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Overview" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Add client" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Configs" src="./media/05-add-nodes-light.png">
</picture>
</details>
## البدء السريع ## البدء السريع
``` ```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
``` ```
أثناء التثبيت، يتم إنشاء اسم مستخدم وكلمة مرور ومسار وصول عشوائية. بعد التثبيت، شغّل `x-ui` لفتح قائمة الإدارة، حيث يمكنك بدء/إيقاف الخدمة، وعرض أو إعادة تعيين بيانات تسجيل الدخول، وإدارة شهادات SSL، والمزيد.
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki). للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
## المنصات المدعومة
**أنظمة التشغيل:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
**المعماريات:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## خيارات قاعدة البيانات
يدعم 3X-UI خلفيتين (backends) يتم اختيارهما أثناء التثبيت:
- **SQLite** (افتراضي) — ملف واحد في `/etc/x-ui/x-ui.db`. بدون إعداد، مثالي لعمليات النشر الصغيرة والمتوسطة.
- **PostgreSQL** — موصى به لأعداد العملاء الكبيرة أو الإعدادات متعددة العقد. يمكن للمثبِّت تثبيت PostgreSQL محليًا لك، أو قبول DSN لخادم موجود.
في وقت التشغيل، يتم اختيار الخلفية عبر متغيرات البيئة (يكتبها المثبِّت لك في `/etc/default/x-ui`):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### ترحيل تثبيت SQLite موجود إلى PostgreSQL
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# ثم عيّن XUI_DB_TYPE و XUI_DB_DSN في /etc/default/x-ui وأعد التشغيل:
systemctl restart x-ui
```
يبقى ملف SQLite الأصلي دون تغيير؛ احذفه يدويًا بعد التحقق من الخلفية الجديدة.
### Docker
يستمر الأمر الافتراضي `docker compose up -d` في استخدام SQLite. للتشغيل مع خدمة PostgreSQL المرفقة، أزِل التعليق عن سطري متغيرات البيئة `XUI_DB_*` في `docker-compose.yml` وشغّل باستخدام البروفايل:
```bash
docker compose --profile postgres up -d
```
تتضمن الصورة Fail2ban (مُفعَّل افتراضيًا) لفرض **حدود IP** لكل عميل. يحظر Fail2ban المخالفين باستخدام `iptables`، الذي يتطلب صلاحية `NET_ADMIN`. يمنح `docker-compose.yml` هذه الصلاحية مسبقًا عبر `cap_add`؛ إذا شغّلت الحاوية باستخدام `docker run` بدلاً من ذلك، فأضِف الصلاحيات بنفسك، وإلا فسيتم تسجيل عمليات الحظر دون تطبيقها أبدًا:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## متغيرات البيئة
| المتغير | الوصف | الافتراضي |
| --- | --- | --- |
| `XUI_DB_TYPE` | خلفية قاعدة البيانات: `sqlite` أو `postgres` | `sqlite` |
| `XUI_DB_DSN` | سلسلة اتصال PostgreSQL (عندما `XUI_DB_TYPE=postgres`) | — |
| `XUI_DB_FOLDER` | مجلد ملف قاعدة بيانات SQLite | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | الحد الأقصى للاتصالات المفتوحة (تجمّع PostgreSQL) | — |
| `XUI_DB_MAX_IDLE_CONNS` | الحد الأقصى للاتصالات الخاملة (تجمّع PostgreSQL) | — |
| `XUI_ENABLE_FAIL2BAN` | تفعيل فرض حدود IP المعتمد على Fail2ban | `true` |
| `XUI_LOG_LEVEL` | مستوى السجل (`debug`، `info`، `warning`، `error`) | `info` |
| `XUI_DEBUG` | تفعيل وضع التصحيح | `false` |
## اللغات المدعومة
تتوفر واجهة اللوحة بـ 13 لغة:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## المساهمة
المساهمات مرحب بها. يرجى قراءة [دليل المساهمة](/CONTRIBUTING.md) قبل فتح مشكلة (issue) أو طلب سحب (pull request).
## شكر خاص إلى ## شكر خاص إلى
- [alireza0](https://github.com/alireza0/) - [alireza0](https://github.com/alireza0/)
+126 -12
View File
@@ -7,28 +7,142 @@
</picture> </picture>
</p> </p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases) <p align="center">
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions) <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#) <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>
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest) <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) <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>
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3) <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>
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3) <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** panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy. **3X-UI** es un panel de control web avanzado y de código abierto para gestionar servidores [Xray-core](https://github.com/XTLS/Xray-core). Ofrece una interfaz limpia y multilingüe para desplegar, configurar y monitorear una amplia gama de protocolos de proxy y VPN — desde un único VPS hasta despliegues multinodo.
Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un soporte de protocolos más amplio, mayor estabilidad, contabilidad de tráfico por cliente y muchas funciones que mejoran la experiencia de uso.
> [!IMPORTANT] > [!IMPORTANT]
> Este proyecto es solo para uso personal y comunicación, por favor no lo use para fines ilegales, por favor no lo use en un entorno de producción. > Este proyecto está destinado únicamente al uso personal. Por favor, no lo uses para fines ilegales ni en un entorno de producción.
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales. ## Características
- **Entradas multiprotocolo** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel y TUN.
- **Transportes y seguridad modernos** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade y XHTTP, protegidos con TLS, XTLS y REALITY.
- **Fallbacks** — sirve varios protocolos en un solo puerto (p. ej. VLESS y Trojan en el 443) usando la función de fallback de Xray.
- **Gestión por cliente** — cuotas de tráfico, fechas de caducidad, límites de IP, estado en línea en tiempo real y enlaces de compartición, códigos QR y suscripciones con un solo clic.
- **Estadísticas de tráfico** — por entrada, por cliente y por salida, con controles de reinicio.
- **Soporte multinodo** — gestiona y escala a través de varios servidores desde un único panel.
- **Salida y enrutamiento** — WARP, NordVPN, reglas de enrutamiento personalizadas, balanceadores de carga y encadenamiento de proxy de salida.
- **Servidor de suscripción integrado** con múltiples formatos de salida.
- **Bot de Telegram** para monitorización y gestión remotas.
- **API RESTful** con documentación Swagger dentro del panel.
- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
- **13 idiomas de interfaz** con temas oscuro y claro.
- **Integración con Fail2ban** para aplicar límites de IP por cliente.
## Capturas de pantalla
<details>
<summary>Haz clic para expandir</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Overview" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Add client" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Configs" src="./media/05-add-nodes-light.png">
</picture>
</details>
## Inicio Rápido ## Inicio Rápido
``` ```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
``` ```
Para documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki). Durante la instalación se generan un nombre de usuario, una contraseña y una ruta de acceso aleatorios. Tras la instalación, ejecuta `x-ui` para abrir el menú de gestión, donde puedes iniciar/detener el servicio, ver o restablecer tus credenciales de acceso, gestionar certificados SSL y mucho más.
Para la documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
## Plataformas Compatibles
**Sistemas operativos:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine y Windows.
**Arquitecturas:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## Opciones de Base de Datos
3X-UI admite dos backends, que se eligen durante la instalación:
- **SQLite** (predeterminado) — un único archivo en `/etc/x-ui/x-ui.db`. Sin configuración, ideal para despliegues pequeños y medianos.
- **PostgreSQL** — recomendado para un gran número de clientes o configuraciones multinodo. El instalador puede instalar PostgreSQL localmente por ti, o aceptar un DSN a un servidor existente.
En tiempo de ejecución, el backend se selecciona mediante variables de entorno (el instalador las escribe por ti en `/etc/default/x-ui`):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### Migrar una instalación de SQLite existente a PostgreSQL
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# luego define XUI_DB_TYPE y XUI_DB_DSN en /etc/default/x-ui y reinicia:
systemctl restart x-ui
```
El archivo SQLite de origen permanece intacto; elimínalo manualmente una vez que hayas verificado el nuevo backend.
### Docker
El comando predeterminado `docker compose up -d` sigue usando SQLite. Para ejecutarlo con el servicio PostgreSQL incluido, descomenta las dos líneas de variables de entorno `XUI_DB_*` en `docker-compose.yml` e inícialo con el perfil:
```bash
docker compose --profile postgres up -d
```
La imagen incluye Fail2ban (habilitado de forma predeterminada) para aplicar **límites de IP** por cliente. Fail2ban banea a los infractores con `iptables`, lo que requiere la capacidad `NET_ADMIN`. `docker-compose.yml` ya la concede mediante `cap_add`; si en su lugar inicias el contenedor con `docker run`, añade tú mismo las capacidades, de lo contrario los baneos se registran pero nunca se aplican:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## Variables de Entorno
| Variable | Descripción | Predeterminado |
| --- | --- | --- |
| `XUI_DB_TYPE` | Backend de base de datos: `sqlite` o `postgres` | `sqlite` |
| `XUI_DB_DSN` | Cadena de conexión de PostgreSQL (cuando `XUI_DB_TYPE=postgres`) | — |
| `XUI_DB_FOLDER` | Directorio del archivo de base de datos SQLite | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | Máximo de conexiones abiertas (pool de PostgreSQL) | — |
| `XUI_DB_MAX_IDLE_CONNS` | Máximo de conexiones inactivas (pool de PostgreSQL) | — |
| `XUI_ENABLE_FAIL2BAN` | Habilitar la aplicación de límites de IP basada en Fail2ban | `true` |
| `XUI_LOG_LEVEL` | Nivel de registro (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Habilitar el modo de depuración | `false` |
## Idiomas Compatibles
La interfaz del panel está disponible en 13 idiomas:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## Contribuir
Las contribuciones son bienvenidas. Por favor, lee la [Guía de contribución](/CONTRIBUTING.md) antes de abrir una incidencia (issue) o una solicitud de incorporación (pull request).
## Un Agradecimiento Especial a ## Un Agradecimiento Especial a
+125 -11
View File
@@ -7,29 +7,143 @@
</picture> </picture>
</p> </p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases) <p align="center">
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions) <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#) <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>
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest) <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) <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>
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3) <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>
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3) <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 طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکل‌های مختلف VPN و پراکسی ارائه می‌دهد. **3X-UI** یک پنل کنترل وب پیشرفته و متن‌باز برای مدیریت سرورهای [Xray-core](https://github.com/XTLS/Xray-core) است. این پنل یک رابط کاربری تمیز و چندزبانه برای استقرار، پیکربندی و نظارت بر طیف گسترده‌ای از پروتکل‌های پراکسی و VPN ارائه می‌دهد — از یک VPS تکی تا استقرارهای چندنودی.
‏3X-UI که به‌عنوان یک فورک بهبودیافته از پروژه‌ی اصلی X-UI ساخته شده است، پشتیبانی گسترده‌تر از پروتکل‌ها، پایداری بهتر، حسابداری ترافیک به‌ازای هر کلاینت و بسیاری از ویژگی‌های رفاهی را اضافه می‌کند.
> [!IMPORTANT] > [!IMPORTANT]
> این پروژه فقط برای استفاده شخصی و ارتباطات است، لطفاً از آن برای اهداف غیرقانونی استفاده نکنید، لطفاً از آن در محیط تولید استفاده نکنید. > این پروژه فقط برای استفاده‌ی شخصی در نظر گرفته شده است. لطفاً از آن برای اهداف غیرقانونی یا در محیط تولید (production) استفاده نکنید.
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد. ## ویژگی‌ها
- **اینباندهای چندپروتکلی** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، Hysteria2، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
- **ترنسپورت‌ها و امنیت مدرن** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، ایمن‌شده با TLS، XTLS و REALITY.
- **فال‌بک (Fallback)** — ارائه‌ی چند پروتکل روی یک پورت واحد (مثلاً VLESS و Trojan روی پورت 443) با استفاده از قابلیت fallback در Xray.
- **مدیریت به‌ازای هر کلاینت** — سهمیه‌ی ترافیک، تاریخ انقضا، محدودیت IP، وضعیت آنلاینِ زنده و لینک‌های اشتراک‌گذاری، کدهای QR و سابسکریپشن‌ها با یک کلیک.
- **آمار ترافیک** — به‌ازای هر اینباند، هر کلاینت و هر اوتباند، همراه با کنترل بازنشانی (reset).
- **پشتیبانی از چند نود** — مدیریت و مقیاس‌دهی روی چندین سرور از یک پنل واحد.
- **اوتباند و مسیریابی** — WARP، NordVPN، قوانین مسیریابی سفارشی، متعادل‌کننده‌های بار (load balancer) و زنجیره‌کردن پراکسی اوتباند.
- **سرور سابسکریپشن داخلی** با چندین فرمت خروجی.
- **ربات تلگرام** برای نظارت و مدیریت از راه دور.
- **RESTful API** همراه با مستندات Swagger درون‌پنل.
- **ذخیره‌سازی منعطف** — SQLite (پیش‌فرض) یا PostgreSQL.
- **‏۱۳ زبان رابط کاربری** با تم‌های تیره و روشن.
- **یکپارچگی با Fail2ban** برای اعمال محدودیت IP به‌ازای هر کلاینت.
## اسکرین‌شات‌ها
<details>
<summary>برای باز شدن کلیک کنید</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Overview" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Add client" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Configs" src="./media/05-add-nodes-light.png">
</picture>
</details>
## شروع سریع ## شروع سریع
``` ```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
``` ```
در حین نصب، یک نام کاربری، رمز عبور و مسیر دسترسی تصادفی تولید می‌شود. پس از نصب، دستور `x-ui` را اجرا کنید تا منوی مدیریت باز شود؛ در آنجا می‌توانید سرویس را شروع/متوقف کنید، اطلاعات ورود خود را ببینید یا بازنشانی کنید، گواهی‌های SSL را مدیریت کنید و کارهای دیگری انجام دهید.
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید. برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
## پلتفرم‌های پشتیبانی‌شده
**سیستم‌عامل‌ها:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
**معماری‌ها:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## گزینه‌های پایگاه‌داده
‏3X-UI از دو بک‌اند پشتیبانی می‌کند که در حین نصب انتخاب می‌شوند:
- **SQLite** (پیش‌فرض) — یک فایل واحد در مسیر `/etc/x-ui/x-ui.db`. بدون نیاز به تنظیمات، ایده‌آل برای استقرارهای کوچک و متوسط.
- **PostgreSQL** — برای تعداد کلاینت بالا یا راه‌اندازی‌های چندنودی توصیه می‌شود. نصب‌کننده می‌تواند PostgreSQL را به‌صورت محلی برایتان نصب کند، یا یک DSN به یک سرور موجود را بپذیرد.
در زمان اجرا، بک‌اند از طریق متغیرهای محیطی انتخاب می‌شود (نصب‌کننده این موارد را برای شما در `/etc/default/x-ui` می‌نویسد):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### انتقال یک نصب موجود SQLite به PostgreSQL
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# سپس XUI_DB_TYPE و XUI_DB_DSN را در /etc/default/x-ui تنظیم کرده و ری‌استارت کنید:
systemctl restart x-ui
```
فایل اصلی SQLite دست‌نخورده باقی می‌ماند؛ پس از اطمینان از صحت بک‌اند جدید، آن را به‌صورت دستی حذف کنید.
### Docker
دستور پیش‌فرض `docker compose up -d` همچنان از SQLite استفاده می‌کند. برای اجرا با سرویس PostgreSQL همراه، دو خط متغیر محیطی `XUI_DB_*` را در `docker-compose.yml` از حالت کامنت خارج کنید و با پروفایل زیر اجرا کنید:
```bash
docker compose --profile postgres up -d
```
این ایمیج، Fail2ban را (که به‌صورت پیش‌فرض فعال است) برای اعمال **محدودیت‌های IP** به‌ازای هر کلاینت همراه دارد. ‏Fail2ban متخلفان را با `iptables` مسدود می‌کند که به مجوز `NET_ADMIN` نیاز دارد. فایل `docker-compose.yml` این مجوز را از قبل از طریق `cap_add` می‌دهد؛ اگر به‌جای آن کانتینر را با `docker run` اجرا می‌کنید، خودتان مجوزها را اضافه کنید، در غیر این صورت مسدودسازی‌ها فقط ثبت می‌شوند اما هرگز اعمال نمی‌شوند:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## متغیرهای محیطی
| متغیر | توضیحات | پیش‌فرض |
| --- | --- | --- |
| `XUI_DB_TYPE` | بک‌اند پایگاه‌داده: `sqlite` یا `postgres` | `sqlite` |
| `XUI_DB_DSN` | رشته‌ی اتصال PostgreSQL (وقتی `XUI_DB_TYPE=postgres`) | — |
| `XUI_DB_FOLDER` | پوشه‌ی فایل پایگاه‌داده‌ی SQLite | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | حداکثر اتصالات باز (استخر PostgreSQL) | — |
| `XUI_DB_MAX_IDLE_CONNS` | حداکثر اتصالات بی‌کار (استخر PostgreSQL) | — |
| `XUI_ENABLE_FAIL2BAN` | فعال‌سازی اعمال محدودیت IP مبتنی بر Fail2ban | `true` |
| `XUI_LOG_LEVEL` | سطح گزارش‌گیری (`debug`، `info`، `warning`، `error`) | `info` |
| `XUI_DEBUG` | فعال‌سازی حالت دیباگ | `false` |
## زبان‌های پشتیبانی‌شده
رابط کاربری پنل به ۱۳ زبان در دسترس است:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## مشارکت
از مشارکت‌ها استقبال می‌شود. لطفاً پیش از باز کردن issue یا pull request، [راهنمای مشارکت](/CONTRIBUTING.md) را مطالعه کنید.
## تشکر ویژه از ## تشکر ویژه از
- [alireza0](https://github.com/alireza0/) - [alireza0](https://github.com/alireza0/)
+94 -12
View File
@@ -7,20 +7,65 @@
</picture> </picture>
</p> </p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases) <p align="center">
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions) <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#) <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>
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest) <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) <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>
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3) <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>
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3) <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** advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols. **3X-UI** is an advanced, open-source web control panel for managing [Xray-core](https://github.com/XTLS/Xray-core) servers. It provides a clean, multi-language interface for deploying, configuring, and monitoring a wide range of proxy and VPN protocols — from a single VPS to multi-node deployments.
Built as an enhanced fork of the original X-UI project, 3X-UI adds broader protocol support, improved stability, per-client traffic accounting, and many quality-of-life features.
> [!IMPORTANT] > [!IMPORTANT]
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment. > This project is intended for personal use only. Please do not use it for illegal purposes or in a production environment.
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features. ## Features
- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
- **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
- **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
- **Per-client management** — traffic quotas, expiry dates, IP limits, live online status, and one-click share links, QR codes, and subscriptions.
- **Traffic statistics** — per inbound, per client, and per outbound, with reset controls.
- **Multi-node support** — manage and scale across multiple servers from a single panel.
- **Outbound & routing** — WARP, NordVPN, custom routing rules, load balancers, and outbound proxy chaining.
- **Built-in subscription server** with multiple output formats.
- **Telegram bot** for remote monitoring and management.
- **RESTful API** with in-panel Swagger documentation.
- **Flexible storage** — SQLite (default) or PostgreSQL.
- **13 UI languages** with dark and light themes.
- **Fail2ban integration** for enforcing per-client IP limits.
## Screenshots
<details>
<summary>Click to expand</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Overview" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Add client" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Configs" src="./media/05-add-nodes-light.png">
</picture>
</details>
## Quick Start ## Quick Start
@@ -28,16 +73,24 @@ As an enhanced fork of the original X-UI project, 3X-UI provides improved stabil
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
``` ```
During installation a random username, password, and access path are generated. After installation, run `x-ui` to open the management menu, where you can start/stop the service, view or reset your login credentials, manage SSL certificates, and more.
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki). For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
## Supported Platforms
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
**Architectures:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## Database Options ## Database Options
3X-UI supports two backends, chosen during the install: 3X-UI supports two backends, chosen during the install:
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments. - **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small and medium deployments.
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server. - **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you): At runtime the backend is selected via environment variables (the installer writes these to `/etc/default/x-ui` for you):
``` ```
XUI_DB_TYPE=postgres XUI_DB_TYPE=postgres
@@ -62,6 +115,35 @@ The default `docker compose up -d` keeps using SQLite. To run with the bundled P
docker compose --profile postgres up -d docker compose --profile postgres up -d
``` ```
The image bundles Fail2ban (enabled by default) to enforce per-client **IP limits**. Fail2ban bans offenders with `iptables`, which requires the `NET_ADMIN` capability. `docker-compose.yml` already grants it via `cap_add`; if you start the container with `docker run` instead, add the capabilities yourself, otherwise bans are logged but never applied:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## Environment Variables
| Variable | Description | Default |
| --- | --- | --- |
| `XUI_DB_TYPE` | Database backend: `sqlite` or `postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL connection string (when `XUI_DB_TYPE=postgres`) | — |
| `XUI_DB_FOLDER` | Directory for the SQLite database file | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | Maximum open connections (PostgreSQL pool) | — |
| `XUI_DB_MAX_IDLE_CONNS` | Maximum idle connections (PostgreSQL pool) | — |
| `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
| `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Enable debug mode | `false` |
## Supported Languages
The panel UI is available in 13 languages:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## Contributing
Contributions are welcome. Please read the [Contributing Guide](/CONTRIBUTING.md) before opening an issue or pull request.
## A Special Thanks to ## A Special Thanks to
- [alireza0](https://github.com/alireza0/) - [alireza0](https://github.com/alireza0/)
+125 -11
View File
@@ -7,29 +7,143 @@
</picture> </picture>
</p> </p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases) <p align="center">
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions) <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#) <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>
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest) <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) <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>
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3) <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>
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3) <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. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов. **3X-UI** — продвинутая веб-панель управления с открытым исходным кодом для управления серверами [Xray-core](https://github.com/XTLS/Xray-core). Она предоставляет аккуратный многоязычный интерфейс для развёртывания, настройки и мониторинга широкого спектра протоколов прокси и VPN — от одного VPS до развёртываний с несколькими узлами.
Созданный как улучшенный форк оригинального проекта X-UI, 3X-UI добавляет более широкую поддержку протоколов, повышенную стабильность, учёт трафика по каждому клиенту и множество функций для удобства использования.
> [!IMPORTANT] > [!IMPORTANT]
> Этот проект предназначен только для личного использования, пожалуйста, не используйте его в незаконных целях и в производственной среде. > Этот проект предназначен только для личного использования. Пожалуйста, не используйте его в незаконных целях или в производственной среде.
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции. ## Возможности
- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
- **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
- **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
- **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
- **Статистика трафика** — по каждому входящему, по каждому клиенту и по каждому исходящему, с возможностью сброса.
- **Поддержка нескольких узлов** — управление и масштабирование на несколько серверов из одной панели.
- **Исходящие подключения и маршрутизация** — WARP, NordVPN, пользовательские правила маршрутизации, балансировщики нагрузки и цепочки исходящих прокси.
- **Встроенный сервер подписок** с несколькими форматами вывода.
- **Telegram-бот** для удалённого мониторинга и управления.
- **RESTful API** с документацией Swagger внутри панели.
- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
- **13 языков интерфейса** с тёмной и светлой темами.
- **Интеграция с Fail2ban** для применения лимитов IP по каждому клиенту.
## Скриншоты
<details>
<summary>Нажмите, чтобы развернуть</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Overview" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Add client" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Configs" src="./media/05-add-nodes-light.png">
</picture>
</details>
## Быстрый старт ## Быстрый старт
``` ```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
``` ```
Во время установки генерируются случайные имя пользователя, пароль и путь доступа. После установки выполните `x-ui`, чтобы открыть меню управления, где можно запускать/останавливать сервис, просматривать или сбрасывать учётные данные для входа, управлять SSL-сертификатами и многое другое.
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki). Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
## Поддерживаемые платформы
**Операционные системы:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine и Windows.
**Архитектуры:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## Варианты базы данных
3X-UI поддерживает два бэкенда, выбираемых при установке:
- **SQLite** (по умолчанию) — единый файл по пути `/etc/x-ui/x-ui.db`. Без настройки, идеально для небольших и средних развёртываний.
- **PostgreSQL** — рекомендуется при большом числе клиентов или конфигурациях с несколькими узлами. Установщик может установить PostgreSQL локально за вас или принять DSN к существующему серверу.
Во время выполнения бэкенд выбирается через переменные окружения (установщик записывает их за вас в `/etc/default/x-ui`):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### Перенос существующей установки SQLite в PostgreSQL
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# затем задайте XUI_DB_TYPE и XUI_DB_DSN в /etc/default/x-ui и перезапустите:
systemctl restart x-ui
```
Исходный файл SQLite остаётся нетронутым; удалите его вручную после проверки нового бэкенда.
### Docker
Команда по умолчанию `docker compose up -d` продолжает использовать SQLite. Чтобы запустить со встроенным сервисом PostgreSQL, раскомментируйте две строки переменных окружения `XUI_DB_*` в `docker-compose.yml` и запустите с профилем:
```bash
docker compose --profile postgres up -d
```
Образ включает Fail2ban (включён по умолчанию) для применения **лимитов IP** по каждому клиенту. Fail2ban блокирует нарушителей с помощью `iptables`, что требует возможности `NET_ADMIN`. `docker-compose.yml` уже предоставляет её через `cap_add`; если вы вместо этого запускаете контейнер через `docker run`, добавьте возможности самостоятельно, иначе блокировки будут регистрироваться, но никогда не применяться:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## Переменные окружения
| Переменная | Описание | По умолчанию |
| --- | --- | --- |
| `XUI_DB_TYPE` | Бэкенд базы данных: `sqlite` или `postgres` | `sqlite` |
| `XUI_DB_DSN` | Строка подключения PostgreSQL (когда `XUI_DB_TYPE=postgres`) | — |
| `XUI_DB_FOLDER` | Каталог для файла базы данных SQLite | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | Максимум открытых соединений (пул PostgreSQL) | — |
| `XUI_DB_MAX_IDLE_CONNS` | Максимум простаивающих соединений (пул PostgreSQL) | — |
| `XUI_ENABLE_FAIL2BAN` | Включить применение лимитов IP на основе Fail2ban | `true` |
| `XUI_LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Включить режим отладки | `false` |
## Поддерживаемые языки
Интерфейс панели доступен на 13 языках:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## Участие в разработке
Вклад приветствуется. Пожалуйста, прочитайте [руководство по участию](/CONTRIBUTING.md), прежде чем открывать issue или pull request.
## Особая благодарность ## Особая благодарность
- [alireza0](https://github.com/alireza0/) - [alireza0](https://github.com/alireza0/)
+125 -11
View File
@@ -7,29 +7,143 @@
</picture> </picture>
</p> </p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases) <p align="center">
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions) <a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#) <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>
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest) <a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html) <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>
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3) <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>
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3) <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 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。 **3X-UI** 是一个先进的开源 Web 控制面板,用于管理 [Xray-core](https://github.com/XTLS/Xray-core) 服务器。它提供简洁、多语言的界面,用于部署、配置和监控各种代理与 VPN 协议——从单台 VPS 到多节点部署
3X-UI 作为原始 X-UI 项目的增强分支(fork),增加了更广泛的协议支持、更好的稳定性、按客户端的流量统计以及许多提升使用体验的功能。
> [!IMPORTANT] > [!IMPORTANT]
> 本项目仅用于个人使用和通信,请勿将其用于非法目的,请勿在生产环境中使用。 > 本项目仅个人使用请勿将其用于非法目的,请勿在生产环境中使用。
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。 ## 功能特性
- **多协议入站** — VLESS、VMess、Trojan、Shadowsocks、WireGuard、Hysteria2、HTTP、SOCKS (Mixed)、Dokodemo-door / Tunnel 和 TUN。
- **现代传输与安全** — TCP (Raw)、mKCP、WebSocket、gRPC、HTTPUpgrade 和 XHTTP,并通过 TLS、XTLS 和 REALITY 加密。
- **回落 (Fallback)** — 通过 Xray 的 fallback 功能在单个端口上提供多种协议(例如在 443 端口上同时使用 VLESS 和 Trojan)。
- **按客户端管理** — 流量配额、到期日期、IP 限制、实时在线状态,以及一键分享链接、二维码和订阅。
- **流量统计** — 按入站、按客户端、按出站统计,并支持重置控制。
- **多节点支持** — 从单一面板管理并扩展到多台服务器。
- **出站与路由** — WARP、NordVPN、自定义路由规则、负载均衡器和出站代理链。
- **内置订阅服务器**,支持多种输出格式。
- **Telegram 机器人**,用于远程监控和管理。
- **RESTful API**,带有面板内置的 Swagger 文档。
- **灵活的存储** — SQLite(默认)或 PostgreSQL。
- **13 种界面语言**,支持深色和浅色主题。
- **Fail2ban 集成**,用于强制执行按客户端的 IP 限制。
## 截图
<details>
<summary>点击展开</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Overview" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Add client" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Configs" src="./media/05-add-nodes-light.png">
</picture>
</details>
## 快速开始 ## 快速开始
``` ```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
``` ```
安装过程中会生成随机的用户名、密码和访问路径。安装完成后,运行 `x-ui` 打开管理菜单,您可以在其中启动/停止服务、查看或重置登录凭据、管理 SSL 证书等。
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。 完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
## 支持的平台
**操作系统:** Ubuntu、Debian、Armbian、Fedora、CentOS、RHEL、AlmaLinux、Rocky Linux、Oracle Linux、Amazon Linux、Virtuozzo、Arch、Manjaro、Parch、openSUSE (Tumbleweed / Leap)、Alpine 和 Windows。
**架构:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`
## 数据库选项
3X-UI 支持两种后端,可在安装时选择:
- **SQLite**(默认)— 位于 `/etc/x-ui/x-ui.db` 的单个文件。无需配置,适合中小型部署。
- **PostgreSQL** — 推荐用于大量客户端或多节点设置。安装程序可以为您在本地安装 PostgreSQL,或接受指向现有服务器的 DSN。
运行时通过环境变量选择后端(安装程序会为您写入 `/etc/default/x-ui`):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### 将现有的 SQLite 安装迁移到 PostgreSQL
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# 然后在 /etc/default/x-ui 中设置 XUI_DB_TYPE 和 XUI_DB_DSN 并重启:
systemctl restart x-ui
```
源 SQLite 文件保持不变;在确认新后端正常工作后,请手动删除它。
### Docker
默认的 `docker compose up -d` 仍使用 SQLite。若要使用捆绑的 PostgreSQL 服务运行,请取消注释 `docker-compose.yml` 中的两行 `XUI_DB_*` 环境变量,并使用该 profile 启动:
```bash
docker compose --profile postgres up -d
```
该镜像捆绑了 Fail2ban(默认启用),用于强制执行按客户端的 **IP 限制**。Fail2ban 使用 `iptables` 封禁违规者,这需要 `NET_ADMIN` 权限。`docker-compose.yml` 已通过 `cap_add` 授予该权限;如果您改用 `docker run` 启动容器,请自行添加这些权限,否则封禁只会被记录而永远不会生效:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## 环境变量
| 变量 | 说明 | 默认值 |
| --- | --- | --- |
| `XUI_DB_TYPE` | 数据库后端:`sqlite``postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL 连接字符串(当 `XUI_DB_TYPE=postgres` 时) | — |
| `XUI_DB_FOLDER` | SQLite 数据库文件所在目录 | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | 最大打开连接数(PostgreSQL 连接池) | — |
| `XUI_DB_MAX_IDLE_CONNS` | 最大空闲连接数(PostgreSQL 连接池) | — |
| `XUI_ENABLE_FAIL2BAN` | 启用基于 Fail2ban 的 IP 限制 | `true` |
| `XUI_LOG_LEVEL` | 日志级别(`debug``info``warning``error` | `info` |
| `XUI_DEBUG` | 启用调试模式 | `false` |
## 支持的语言
面板界面提供 13 种语言:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## 贡献
欢迎贡献。在提交 issue 或 pull request 之前,请阅读[贡献指南](/CONTRIBUTING.md)。
## 特别感谢 ## 特别感谢
- [alireza0](https://github.com/alireza0/) - [alireza0](https://github.com/alireza0/)
+1 -1
View File
@@ -1 +1 @@
3.2.5 3.2.6
+4
View File
@@ -72,6 +72,7 @@ func initModels() error {
&model.ClientInbound{}, &model.ClientInbound{},
&model.ClientGroup{}, &model.ClientGroup{},
&model.InboundFallback{}, &model.InboundFallback{},
&model.NodeClientTraffic{},
} }
for _, mdl := range models { for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil { if err := db.AutoMigrate(mdl); err != nil {
@@ -203,6 +204,9 @@ func runSeeders(isUsersEmpty bool) error {
} }
for _, user := range users { for _, user := range users {
if crypto.IsHashed(user.Password) {
continue
}
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil { if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err) log.Printf("Error hashing password for user '%s': %v", user.Username, err)
+1 -1
View File
@@ -133,7 +133,7 @@ func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing
} }
subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`) subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`)
for i := 0; i < 2; i++ { for i := range 2 {
obj := clients[i].(map[string]any) obj := clients[i].(map[string]any)
sub, _ := obj["subId"].(string) sub, _ := obj["subId"].(string)
if !subIdPattern.MatchString(sub) { if !subIdPattern.MatchString(sub) {
+31 -12
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"path" "path"
"reflect" "reflect"
"strings"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
@@ -36,6 +37,7 @@ func migrationModels() []any {
&model.ClientRecord{}, &model.ClientRecord{},
&model.ClientInbound{}, &model.ClientInbound{},
&model.InboundFallback{}, &model.InboundFallback{},
&model.NodeClientTraffic{},
} }
} }
@@ -102,25 +104,42 @@ func MigrateData(srcPath, dstDSN string) error {
return nil return nil
} }
// copyTable streams every row of `mdl` from src to dst in batches.
func copyTable(src, dst *gorm.DB, mdl any) (int, error) { func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
const batchSize = 500
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem())) sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
batchPtr := reflect.New(sliceType)
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0)) // Resolve primary-key columns so paging is deterministic across successive
// LIMIT/OFFSET reads. The model set is trusted (not user input).
stmt := &gorm.Statement{DB: src}
if err := stmt.Parse(mdl); err != nil {
return 0, err
}
order := strings.Join(stmt.Schema.PrimaryFieldDBNames, ", ")
total := 0 total := 0
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error { for offset := 0; ; offset += batchSize {
batch := batchPtr.Elem() batchPtr := reflect.New(sliceType)
if batch.Len() == 0 { q := src.Model(mdl).Limit(batchSize).Offset(offset)
return nil if order != "" {
q = q.Order(order)
}
if err := q.Find(batchPtr.Interface()).Error; err != nil {
return total, err
}
n := batchPtr.Elem().Len()
if n == 0 {
break
} }
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil { if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
return err return total, err
} }
total += batch.Len() total += n
return nil if n < batchSize {
}).Error break
return total, err }
}
return total, nil
} }
// resetPostgresSequences advances each migrated table's id sequence past MAX(id), // resetPostgresSequences advances each migrated table's id sequence past MAX(id),
+64
View File
@@ -0,0 +1,64 @@
package database
import (
"os"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigrateData_CompositeKeyTableLargerThanBatch(t *testing.T) {
dsn := os.Getenv("XUI_TEST_PG_DSN")
if dsn == "" {
t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
}
// Seed a SQLite source with the full schema and >500 client_inbounds rows.
srcPath := t.TempDir() + "/x-ui.db"
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
for _, m := range migrationModels() {
if err := src.AutoMigrate(m); err != nil {
t.Fatalf("automigrate %T: %v", m, err)
}
}
const n = 600 // > batchSize (500) so the between-batches path is exercised
links := make([]model.ClientInbound, 0, n)
for i := 1; i <= n; i++ {
links = append(links, model.ClientInbound{ClientId: i, InboundId: 1})
}
if err := src.CreateInBatches(links, 200).Error; err != nil {
t.Fatalf("seed client_inbounds: %v", err)
}
if sqlDB, err := src.DB(); err == nil {
sqlDB.Close() // flush before MigrateData reopens the file
}
// Make the test re-runnable: drop any tables from a previous run.
dst, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open postgres: %v", err)
}
if err := dst.Migrator().DropTable(migrationModels()...); err != nil {
t.Fatalf("drop tables: %v", err)
}
if err := MigrateData(srcPath, dsn); err != nil {
t.Fatalf("MigrateData: %v", err) // fails here before the fix
}
var got int64
if err := dst.Model(&model.ClientInbound{}).Count(&got).Error; err != nil {
t.Fatalf("count: %v", err)
}
if got != n {
t.Fatalf("client_inbounds rows = %d, want %d", got, n)
}
}
+4 -1
View File
@@ -55,7 +55,7 @@ type Inbound struct {
// Xray configuration fields // Xray configuration fields
Listen string `json:"listen" form:"listen"` Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` 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"` Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
Settings string `json:"settings" form:"settings"` Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
@@ -307,6 +307,7 @@ func StripVlessInboundEncryption(settings string) (string, bool) {
// inbound with "users must have empty method" when a client carries // inbound with "users must have empty method" when a client carries
// one — strip stale entries left over from a switch off a legacy // one — strip stale entries left over from a switch off a legacy
// cipher. // cipher.
//
// Returns the rewritten settings string and true when anything changed. // Returns the rewritten settings string and true when anything changed.
func HealShadowsocksClientMethods(settings string) (string, bool) { func HealShadowsocksClientMethods(settings string) (string, bool) {
if settings == "" { if settings == "" {
@@ -379,6 +380,8 @@ type Node struct {
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"` ApiToken string `json:"apiToken" form:"apiToken" validate:"required"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"` Enable bool `json:"enable" form:"enable" gorm:"default:true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when // Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is // the row is otherwise unchanged so the UI's "last seen" tooltip is
+9
View File
@@ -0,0 +1,9 @@
package model
type NodeClientTraffic struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
NodeId int `json:"nodeId" gorm:"uniqueIndex:idx_node_email,priority:1;not null"`
Email string `json:"email" gorm:"uniqueIndex:idx_node_email,priority:2;not null"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
+7
View File
@@ -5,6 +5,13 @@ services:
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
container_name: 3xui_app container_name: 3xui_app
# hostname: yourhostname <- optional # hostname: yourhostname <- optional
# The bundled Fail2ban (XUI_ENABLE_FAIL2BAN below) enforces the IP limit
# with iptables, which needs NET_ADMIN. Without these caps a ban is logged
# and shown in fail2ban status but never actually applied. NET_RAW covers
# ip6tables. If you disable Fail2ban, you can drop cap_add.
cap_add:
- NET_ADMIN
- NET_RAW
volumes: volumes:
- $PWD/db/:/etc/x-ui/ - $PWD/db/:/etc/x-ui/
- $PWD/cert/:/root/cert/ - $PWD/cert/:/root/cert/
+50
View File
@@ -4203,6 +4203,56 @@
} }
} }
}, },
"/panel/api/nodes/certFingerprint": {
"post": {
"tags": [
"Nodes"
],
"summary": "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
"operationId": "post_panel_api_nodes_certFingerprint",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"scheme": "https",
"address": "node1.example.com",
"port": 2053,
"basePath": "/"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": "k3b1...base64-sha256...="
}
}
}
}
}
}
},
"/panel/api/nodes/probe/{id}": { "/panel/api/nodes/probe/{id}": {
"post": { "post": {
"tags": [ "tags": [
@@ -70,5 +70,7 @@ export function useNodeMutations() {
const raw = await HttpUtil.post('/panel/api/nodes/test', payload); const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
return parseMsg(raw, ProbeResultSchema, 'nodes/test'); return parseMsg(raw, ProbeResultSchema, 'nodes/test');
}, },
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
}; };
} }
+2
View File
@@ -333,10 +333,12 @@ export interface Node {
name: string; name: string;
onlineCount: number; onlineCount: number;
panelVersion: string; panelVersion: string;
pinnedCertSha256: string;
port: number; port: number;
remark: string; remark: string;
scheme: string; scheme: string;
status: string; status: string;
tlsVerifyMode: string;
updatedAt: number; updatedAt: number;
uptimeSecs: number; uptimeSecs: number;
xrayVersion: string; xrayVersion: string;
+3 -1
View File
@@ -291,7 +291,7 @@ export const InboundSchema = z.object({
lastTrafficResetTime: z.number().int(), lastTrafficResetTime: z.number().int(),
listen: z.string(), listen: z.string(),
nodeId: z.number().int().nullable().optional(), nodeId: z.number().int().nullable().optional(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']), protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
remark: z.string(), remark: z.string(),
settings: z.unknown(), settings: z.unknown(),
@@ -350,10 +350,12 @@ export const NodeSchema = z.object({
name: z.string(), name: z.string(),
onlineCount: z.number().int(), onlineCount: z.number().int(),
panelVersion: z.string(), panelVersion: z.string(),
pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
remark: z.string(), remark: z.string(),
scheme: z.enum(['http', 'https']), scheme: z.enum(['http', 'https']),
status: z.string(), status: z.string(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
updatedAt: z.number().int(), updatedAt: z.number().int(),
uptimeSecs: z.number().int(), uptimeSecs: z.number().int(),
xrayVersion: z.string(), xrayVersion: z.string(),
+15 -7
View File
@@ -197,13 +197,21 @@ export function useXraySetting(): UseXraySettingResult {
}, [queryClient]); }, [queryClient]);
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: async () => mutationFn: async () => {
HttpUtil.post('/panel/xray/update', { const sentXraySetting = xraySettingRef.current;
xraySetting: xraySettingRef.current, const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL, const msg = await HttpUtil.post('/panel/xray/update', {
}), xraySetting: sentXraySetting,
onSuccess: (msg) => { outboundTestUrl: sentTestUrl,
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() }); });
return { msg, sentXraySetting, sentTestUrl };
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
if (!msg?.success) return;
oldXraySettingRef.current = sentXraySetting;
oldOutboundTestUrlRef.current = sentTestUrl;
setSaveDisabled(true);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, },
}); });
+12 -2
View File
@@ -610,6 +610,9 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(',')); if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList); if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
if (tls.serverName.length > 0) params.set('sni', tls.serverName); if (tls.serverName.length > 0) params.set('sni', tls.serverName);
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.join(','));
}
const udpMasks = stream.finalmask?.udp; const udpMasks = stream.finalmask?.udp;
if (Array.isArray(udpMasks)) { if (Array.isArray(udpMasks)) {
@@ -703,15 +706,22 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
export type { WireguardInboundPeer }; export type { WireguardInboundPeer };
function isUnixSocketListen(listen: string): boolean {
return listen.startsWith('/') || listen.startsWith('@');
}
// Orchestrators. // Orchestrators.
// resolveAddr picks the host that goes into share/sub links. Order: // resolveAddr picks the host that goes into share/sub links. Order:
// 1. hostOverride (caller supplies node address for node-managed inbounds) // 1. hostOverride (caller supplies node address for node-managed inbounds)
// 2. inbound's bind listen (when explicit, not 0.0.0.0) // 2. inbound's bind listen (when it's an explicit reachable address —
// not 0.0.0.0 and not a unix domain socket path)
// 3. fallbackHostname (caller-supplied — typically window.location.hostname // 3. fallbackHostname (caller-supplied — typically window.location.hostname
// in the browser; tests pass a fixed value) // in the browser; tests pass a fixed value)
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string { export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
if (hostOverride.length > 0) return hostOverride; if (hostOverride.length > 0) return hostOverride;
if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0') return inbound.listen; if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0' && !isUnixSocketListen(inbound.listen)) {
return inbound.listen;
}
return fallbackHostname; return fallbackHostname;
} }
@@ -123,7 +123,7 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
port, port,
id, id,
flow, flow,
encryption: (encryption === 'none' ? 'none' : 'none') as 'none', encryption: encryption || 'none',
reverseTag, reverseTag,
reverseSniffing, reverseSniffing,
testpre: asNumber(raw.testpre, 0), testpre: asNumber(raw.testpre, 0),
@@ -210,6 +210,7 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
reality.publicKey = params.get('pbk') ?? ''; reality.publicKey = params.get('pbk') ?? '';
reality.shortId = params.get('sid') ?? ''; reality.shortId = params.get('sid') ?? '';
reality.spiderX = params.get('spx') ?? ''; reality.spiderX = params.get('spx') ?? '';
reality.mldsa65Verify = params.get('pqv') ?? '';
} }
} }
@@ -403,6 +404,7 @@ export function parseHysteria2Link(link: string): Raw | null {
const address = url.hostname; const address = url.hostname;
const port = Number(url.port) || 443; const port = Number(url.port) || 443;
const params = url.searchParams; const params = url.searchParams;
const alpn = params.get('alpn');
const stream: Raw = { const stream: Raw = {
network: 'hysteria', network: 'hysteria',
security: 'tls', security: 'tls',
@@ -411,13 +413,14 @@ export function parseHysteria2Link(link: string): Raw | null {
}, },
tlsSettings: { tlsSettings: {
serverName: params.get('sni') ?? '', serverName: params.get('sni') ?? '',
alpn: ['h3'], alpn: alpn ? alpn.split(',') : ['h3'],
fingerprint: '', fingerprint: params.get('fp') ?? '',
echConfigList: '', echConfigList: params.get('ech') ?? '',
verifyPeerCertByName: '', verifyPeerCertByName: '',
pinnedPeerCertSha256: params.get('pinSHA256') ?? '', pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
}, },
}; };
applyFinalMaskParam(stream, params);
return { return {
protocol: 'hysteria', protocol: 'hysteria',
tag: decodeRemark(url), tag: decodeRemark(url),
+7
View File
@@ -769,6 +769,13 @@ export const sections: readonly Section[] = [
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}', body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}', 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}',
}, },
{
method: 'POST',
path: '/panel/api/nodes/certFingerprint',
summary: "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}',
response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}',
},
{ {
method: 'POST', method: 'POST',
path: '/panel/api/nodes/probe/:id', path: '/panel/api/nodes/probe/:id',
@@ -390,6 +390,8 @@ export default function ClientFormModal({
cancelText={t('cancel')} cancelText={t('cancel')}
okButtonProps={{ loading: submitting }} okButtonProps={{ loading: submitting }}
width={720} width={720}
style={{ top: 20 }}
styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
onOk={onSubmit} onOk={onSubmit}
onCancel={close} onCancel={close}
> >
@@ -44,12 +44,13 @@ export default function FallbacksCard({
value={record.childId} value={record.childId}
options={fallbackChildOptions} options={fallbackChildOptions}
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'} placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
allowClear
showSearch={{ showSearch={{
filterOption: (input, option) => filterOption: (input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()), ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}} }}
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={(v) => updateFallback(record.rowKey, { childId: v })} onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
/> />
<Button <Button
disabled={idx === 0} disabled={idx === 0}
@@ -161,6 +161,8 @@ export default function InboundFormModal({
const streamEnabled = canEnableStream({ protocol }); const streamEnabled = canEnableStream({ protocol });
const wPort = Form.useWatch('port', form); const wPort = Form.useWatch('port', form);
const wListen = (Form.useWatch('listen', form) ?? '') as string;
const isUdsListen = wListen.startsWith('/');
const wNodeId = Form.useWatch('nodeId', form) ?? null; const wNodeId = Form.useWatch('nodeId', form) ?? null;
const wTag = Form.useWatch('tag', form) ?? ''; const wTag = Form.useWatch('tag', form) ?? '';
const wSsNetwork = Form.useWatch(['settings', 'network'], form); const wSsNetwork = Form.useWatch(['settings', 'network'], form);
@@ -479,7 +481,11 @@ export default function InboundFormModal({
<Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} /> <Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="listen" label={t('pages.inbounds.address')}> <Form.Item
name="listen"
label={t('pages.inbounds.address')}
extra={t('pages.inbounds.form.listenHelp')}
>
<Input placeholder={t('pages.inbounds.monitorDesc')} /> <Input placeholder={t('pages.inbounds.monitorDesc')} />
</Form.Item> </Form.Item>
@@ -488,7 +494,7 @@ export default function InboundFormModal({
label={t('pages.inbounds.port')} label={t('pages.inbounds.port')}
rules={[antdRule(InboundFormBaseSchema.shape.port, t)]} rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
> >
<InputNumber min={1} max={65535} /> <InputNumber min={isUdsListen ? 0 : 1} max={65535} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -56,13 +56,6 @@ export default function RawForm() {
}} }}
</Form.Item> </Form.Item>
</Form.Item> </Form.Item>
{/* Per Xray docs (transports/raw.html#httpheaderobject), the
`request` object is honored only by outbound proxies; the
inbound listener reads `response`. Showing Host / Path /
Method / Version / request-headers on the inbound side was
a regression from this modal's earlier iteration those
inputs wrote to the wire but xray-core ignored them. The
inbound modal now only exposes the response side. */}
<Form.Item <Form.Item
noStyle noStyle
shouldUpdate={(prev, curr) => shouldUpdate={(prev, curr) =>
@@ -130,7 +130,7 @@ export default function SockoptForm({
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name={['streamSettings', 'sockopt', 'interfaceName']} name={['streamSettings', 'sockopt', 'interface']}
label={t('pages.inbounds.info.interfaceName')} label={t('pages.inbounds.info.interfaceName')}
> >
<Input /> <Input />
@@ -39,7 +39,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
}[]) }[])
.map((r) => ({ .map((r) => ({
rowKey: `fb-${++fallbackKeyRef.current}`, rowKey: `fb-${++fallbackKeyRef.current}`,
childId: r.childId, childId: r.childId && r.childId > 0 ? r.childId : null,
name: r.name || '', name: r.name || '',
alpn: r.alpn || '', alpn: r.alpn || '',
path: r.path || '', path: r.path || '',
@@ -52,7 +52,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
const saveFallbacks = async (masterId: number) => { const saveFallbacks = async (masterId: number) => {
if (!masterId) return true; if (!masterId) return true;
const payload = { const payload = {
fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({ fallbacks: fallbacks.filter((c) => c.childId || (c.dest ?? '').trim()).map((c, i) => ({
childId: c.childId, childId: c.childId,
name: c.name, name: c.name,
alpn: c.alpn, alpn: c.alpn,
@@ -26,6 +26,7 @@ interface NodeFormModalProps {
mode: Mode; mode: Mode;
node: NodeRecord | null; node: NodeRecord | null;
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>; testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>; save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@@ -42,6 +43,8 @@ function defaultValues(): NodeFormValues {
apiToken: '', apiToken: '',
enable: true, enable: true,
allowPrivateAddress: false, allowPrivateAddress: false,
tlsVerifyMode: 'verify',
pinnedCertSha256: '',
}; };
} }
@@ -50,6 +53,7 @@ export default function NodeFormModal({
mode, mode,
node, node,
testConnection, testConnection,
fetchFingerprint,
save, save,
onOpenChange, onOpenChange,
}: NodeFormModalProps) { }: NodeFormModalProps) {
@@ -59,7 +63,9 @@ export default function NodeFormModal({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [fetchingPin, setFetchingPin] = useState(false);
const [testResult, setTestResult] = useState<ProbeResult | null>(null); const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -94,6 +100,8 @@ export default function NodeFormModal({
apiToken: values.apiToken.trim(), apiToken: values.apiToken.trim(),
enable: values.enable, enable: values.enable,
allowPrivateAddress: values.allowPrivateAddress, allowPrivateAddress: values.allowPrivateAddress,
tlsVerifyMode: values.tlsVerifyMode,
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
}; };
} }
@@ -118,6 +126,27 @@ export default function NodeFormModal({
} }
} }
async function onFetchPin() {
try {
await form.validateFields(['address', 'port']);
} catch {
return;
}
setFetchingPin(true);
try {
const payload = buildPayload(form.getFieldsValue(true));
const msg = await fetchFingerprint(payload);
if (msg?.success && msg.obj) {
form.setFieldValue('pinnedCertSha256', msg.obj);
messageApi.success(t('pages.nodes.pinFetched'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
}
} finally {
setFetchingPin(false);
}
}
async function onFinish(values: NodeFormValues) { async function onFinish(values: NodeFormValues) {
const result = NodeFormSchema.safeParse(values); const result = NodeFormSchema.safeParse(values);
if (!result.success) { if (!result.success) {
@@ -233,6 +262,44 @@ export default function NodeFormModal({
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
extra={t('pages.nodes.tlsVerifyModeHint')}
>
<Select
options={[
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
]}
/>
</Form.Item>
{tlsVerifyMode === 'skip' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.tlsSkipWarning')}
/>
)}
{tlsVerifyMode === 'pin' && (
<Form.Item
label={t('pages.nodes.pinnedCert')}
name="pinnedCertSha256"
extra={t('pages.nodes.pinnedCertHint')}
>
<Input.Search
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
enterButton={t('pages.nodes.fetchPin')}
loading={fetchingPin}
onSearch={onFetchPin}
/>
</Form.Item>
)}
<Form.Item <Form.Item
label={t('pages.nodes.apiToken')} label={t('pages.nodes.apiToken')}
name="apiToken" name="apiToken"
+2 -1
View File
@@ -30,7 +30,7 @@ export default function NodesPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery(); const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations(); const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
const { data: latestVersion = '' } = useQuery({ const { data: latestVersion = '' } = useQuery({
queryKey: ['server', 'panelUpdateInfo'], queryKey: ['server', 'panelUpdateInfo'],
@@ -231,6 +231,7 @@ export default function NodesPage() {
mode={formMode} mode={formMode}
node={formNode} node={formNode}
testConnection={testConnection} testConnection={testConnection}
fetchFingerprint={fetchFingerprint}
save={onSave} save={onSave}
onOpenChange={setFormOpen} onOpenChange={setFormOpen}
/> />
+1 -1
View File
@@ -205,7 +205,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} /> onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}> <SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
<InputNumber value={allSetting.trafficDiff} min={0} style={{ width: '100%' }} <InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} /> onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
</SettingListItem> </SettingListItem>
</> </>
+1 -1
View File
@@ -168,7 +168,7 @@ export default function BasicsTab({
['statsInboundUplink', t('pages.xray.statsInboundUplink')], ['statsInboundUplink', t('pages.xray.statsInboundUplink')],
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')], ['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')], ['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
['statsOutboundDownlink', 'Outbound downlink stats'], ['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
].map(([field, label]) => ( ].map(([field, label]) => (
<SettingListItem <SettingListItem
key={field} key={field}
@@ -575,7 +575,9 @@ export default function OutboundFormModal({
{security === 'reality' && realityAllowed && <RealityForm />} {security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) || !streamAllowed) && <SockoptForm form={form} />} {((streamAllowed && network) || !streamAllowed) && (
<SockoptForm form={form} outboundTags={existingTags} />
)}
<FinalMaskForm <FinalMaskForm
name={['streamSettings', 'finalmask']} name={['streamSettings', 'finalmask']}
@@ -20,7 +20,7 @@ export default function TlsForm() {
<Select <Select
allowClear allowClear
placeholder={t('none')} placeholder={t('none')}
options={UTLS_OPTIONS} options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -47,6 +47,15 @@ export default function RawForm({ form }: { form: FormInstance<OutboundFormValue
</Form.Item> </Form.Item>
{type === 'http' && ( {type === 'http' && (
<> <>
<Form.Item
label={t('pages.inbounds.form.requestVersion')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'version',
]}
>
<Input placeholder="1.1" />
</Form.Item>
<Form.Item <Form.Item
label={t('pages.inbounds.form.requestMethod')} label={t('pages.inbounds.form.requestMethod')}
name={[ name={[
@@ -57,13 +66,19 @@ export default function RawForm({ form }: { form: FormInstance<OutboundFormValue
<Input placeholder="GET" /> <Input placeholder="GET" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('pages.inbounds.form.requestVersion')} label={t('pages.inbounds.form.requestPath')}
name={[ name={[
'streamSettings', 'tcpSettings', 'header', 'streamSettings', 'tcpSettings', 'header',
'request', 'version', 'request', 'path',
]} ]}
getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
getValueFromEvent={(e) => {
const raw = (e?.target?.value ?? '') as string;
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
return parts.length > 0 ? parts : ['/'];
}}
> >
<Input placeholder="1.1" /> <Input placeholder="/" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('pages.inbounds.form.requestHeaders')} label={t('pages.inbounds.form.requestHeaders')}
@@ -7,7 +7,13 @@ import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants'; import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
export default function SockoptForm({ form }: { form: FormInstance<OutboundFormValues> }) { export default function SockoptForm({
form,
outboundTags = [],
}: {
form: FormInstance<OutboundFormValues>;
outboundTags?: string[];
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Form.Item shouldUpdate noStyle> <Form.Item shouldUpdate noStyle>
@@ -16,6 +22,14 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
'streamSettings', 'streamSettings',
'sockopt', 'sockopt',
]); ]);
const dialerProxy = (form.getFieldValue([
'streamSettings',
'sockopt',
'dialerProxy',
]) ?? '') as string;
const dialerProxyOptions = Array.from(
new Set([...outboundTags, dialerProxy].filter(Boolean)),
).map((tg) => ({ value: tg, label: tg }));
return ( return (
<> <>
<Form.Item label={t('pages.xray.outboundForm.sockopts')}> <Form.Item label={t('pages.xray.outboundForm.sockopts')}>
@@ -34,8 +48,14 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
<Form.Item <Form.Item
label={t('pages.inbounds.form.dialerProxy')} label={t('pages.inbounds.form.dialerProxy')}
name={['streamSettings', 'sockopt', 'dialerProxy']} name={['streamSettings', 'sockopt', 'dialerProxy']}
tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
> >
<Input /> <Select
allowClear
showSearch
placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
options={dialerProxyOptions}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('pages.xray.wireguard.domainStrategy')} label={t('pages.xray.wireguard.domainStrategy')}
@@ -89,7 +109,7 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('pages.xray.outboundForm.interface')} label={t('pages.xray.outboundForm.interface')}
name={['streamSettings', 'sockopt', 'interfaceName']} name={['streamSettings', 'sockopt', 'interface']}
> >
<Input /> <Input />
</Form.Item> </Form.Item>
@@ -38,6 +38,7 @@ interface WarpConfig {
model?: string; model?: string;
enabled?: boolean; enabled?: boolean;
config?: { config?: {
client_id?: string;
interface?: { addresses?: { v4?: string; v6?: string } }; interface?: { addresses?: { v4?: string; v6?: string } };
peers?: { public_key?: string; endpoint?: { host?: string } }[]; peers?: { public_key?: string; endpoint?: { host?: string } }[];
}; };
@@ -99,7 +100,7 @@ export default function WarpModal({
mtu: 1420, mtu: 1420,
secretKey: data?.private_key, secretKey: data?.private_key,
address: addressesFor(cfg.interface?.addresses || {}), address: addressesFor(cfg.interface?.addresses || {}),
reserved: reservedFor(data?.client_id), reserved: reservedFor(cfg.client_id ?? data?.client_id),
domainStrategy: 'ForceIP', domainStrategy: 'ForceIP',
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }], peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
noKernelTun: false, noKernelTun: false,
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { PortSchema, SniffingSchema } from '@/schemas/primitives'; import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
import { InboundSettingsSchema } from '@/schemas/protocols/inbound'; import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
import { SecuritySettingsSchema } from '@/schemas/protocols/security'; import { SecuritySettingsSchema } from '@/schemas/protocols/security';
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream'; import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
@@ -32,7 +32,7 @@ export const InboundCoreSchema = z.object({
enable: z.boolean().default(true), enable: z.boolean().default(true),
expiryTime: z.number().int().default(0), expiryTime: z.number().int().default(0),
listen: z.string().default(''), listen: z.string().default(''),
port: PortSchema, port: InboundPortSchema,
tag: z.string().default(''), tag: z.string().default(''),
sniffing: SniffingSchema.default({ sniffing: SniffingSchema.default({
enabled: false, enabled: false,
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { PortSchema, SniffingSchema } from '@/schemas/primitives'; import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
import { InboundSettingsSchema } from '@/schemas/protocols/inbound'; import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
import { SecuritySettingsSchema } from '@/schemas/protocols/security'; import { SecuritySettingsSchema } from '@/schemas/protocols/security';
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream'; import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
@@ -44,7 +44,7 @@ export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
export const InboundFormBaseSchema = z.object({ export const InboundFormBaseSchema = z.object({
remark: z.string().default(''), remark: z.string().default(''),
enable: z.boolean().default(true), enable: z.boolean().default(true),
port: PortSchema, port: InboundPortSchema,
listen: z.string().default(''), listen: z.string().default(''),
tag: z.string().default(''), tag: z.string().default(''),
expiryTime: z.number().int().default(0), expiryTime: z.number().int().default(0),
+4
View File
@@ -24,6 +24,8 @@ export const NodeRecordSchema = z.object({
lastHeartbeat: z.number().optional(), lastHeartbeat: z.number().optional(),
lastError: z.string().optional(), lastError: z.string().optional(),
allowPrivateAddress: z.boolean().optional(), allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(),
}).loose(); }).loose();
export const NodeListSchema = z.array(NodeRecordSchema); export const NodeListSchema = z.array(NodeRecordSchema);
@@ -46,6 +48,8 @@ export const NodeFormSchema = z.object({
apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
enable: z.boolean(), enable: z.boolean(),
allowPrivateAddress: z.boolean(), allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
pinnedCertSha256: z.string(),
}); });
export type NodeRecord = z.infer<typeof NodeRecordSchema>; export type NodeRecord = z.infer<typeof NodeRecordSchema>;
+3
View File
@@ -2,3 +2,6 @@ import { z } from 'zod';
export const PortSchema = z.number().int().min(1).max(65535); export const PortSchema = z.number().int().min(1).max(65535);
export type Port = z.infer<typeof PortSchema>; export type Port = z.infer<typeof PortSchema>;
export const InboundPortSchema = z.number().int().min(0).max(65535);
export type InboundPort = z.infer<typeof InboundPortSchema>;
@@ -65,7 +65,7 @@ export const SockoptStreamSettingsSchema = z.object({
tcpcongestion: TcpCongestionSchema.default('bbr'), tcpcongestion: TcpCongestionSchema.default('bbr'),
V6Only: z.boolean().default(false), V6Only: z.boolean().default(false),
tcpWindowClamp: z.number().int().min(0).default(600), tcpWindowClamp: z.number().int().min(0).default(600),
interfaceName: z.string().default(''), interface: z.string().default(''),
trustedXForwardedFor: z.array(z.string()).default([]), trustedXForwardedFor: z.array(z.string()).default([]),
addressPortStrategy: AddressPortStrategySchema.default('none'), addressPortStrategy: AddressPortStrategySchema.default('none'),
happyEyeballs: HappyEyeballsSchema.optional(), happyEyeballs: HappyEyeballsSchema.optional(),
+1 -1
View File
@@ -16,7 +16,7 @@ export const AllSettingSchema = z.object({
panelProxy: z.string().optional(), panelProxy: z.string().optional(),
pageSize: z.number().int().min(1).max(1000).optional(), pageSize: z.number().int().min(1).max(1000).optional(),
expireDiff: nonNegativeInt.optional(), expireDiff: nonNegativeInt.optional(),
trafficDiff: nonNegativeInt.optional(), trafficDiff: nonNegativeInt.max(100).optional(),
remarkModel: z.string().optional(), remarkModel: z.string().optional(),
datepicker: z.enum(['gregorian', 'jalalian']).optional(), datepicker: z.enum(['gregorian', 'jalalian']).optional(),
tgBotEnable: z.boolean().optional(), tgBotEnable: z.boolean().optional(),
-1
View File
@@ -45,7 +45,6 @@
.nodes-page .ant-card.ant-card-hoverable:hover, .nodes-page .ant-card.ant-card-hoverable:hover,
.groups-page .ant-card.ant-card-hoverable:hover, .groups-page .ant-card.ant-card-hoverable:hover,
.api-docs-page .ant-card.ant-card-hoverable:hover { .api-docs-page .ant-card.ant-card-hoverable:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
} }
@@ -8,7 +8,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
"customSockopt": [], "customSockopt": [],
"dialerProxy": "", "dialerProxy": "",
"domainStrategy": "AsIs", "domainStrategy": "AsIs",
"interfaceName": "", "interface": "",
"mark": 0, "mark": 0,
"penetrate": false, "penetrate": false,
"tcpFastOpen": false, "tcpFastOpen": false,
@@ -32,7 +32,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses full byte-stably 1`] = `
"customSockopt": [], "customSockopt": [],
"dialerProxy": "out-proxy-tag", "dialerProxy": "out-proxy-tag",
"domainStrategy": "UseIP", "domainStrategy": "UseIP",
"interfaceName": "eth0", "interface": "eth0",
"mark": 100, "mark": 100,
"penetrate": false, "penetrate": false,
"tcpFastOpen": true, "tcpFastOpen": true,
@@ -59,7 +59,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tcp-tuning byte-stably 1`
"customSockopt": [], "customSockopt": [],
"dialerProxy": "", "dialerProxy": "",
"domainStrategy": "AsIs", "domainStrategy": "AsIs",
"interfaceName": "", "interface": "",
"mark": 0, "mark": 0,
"penetrate": false, "penetrate": false,
"tcpFastOpen": true, "tcpFastOpen": true,
@@ -83,7 +83,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
"customSockopt": [], "customSockopt": [],
"dialerProxy": "", "dialerProxy": "",
"domainStrategy": "ForceIPv4", "domainStrategy": "ForceIPv4",
"interfaceName": "", "interface": "",
"mark": 255, "mark": 255,
"penetrate": true, "penetrate": true,
"tcpFastOpen": false, "tcpFastOpen": false,
@@ -14,6 +14,6 @@
"tcpcongestion": "cubic", "tcpcongestion": "cubic",
"V6Only": false, "V6Only": false,
"tcpWindowClamp": 600, "tcpWindowClamp": 600,
"interfaceName": "eth0", "interface": "eth0",
"trustedXForwardedFor": ["10.0.0.0/8", "192.168.0.0/16"] "trustedXForwardedFor": ["10.0.0.0/8", "192.168.0.0/16"]
} }
+13
View File
@@ -196,6 +196,19 @@ describe('resolveAddr precedence', () => {
)).toBe('fallback.test'); )).toBe('fallback.test');
}); });
it('skips a unix socket path listen and falls through to fallbackHostname', () => {
expect(resolveAddr(
{ ...baseInbound, listen: '/run/xray/in.sock' } as never,
'',
'fallback.test',
)).toBe('fallback.test');
expect(resolveAddr(
{ ...baseInbound, listen: '@xray-abstract' } as never,
'',
'fallback.test',
)).toBe('fallback.test');
});
it('falls through to fallbackHostname when listen is empty', () => { it('falls through to fallbackHostname when listen is empty', () => {
expect(resolveAddr( expect(resolveAddr(
baseInbound as never, baseInbound as never,
@@ -74,6 +74,25 @@ describe('outbound-form-adapter: round-trip', () => {
}); });
}); });
it('vless preserves a non-none encryption value (post-quantum)', () => {
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
const wire = {
protocol: 'vless',
settings: {
address: 'srv',
port: 443,
id: '11111111-2222-4333-8444-555555555555',
flow: '',
encryption: enc,
},
};
const form = rawOutboundToFormValues(wire);
if (form.protocol === 'vless') {
expect(form.settings.encryption).toBe(enc);
}
expect((formValuesToWirePayload(form).settings as Record<string, unknown>).encryption).toBe(enc);
});
it('vless emits reverse + sniffing when reverseTag is set', () => { it('vless emits reverse + sniffing when reverseTag is set', () => {
const wire = { const wire = {
protocol: 'vless', protocol: 'vless',
@@ -173,6 +173,23 @@ describe('parseVlessLink', () => {
expect(reality.shortId).toBe('abcd'); expect(reality.shortId).toBe('abcd');
expect(reality.serverName).toBe('cloudflare.com'); expect(reality.serverName).toBe('cloudflare.com');
}); });
it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => {
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
const pqv = 'GIsemxbGPjDRH1ONfmoGlVkJ4etNuLmYDvzpjmFFreDLd8WjoJxJ4Fmt_NQJaC6';
const link
= 'vless://9406c224-8ac6-4675-ae0b-f93785959418@localhost:1121'
+ `?encryption=${enc}&pqv=${pqv}`
+ '&security=reality&sid=29cf418813d5bac7&sni=aws.amazon.com'
+ '&pbk=aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I&fp=chrome&spx=%2FOUTjB7xHRiP4zBP&type=tcp'
+ '#giqssbgmo9';
const out = parseVlessLink(link);
const settings = out?.settings as { encryption: string };
expect(settings.encryption).toBe(enc);
const reality = (out?.streamSettings as Record<string, unknown>).realitySettings as Record<string, unknown>;
expect(reality.mldsa65Verify).toBe(pqv);
expect(reality.publicKey).toBe('aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I');
});
}); });
describe('parseTrojanLink', () => { describe('parseTrojanLink', () => {
@@ -250,6 +267,32 @@ describe('parseHysteria2Link', () => {
const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com'); const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com');
expect(out?.protocol).toBe('hysteria'); expect(out?.protocol).toBe('hysteria');
}); });
it('parses alpn, fingerprint and the salamander UDP mask (fm) — #4760', () => {
const link = 'hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?'
+ 'alpn=h2%2Chttp%2F1.1&'
+ 'fm=%7B%22udp%22%3A%5B%7B%22settings%22%3A%7B%22password%22%3A%22ftwfgb9655hh2mgo%22%7D%2C%22type%22%3A%22salamander%22%7D%5D%7D&'
+ 'fp=chrome&obfs=salamander&obfs-password=655hh2mgo&security=tls&sni=news.domain.org'
+ '#hy2-ej596ty350qs';
const out = parseHysteria2Link(link);
expect(out).not.toBeNull();
const stream = out!.streamSettings as Record<string, unknown>;
const tls = stream.tlsSettings as Record<string, unknown>;
expect(tls.alpn).toEqual(['h2', 'http/1.1']);
expect(tls.fingerprint).toBe('chrome');
expect(tls.serverName).toBe('news.domain.org');
const finalmask = stream.finalmask as Record<string, unknown>;
expect(finalmask).toBeDefined();
const udp = finalmask.udp as Array<Record<string, unknown>>;
expect(udp[0].type).toBe('salamander');
expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
});
it('defaults alpn to h3 when the link omits it', () => {
const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
expect(tls.alpn).toEqual(['h3']);
});
}); });
describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => { describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+34
View File
@@ -60,6 +60,8 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
return "", "", nil return "", "", nil
} }
ensureUniqueProxyNames(proxies)
emails := make([]string, 0, len(seenEmails)) emails := make([]string, 0, len(seenEmails))
for e := range seenEmails { for e := range seenEmails {
emails = append(emails, e) emails = append(emails, e)
@@ -93,6 +95,38 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
return string(finalYAML), header, nil return string(finalYAML), header, nil
} }
// ensureUniqueProxyNames keeps every proxy "name" non-empty and unique:
// mihomo rejects the whole config on a duplicate name (the empty string
// genRemark returns for a remark-less inbound counts), vanishing the Clash
// profile on refresh. See issue #4641.
func ensureUniqueProxyNames(proxies []map[string]any) {
seen := make(map[string]struct{}, len(proxies))
for i, proxy := range proxies {
base, _ := proxy["name"].(string)
if base == "" {
base = fallbackProxyName(proxy, i)
}
name := base
for n := 2; ; n++ {
if _, dup := seen[name]; !dup {
break
}
name = fmt.Sprintf("%s-%d", base, n)
}
seen[name] = struct{}{}
proxy["name"] = name
}
}
func fallbackProxyName(proxy map[string]any, idx int) string {
typ, _ := proxy["type"].(string)
server, _ := proxy["server"].(string)
if typ != "" && server != "" {
return fmt.Sprintf("%s-%s-%v", typ, server, proxy["port"])
}
return fmt.Sprintf("proxy-%d", idx+1)
}
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any { func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
stream := s.streamData(inbound.StreamSettings) stream := s.streamData(inbound.StreamSettings)
// For node-managed inbounds the Clash proxy "server" must be the // For node-managed inbounds the Clash proxy "server" must be the
+34
View File
@@ -5,6 +5,40 @@ import (
"testing" "testing"
) )
func TestEnsureUniqueProxyNames(t *testing.T) {
proxies := []map[string]any{
{"name": "", "type": "vless", "server": "a.com", "port": 443},
{"name": "", "type": "vmess", "server": "b.com", "port": 8443},
{"name": "node"},
{"name": "node"},
{"name": ""},
}
ensureUniqueProxyNames(proxies)
seen := map[string]bool{}
for i, p := range proxies {
name, _ := p["name"].(string)
if name == "" {
t.Fatalf("proxy %d still has an empty name (mihomo would reject the config, #4641)", i)
}
if seen[name] {
t.Fatalf("proxy %d has duplicate name %q (mihomo rejects the whole config, #4641)", i, name)
}
seen[name] = true
}
if got := proxies[0]["name"]; got != "vless-a.com-443" {
t.Errorf("empty name fallback = %q, want vless-a.com-443", got)
}
if proxies[2]["name"] == proxies[3]["name"] {
t.Errorf("duplicate %q was not disambiguated", proxies[2]["name"])
}
if got := proxies[4]["name"]; got != "proxy-5" {
t.Errorf("typeless empty name fallback = %q, want proxy-5", got)
}
}
func TestApplyTransport_XHTTP(t *testing.T) { func TestApplyTransport_XHTTP(t *testing.T) {
svc := &SubClashService{} svc := &SubClashService{}
proxy := map[string]any{} proxy := map[string]any{}
+4
View File
@@ -277,6 +277,10 @@ func (a *SUBController) subClashs(c *gin.Context) {
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
} }
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules) a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
if a.subTitle != "" {
// Clash clients commonly use Content-Disposition to choose the imported profile name.
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, a.subTitle))
}
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub)) c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
} }
} }
+41 -11
View File
@@ -158,34 +158,61 @@ func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraff
if len(emails) == 0 { if len(emails) == 0 {
return agg, 0 return agg, 0
} }
db := database.GetDB()
var rows []xray.ClientTraffic var rows []xray.ClientTraffic
if err := database.GetDB(). if err := db.
Model(&xray.ClientTraffic{}). Model(&xray.ClientTraffic{}).
Where("email IN ?", emails). Where("email IN ?", emails).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
logger.Warning("SubService - AggregateTrafficByEmails: load by email:", err) logger.Warning("SubService - AggregateTrafficByEmails: load by email:", err)
return agg, 0 return agg, 0
} }
// total/expiry are configured limits owned by the clients table, not the
// runtime traffic rows. In a multi-node setup the node snapshot can reset
// client_traffics.total/expiry_time to 0, so fall back to the clients
// table to keep the Subscription-Userinfo header in sync with the UI (#4645).
limits := make(map[string][2]int64, len(emails))
var records []model.ClientRecord
if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Find(&records).Error; err != nil {
logger.Warning("SubService - AggregateTrafficByEmails: load client limits:", err)
} else {
for _, r := range records {
limits[r.Email] = [2]int64{r.TotalGB, r.ExpiryTime}
}
}
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
for i, ct := range rows { first := true
for _, ct := range rows {
if ct.LastOnline > lastOnline { if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline lastOnline = ct.LastOnline
} }
if i == 0 { total, expiry := ct.Total, ct.ExpiryTime
if lim, ok := limits[ct.Email]; ok {
if total == 0 {
total = lim[0]
}
if expiry == 0 {
expiry = lim[1]
}
}
if first {
agg.Up = ct.Up agg.Up = ct.Up
agg.Down = ct.Down agg.Down = ct.Down
agg.Total = ct.Total agg.Total = total
agg.ExpiryTime = subscriptionExpiryFromClient(now, ct.ExpiryTime) agg.ExpiryTime = subscriptionExpiryFromClient(now, expiry)
first = false
continue continue
} }
agg.Up += ct.Up agg.Up += ct.Up
agg.Down += ct.Down agg.Down += ct.Down
if agg.Total == 0 || ct.Total == 0 { if agg.Total == 0 || total == 0 {
agg.Total = 0 agg.Total = 0
} else { } else {
agg.Total += ct.Total agg.Total += total
} }
normalized := subscriptionExpiryFromClient(now, ct.ExpiryTime) normalized := subscriptionExpiryFromClient(now, expiry)
if normalized != agg.ExpiryTime { if normalized != agg.ExpiryTime {
agg.ExpiryTime = 0 agg.ExpiryTime = 0
} }
@@ -576,11 +603,14 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) params["fp"], _ = fpValue.(string)
} }
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
if insecure.(bool) { if ech, _ := echValue.(string); ech != "" {
params["insecure"] = "1" params["ech"] = ech
} }
} }
if pins, ok := pinnedSha256List(tlsSettings); ok {
params["pinSHA256"] = strings.Join(pins, ",")
}
} }
// salamander obfs (Hysteria2). The panel-side link generator already // salamander obfs (Hysteria2). The panel-side link generator already
+56
View File
@@ -0,0 +1,56 @@
package sub
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
)
func TestAggregateTrafficByEmails_FallsBackToClientLimits(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const email = "node-client@example.com"
const totalBytes = int64(300) * 1024 * 1024 * 1024
const expiry = int64(1893456000000)
db := database.GetDB()
if err := db.Create(&model.ClientRecord{
Email: email,
TotalGB: totalBytes,
ExpiryTime: expiry,
Enable: true,
}).Error; err != nil {
t.Fatalf("seed client record: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
Email: email,
Up: 111,
Down: 222,
Total: 0,
ExpiryTime: 0,
Enable: true,
}).Error; err != nil {
t.Fatalf("seed client traffic: %v", err)
}
var s SubService
agg, _ := s.AggregateTrafficByEmails([]string{email})
if agg.Up != 111 || agg.Down != 222 {
t.Errorf("usage = up %d/down %d, want 111/222", agg.Up, agg.Down)
}
if agg.Total != totalBytes {
t.Errorf("total = %d, want %d (fallback to clients table)", agg.Total, totalBytes)
}
if agg.ExpiryTime != expiry {
t.Errorf("expiry = %d, want %d (fallback to clients table)", agg.ExpiryTime, expiry)
}
}
+5
View File
@@ -15,3 +15,8 @@ func HashPasswordAsBcrypt(password string) (string, error) {
func CheckPasswordHash(hash, password string) bool { func CheckPasswordHash(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
} }
func IsHashed(s string) bool {
_, err := bcrypt.Cost([]byte(s))
return err == nil
}
+24
View File
@@ -34,6 +34,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.POST("/setEnable/:id", a.setEnable) g.POST("/setEnable/:id", a.setEnable)
g.POST("/test", a.test) g.POST("/test", a.test)
g.POST("/certFingerprint", a.certFingerprint)
g.POST("/probe/:id", a.probe) g.POST("/probe/:id", a.probe)
g.POST("/updatePanel", a.updatePanel) g.POST("/updatePanel", a.updatePanel)
g.GET("/history/:id/:metric/:bucket", a.history) g.GET("/history/:id/:metric/:bucket", a.history)
@@ -143,6 +144,29 @@ func (a *NodeController) test(c *gin.Context) {
jsonObj(c, patch.ToUI(err == nil), nil) jsonObj(c, patch.ToUI(err == nil), nil)
} }
func (a *NodeController) certFingerprint(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
if n.Scheme == "" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
jsonObj(c, fp, nil)
}
func (a *NodeController) probe(c *gin.Context) { func (a *NodeController) probe(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
+29 -19
View File
@@ -41,8 +41,8 @@ const defaultXrayAPIPort = 62789
// //
// Without this eviction, an IP that connected once and then went away // Without this eviction, an IP that connected once and then went away
// keeps sitting in the table with its old timestamp. Because the // keeps sitting in the table with its old timestamp. Because the
// excess-IP selector sorts ascending ("oldest wins, newest loses") to // excess-IP selector sorts ascending ("newest wins, oldest loses") to
// protect the original/current connections, that stale entry keeps // protect the most recent connections, that stale entry keeps
// occupying a slot and the IP that is *actually* currently using the // occupying a slot and the IP that is *actually* currently using the
// config gets classified as "new excess" and banned by fail2ban on // config gets classified as "new excess" and banned by fail2ban on
// every single run — producing the continuous ban loop from #4077. // every single run — producing the continuous ban loop from #4077.
@@ -66,8 +66,12 @@ func (j *CheckClientIpJob) Run() {
} }
shouldClearAccessLog := false shouldClearAccessLog := false
iplimitActive := j.hasLimitIp() fail2BanEnabled := isFail2BanEnabled()
f2bInstalled := j.checkFail2BanInstalled() iplimitActive := fail2BanEnabled && j.hasLimitIp()
f2bInstalled := false
if iplimitActive {
f2bInstalled = j.checkFail2BanInstalled()
}
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive) isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
if isAccessLogAvailable { if isAccessLogAvailable {
@@ -80,9 +84,7 @@ func (j *CheckClientIpJob) Run() {
if f2bInstalled { if f2bInstalled {
shouldClearAccessLog = j.processLogFile() shouldClearAccessLog = j.processLogFile()
} else { } else {
if !f2bInstalled { logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
}
} }
} }
} }
@@ -255,15 +257,13 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
// //
// only live ips count toward the per-client limit. historical ones stay // only live ips count toward the per-client limit. historical ones stay
// in the db so the panel keeps showing them, but they must not take a // in the db so the panel keeps showing them, but they must not take a
// protected slot. the 30min cutoff alone isn't tight enough: an ip that // live slot or get re-banned. the 30min cutoff alone isn't tight enough:
// stopped connecting a few minutes ago still looks fresh to // an ip that stopped connecting a few minutes ago still looks fresh to
// mergeClientIps, and since the over-limit picker sorts ascending and // mergeClientIps, and without this split it would keep triggering
// keeps the oldest, those idle entries used to win the slot while the // fail2ban even though it isn't currently connected. see #4077 / #4091.
// ip actually connecting got classified as excess and sent to fail2ban
// every tick. see #4077 / #4091.
// //
// live is sorted ascending so the "protect original, ban newcomer" // live is sorted ascending by timestamp (oldest → newest), so we keep
// rule still holds when several ips are really connecting at once. // the most recent entries at the end of the slice (last IP wins).
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) { func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
live = make([]IPWithTimestamp, 0, len(observedThisScan)) live = make([]IPWithTimestamp, 0, len(observedThisScan))
historical = make([]IPWithTimestamp, 0, len(ipMap)) historical = make([]IPWithTimestamp, 0, len(ipMap))
@@ -281,12 +281,21 @@ func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool)
} }
func (j *CheckClientIpJob) checkFail2BanInstalled() bool { func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
if !isFail2BanEnabled() {
return false
}
cmd := "fail2ban-client" cmd := "fail2ban-client"
args := []string{"-h"} args := []string{"-h"}
err := exec.Command(cmd, args...).Run() err := exec.Command(cmd, args...).Run()
return err == nil return err == nil
} }
func isFail2BanEnabled() bool {
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
return !ok || value == "true"
}
func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool { func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
accessLogPath, err := xray.GetAccessLogPath() accessLogPath, err := xray.GetAccessLogPath()
if err != nil { if err != nil {
@@ -408,9 +417,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
if len(liveIps) > limitIp { if len(liveIps) > limitIp {
shouldCleanLog = true shouldCleanLog = true
// protect the oldest live ip, ban newcomers. // keep the newest live ips, ban older ones.
keptLive = liveIps[:limitIp] cutoff := len(liveIps) - limitIp
bannedLive := liveIps[limitIp:] keptLive = liveIps[cutoff:]
bannedLive := liveIps[:cutoff]
// Open log file only when a ban entry needs to be written. // Open log file only when a ban entry needs to be written.
// Use a local logger to avoid mutating the global log.* state, // Use a local logger to avoid mutating the global log.* state,
@@ -456,7 +466,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
} }
if len(j.disAllowedIps) > 0 { if len(j.disAllowedIps) > 0 {
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d new IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps)) logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
} }
return shouldCleanLog return shouldCleanLog
+54 -10
View File
@@ -128,6 +128,49 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
return out return out
} }
func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
setupIntegrationDB(t)
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t)
const email = "disabled-fail2ban"
seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
binDir := t.TempDir()
accessLog := filepath.Join(t.TempDir(), "access.log")
t.Setenv("XUI_BIN_FOLDER", binDir)
configData, err := json.Marshal(map[string]any{
"log": map[string]any{"access": accessLog},
})
if err != nil {
t.Fatalf("marshal xray config: %v", err)
}
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
t.Fatalf("write xray config: %v", err)
}
if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
t.Fatalf("write access log: %v", err)
}
j := NewCheckClientIpJob()
j.Run()
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
body, _ := os.ReadFile(readIpLimitLogPath())
t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
}
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
t.Fatalf("count InboundClientIps: %v", err)
}
if count != 0 {
t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
}
}
// #4091 repro: client has limit=3, db still holds 3 idle ips from a // #4091 repro: client has limit=3, db still holds 3 idle ips from a
// few minutes ago, only one live ip is actually connecting. pre-fix: // few minutes ago, only one live ip is actually connecting. pre-fix:
// live ip got banned every tick and never appeared in the panel. // live ip got banned every tick and never appeared in the panel.
@@ -179,7 +222,8 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
} }
// opposite invariant: when several ips are actually live and exceed // opposite invariant: when several ips are actually live and exceed
// the limit, the newcomer still gets banned. // the limit, the oldest connection is dropped and the most recent one
// keeps the slot (last-IP-wins policy from #3735, restored in #4699).
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) { func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
setupIntegrationDB(t) setupIntegrationDB(t)
@@ -193,8 +237,8 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
j := NewCheckClientIpJob() j := NewCheckClientIpJob()
// both live, limit=1. use distinct timestamps so sort-by-timestamp // both live, limit=1. use distinct timestamps so sort-by-timestamp
// is deterministic: 10.1.0.1 is the original (older), 192.0.2.9 // is deterministic: 10.1.0.1 is the original (older) and must get
// joined later and must get banned. // banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
live := []IPWithTimestamp{ live := []IPWithTimestamp{
{IP: "10.1.0.1", Timestamp: now - 5}, {IP: "10.1.0.1", Timestamp: now - 5},
{IP: "192.0.2.9", Timestamp: now}, {IP: "192.0.2.9", Timestamp: now},
@@ -205,16 +249,16 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
if !shouldCleanLog { if !shouldCleanLog {
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit") t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
} }
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "192.0.2.9" { if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
t.Fatalf("expected 192.0.2.9 to be banned; disAllowedIps = %v", j.disAllowedIps) t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
} }
persisted := ipSet(readClientIps(t, email)) persisted := ipSet(readClientIps(t, email))
if _, ok := persisted["10.1.0.1"]; !ok { if _, ok := persisted["192.0.2.9"]; !ok {
t.Errorf("original IP 10.1.0.1 must still be persisted; got %v", persisted) t.Errorf("newest IP 192.0.2.9 must still be persisted; got %v", persisted)
} }
if _, ok := persisted["192.0.2.9"]; ok { if _, ok := persisted["10.1.0.1"]; ok {
t.Errorf("banned IP 192.0.2.9 must NOT be persisted; got %v", persisted) t.Errorf("banned IP 10.1.0.1 must NOT be persisted; got %v", persisted)
} }
// 3xipl.log must contain the ban line in the exact fail2ban format. // 3xipl.log must contain the ban line in the exact fail2ban format.
@@ -222,7 +266,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read 3xipl.log: %v", err) t.Fatalf("read 3xipl.log: %v", err)
} }
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 192.0.2.9" wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 10.1.0.1"
if !contains(string(body), wantSubstr) { if !contains(string(body), wantSubstr) {
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body) t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
} }
+79 -3
View File
@@ -1,7 +1,10 @@
package job package job
import ( import (
"os"
"path/filepath"
"reflect" "reflect"
"runtime"
"testing" "testing"
) )
@@ -107,9 +110,10 @@ func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing
} }
} }
func TestPartitionLiveIps_ConcurrentLiveIpsStillBanNewcomers(t *testing.T) { func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) {
// keep the "protect original, ban newcomer" policy when several ips // when several ips are really live, partition returns them all in the
// are really live. with limit=1, A must stay and B must be banned. // live set sorted ascending by timestamp. updateInboundClientIps then
// keeps the newest and bans the oldest (last-IP-wins, #4699).
ipMap := map[string]int64{ ipMap := map[string]int64{
"A": 5000, "A": 5000,
"B": 5500, "B": 5500,
@@ -144,3 +148,75 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got) t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
} }
} }
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t)
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN=false")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
}
func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "")
marker := fakeFail2BanClient(t)
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN is empty")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
}
func TestIsFail2BanEnabled_DefaultsToEnabledWhenUnset(t *testing.T) {
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
t.Cleanup(func() {
if ok {
os.Setenv("XUI_ENABLE_FAIL2BAN", value)
} else {
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
}
})
if !isFail2BanEnabled() {
t.Fatal("fail2ban should default to enabled when XUI_ENABLE_FAIL2BAN is unset")
}
}
func TestCheckFail2BanInstalled_EnabledEnvProbesClient(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
marker := fakeFail2BanClient(t)
if !(&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be available when the client probe succeeds")
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("fail2ban-client should have been executed: %v", err)
}
}
func fakeFail2BanClient(t *testing.T) string {
t.Helper()
dir := t.TempDir()
marker := filepath.Join(dir, "probe-called")
fakeClient := filepath.Join(dir, "fail2ban-client")
script := "#!/bin/sh\n: > \"$FAIL2BAN_PROBE_MARKER\"\nexit 0\n"
if runtime.GOOS == "windows" {
fakeClient += ".bat"
script = "@echo off\ntype nul > \"%FAIL2BAN_PROBE_MARKER%\"\nexit /b 0\n"
}
if err := os.WriteFile(fakeClient, []byte(script), 0o755); err != nil {
t.Fatalf("write fake fail2ban-client: %v", err)
}
t.Setenv("FAIL2BAN_PROBE_MARKER", marker)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return marker
}
+244
View File
@@ -0,0 +1,244 @@
package service
import (
"encoding/json"
"path/filepath"
"sort"
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func setupBulkDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
}
func clientsSettings(t *testing.T, clients []model.Client) string {
t.Helper()
b, err := json.Marshal(map[string][]model.Client{"clients": clients})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
return string(b)
}
func emailsOf(clients []model.Client) []string {
out := make([]string, 0, len(clients))
for _, c := range clients {
out = append(out, c.Email)
}
return out
}
func sortedEmails(list []model.Client) []string {
out := emailsOf(list)
sort.Strings(out)
return out
}
func mkInbound(t *testing.T, port int, proto model.Protocol, settings string) *model.Inbound {
t.Helper()
ib := &model.Inbound{
Tag: string(proto) + "-" + filepath.Base(t.TempDir()),
Enable: true,
Port: port,
Protocol: proto,
Settings: settings,
}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound %d: %v", port, err)
}
return ib
}
// TestBulkAttachDetach_VLESS exercises the batched attach/detach round-trip on
// VLESS inbounds: linkage, settings JSON, idempotency, skip, and record survival.
func TestBulkAttachDetach_VLESS(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib1 := mkInbound(t, 20001, model.VLESS, clientsSettings(t, source))
ib2 := mkInbound(t, 20002, model.VLESS, `{"clients":[]}`)
ib3 := mkInbound(t, 20003, model.VLESS, `{"clients":[]}`)
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
t.Fatalf("seed source linkage: %v", err)
}
emails := emailsOf(source)
res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
if err != nil {
t.Fatalf("BulkAttach: %v", err)
}
if len(res.Errors) != 0 {
t.Fatalf("BulkAttach errors: %v", res.Errors)
}
if len(res.Skipped) != 0 {
t.Fatalf("BulkAttach skipped unexpectedly: %v", res.Skipped)
}
if len(res.Attached) != 6 {
t.Fatalf("expected 6 attach entries (3 clients x 2 inbounds), got %d: %v", len(res.Attached), res.Attached)
}
for _, ib := range []*model.Inbound{ib2, ib3} {
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound(%d): %v", ib.Id, err)
}
if got := sortedEmails(list); len(got) != 3 {
t.Fatalf("inbound %d: expected 3 linked clients, got %v", ib.Id, got)
}
reloaded, err := inboundSvc.GetInbound(ib.Id)
if err != nil {
t.Fatalf("GetInbound(%d): %v", ib.Id, err)
}
jsonClients, err := inboundSvc.GetClients(reloaded)
if err != nil {
t.Fatalf("GetClients(%d): %v", ib.Id, err)
}
if len(jsonClients) != 3 {
t.Fatalf("inbound %d settings JSON: expected 3 clients, got %d", ib.Id, len(jsonClients))
}
}
res2, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
if err != nil {
t.Fatalf("BulkAttach (idempotent): %v", err)
}
if len(res2.Attached) != 0 {
t.Fatalf("re-attach should add nothing, got Attached=%v", res2.Attached)
}
if len(res2.Skipped) != 6 {
t.Fatalf("re-attach should skip all 6, got Skipped=%v", res2.Skipped)
}
dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
if err != nil {
t.Fatalf("BulkDetach: %v", err)
}
if len(dres.Errors) != 0 {
t.Fatalf("BulkDetach errors: %v", dres.Errors)
}
if len(dres.Detached) != 3 {
t.Fatalf("expected 3 detached emails, got %v", dres.Detached)
}
for _, ib := range []*model.Inbound{ib2, ib3} {
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound after detach(%d): %v", ib.Id, err)
}
if len(list) != 0 {
t.Fatalf("inbound %d should have no clients after detach, got %v", ib.Id, sortedEmails(list))
}
reloaded, _ := inboundSvc.GetInbound(ib.Id)
jsonClients, _ := inboundSvc.GetClients(reloaded)
if len(jsonClients) != 0 {
t.Fatalf("inbound %d settings JSON should be empty after detach, got %d", ib.Id, len(jsonClients))
}
}
for _, e := range emails {
rec, err := svc.GetRecordByEmail(nil, e)
if err != nil {
t.Fatalf("record %q should survive detach: %v", e, err)
}
ids, err := svc.GetInboundIdsForRecord(rec.Id)
if err != nil {
t.Fatalf("GetInboundIdsForRecord(%q): %v", e, err)
}
if len(ids) != 1 || ids[0] != ib1.Id {
t.Fatalf("record %q should remain attached only to source inbound %d, got %v", e, ib1.Id, ids)
}
}
}
// TestBulkDetach_SkipsUnattached verifies emails not on any requested inbound
// land in Skipped, not Detached, and produce no error.
func TestBulkDetach_SkipsUnattached(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "only-on-1@x", ID: "44444444-4444-4444-4444-444444444444", SubID: "s1", Enable: true},
}
ib1 := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
ib2 := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`)
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
t.Fatalf("seed: %v", err)
}
dres, restart, err := svc.BulkDetach(inboundSvc, []string{"only-on-1@x"}, []int{ib2.Id})
if err != nil {
t.Fatalf("BulkDetach: %v", err)
}
if restart {
t.Fatalf("no-op detach should not require restart")
}
if len(dres.Detached) != 0 {
t.Fatalf("nothing should be detached, got %v", dres.Detached)
}
if len(dres.Skipped) != 1 || dres.Skipped[0] != "only-on-1@x" {
t.Fatalf("expected the email in Skipped, got %v", dres.Skipped)
}
if len(dres.Errors) != 0 {
t.Fatalf("unexpected errors: %v", dres.Errors)
}
}
// TestBulkAttachDetach_Trojan checks the protocol-specific key matching in the
// batched detach path (Trojan keys on password, not id).
func TestBulkAttachDetach_Trojan(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "t1@x", Password: "pw-t1", SubID: "t1", Enable: true},
{Email: "t2@x", Password: "pw-t2", SubID: "t2", Enable: true},
}
ib1 := mkInbound(t, 22001, model.Trojan, clientsSettings(t, source))
ib2 := mkInbound(t, 22002, model.Trojan, `{"clients":[]}`)
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
t.Fatalf("seed: %v", err)
}
emails := emailsOf(source)
if res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id}); err != nil {
t.Fatalf("BulkAttach: %v", err)
} else if len(res.Errors) != 0 || len(res.Attached) != 2 {
t.Fatalf("attach result unexpected: attached=%v errors=%v", res.Attached, res.Errors)
}
list, _ := svc.ListForInbound(nil, ib2.Id)
if len(list) != 2 {
t.Fatalf("expected 2 trojan clients on ib2, got %v", sortedEmails(list))
}
dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id})
if err != nil {
t.Fatalf("BulkDetach: %v", err)
}
if len(dres.Detached) != 2 || len(dres.Errors) != 0 {
t.Fatalf("detach result unexpected: detached=%v errors=%v", dres.Detached, dres.Errors)
}
if list, _ := svc.ListForInbound(nil, ib2.Id); len(list) != 0 {
t.Fatalf("trojan clients should be gone from ib2, got %v", sortedEmails(list))
}
}
+199 -18
View File
@@ -245,10 +245,7 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) { if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
row.CreatedAt = incoming.CreatedAt row.CreatedAt = incoming.CreatedAt
} }
preservedUpdatedAt := row.UpdatedAt preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
if incoming.UpdatedAt > preservedUpdatedAt {
preservedUpdatedAt = incoming.UpdatedAt
}
row.UpdatedAt = preservedUpdatedAt row.UpdatedAt = preservedUpdatedAt
if err := tx.Save(row).Error; err != nil { if err := tx.Save(row).Error; err != nil {
return err return err
@@ -886,6 +883,12 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
records = append(records, rec) records = append(records, rec)
} }
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
if sidErr != nil {
emailSubIDs = nil
logger.Warningf("[BulkAttach] getAllEmailSubIDs: %v", sidErr)
}
needRestart := false needRestart := false
for _, ibId := range inboundIds { for _, ibId := range inboundIds {
inbound, err := inboundSvc.GetInbound(ibId) inbound, err := inboundSvc.GetInbound(ibId)
@@ -927,7 +930,7 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
recordErr("inbound %d: %v", ibId, err) recordErr("inbound %d: %v", ibId, err)
continue continue
} }
nr, err := s.AddInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}) nr, err := s.addInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}, emailSubIDs)
if err != nil { if err != nil {
recordErr("inbound %d: %v", ibId, err) recordErr("inbound %d: %v", ibId, err)
continue continue
@@ -972,7 +975,10 @@ func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string,
requested[id] = struct{}{} requested[id] = struct{}{}
} }
needRestart := false recsByInbound := make(map[int][]*model.ClientRecord)
emailOrder := make([]string, 0, len(emails))
emailRepr := make(map[string]string, len(emails))
emailFailed := make(map[string]bool, len(emails))
seenEmail := make(map[string]struct{}, len(emails)) seenEmail := make(map[string]struct{}, len(emails))
for _, email := range emails { for _, email := range emails {
if email == "" { if email == "" {
@@ -994,30 +1000,194 @@ func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string,
recordErr("%s: %v", email, err) recordErr("%s: %v", email, err)
continue continue
} }
intersection := make([]int, 0, len(currentIds)) matched := false
for _, id := range currentIds { for _, id := range currentIds {
if _, ok := requested[id]; ok { if _, ok := requested[id]; ok {
intersection = append(intersection, id) recsByInbound[id] = append(recsByInbound[id], rec)
matched = true
} }
} }
if len(intersection) == 0 { if !matched {
result.Skipped = append(result.Skipped, rec.Email) result.Skipped = append(result.Skipped, rec.Email)
continue continue
} }
nr, err := s.Detach(inboundSvc, rec.Id, intersection) emailOrder = append(emailOrder, key)
emailRepr[key] = rec.Email
}
needRestart := false
for _, ibId := range inboundIds {
recs, ok := recsByInbound[ibId]
if !ok {
continue
}
delete(recsByInbound, ibId)
nr, err := s.delInboundClients(inboundSvc, ibId, recs, true)
if err != nil { if err != nil {
recordErr("%s: %v", rec.Email, err) recordErr("inbound %d: %v", ibId, err)
for _, rec := range recs {
emailFailed[strings.ToLower(rec.Email)] = true
}
continue continue
} }
if nr { if nr {
needRestart = true needRestart = true
} }
result.Detached = append(result.Detached, rec.Email) }
for _, key := range emailOrder {
if emailFailed[key] {
continue
}
result.Detached = append(result.Detached, emailRepr[key])
} }
return result, needRestart, nil return result, needRestart, nil
} }
// delInboundClients removes several clients from a single inbound in one pass:
// one settings rewrite, one runtime sweep, one Save and one SyncInbound for the
// whole batch, instead of repeating the full per-client cycle. It mirrors the
// semantics of DelInboundClient for each removed client. needRestart is the OR
// across all removals.
func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId int, recs []*model.ClientRecord, keepTraffic bool) (bool, error) {
if len(recs) == 0 {
return false, nil
}
defer lockInbound(inboundId).Unlock()
oldInbound, err := inboundSvc.GetInbound(inboundId)
if err != nil {
logger.Error("Load Old Data Error")
return false, err
}
var settings map[string]any
if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
return false, err
}
clientKey := "id"
switch oldInbound.Protocol {
case "trojan":
clientKey = "password"
case "shadowsocks":
clientKey = "email"
case "hysteria":
clientKey = "auth"
}
wanted := make(map[string]struct{}, len(recs))
for _, rec := range recs {
if k := clientKeyForProtocol(oldInbound.Protocol, rec); k != "" {
wanted[k] = struct{}{}
}
}
interfaceClients, ok := settings["clients"].([]any)
if !ok {
return false, common.NewError("invalid clients format in inbound settings")
}
type removedClient struct {
email string
needApiDel bool
}
removed := make([]removedClient, 0, len(wanted))
newClients := make([]any, 0, len(interfaceClients))
for _, client := range interfaceClients {
c, ok := client.(map[string]any)
if !ok {
newClients = append(newClients, client)
continue
}
cid, _ := c[clientKey].(string)
if _, hit := wanted[cid]; hit && cid != "" {
email, _ := c["email"].(string)
enable, _ := c["enable"].(bool)
removed = append(removed, removedClient{email: email, needApiDel: enable})
continue
}
newClients = append(newClients, client)
}
if len(removed) == 0 {
return false, nil
}
db := database.GetDB()
newClients = compactOrphans(db, newClients)
if newClients == nil {
newClients = []any{}
}
settings["clients"] = newClients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
needRestart := false
for _, r := range removed {
email := r.email
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
if err != nil {
return needRestart, err
}
if !emailShared && !keepTraffic {
if err := inboundSvc.DelClientIPs(db, email); err != nil {
logger.Error("Error in delete client IPs")
return needRestart, err
}
}
if len(email) > 0 {
var enables []bool
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error; err != nil {
logger.Error("Get stats error")
return needRestart, err
}
notDepleted := len(enables) > 0 && enables[0]
if !emailShared && !keepTraffic {
if err := inboundSvc.DelClientStat(db, email); err != nil {
logger.Error("Delete stats Data Error")
return needRestart, err
}
}
if r.needApiDel && notDepleted && oldInbound.NodeID == nil {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
needRestart = true
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 != nil {
if !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
needRestart = true
}
}
}
}
if oldInbound.NodeID != nil && len(email) > 0 {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
return needRestart, rterr
}
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
return needRestart, err1
}
}
}
if err := db.Save(oldInbound).Error; err != nil {
return needRestart, err
}
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
return needRestart, gcErr
}
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) { func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" { if email == "" {
return false, common.NewError("client email is required") return false, common.NewError("client email is required")
@@ -2884,10 +3054,13 @@ func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []
return needRestart, nil return needRestart, nil
} }
func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) { func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client, emailSubIDs map[string]string) (string, error) {
emailSubIDs, err := inboundSvc.getAllEmailSubIDs() if emailSubIDs == nil {
if err != nil { var err error
return "", err emailSubIDs, err = inboundSvc.getAllEmailSubIDs()
if err != nil {
return "", err
}
} }
seen := make(map[string]string, len(clients)) seen := make(map[string]string, len(clients))
for _, client := range clients { for _, client := range clients {
@@ -2912,6 +3085,14 @@ func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, c
} }
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) { func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
return s.addInboundClient(inboundSvc, data, nil)
}
// addInboundClient is AddInboundClient with an optional precomputed email→subId
// map. Bulk callers pass a single snapshot so the global getAllEmailSubIDs scan
// runs once for the whole batch instead of once per target inbound; a nil map
// makes it compute its own (the single-add path).
func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model.Inbound, emailSubIDs map[string]string) (bool, error) {
defer lockInbound(data.Id).Unlock() defer lockInbound(data.Id).Unlock()
clients, err := inboundSvc.GetClients(data) clients, err := inboundSvc.GetClients(data)
@@ -2940,7 +3121,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
interfaceClients[i] = cm interfaceClients[i] = cm
} }
} }
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients) existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, emailSubIDs)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -3159,7 +3340,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
} }
if clients[0].Email != oldEmail { if clients[0].Email != oldEmail {
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients) existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, nil)
if err != nil { if err != nil {
return false, err return false, err
} }
+11 -7
View File
@@ -63,12 +63,16 @@ func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error
return err return err
} }
for i, c := range items { for i, c := range items {
if c.ChildId <= 0 || c.ChildId == masterId { childId := c.ChildId
if childId == masterId {
childId = 0
}
if childId <= 0 && strings.TrimSpace(c.Dest) == "" {
continue continue
} }
row := model.InboundFallback{ row := model.InboundFallback{
MasterId: masterId, MasterId: masterId,
ChildId: c.ChildId, ChildId: childId,
Name: c.Name, Name: c.Name,
Alpn: c.Alpn, Alpn: c.Alpn,
Path: c.Path, Path: c.Path,
@@ -117,12 +121,12 @@ func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[s
out := make([]map[string]any, 0, len(rows)) out := make([]map[string]any, 0, len(rows))
for _, r := range rows { for _, r := range rows {
child, ok := byId[r.ChildId] dest := strings.TrimSpace(r.Dest)
if !ok {
continue
}
dest := r.Dest
if dest == "" { if dest == "" {
child, ok := byId[r.ChildId]
if !ok {
continue
}
listen := strings.TrimSpace(child.Listen) listen := strings.TrimSpace(child.Listen)
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" { if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
listen = "127.0.0.1" listen = "127.0.0.1"
+84 -27
View File
@@ -479,7 +479,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err != nil { if err != nil {
return inbound, false, err return inbound, false, err
} }
existEmail, err := s.clientService.checkEmailsExistForClients(s, clients) existEmail, err := s.clientService.checkEmailsExistForClients(s, clients, nil)
if err != nil { if err != nil {
return inbound, false, err return inbound, false, err
} }
@@ -1251,6 +1251,18 @@ const resetGracePeriodMs int64 = 30000
// long after a real disconnect. // long after a real disconnect.
const onlineGracePeriodMs int64 = 20000 const onlineGracePeriodMs int64 = 20000
type nodeTrafficCounter struct {
Up int64
Down int64
}
func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email string, up, down int64) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "node_id"}, {Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"up", "down"}),
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
}
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) { func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
var structuralChange bool var structuralChange bool
err := submitTrafficWrite(func() error { err := submitTrafficWrite(func() error {
@@ -1313,6 +1325,26 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
centralCSByEmail[centralClientStats[i].Email] = &centralClientStats[i] centralCSByEmail[centralClientStats[i].Email] = &centralClientStats[i]
} }
nodeBaselines := make(map[string]nodeTrafficCounter)
var baselineRows []model.NodeClientTraffic
if err := db.Model(&model.NodeClientTraffic{}).
Where("node_id = ?", nodeID).
Find(&baselineRows).Error; err != nil {
return false, err
}
for i := range baselineRows {
nodeBaselines[baselineRows[i].Email] = nodeTrafficCounter{Up: baselineRows[i].Up, Down: baselineRows[i].Down}
}
var existingEmailsList []string
if err := db.Model(xray.ClientTraffic{}).Pluck("email", &existingEmailsList).Error; err != nil {
return false, err
}
existingEmails := make(map[string]struct{}, len(existingEmailsList))
for _, e := range existingEmailsList {
existingEmails[e] = struct{}{}
}
var defaultUserId int var defaultUserId int
if len(central) > 0 { if len(central) > 0 {
defaultUserId = central[0].UserId defaultUserId = central[0].UserId
@@ -1458,6 +1490,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if _, kept := snapTags[c.Tag]; kept { if _, kept := snapTags[c.Tag]; kept {
continue continue
} }
var goneEmails []string
if err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", c.Id).
Pluck("email", &goneEmails).Error; err != nil {
return false, err
}
if len(goneEmails) > 0 {
if err := tx.Where("node_id = ? AND email IN ?", nodeID, goneEmails).
Delete(&model.NodeClientTraffic{}).Error; err != nil {
return false, err
}
}
if err := tx.Where("inbound_id = ?", c.Id). if err := tx.Where("inbound_id = ?", c.Id).
Delete(&xray.ClientTraffic{}).Error; err != nil { Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err return false, err
@@ -1481,17 +1525,22 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if !ok { if !ok {
continue continue
} }
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
snapEmails := make(map[string]struct{}, len(snapIb.ClientStats)) snapEmails := make(map[string]struct{}, len(snapIb.ClientStats))
for _, cs := range snapIb.ClientStats { for _, cs := range snapIb.ClientStats {
snapEmails[cs.Email] = struct{}{} snapEmails[cs.Email] = struct{}{}
existing := centralCS[csKey{c.Id, cs.Email}] base, seen := nodeBaselines[cs.Email]
if existing == nil { var deltaUp, deltaDown int64
existing = centralCSByEmail[cs.Email] if seen {
if deltaUp = cs.Up - base.Up; deltaUp < 0 {
deltaUp = cs.Up
}
if deltaDown = cs.Down - base.Down; deltaDown < 0 {
deltaDown = cs.Down
}
} }
if existing == nil {
if _, rowExists := existingEmails[cs.Email]; !rowExists {
row := &xray.ClientTraffic{ row := &xray.ClientTraffic{
InboundId: c.Id, InboundId: c.Id,
Email: cs.Email, Email: cs.Email,
@@ -1509,42 +1558,40 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
centralCS[csKey{c.Id, cs.Email}] = row centralCS[csKey{c.Id, cs.Email}] = row
centralCSByEmail[cs.Email] = row centralCSByEmail[cs.Email] = row
existingEmails[cs.Email] = struct{}{}
structuralChange = true structuralChange = true
continue if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
}
if existing.Enable != cs.Enable ||
existing.Total != cs.Total ||
existing.ExpiryTime != cs.ExpiryTime ||
existing.Reset != cs.Reset {
structuralChange = true
}
if inGrace && cs.Up+cs.Down > 0 {
if err := tx.Exec(
`UPDATE client_traffics
SET enable = ?, total = ?, expiry_time = ?, reset = ?
WHERE email = ?`,
cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, cs.Email,
).Error; err != nil {
return false, err return false, err
} }
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
continue continue
} }
if existing := centralCSByEmail[cs.Email]; existing != nil &&
(existing.Enable != cs.Enable ||
existing.Total != cs.Total ||
existing.ExpiryTime != cs.ExpiryTime ||
existing.Reset != cs.Reset) {
structuralChange = true
}
if err := tx.Exec( if err := tx.Exec(
fmt.Sprintf( fmt.Sprintf(
`UPDATE client_traffics `UPDATE client_traffics
SET up = ?, down = ?, enable = ?, total = ?, expiry_time = ?, reset = ?, SET up = up + ?, down = down + ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
last_online = %s last_online = %s
WHERE email = ?`, WHERE email = ?`,
database.GreatestExpr("last_online", "?"), database.GreatestExpr("last_online", "?"),
), ),
cs.Up, cs.Down, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
cs.LastOnline, cs.Email, cs.LastOnline, cs.Email,
).Error; err != nil { ).Error; err != nil {
return false, err return false, err
} }
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
return false, err
}
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
} }
for k, existing := range centralCS { for k, existing := range centralCS {
@@ -1554,6 +1601,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if _, kept := snapEmails[k.email]; kept { if _, kept := snapEmails[k.email]; kept {
continue continue
} }
if err := tx.Where("node_id = ? AND email = ?", nodeID, existing.Email).
Delete(&model.NodeClientTraffic{}).Error; err != nil {
return false, err
}
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email). if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
Delete(&xray.ClientTraffic{}).Error; err != nil { Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err return false, err
@@ -1671,6 +1722,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil { if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err) logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
} }
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
}
structuralChange = true structuralChange = true
} }
} }
@@ -2329,7 +2383,10 @@ func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail
} }
func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error { func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
return tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
return err
}
return tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error
} }
func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error { func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
+133 -1
View File
@@ -2,6 +2,11 @@ package service
import ( import (
"context" "context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -42,6 +47,113 @@ var nodeHTTPClient = &http.Client{
}, },
} }
// nodeHTTPClientFor returns the HTTP client used to reach a node, honoring its
// per-node TLS verification mode. "verify" (or any http node) uses the shared
// client with default certificate validation. "skip" disables validation.
// "pin" disables the default chain check but verifies the leaf certificate's
// SHA-256 against the stored pin, keeping MITM protection for self-signed certs.
func nodeHTTPClientFor(n *model.Node) (*http.Client, error) {
mode := n.TlsVerifyMode
if mode == "" {
mode = "verify"
}
if mode == "verify" || n.Scheme == "http" {
return nodeHTTPClient, nil
}
tlsCfg := &tls.Config{InsecureSkipVerify: true}
if mode == "pin" {
want, err := decodeCertPin(n.PinnedCertSha256)
if err != nil {
return nil, err
}
tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return common.NewError("node presented no certificate")
}
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
if subtle.ConstantTimeCompare(sum[:], want) != 1 {
return common.NewError("node certificate does not match pinned SHA-256")
}
return nil
}
}
return &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
TLSClientConfig: tlsCfg,
},
}, nil
}
// decodeCertPin accepts a SHA-256 certificate hash as base64 (the format used
// by Xray's pinnedPeerCertSha256) or hex with optional colons (the openssl
// -fingerprint style) and returns the 32 raw bytes.
func decodeCertPin(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, common.NewError("certificate pin is empty")
}
if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
return b, nil
}
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
return b, nil
}
}
return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
}
// FetchCertFingerprint connects to the node over HTTPS without verifying the
// certificate and returns the leaf certificate's SHA-256 as base64, so the UI
// can offer a "fetch and pin current certificate" action.
func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) {
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
return "", err
}
scheme := n.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if scheme != "https" {
return "", common.NewError("certificate pinning is only available for https nodes")
}
if n.Port <= 0 || n.Port > 65535 {
return "", common.NewError("node port must be 1-65535")
}
probeURL := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
}
req, err := http.NewRequestWithContext(
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
http.MethodGet, probeURL.String(), nil)
if err != nil {
return "", err
}
client := &http.Client{
Transport: &http.Transport{
DialContext: netsafe.SSRFGuardedDialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check]
},
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
return "", common.NewError("node did not present a TLS certificate")
}
sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
return base64.StdEncoding.EncodeToString(sum[:]), nil
}
func (s *NodeService) GetAll() ([]*model.Node, error) { func (s *NodeService) GetAll() ([]*model.Node, error) {
db := database.GetDB() db := database.GetDB()
var nodes []*model.Node var nodes []*model.Node
@@ -187,6 +299,15 @@ func (s *NodeService) normalize(n *model.Node) error {
if n.Scheme != "http" && n.Scheme != "https" { if n.Scheme != "http" && n.Scheme != "https" {
n.Scheme = "https" n.Scheme = "https"
} }
if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" {
n.TlsVerifyMode = "verify"
}
n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
if n.TlsVerifyMode == "pin" {
if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
return common.NewError(err.Error())
}
}
n.BasePath = normalizeBasePath(n.BasePath) n.BasePath = normalizeBasePath(n.BasePath)
return nil return nil
} }
@@ -218,6 +339,8 @@ func (s *NodeService) Update(id int, in *model.Node) error {
"api_token": in.ApiToken, "api_token": in.ApiToken,
"enable": in.Enable, "enable": in.Enable,
"allow_private_address": in.AllowPrivateAddress, "allow_private_address": in.AllowPrivateAddress,
"tls_verify_mode": in.TlsVerifyMode,
"pinned_cert_sha256": in.PinnedCertSha256,
} }
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err return err
@@ -233,6 +356,9 @@ func (s *NodeService) Delete(id int) error {
if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil { if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
return err return err
} }
if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
if mgr := runtime.GetManager(); mgr != nil { if mgr := runtime.GetManager(); mgr != nil {
mgr.InvalidateNode(id) mgr.InvalidateNode(id)
} }
@@ -362,8 +488,14 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
client, err := nodeHTTPClientFor(n)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
start := time.Now() start := time.Now()
resp, err := nodeHTTPClient.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
patch.LastError = err.Error() patch.LastError = err.Error()
return patch, err return patch, err
+209
View File
@@ -0,0 +1,209 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm"
)
func initTrafficTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
return database.GetDB()
}
func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) {
t.Helper()
nid := nodeID
ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create node inbound %q: %v", tag, err)
}
}
func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) {
t.Helper()
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
}
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
}
}
func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic {
t.Helper()
var ct xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil {
t.Fatalf("read client_traffics %q: %v", email, err)
}
return ct
}
func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) {
t.Helper()
if ct.Up != wantUp || ct.Down != wantDown {
t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown)
}
}
func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
createNodeInbound(t, db, 2, "n2-in", 41002)
svc := &InboundService{}
const email = "shared"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 100, 100, "after baselines")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 210, 210, "after both nodes grow")
}
func TestSingleNode_MirrorsCorrectly(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "solo"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 500, 600, "first sync")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 700, 800, "second sync mirrors cumulative")
}
func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "legacy"
var ib model.Inbound
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Up: 1000, Down: 2000, Enable: true}).Error; err != nil {
t.Fatalf("seed pre-existing row: %v", err)
}
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1000, Down: 2000, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 1000, 2000, "first snapshot must not double-count")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1100, Down: 2100, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues")
}
func TestNodeCounterReset_Clamped(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "restart"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true})
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 950, 950, "before node reset")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
ct := readTraffic(t, db, email)
if ct.Up < 0 || ct.Down < 0 {
t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
}
assertUpDown(t, ct, 1000, 1000, "after node counter reset (clamped)")
}
func TestCentralReset_NoReAdd(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
createNodeInbound(t, db, 2, "n2-in", 41002)
svc := &InboundService{}
const email = "reset"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
t.Fatalf("simulate central reset: %v", err)
}
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 210, Down: 210, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 205, Down: 205, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
}
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
const email = "gone"
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: email, Enable: true}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 10}).Error; err != nil {
t.Fatalf("seed node baseline 1: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 20, Down: 20}).Error; err != nil {
t.Fatalf("seed node baseline 2: %v", err)
}
if err := svc.DelClientStat(db, email); err != nil {
t.Fatalf("DelClientStat: %v", err)
}
var cnt int64
if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
t.Fatalf("count baselines: %v", err)
}
if cnt != 0 {
t.Errorf("expected node baselines cleaned, found %d", cnt)
}
}
func TestNodeDelete_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
nodeSvc := NodeService{}
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "a", Up: 1, Down: 1}).Error; err != nil {
t.Fatalf("seed node 7 a: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "b", Up: 2, Down: 2}).Error; err != nil {
t.Fatalf("seed node 7 b: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 8, Email: "c", Up: 3, Down: 3}).Error; err != nil {
t.Fatalf("seed node 8 c: %v", err)
}
if err := nodeSvc.Delete(7); err != nil {
t.Fatalf("NodeService.Delete(7): %v", err)
}
var sevenCnt, eightCnt int64
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 7).Count(&sevenCnt)
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 8).Count(&eightCnt)
if sevenCnt != 0 {
t.Errorf("node 7 baselines not cleaned: %d remain", sevenCnt)
}
if eightCnt != 1 {
t.Errorf("node 8 baseline should survive, found %d", eightCnt)
}
}
+5 -5
View File
@@ -26,11 +26,11 @@ func TestBuildSubURIBase(t *testing.T) {
} }
cases := []struct { cases := []struct {
name string name string
subDomain, port string subDomain, port string
cert, key string cert, key string
host string host string
want string want string
}{ }{
{"no domain, plain, non-standard port", "", "2096", "", "", "panel.example.com", "http://panel.example.com:2096"}, {"no domain, plain, non-standard port", "", "2096", "", "", "panel.example.com", "http://panel.example.com:2096"},
{"host carries a port — stripped, sub port applied", "", "2096", "", "", "panel.example.com:9999", "http://panel.example.com:2096"}, {"host carries a port — stripped, sub port applied", "", "2096", "", "", "panel.example.com:9999", "http://panel.example.com:2096"},
+5
View File
@@ -106,6 +106,11 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
"license_key": license, "license_key": license,
"private_key": secretKey, "private_key": secretKey,
} }
if config, ok := rsp["config"].(map[string]any); ok {
if clientID, ok := config["client_id"].(string); ok {
warpData["client_id"] = clientID
}
}
warpJSON, err := json.MarshalIndent(warpData, "", " ") warpJSON, err := json.MarshalIndent(warpData, "", " ")
if err != nil { if err != nil {
return "", err return "", err
+18 -3
View File
@@ -264,7 +264,7 @@
"localPanel": "بانل محلي", "localPanel": "بانل محلي",
"fallbacks": { "fallbacks": {
"title": "Fallbacks", "title": "Fallbacks",
"help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى inbound آخر. اختر فرعًا أدناه وسيتم ملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقل الفرع — في الغالب لا تحتاج إلى أي تعديل إضافي. يجب أن يستمع كل فرع على 127.0.0.1 مع security=none.", "help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى مكان آخر. اختر inbound فرعيًا أدناه لملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقله، أو اترك القائمة فارغة واضبط Dest مباشرةً (مثل 8080 أو 127.0.0.1:8080) للتوجيه إلى خادم خارجي مثل Nginx. يجب أن يستمع كل inbound فرعي على 127.0.0.1 مع security=none.",
"empty": "لا توجد fallbacks بعد", "empty": "لا توجد fallbacks بعد",
"add": "إضافة fallback", "add": "إضافة fallback",
"pickInbound": "اختر inbound", "pickInbound": "اختر inbound",
@@ -570,7 +570,8 @@
"getNewCert": "احصل على شهادة جديدة", "getNewCert": "احصل على شهادة جديدة",
"mldsa65Seed": "mldsa65 Seed", "mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify", "mldsa65Verify": "mldsa65 Verify",
"getNewSeed": "احصل على Seed جديد" "getNewSeed": "احصل على Seed جديد",
"listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock) للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0."
}, },
"info": { "info": {
"mode": "الوضع", "mode": "الوضع",
@@ -868,7 +869,19 @@
"updateStarted": "بدأ تحديث اللوحة", "updateStarted": "بدأ تحديث اللوحة",
"updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}", "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
"updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة" "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
} },
"tlsVerifyMode": "التحقق من TLS",
"tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
"tlsVerify": "تحقّق (CA الافتراضية)",
"tlsPin": "تثبيت الشهادة (SHA-256)",
"tlsSkip": "تخطّي التحقق",
"tlsSkipWarning": "تخطّي التحقق يزيل الحماية من هجمات الوسيط — قد يُعترض رمز الـ API. يُفضَّل تثبيت الشهادة بدلاً من ذلك.",
"pinnedCert": "SHA-256 للشهادة المثبّتة",
"pinnedCertHint": "SHA-256 لشهادة العقدة بصيغة base64 أو hex. استخدم \"جلب\" لقراءتها من العقدة الآن.",
"pinnedCertPlaceholder": "SHA-256 بصيغة base64 أو hex",
"fetchPin": "جلب",
"pinFetched": "تم جلب شهادة العقدة الحالية",
"pinFetchFailed": "تعذّر جلب الشهادة"
}, },
"settings": { "settings": {
"title": "إعدادات البانل", "title": "إعدادات البانل",
@@ -1193,6 +1206,8 @@
"tagRequired": "الوسم مطلوب", "tagRequired": "الوسم مطلوب",
"tagPlaceholder": "وسم-فريد", "tagPlaceholder": "وسم-فريد",
"localIpPlaceholder": "IP محلي", "localIpPlaceholder": "IP محلي",
"dialerProxyPlaceholder": "اختر مخرجًا لتمرير الاتصال عبره",
"dialerProxyHint": "وجّه هذا المخرج عبر مخرج آخر (حسب الوسم) لبناء سلسلة بروكسي. اتركه فارغًا للاتصال المباشر.",
"addressRequired": "العنوان مطلوب", "addressRequired": "العنوان مطلوب",
"portRequired": "المنفذ مطلوب", "portRequired": "المنفذ مطلوب",
"optional": "اختياري", "optional": "اختياري",
+18 -3
View File
@@ -264,7 +264,7 @@
"localPanel": "Local panel", "localPanel": "Local panel",
"fallbacks": { "fallbacks": {
"title": "Fallbacks", "title": "Fallbacks",
"help": "When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.", "help": "When a connection on this inbound does not match any client, route it elsewhere. Pick a child inbound below to auto-fill the routing fields (SNI / ALPN / path / xver) from its transport, or leave the picker empty and set Dest directly (e.g. 8080 or 127.0.0.1:8080) to route to an external server such as Nginx. Each child inbound should listen on 127.0.0.1 with security=none.",
"empty": "No fallbacks yet", "empty": "No fallbacks yet",
"add": "Add fallback", "add": "Add fallback",
"pickInbound": "Pick an inbound", "pickInbound": "Pick an inbound",
@@ -570,7 +570,8 @@
"getNewCert": "Get New Cert", "getNewCert": "Get New Cert",
"mldsa65Seed": "mldsa65 Seed", "mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify", "mldsa65Verify": "mldsa65 Verify",
"getNewSeed": "Get New Seed" "getNewSeed": "Get New Seed",
"listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock) to listen on a socket instead of a TCP port — set Port to 0 in that case."
}, },
"info": { "info": {
"mode": "Mode", "mode": "Mode",
@@ -868,7 +869,19 @@
"updateStarted": "Panel update started", "updateStarted": "Panel update started",
"updateResult": "Update triggered on {ok} node(s), {failed} failed", "updateResult": "Update triggered on {ok} node(s), {failed} failed",
"updateNoneEligible": "Select at least one online, enabled node" "updateNoneEligible": "Select at least one online, enabled node"
} },
"tlsVerifyMode": "TLS verification",
"tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",
"tlsVerify": "Verify (default CA)",
"tlsPin": "Pin certificate (SHA-256)",
"tlsSkip": "Skip verification",
"tlsSkipWarning": "Skipping verification removes protection against man-in-the-middle attacks — the API token could be intercepted. Prefer pinning the certificate.",
"pinnedCert": "Pinned certificate SHA-256",
"pinnedCertHint": "Base64 or hex SHA-256 of the node's certificate. Use Fetch to read it from the node now.",
"pinnedCertPlaceholder": "base64 or hex SHA-256",
"fetchPin": "Fetch",
"pinFetched": "Fetched the node's current certificate",
"pinFetchFailed": "Could not fetch the certificate"
}, },
"settings": { "settings": {
"title": "Panel Settings", "title": "Panel Settings",
@@ -1193,6 +1206,8 @@
"tagRequired": "Tag is required", "tagRequired": "Tag is required",
"tagPlaceholder": "unique-tag", "tagPlaceholder": "unique-tag",
"localIpPlaceholder": "local IP", "localIpPlaceholder": "local IP",
"dialerProxyPlaceholder": "Select an outbound to chain through",
"dialerProxyHint": "Dial this outbound through another outbound (by tag) to build a proxy chain. Leave empty to connect directly.",
"addressRequired": "Address is required", "addressRequired": "Address is required",
"portRequired": "Port is required", "portRequired": "Port is required",
"optional": "optional", "optional": "optional",

Some files were not shown because too many files have changed in this diff Show More