mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-05 21:52:08 +03:00
Compare commits
15 Commits
v3.9.0
..
dev-latest
| Author | SHA1 | Date | |
|---|---|---|---|
| 49fbcdc09c | |||
| aacfaebab8 | |||
| e897b0957a | |||
| b42a1c0ba1 | |||
| a8d65a55b0 | |||
| d7da64f2f0 | |||
| 2c5fc8e72c | |||
| d4a7086c4e | |||
| d1b60799ec | |||
| 5819a01cdc | |||
| dae91276aa | |||
| f843fe5570 | |||
| 2ffc694a6d | |||
| 815c9c5772 | |||
| 05eb06f333 |
@@ -141,6 +141,22 @@ jobs:
|
|||||||
# internal/web/service runs ~10x slower under -race and overruns the 10m default.
|
# internal/web/service runs ~10x slower under -race and overruns the 10m default.
|
||||||
go test -race -shuffle=on -count=1 -timeout 25m $(cat /tmp/go-packages.txt)
|
go test -race -shuffle=on -count=1 -timeout 25m $(cat /tmp/go-packages.txt)
|
||||||
|
|
||||||
|
# A real master and node panel, each its own process, driven through node sync.
|
||||||
|
# A SKIP here means the binary was never handed over, so it fails the job.
|
||||||
|
node-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Master + node end to end
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
make node-e2e 2>&1 | tee /tmp/node-e2e.log
|
||||||
|
if grep -q -- '--- SKIP' /tmp/node-e2e.log; then echo "node-e2e skipped"; exit 1; fi
|
||||||
|
|
||||||
# Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
|
# Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
|
||||||
# generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
|
# generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
|
||||||
fuzz-smoke:
|
fuzz-smoke:
|
||||||
|
|||||||
@@ -160,9 +160,10 @@ file locations when it can answer in one hop.
|
|||||||
`-race`); `httptest` for HTTP. Keep `database.InitDB` for reopening a file or
|
`-race`); `httptest` for HTTP. Keep `database.InitDB` for reopening a file or
|
||||||
migrating a hand-built legacy DB. `internal/sub`'s `initSubDB(t)` is the template.
|
migrating a hand-built legacy DB. `internal/sub`'s `initSubDB(t)` is the template.
|
||||||
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
|
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
|
||||||
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
|
- Postgres, xray-gRPC-e2e, master+node and scale tests `t.Skip` unless
|
||||||
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
|
`XUI_TEST_PG_DSN`, `XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY`,
|
||||||
green `go test ./...` does not mean those paths ran.
|
`XUI_NODE_E2E_BINARY` or `XUI_SCALE_TEST` is set — a green `go test ./...`
|
||||||
|
does not mean those paths ran.
|
||||||
|
|
||||||
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
|
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
|
||||||
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
|
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
|
||||||
@@ -191,9 +192,13 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every
|
|||||||
make verify # gen-check + lint + typecheck + test + build + build-storybook
|
make verify # gen-check + lint + typecheck + test + build + build-storybook
|
||||||
|
|
||||||
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
|
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
|
||||||
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a
|
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure),
|
||||||
|
`make node-e2e` (a real master and node panel, `internal/nodee2e/`) and a
|
||||||
30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when
|
30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when
|
||||||
you touch DB/dialect or parser code.
|
you touch DB/dialect, node sync or parser code. Node sync has two layers: every
|
||||||
|
`runtime.Remote` call gets a cell in `internal/web/node_contract_test.go` (fast,
|
||||||
|
in `make test-go`; a method without one fails it), and a multi-tick flow (cron,
|
||||||
|
adopt, node down) gets one in `internal/nodee2e/node_sync_test.go`.
|
||||||
|
|
||||||
Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
|
Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
|
||||||
`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
|
`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ test-go: dist-stub ## Go tests (shuffle, no cache)
|
|||||||
race: dist-stub ## Go tests with the race detector (needs a C compiler)
|
race: dist-stub ## Go tests with the race detector (needs a C compiler)
|
||||||
go test -race -shuffle=on -count=1 -timeout 25m $(GO_PKGS)
|
go test -race -shuffle=on -count=1 -timeout 25m $(GO_PKGS)
|
||||||
|
|
||||||
|
.PHONY: node-e2e
|
||||||
|
# Two real panel processes (master + node); test-go only runs nodee2e as a skip.
|
||||||
|
NODE_E2E_BIN = $(CURDIR)/.cache/node-e2e/x-ui$(shell go env GOEXE)
|
||||||
|
node-e2e: dist-stub ## Master+node sync end to end with two real panel processes
|
||||||
|
go build -o $(NODE_E2E_BIN) .
|
||||||
|
XUI_NODE_E2E_BINARY=$(NODE_E2E_BIN) go test -count=1 -timeout 20m -v ./internal/nodee2e/
|
||||||
|
|
||||||
.PHONY: test-fe
|
.PHONY: test-fe
|
||||||
test-fe: ## Frontend tests (vitest)
|
test-fe: ## Frontend tests (vitest)
|
||||||
cd $(FRONTEND) && npm test
|
cd $(FRONTEND) && npm test
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
|
|||||||
- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
|
- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, 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.
|
- **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
|
||||||
- **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install.
|
- **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install.
|
||||||
- **TUIC v5 sidecar** — High-performance QUIC-based proxy with native UDP relay traffic metering, 0-RTT handshakes, and BBR congestion control.
|
- **Native TUIC v5 server** — In-process Go QUIC server with Xray routing and per-client traffic accounting; BBR and New Reno are available server-side. CUBIC is preserved in the client profile but currently falls back to New Reno on the server.
|
||||||
- **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections.
|
- **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections.
|
||||||
- **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
|
- **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 with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions.
|
- **Per-client management** — traffic quotas, expiry dates, IP limits with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions.
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
|
- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
|
||||||
- **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
|
- **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
|
||||||
- **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов.
|
- **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов.
|
||||||
- **Встроенный TUIC v5** — высокопроизводительный прокси на базе QUIC с нативным учётом трафика через UDP-релей, 0-RTT рукопожатиями и контролем перегрузок BBR.
|
- **Нативный TUIC v5** — Go QUIC-сервер работает внутри процесса панели. Трафик маршрутизируется через Xray, а учёт ведётся по клиентам. На сервере доступны BBR и New Reno; CUBIC сохраняется в профиле клиента, но на сервере пока использует New Reno.
|
||||||
- **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения.
|
- **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения.
|
||||||
- **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
|
- **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
|
||||||
- **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
|
- **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ unstable networks.
|
|||||||
|
|
||||||
## Key settings
|
## Key settings
|
||||||
|
|
||||||
### Server & QUIC parameters
|
### Server, QUIC & client-profile parameters
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Port** | UDP port for incoming client QUIC connections. |
|
| **Port** | UDP port for incoming client QUIC connections. |
|
||||||
| **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. |
|
| **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. |
|
||||||
| **SNI** | Server Name Indication matching your TLS certificate domain name. |
|
| **SNI** | Client-profile Server Name Indication. Set it to the domain covered by the server certificate; this field does not configure the listener certificate. |
|
||||||
| **Congestion Control** | QUIC congestion control algorithm: `bbr` (recommended for high throughput), `cubic`, or `new_reno`. The server runs `bbr` or `new_reno`; `cubic` is sent to clients but served as `new_reno`. |
|
| **Congestion Control** | QUIC congestion control algorithm used in the server setting and exported client profile: `bbr`, `cubic`, or `new_reno`. The server runs BBR or New Reno; when CUBIC is selected, the client profile keeps CUBIC while this server currently falls back to New Reno. |
|
||||||
| **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). |
|
| **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). |
|
||||||
| **UDP Relay Mode** | Packet encapsulation mode: `native` (QUIC datagrams, recommended) or `quic`. |
|
| **UDP Relay Mode** | Client-profile packet mode: `native` (QUIC datagrams) or `quic` (unidirectional streams). The server accepts both modes regardless of this exported preference. |
|
||||||
| **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. |
|
| **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. |
|
||||||
| **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). |
|
| **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). |
|
||||||
| **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). |
|
| **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). |
|
||||||
@@ -105,6 +105,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
|||||||
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
|
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
|
||||||
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
|
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
|
||||||
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
|
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
|
||||||
|
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again.
|
||||||
|
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
|
||||||
- **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
|
- **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
|
||||||
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
|
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ icon: Zap
|
|||||||
|
|
||||||
## Ключевые параметры
|
## Ключевые параметры
|
||||||
|
|
||||||
### Параметры сервера и QUIC
|
### Параметры сервера, QUIC и клиентского профиля
|
||||||
|
|
||||||
| Поле | Описание |
|
| Поле | Описание |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
|
| **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
|
||||||
| **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
|
| **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
|
||||||
| **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. |
|
| **SNI** | Server Name Indication для профиля клиента. Укажите домен, покрытый сертификатом сервера; это поле не настраивает сертификат listener'а. |
|
||||||
| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. Сервер работает с `bbr` или `new_reno`; `cubic` передаётся клиентам, но на сервере применяется как `new_reno`. |
|
| **Контроль перегрузок** | Алгоритм QUIC в настройках сервера и экспортируемом профиле: `bbr`, `cubic` или `new_reno`. Сервер использует BBR или New Reno; при выборе CUBIC клиентский профиль сохраняет CUBIC, а сервер пока применяет New Reno. |
|
||||||
| **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
|
| **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
|
||||||
| **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. |
|
| **Режим UDP Relay** | Режим UDP в профиле клиента: `native` (QUIC datagrams) или `quic` (однонаправленные потоки). Сервер принимает оба режима независимо от этого значения. |
|
||||||
| **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
|
| **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
|
||||||
| **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). |
|
| **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). |
|
||||||
| **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). |
|
| **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). |
|
||||||
@@ -104,6 +104,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
|||||||
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
|
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
|
||||||
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
|
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
|
||||||
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
|
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
|
||||||
|
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет.
|
||||||
|
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
|
||||||
- **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
|
- **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
|
||||||
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
|
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|||||||
@@ -16027,15 +16027,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "traffic",
|
"type": "traffic",
|
||||||
"summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.",
|
"summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
|
||||||
"payloadSchema": {
|
"payloadSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"onlineClients",
|
|
||||||
"onlineByGuid",
|
|
||||||
"activeInbounds",
|
|
||||||
"lastOnlineMap"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"traffics": {
|
"traffics": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -16049,6 +16043,18 @@
|
|||||||
"$ref": "#/components/schemas/ClientTraffic"
|
"$ref": "#/components/schemas/ClientTraffic"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"clientTrafficSource": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"xray",
|
||||||
|
"tuic"
|
||||||
|
],
|
||||||
|
"description": "Present for native TUIC samples; omitted Xray samples default to xray."
|
||||||
|
},
|
||||||
|
"clientTrafficIntervalMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Sampling interval used to calculate client speed, in milliseconds."
|
||||||
|
},
|
||||||
"nodeTraffics": {
|
"nodeTraffics": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
@@ -16092,13 +16098,24 @@
|
|||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"traffics",
|
"traffics",
|
||||||
"clientTraffics"
|
"clientTraffics",
|
||||||
|
"onlineClients",
|
||||||
|
"onlineByGuid",
|
||||||
|
"activeInbounds",
|
||||||
|
"lastOnlineMap"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"nodeTraffics"
|
"nodeTraffics"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"clientTraffics",
|
||||||
|
"clientTrafficSource",
|
||||||
|
"clientTrafficIntervalMs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16027,15 +16027,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "traffic",
|
"type": "traffic",
|
||||||
"summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.",
|
"summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
|
||||||
"payloadSchema": {
|
"payloadSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"onlineClients",
|
|
||||||
"onlineByGuid",
|
|
||||||
"activeInbounds",
|
|
||||||
"lastOnlineMap"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"traffics": {
|
"traffics": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -16049,6 +16043,18 @@
|
|||||||
"$ref": "#/components/schemas/ClientTraffic"
|
"$ref": "#/components/schemas/ClientTraffic"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"clientTrafficSource": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"xray",
|
||||||
|
"tuic"
|
||||||
|
],
|
||||||
|
"description": "Present for native TUIC samples; omitted Xray samples default to xray."
|
||||||
|
},
|
||||||
|
"clientTrafficIntervalMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Sampling interval used to calculate client speed, in milliseconds."
|
||||||
|
},
|
||||||
"nodeTraffics": {
|
"nodeTraffics": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
@@ -16092,13 +16098,24 @@
|
|||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"traffics",
|
"traffics",
|
||||||
"clientTraffics"
|
"clientTraffics",
|
||||||
|
"onlineClients",
|
||||||
|
"onlineByGuid",
|
||||||
|
"activeInbounds",
|
||||||
|
"lastOnlineMap"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"nodeTraffics"
|
"nodeTraffics"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"clientTraffics",
|
||||||
|
"clientTrafficSource",
|
||||||
|
"clientTrafficIntervalMs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ interface PaletteItem {
|
|||||||
|
|
||||||
export default function CommandPalette() {
|
export default function CommandPalette() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
||||||
const { isOpen, close } = useCommandPalette();
|
const { isOpen, close } = useCommandPalette();
|
||||||
@@ -194,14 +195,14 @@ export default function CommandPalette() {
|
|||||||
const copySubscription = useCallback(
|
const copySubscription = useCallback(
|
||||||
async (client: ClientRecord) => {
|
async (client: ClientRecord) => {
|
||||||
if (!client.subId || !allSetting.subURI) {
|
if (!client.subId || !allSetting.subURI) {
|
||||||
message.warning(t('pages.clients.noSubId'));
|
messageApi.warning(t('pages.clients.noSubId'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const link = `${allSetting.subURI}${client.subId}`;
|
const link = `${allSetting.subURI}${client.subId}`;
|
||||||
const ok = await ClipboardManager.copyText(link);
|
const ok = await ClipboardManager.copyText(link);
|
||||||
if (ok) message.success(t('copied'));
|
if (ok) messageApi.success(t('copied'));
|
||||||
},
|
},
|
||||||
[allSetting.subURI, t],
|
[allSetting.subURI, messageApi, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const restartXray = useCallback(async () => {
|
const restartXray = useCallback(async () => {
|
||||||
@@ -210,9 +211,9 @@ export default function CommandPalette() {
|
|||||||
silentSuccess: true,
|
silentSuccess: true,
|
||||||
});
|
});
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
message.success(t('commandPalette.restartXraySuccess'));
|
messageApi.success(t('commandPalette.restartXraySuccess'));
|
||||||
}
|
}
|
||||||
}, [close, t]);
|
}, [close, messageApi, t]);
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
const cycleTheme = useCallback(() => {
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
@@ -679,13 +680,15 @@ export default function CommandPalette() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
// Kept mounted while closed: restartXray closes the palette before its toast.
|
||||||
|
if (!isOpen) return messageContextHolder;
|
||||||
|
|
||||||
let lastCategory = '';
|
let lastCategory = '';
|
||||||
const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
|
const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
<div
|
<div
|
||||||
className={`command-palette-backdrop ${themeModeClass}`}
|
className={`command-palette-backdrop ${themeModeClass}`}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export interface ClientSpeedEntry {
|
|||||||
down: number;
|
down: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientSpeedSource = 'xray' | 'tuic';
|
||||||
|
|
||||||
type ClientStatRow = ClientTraffic & { email?: string };
|
type ClientStatRow = ClientTraffic & { email?: string };
|
||||||
|
|
||||||
export function sameSpeedMap(
|
export function sameSpeedMap(
|
||||||
@@ -299,7 +301,31 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
// settings request still lets the page fall back and render.
|
// settings request still lets the page fall back and render.
|
||||||
const settingsReady = defaultsQuery.isFetched;
|
const settingsReady = defaultsQuery.isFetched;
|
||||||
|
|
||||||
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
const [clientSpeedBySource, setClientSpeedBySource] = useState<
|
||||||
|
Partial<Record<ClientSpeedSource, Record<string, ClientSpeedEntry>>>
|
||||||
|
>({});
|
||||||
|
const clientSpeedExpiryTimers = useRef<Partial<Record<ClientSpeedSource, number>>>({});
|
||||||
|
const clientSpeedSourceVersions = useRef<Record<ClientSpeedSource, number>>({ xray: 0, tuic: 0 });
|
||||||
|
const clientSpeed = useMemo(() => {
|
||||||
|
const combined: Record<string, ClientSpeedEntry> = {};
|
||||||
|
for (const source of Object.values(clientSpeedBySource)) {
|
||||||
|
if (!source) continue;
|
||||||
|
for (const [email, speed] of Object.entries(source)) {
|
||||||
|
const current = combined[email] ?? { up: 0, down: 0 };
|
||||||
|
combined[email] = { up: current.up + speed.up, down: current.down + speed.down };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return combined;
|
||||||
|
}, [clientSpeedBySource]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
for (const timer of Object.values(clientSpeedExpiryTimers.current)) {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
||||||
|
|
||||||
const invalidateAll = useCallback(() => {
|
const invalidateAll = useCallback(() => {
|
||||||
@@ -725,6 +751,8 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
const p = payload as {
|
const p = payload as {
|
||||||
onlineClients?: string[];
|
onlineClients?: string[];
|
||||||
clientTraffics?: { email: string; up: number; down: number }[];
|
clientTraffics?: { email: string; up: number; down: number }[];
|
||||||
|
clientTrafficSource?: 'xray' | 'tuic';
|
||||||
|
clientTrafficIntervalMs?: number;
|
||||||
};
|
};
|
||||||
if (Array.isArray(p.onlineClients)) {
|
if (Array.isArray(p.onlineClients)) {
|
||||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||||
@@ -736,17 +764,45 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
// dropped and an unchanged result returns the previous object — which lets
|
// dropped and an unchanged result returns the previous object — which lets
|
||||||
// React bail out of the update instead of re-rendering the table.
|
// React bail out of the update instead of re-rendering the table.
|
||||||
const next: Record<string, ClientSpeedEntry> = {};
|
const next: Record<string, ClientSpeedEntry> = {};
|
||||||
|
const source = p.clientTrafficSource === 'tuic' ? 'tuic' : 'xray';
|
||||||
|
const sampleIntervalMs =
|
||||||
|
typeof p.clientTrafficIntervalMs === 'number' &&
|
||||||
|
Number.isFinite(p.clientTrafficIntervalMs) &&
|
||||||
|
p.clientTrafficIntervalMs > 0
|
||||||
|
? p.clientTrafficIntervalMs
|
||||||
|
: TRAFFIC_POLL_INTERVAL_S * 1000;
|
||||||
|
const sampleIntervalSeconds = sampleIntervalMs / 1000;
|
||||||
for (const ct of p.clientTraffics) {
|
for (const ct of p.clientTraffics) {
|
||||||
if (!ct || !ct.email) continue;
|
if (!ct || !ct.email) continue;
|
||||||
const up = ct.up || 0;
|
const up = ct.up || 0;
|
||||||
const down = ct.down || 0;
|
const down = ct.down || 0;
|
||||||
if (up === 0 && down === 0) continue;
|
if (up === 0 && down === 0) continue;
|
||||||
|
const current = next[ct.email] ?? { up: 0, down: 0 };
|
||||||
next[ct.email] = {
|
next[ct.email] = {
|
||||||
up: up / TRAFFIC_POLL_INTERVAL_S,
|
up: current.up + up / sampleIntervalSeconds,
|
||||||
down: down / TRAFFIC_POLL_INTERVAL_S,
|
down: current.down + down / sampleIntervalSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
|
setClientSpeedBySource((prev) =>
|
||||||
|
sameSpeedMap(prev[source] ?? {}, next) ? prev : { ...prev, [source]: next },
|
||||||
|
);
|
||||||
|
|
||||||
|
const version = ++clientSpeedSourceVersions.current[source];
|
||||||
|
const previousTimer = clientSpeedExpiryTimers.current[source];
|
||||||
|
if (previousTimer !== undefined) window.clearTimeout(previousTimer);
|
||||||
|
clientSpeedExpiryTimers.current[source] = window.setTimeout(
|
||||||
|
() => {
|
||||||
|
if (clientSpeedSourceVersions.current[source] !== version) return;
|
||||||
|
delete clientSpeedExpiryTimers.current[source];
|
||||||
|
setClientSpeedBySource((prev) => {
|
||||||
|
if (!prev[source]) return prev;
|
||||||
|
const nextSources = { ...prev };
|
||||||
|
delete nextSources[source];
|
||||||
|
return nextSources;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Math.min(sampleIntervalMs * 2, 120_000),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
|
|||||||
import { getHeaderValue } from './headers';
|
import { getHeaderValue } from './headers';
|
||||||
import { canEnableTlsFlow } from './protocol-capabilities';
|
import { canEnableTlsFlow } from './protocol-capabilities';
|
||||||
import { deriveSpiderX } from './spider-x';
|
import { deriveSpiderX } from './spider-x';
|
||||||
|
import { vlessEncryptionAuthKind } from './vless-encryption';
|
||||||
import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
|
import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
|
||||||
|
|
||||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||||
@@ -1723,9 +1724,13 @@ function wgPeerCommentSuffix(peer: unknown): string {
|
|||||||
return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
|
return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the post-quantum key payloads outgrow a QR; the REALITY ML-KEM hint and the
|
||||||
|
// mlkem768x25519plus prefix of an X25519-authenticated encryption do not (#6730).
|
||||||
export function isPostQuantumLink(link: string): boolean {
|
export function isPostQuantumLink(link: string): boolean {
|
||||||
if (/[?&]pqv=/.test(link)) return true;
|
const withoutRemark = link.split('#', 1)[0];
|
||||||
if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
|
const queryStart = withoutRemark.indexOf('?');
|
||||||
if (link.includes('ML-KEM-768')) return true;
|
if (queryStart < 0) return false;
|
||||||
return false;
|
const params = new URLSearchParams(withoutRemark.slice(queryStart + 1));
|
||||||
|
if (params.get('pqv')) return true;
|
||||||
|
return vlessEncryptionAuthKind(params.get('encryption') ?? '')?.startsWith('mlkem768') ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,10 +120,18 @@ const statusPayloadSchema = {
|
|||||||
|
|
||||||
const trafficPayloadSchema = {
|
const trafficPayloadSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['onlineClients', 'onlineByGuid', 'activeInbounds', 'lastOnlineMap'],
|
|
||||||
properties: {
|
properties: {
|
||||||
traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } },
|
traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } },
|
||||||
clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
|
clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
|
||||||
|
clientTrafficSource: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['xray', 'tuic'],
|
||||||
|
description: 'Present for native TUIC samples; omitted Xray samples default to xray.',
|
||||||
|
},
|
||||||
|
clientTrafficIntervalMs: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Sampling interval used to calculate client speed, in milliseconds.',
|
||||||
|
},
|
||||||
nodeTraffics: {
|
nodeTraffics: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
@@ -134,7 +142,20 @@ const trafficPayloadSchema = {
|
|||||||
activeInbounds: stringArrayMap,
|
activeInbounds: stringArrayMap,
|
||||||
lastOnlineMap: timestampMap,
|
lastOnlineMap: timestampMap,
|
||||||
},
|
},
|
||||||
oneOf: [{ required: ['traffics', 'clientTraffics'] }, { required: ['nodeTraffics'] }],
|
oneOf: [
|
||||||
|
{
|
||||||
|
required: [
|
||||||
|
'traffics',
|
||||||
|
'clientTraffics',
|
||||||
|
'onlineClients',
|
||||||
|
'onlineByGuid',
|
||||||
|
'activeInbounds',
|
||||||
|
'lastOnlineMap',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ required: ['nodeTraffics'] },
|
||||||
|
{ required: ['clientTraffics', 'clientTrafficSource', 'clientTrafficIntervalMs'] },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const clientStatsPayloadSchema = {
|
const clientStatsPayloadSchema = {
|
||||||
@@ -205,7 +226,7 @@ export function buildWebSocketEvents(
|
|||||||
{
|
{
|
||||||
type: 'traffic',
|
type: 'traffic',
|
||||||
summary:
|
summary:
|
||||||
'Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.',
|
'Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.',
|
||||||
payloadSchema: trafficPayloadSchema,
|
payloadSchema: trafficPayloadSchema,
|
||||||
example: {
|
example: {
|
||||||
type: 'traffic',
|
type: 'traffic',
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function AttachClientsModal({
|
|||||||
// React resets this during render rather than in an effect so the modal's
|
// React resets this during render rather than in an effect so the modal's
|
||||||
// first open frame already shows cleared fields.
|
// first open frame already shows cleared fields.
|
||||||
const openSource = open ? source : null;
|
const openSource = open ? source : null;
|
||||||
const [syncedSource, setSyncedSource] = useState(openSource);
|
const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
|
||||||
if (openSource !== syncedSource) {
|
if (openSource !== syncedSource) {
|
||||||
setSyncedSource(openSource);
|
setSyncedSource(openSource);
|
||||||
if (openSource) {
|
if (openSource) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function DetachClientsModal({
|
|||||||
|
|
||||||
// Reset during render, not in an effect, so the first frame is already clean.
|
// Reset during render, not in an effect, so the first frame is already clean.
|
||||||
const openSource = open ? source : null;
|
const openSource = open ? source : null;
|
||||||
const [syncedSource, setSyncedSource] = useState(openSource);
|
const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
|
||||||
if (openSource !== syncedSource) {
|
if (openSource !== syncedSource) {
|
||||||
setSyncedSource(openSource);
|
setSyncedSource(openSource);
|
||||||
if (openSource) {
|
if (openSource) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { HttpUtil } from '@/utils';
|
|||||||
|
|
||||||
export default function TuicFields() {
|
export default function TuicFields() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const { control, setValue } = useFormContext();
|
const { control, setValue } = useFormContext();
|
||||||
const [loadingPanelCert, setLoadingPanelCert] = useState(false);
|
const [loadingPanelCert, setLoadingPanelCert] = useState(false);
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export default function TuicFields() {
|
|||||||
const autofillFromSni = () => {
|
const autofillFromSni = () => {
|
||||||
const cleanSni = (sni || '').trim();
|
const cleanSni = (sni || '').trim();
|
||||||
if (!cleanSni) {
|
if (!cleanSni) {
|
||||||
message.warning(t('pages.xray.tuic.sniRequired'));
|
messageApi.warning(t('pages.xray.tuic.sniRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
|
setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
|
||||||
@@ -51,12 +52,12 @@ export default function TuicFields() {
|
|||||||
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
||||||
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
if (!msg?.success) {
|
if (!msg?.success) {
|
||||||
message.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
|
const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
|
||||||
if (!obj?.webCertFile && !obj?.webKeyFile) {
|
if (!obj?.webCertFile && !obj?.webKeyFile) {
|
||||||
message.warning(t('pages.inbounds.setDefaultCertEmpty'));
|
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (obj.webCertFile) {
|
if (obj.webCertFile) {
|
||||||
@@ -65,9 +66,9 @@ export default function TuicFields() {
|
|||||||
if (obj.webKeyFile) {
|
if (obj.webKeyFile) {
|
||||||
setValue('settings.server.private_key', obj.webKeyFile);
|
setValue('settings.server.private_key', obj.webKeyFile);
|
||||||
}
|
}
|
||||||
message.success(t('pages.inbounds.setSuccess'));
|
messageApi.success(t('pages.inbounds.setSuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
message.error(t('somethingWentWrong'));
|
messageApi.error(t('somethingWentWrong'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPanelCert(false);
|
setLoadingPanelCert(false);
|
||||||
}
|
}
|
||||||
@@ -145,6 +146,7 @@ export default function TuicFields() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<Form.Item label={t('pages.inbounds.publicKey')}>
|
<Form.Item label={t('pages.inbounds.publicKey')}>
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
value={certificate}
|
value={certificate}
|
||||||
@@ -164,7 +166,7 @@ export default function TuicFields() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label=" ">
|
<Form.Item label=" ">
|
||||||
<Space>
|
<Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CloudDownloadOutlined />}
|
icon={<CloudDownloadOutlined />}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
.actions-row {
|
.actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
|
|
||||||
.actions-row {
|
.actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function HappSettingsContent({
|
|||||||
remoteSourceBadge,
|
remoteSourceBadge,
|
||||||
}: HappSettingsContentProps) {
|
}: HappSettingsContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
// Generator choices stay local until Apply updates the draft; page Save persists it.
|
// Generator choices stay local until Apply updates the draft; page Save persists it.
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
|
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
|
||||||
const [includeAdblock, setIncludeAdblock] = useState(false);
|
const [includeAdblock, setIncludeAdblock] = useState(false);
|
||||||
@@ -37,18 +38,19 @@ export default function HappSettingsContent({
|
|||||||
const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
|
const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
updateSetting({ subRoutingRules: payload });
|
updateSetting({ subRoutingRules: payload });
|
||||||
message.success(t('pages.settings.subHappPresetApplied'));
|
messageApi.success(t('pages.settings.subHappPresetApplied'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBuildDeeplink = (deeplink: string) => {
|
const handleBuildDeeplink = (deeplink: string) => {
|
||||||
updateSetting({ subRoutingRules: deeplink });
|
updateSetting({ subRoutingRules: deeplink });
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
messageApi.success(t('pages.settings.subHappDeeplinkGenerated'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.subHappAutoDetect')}
|
title={t('pages.settings.subHappAutoDetect')}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function RoutingTab({
|
|||||||
isMobile,
|
isMobile,
|
||||||
}: RoutingTabProps) {
|
}: RoutingTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||||
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
|
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
|
||||||
@@ -179,7 +180,7 @@ export default function RoutingTab({
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(value);
|
parsed = JSON.parse(value);
|
||||||
} catch {
|
} catch {
|
||||||
message.error(t('pages.xray.importInvalidJson'));
|
messageApi.error(t('pages.xray.importInvalidJson'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
|
const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
|
||||||
@@ -191,7 +192,7 @@ export default function RoutingTab({
|
|||||||
? obj.routing!.rules
|
? obj.routing!.rules
|
||||||
: null;
|
: null;
|
||||||
if (!list) {
|
if (!list) {
|
||||||
message.error(t('pages.xray.importInvalidJson'));
|
messageApi.error(t('pages.xray.importInvalidJson'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
@@ -347,6 +348,7 @@ export default function RoutingTab({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
|
{messageContextHolder}
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultActiveKey="basic"
|
defaultActiveKey="basic"
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import AttachClientsModal from '@/pages/inbounds/clients/AttachClientsModal';
|
||||||
|
import { DBInbound } from '@/models/dbinbound';
|
||||||
|
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function sourceInbound() {
|
||||||
|
return new DBInbound({
|
||||||
|
id: 7,
|
||||||
|
port: 443,
|
||||||
|
listen: '',
|
||||||
|
protocol: 'vless',
|
||||||
|
remark: 'edge',
|
||||||
|
enable: true,
|
||||||
|
settings: JSON.stringify({
|
||||||
|
clients: [
|
||||||
|
{ id: 'uuid-1', email: 'alice@test' },
|
||||||
|
{ id: 'uuid-2', email: 'bob@test' },
|
||||||
|
],
|
||||||
|
decryption: 'none',
|
||||||
|
}),
|
||||||
|
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
|
||||||
|
sniffing: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AttachClientsModal', () => {
|
||||||
|
it('lists the source clients, selected, when first mounted already open', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<AttachClientsModal open source={sourceInbound()} dbInbounds={[]} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('alice@test')).toBeTruthy();
|
||||||
|
expect(screen.getByText('bob@test')).toBeTruthy();
|
||||||
|
|
||||||
|
const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||||
|
const rowBoxes = boxes.slice(1);
|
||||||
|
expect(rowBoxes).toHaveLength(2);
|
||||||
|
expect(rowBoxes.every((b) => b.checked)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -115,4 +115,71 @@ describe('client summary always reflects the server, never a client_stats recomp
|
|||||||
|
|
||||||
expect(result.current.summary).toEqual(serverSummary);
|
expect(result.current.summary).toEqual(serverSummary);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('combines independently sampled TUIC and Xray speeds and replaces each source snapshot', async () => {
|
||||||
|
const result = await loadedHook();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.applyTrafficEvent({
|
||||||
|
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 150 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 20, down: 30 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.applyTrafficEvent({
|
||||||
|
clientTrafficSource: 'tuic',
|
||||||
|
clientTrafficIntervalMs: 10_000,
|
||||||
|
clientTraffics: [
|
||||||
|
{ email: 'shared@example.test', up: 300, down: 100 },
|
||||||
|
{ email: 'shared@example.test', up: 100, down: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 60, down: 50 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.applyTrafficEvent({
|
||||||
|
clientTraffics: [{ email: 'shared@example.test', up: 50, down: 25 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 50, down: 25 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.applyTrafficEvent({
|
||||||
|
clientTrafficSource: 'tuic',
|
||||||
|
clientTrafficIntervalMs: 10_000,
|
||||||
|
clientTraffics: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 10, down: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires stale per-source speeds without clearing the other source', async () => {
|
||||||
|
const result = await loadedHook();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
act(() => {
|
||||||
|
result.current.applyTrafficEvent({
|
||||||
|
clientTrafficSource: 'xray',
|
||||||
|
clientTrafficIntervalMs: 1_000,
|
||||||
|
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 200 }],
|
||||||
|
});
|
||||||
|
result.current.applyTrafficEvent({
|
||||||
|
clientTrafficSource: 'tuic',
|
||||||
|
clientTrafficIntervalMs: 2_000,
|
||||||
|
clientTraffics: [{ email: 'shared@example.test', up: 300, down: 400 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 250, down: 400 });
|
||||||
|
|
||||||
|
act(() => vi.advanceTimersByTime(2_000));
|
||||||
|
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 150, down: 200 });
|
||||||
|
|
||||||
|
act(() => vi.advanceTimersByTime(2_000));
|
||||||
|
expect(result.current.clientSpeed).toEqual({});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import DetachClientsModal from '@/pages/inbounds/clients/DetachClientsModal';
|
||||||
|
import { DBInbound } from '@/models/dbinbound';
|
||||||
|
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function sourceInbound() {
|
||||||
|
return new DBInbound({
|
||||||
|
id: 7,
|
||||||
|
port: 443,
|
||||||
|
listen: '',
|
||||||
|
protocol: 'vless',
|
||||||
|
remark: 'edge',
|
||||||
|
enable: true,
|
||||||
|
settings: JSON.stringify({
|
||||||
|
clients: [
|
||||||
|
{ id: 'uuid-1', email: 'alice@test' },
|
||||||
|
{ id: 'uuid-2', email: 'bob@test' },
|
||||||
|
],
|
||||||
|
decryption: 'none',
|
||||||
|
}),
|
||||||
|
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
|
||||||
|
sniffing: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DetachClientsModal', () => {
|
||||||
|
it('lists the attached clients when first mounted already open', async () => {
|
||||||
|
renderWithProviders(<DetachClientsModal open source={sourceInbound()} onClose={() => {}} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('alice@test')).toBeTruthy();
|
||||||
|
expect(screen.getByText('bob@test')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
import { act, cleanup, fireEvent, screen, within } from '@testing-library/react';
|
||||||
import { EditorView } from 'codemirror';
|
import { EditorView } from 'codemirror';
|
||||||
|
|
||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
@@ -319,4 +319,19 @@ describe('Happ routing editor', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||||
expect(generatedProfile()).toEqual(minimal);
|
expect(generatedProfile()).toEqual(minimal);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The static message API outlived the test file and logged act() warnings
|
||||||
|
// after teardown, failing CI with "Closing rpc while onUserConsoleLog was pending".
|
||||||
|
it('takes its toast down with it when unmounted', async () => {
|
||||||
|
renderSettings();
|
||||||
|
openEditor();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||||
|
await screen.findByText('Deeplink generated and applied to routing rules');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toContain(
|
||||||
|
'Deeplink generated and applied to routing rules',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, screen } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import { message } from 'antd';
|
|
||||||
|
|
||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||||
|
|
||||||
import { renderWithProviders } from './test-utils';
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
vi.spyOn(message, 'success').mockImplementation(() => undefined as never);
|
|
||||||
|
|
||||||
const chinaProfile = {
|
const chinaProfile = {
|
||||||
Name: 'Bypass-CN',
|
Name: 'Bypass-CN',
|
||||||
GlobalProxy: 'true',
|
GlobalProxy: 'true',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
genVmessLink,
|
genVmessLink,
|
||||||
genWireguardConfig,
|
genWireguardConfig,
|
||||||
genWireguardLink,
|
genWireguardLink,
|
||||||
|
isPostQuantumLink,
|
||||||
preferPublicHost,
|
preferPublicHost,
|
||||||
resolveAddr,
|
resolveAddr,
|
||||||
} from '@/lib/xray/inbound-link';
|
} from '@/lib/xray/inbound-link';
|
||||||
@@ -1415,3 +1416,46 @@ describe('genTuicLink', () => {
|
|||||||
expect(link).not.toContain('#TUIC-Node-US-US');
|
expect(link).not.toContain('#TUIC-Node-US-US');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isPostQuantumLink', () => {
|
||||||
|
type RealityFixture = {
|
||||||
|
settings: { clients: Array<{ id: string }>; encryption?: string };
|
||||||
|
streamSettings: { realitySettings: { settings: { mldsa65Verify?: string } } };
|
||||||
|
};
|
||||||
|
const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
|
||||||
|
const clientId = (raw as RealityFixture).settings.clients[0].id;
|
||||||
|
const x25519Key = 'G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
|
||||||
|
const mlkem768Key = 'A'.repeat(1579);
|
||||||
|
|
||||||
|
function realityLink(edit: (inbound: RealityFixture) => void = () => {}): string {
|
||||||
|
const copy = structuredClone(raw) as RealityFixture;
|
||||||
|
edit(copy);
|
||||||
|
return genVlessLink({ inbound: InboundSchema.parse(copy), address: 'example.test', clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// #6730: the REALITY ML-KEM support hint is a short flag, not a large PQ payload.
|
||||||
|
it('keeps the QR for a plain REALITY link', () => {
|
||||||
|
expect(isPostQuantumLink(realityLink())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the QR for VLESS encryption authenticated by an X25519 key', () => {
|
||||||
|
const link = realityLink((ib) => {
|
||||||
|
ib.settings.encryption = `mlkem768x25519plus.native.0rtt.${x25519Key}`;
|
||||||
|
});
|
||||||
|
expect(isPostQuantumLink(link)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the QR for VLESS encryption authenticated by an ML-KEM-768 key', () => {
|
||||||
|
const link = realityLink((ib) => {
|
||||||
|
ib.settings.encryption = `mlkem768x25519plus.native.0rtt.${mlkem768Key}`;
|
||||||
|
});
|
||||||
|
expect(isPostQuantumLink(link)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the QR for a REALITY link carrying an ML-DSA-65 verify key', () => {
|
||||||
|
const link = realityLink((ib) => {
|
||||||
|
ib.streamSettings.realitySettings.settings.mldsa65Verify = 'B'.repeat(2603);
|
||||||
|
});
|
||||||
|
expect(isPostQuantumLink(link)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { join, relative, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const srcRoot = resolve(fileURLToPath(import.meta.url), '../..');
|
||||||
|
const staticCall = /(?<![\w.$])message\.(success|error|warning|info|loading|open)\(/;
|
||||||
|
|
||||||
|
function sourceFiles(dir: string): string[] {
|
||||||
|
return readdirSync(dir).flatMap((name) => {
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) return name === 'test' ? [] : sourceFiles(path);
|
||||||
|
return /\.tsx?$/.test(name) ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// antd's static message renders outside React: it ignores the theme and its
|
||||||
|
// timers outlive the component, which broke CI after the Happ tests tore down.
|
||||||
|
describe('antd message', () => {
|
||||||
|
it('is only used through message.useMessage()', () => {
|
||||||
|
const offenders = sourceFiles(srcRoot).flatMap((file) =>
|
||||||
|
readFileSync(file, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.flatMap((line, i) =>
|
||||||
|
staticCall.test(line) ? [`${relative(srcRoot, file)}:${i + 1}: ${line.trim()}`] : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+168
@@ -1384,6 +1384,169 @@ setup_fail2ban() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Major version of the local systemd, 0 when it cannot be determined. The
|
||||||
|
# SystemCallFilter=@system-service group only exists from systemd 239 on (other
|
||||||
|
# @-named groups exist since 231); on older versions an unknown group is not
|
||||||
|
# ignored safely, the filter stays in force and leaves a whitelist the panel
|
||||||
|
# cannot run under.
|
||||||
|
_xui_systemd_major_version() {
|
||||||
|
local version=""
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')"
|
||||||
|
fi
|
||||||
|
if [[ ! "$version" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo 0
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The shipped units list hardening that older systemd does not know: the
|
||||||
|
# directive is logged and ignored at load time rather than rejected, so the
|
||||||
|
# panel still starts, only without that protection. Each entry is the systemd
|
||||||
|
# release that introduced the directive (systemd.exec(5)); everything else in
|
||||||
|
# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219).
|
||||||
|
# SystemCallFilter= is listed because the drop-in only writes it from 239 on.
|
||||||
|
_xui_warn_unsupported_hardening() {
|
||||||
|
local version entry missing=""
|
||||||
|
version="$(_xui_systemd_major_version)"
|
||||||
|
[[ "$version" -gt 0 ]] || return 0
|
||||||
|
for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \
|
||||||
|
ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \
|
||||||
|
SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \
|
||||||
|
ProtectKernelLogs:244 ProtectClock:245; do
|
||||||
|
if [[ "$version" -lt "${entry##*:}" ]]; then
|
||||||
|
missing="${missing:+$missing, }${entry%%:*} (${entry##*:})"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ -n "$missing" ]] || return 0
|
||||||
|
echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}"
|
||||||
|
echo " Not applied, needs a newer systemd: ${missing}."
|
||||||
|
if [[ "$version" -lt 231 ]]; then
|
||||||
|
echo " The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs."
|
||||||
|
fi
|
||||||
|
echo " The rest of the hardening is in force. Upgrade systemd to apply the above."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem=
|
||||||
|
# strict would make the whole hierarchy read-only (only the kernel API
|
||||||
|
# filesystems stay as they are), and that would break the panel's own use of
|
||||||
|
# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER,
|
||||||
|
# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place
|
||||||
|
# the files in -- the unit's WorkingDirectory on a stock install, and what a
|
||||||
|
# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit
|
||||||
|
# either misses a relocated store -- the panel then cannot write its own SQLite
|
||||||
|
# database and sits in a Restart=on-failure loop -- or forces the operator to
|
||||||
|
# edit a file that every install/update overwrites from the release tarball.
|
||||||
|
# install.sh and update.sh therefore regenerate the drop-in from the folders
|
||||||
|
# actually in use, and the unit's own ReadWritePaths only carry the
|
||||||
|
# plain-install defaults. A relocated store means re-running install or update:
|
||||||
|
# the drop-in is only written here.
|
||||||
|
_xui_service_write_paths_dropin() {
|
||||||
|
# $1 is the env file to resolve the XUI_* folders from; callers pass nothing
|
||||||
|
# and get the OS-specific path the unit itself uses.
|
||||||
|
local env_file="${1:-}"
|
||||||
|
local dropin_dir dropin temp_file
|
||||||
|
local db_folder log_folder bin_folder main_folder
|
||||||
|
local path line="" whitespace_paths="" seen_paths="" escaped_path
|
||||||
|
|
||||||
|
if [[ -z "$env_file" ]]; then
|
||||||
|
case "${release}" in
|
||||||
|
ubuntu | debian | armbian)
|
||||||
|
env_file="/etc/default/x-ui"
|
||||||
|
;;
|
||||||
|
arch | manjaro | parch | alpine)
|
||||||
|
env_file="/etc/conf.d/x-ui"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
env_file="/etc/sysconfig/x-ui"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [[ -r "$env_file" ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$env_file"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# XUI_* wins over the script's own default: the unit hands that same env
|
||||||
|
# file to the panel through EnvironmentFile=, so these are the folders it
|
||||||
|
# will actually use.
|
||||||
|
main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}"
|
||||||
|
db_folder="${XUI_DB_FOLDER:-/etc/x-ui}"
|
||||||
|
log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||||
|
# An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working
|
||||||
|
# directory, which the unit sets to the main folder.
|
||||||
|
bin_folder="${XUI_BIN_FOLDER:-bin}"
|
||||||
|
if [[ "$bin_folder" != /* ]]; then
|
||||||
|
bin_folder="${main_folder%/}/${bin_folder#./}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do
|
||||||
|
[[ "$path" == /* ]] || continue
|
||||||
|
# ReadWritePaths= is a whitespace-separated list, and a folder whose
|
||||||
|
# name contains whitespace cannot be written into it without relying on
|
||||||
|
# quoting. A wrong entry makes systemd reject the whole drop-in and the
|
||||||
|
# panel would not start, so leave such a folder out and say so instead.
|
||||||
|
if [[ "$path" != "${path//[[:space:]]/}" ]]; then
|
||||||
|
whitespace_paths="${whitespace_paths:+$whitespace_paths }$path"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case " $seen_paths " in
|
||||||
|
*" $path "*) continue ;;
|
||||||
|
esac
|
||||||
|
seen_paths="${seen_paths}${seen_paths:+ }$path"
|
||||||
|
# systemd expands %-specifiers in unit files, so a folder name carrying
|
||||||
|
# a literal % has to be written as %%, or the entry stops naming the
|
||||||
|
# folder systemd is meant to keep writable.
|
||||||
|
escaped_path="${path//%/%%}"
|
||||||
|
line="${line} -${escaped_path}"
|
||||||
|
done
|
||||||
|
if [[ -n "$whitespace_paths" ]]; then
|
||||||
|
echo "Warning: these folders contain whitespace and were left out of" >&2
|
||||||
|
echo " 10-xui-sandbox.conf: $whitespace_paths" >&2
|
||||||
|
echo " The panel cannot write to them under the unit's sandbox." >&2
|
||||||
|
fi
|
||||||
|
line="${line# }"
|
||||||
|
[[ -n "$line" ]] || return 1
|
||||||
|
|
||||||
|
dropin_dir="${xui_service}/x-ui.service.d"
|
||||||
|
dropin="${dropin_dir}/10-xui-sandbox.conf"
|
||||||
|
temp_file="${dropin}.tmp.$$"
|
||||||
|
|
||||||
|
mkdir -p "$dropin_dir" || return 1
|
||||||
|
cat > "$temp_file" << EOF
|
||||||
|
# Regenerated by install.sh/update.sh on every install and update: edits here
|
||||||
|
# are lost, and the list only reflects the XUI_* variables read from
|
||||||
|
# ${env_file} at that moment. Re-run install/update after moving a store.
|
||||||
|
# It lists the folders the panel writes to. Put local additions in their own
|
||||||
|
# drop-in, for example 20-x-ui-local.conf, which nothing here touches.
|
||||||
|
[Service]
|
||||||
|
ReadWritePaths=${line}
|
||||||
|
ReadWriteDirectories=${line}
|
||||||
|
EOF
|
||||||
|
if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then
|
||||||
|
cat >> "$temp_file" << 'EOF'
|
||||||
|
# @system-service needs systemd >= 239; on older versions the unknown group
|
||||||
|
# would leave the panel with a filter it cannot start under (x-ui.service.*).
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$temp_file" ]]; then
|
||||||
|
rm -f "$temp_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
chmod 644 "$temp_file"
|
||||||
|
mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; }
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
|
# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
|
||||||
# atomic mv, so a failed cp/curl or an interrupted mv never leaves a
|
# atomic mv, so a failed cp/curl or an interrupted mv never leaves a
|
||||||
# truncated unit file at the live path -- systemd would then fail to parse
|
# truncated unit file at the live path -- systemd would then fail to parse
|
||||||
@@ -1415,6 +1578,11 @@ _install_xui_service_unit() {
|
|||||||
rm -f "$temp_file"
|
rm -f "$temp_file"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
if ! _xui_service_write_paths_dropin; then
|
||||||
|
echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}"
|
||||||
|
echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}"
|
||||||
|
fi
|
||||||
|
_xui_warn_unsupported_hardening
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// Package nodee2e drives a real master panel and a real node panel, each its own
|
||||||
|
// process, through the node-sync paths. Gated by XUI_NODE_E2E_BINARY.
|
||||||
|
package nodee2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
const settleTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
func panelBinary(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
bin := os.Getenv("XUI_NODE_E2E_BINARY")
|
||||||
|
if bin == "" {
|
||||||
|
t.Skip("XUI_NODE_E2E_BINARY not set; run `make node-e2e`")
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(bin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve %s: %v", bin, err)
|
||||||
|
}
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
|
||||||
|
type panel struct {
|
||||||
|
t *testing.T
|
||||||
|
name string
|
||||||
|
bin string
|
||||||
|
dir string
|
||||||
|
port int
|
||||||
|
token string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
logOut *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("free port: %v", err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
return l.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) env() []string {
|
||||||
|
return append(os.Environ(),
|
||||||
|
"XUI_DB_FOLDER="+filepath.Join(p.dir, "db"),
|
||||||
|
"XUI_LOG_FOLDER="+filepath.Join(p.dir, "log"),
|
||||||
|
"XUI_BIN_FOLDER="+filepath.Join(p.dir, "bin"),
|
||||||
|
"XUI_ENABLE_FAIL2BAN=false",
|
||||||
|
"MSYS_NO_PATHCONV=1",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) cli(args ...string) string {
|
||||||
|
p.t.Helper()
|
||||||
|
cmd := exec.Command(p.bin, args...)
|
||||||
|
cmd.Env = p.env()
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
p.t.Fatalf("%s %v: %v\n%s", p.name, args, err, out)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiTokenLine = regexp.MustCompile(`(?m)^apiToken:\s*(\S+)`)
|
||||||
|
|
||||||
|
func (p *panel) mintToken(name, scope string) string {
|
||||||
|
p.t.Helper()
|
||||||
|
out := p.cli("setting", "-getApiToken", "-tokenName", name, "-tokenScope", scope)
|
||||||
|
m := apiTokenLine.FindStringSubmatch(out)
|
||||||
|
if m == nil {
|
||||||
|
p.t.Fatalf("%s: no apiToken in output:\n%s", p.name, out)
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPanel prepares a panel's database: credentials, a private port, its own
|
||||||
|
// sub-server port (two panels on one host would race for 2096) and an admin token.
|
||||||
|
func newPanel(t *testing.T, bin, name string) *panel {
|
||||||
|
t.Helper()
|
||||||
|
p := preparePanel(t, bin, name)
|
||||||
|
p.token = p.mintToken("e2e-driver", "admin")
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedDBMu guards the process-global database handle the harness borrows
|
||||||
|
// while the scopes run in parallel.
|
||||||
|
var sharedDBMu sync.Mutex
|
||||||
|
|
||||||
|
func preparePanel(t *testing.T, bin, name string) *panel {
|
||||||
|
t.Helper()
|
||||||
|
p := &panel{t: t, name: name, bin: bin, dir: t.TempDir(), port: freePort(t)}
|
||||||
|
for _, d := range []string{"db", "log", "bin"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(p.dir, d), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.cli("setting", "-username", "e2e", "-password", "e2e-pass", "-port", strconv.Itoa(p.port), "-webBasePath", "/")
|
||||||
|
sharedDBMu.Lock()
|
||||||
|
defer sharedDBMu.Unlock()
|
||||||
|
if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("%s: open db: %v", name, err)
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
db.Exec("DELETE FROM settings WHERE key = ?", "subPort")
|
||||||
|
if err := db.Exec("INSERT INTO settings(key, value) VALUES (?, ?)", "subPort", strconv.Itoa(freePort(t))).Error; err != nil {
|
||||||
|
t.Fatalf("%s: set subPort: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := database.CloseDB(); err != nil {
|
||||||
|
t.Fatalf("%s: close db: %v", name, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(p.stop)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) start() {
|
||||||
|
p.t.Helper()
|
||||||
|
logOut, err := os.OpenFile(filepath.Join(p.dir, "stdout.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
p.t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.logOut = logOut
|
||||||
|
p.cmd = exec.Command(p.bin, "run")
|
||||||
|
p.cmd.Env = p.env()
|
||||||
|
p.cmd.Stdout = logOut
|
||||||
|
p.cmd.Stderr = logOut
|
||||||
|
if err := p.cmd.Start(); err != nil {
|
||||||
|
p.t.Fatalf("%s: start: %v", p.name, err)
|
||||||
|
}
|
||||||
|
eventually(p.t, settleTimeout, p.name+" answers /server/status", func() (bool, string) {
|
||||||
|
env, err := p.try(http.MethodGet, "/panel/api/server/status", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
return env.Success, env.Msg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) stop() {
|
||||||
|
if p.cmd == nil || p.cmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = p.cmd.Process.Kill()
|
||||||
|
_, _ = p.cmd.Process.Wait()
|
||||||
|
p.cmd = nil
|
||||||
|
if p.logOut != nil {
|
||||||
|
_ = p.logOut.Close()
|
||||||
|
p.logOut = nil
|
||||||
|
}
|
||||||
|
if p.t.Failed() {
|
||||||
|
if b, err := os.ReadFile(filepath.Join(p.dir, "stdout.log")); err == nil {
|
||||||
|
tail := string(b)
|
||||||
|
if len(tail) > 6000 {
|
||||||
|
tail = tail[len(tail)-6000:]
|
||||||
|
}
|
||||||
|
p.t.Logf("---- %s stdout tail ----\n%s", p.name, tail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteInboundRow simulates a node that lost an inbound (restore, reinstall)
|
||||||
|
// while stopped; it must not run against a live panel.
|
||||||
|
func (p *panel) deleteInboundRow(id int) {
|
||||||
|
p.t.Helper()
|
||||||
|
if p.cmd != nil {
|
||||||
|
p.t.Fatalf("%s: deleteInboundRow on a running panel", p.name)
|
||||||
|
}
|
||||||
|
sharedDBMu.Lock()
|
||||||
|
defer sharedDBMu.Unlock()
|
||||||
|
if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
|
||||||
|
p.t.Fatalf("%s: open db: %v", p.name, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.CloseDB() }()
|
||||||
|
db := database.GetDB()
|
||||||
|
for _, q := range []string{"DELETE FROM client_inbounds WHERE inbound_id = ?", "DELETE FROM client_traffics WHERE inbound_id = ?", "DELETE FROM inbounds WHERE id = ?"} {
|
||||||
|
if err := db.Exec(q, id).Error; err != nil {
|
||||||
|
p.t.Fatalf("%s: %s: %v", p.name, q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) url() string { return "http://127.0.0.1:" + strconv.Itoa(p.port) }
|
||||||
|
|
||||||
|
type envelope struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Obj json.RawMessage `json:"obj"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) try(method, path string, body any) (*envelope, error) {
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, p.url()+path, rd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
|
||||||
|
}
|
||||||
|
var env envelope
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s: %w (%s)", path, err, raw)
|
||||||
|
}
|
||||||
|
return &env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// call fails the test on transport errors or success:false.
|
||||||
|
func (p *panel) call(method, path string, body any) json.RawMessage {
|
||||||
|
p.t.Helper()
|
||||||
|
env, err := p.try(method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
p.t.Fatalf("%s %s %s: %v", p.name, method, path, err)
|
||||||
|
}
|
||||||
|
if !env.Success {
|
||||||
|
p.t.Fatalf("%s %s %s: success=false msg=%q", p.name, method, path, env.Msg)
|
||||||
|
}
|
||||||
|
return env.Obj
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventually(t *testing.T, timeout time.Duration, what string, check func() (bool, string)) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
last := ""
|
||||||
|
for {
|
||||||
|
ok, detail := check()
|
||||||
|
if ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last = detail
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out after %s waiting for %s; last: %s", timeout, what, last)
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inboundView is the subset of an inbound row the scenarios assert on.
|
||||||
|
type inboundView struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
NodeID *int `json:"nodeId"`
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientEntry map[string]any
|
||||||
|
|
||||||
|
func (c clientEntry) email() string { s, _ := c["email"].(string); return s }
|
||||||
|
|
||||||
|
func (ib inboundView) clients() []clientEntry {
|
||||||
|
raw := ib.Settings
|
||||||
|
var asString string
|
||||||
|
if json.Unmarshal(raw, &asString) == nil {
|
||||||
|
raw = json.RawMessage(asString)
|
||||||
|
}
|
||||||
|
var s struct {
|
||||||
|
Clients []clientEntry `json:"clients"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &s)
|
||||||
|
return s.Clients
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ib inboundView) emails() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range ib.clients() {
|
||||||
|
out = append(out, c.email())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ib inboundView) client(email string) clientEntry {
|
||||||
|
for _, c := range ib.clients() {
|
||||||
|
if strings.EqualFold(c.email(), email) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) inbounds() []inboundView {
|
||||||
|
p.t.Helper()
|
||||||
|
var list []inboundView
|
||||||
|
if err := json.Unmarshal(p.call(http.MethodGet, "/panel/api/inbounds/list", nil), &list); err != nil {
|
||||||
|
p.t.Fatalf("%s: decode inbound list: %v", p.name, err)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) inboundOnPort(port int) (inboundView, bool) {
|
||||||
|
p.t.Helper()
|
||||||
|
for _, ib := range p.inbounds() {
|
||||||
|
if ib.Port == port {
|
||||||
|
return ib, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inboundView{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
const tcpStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`
|
||||||
|
|
||||||
|
func vlessInbound(remark string, port int, nodeID *int, clients ...map[string]any) map[string]any {
|
||||||
|
if clients == nil {
|
||||||
|
clients = []map[string]any{}
|
||||||
|
}
|
||||||
|
settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
|
||||||
|
body := map[string]any{
|
||||||
|
"remark": remark, "enable": true, "port": port, "protocol": "vless",
|
||||||
|
"settings": string(settings), "streamSettings": tcpStream, "sniffing": `{}`,
|
||||||
|
}
|
||||||
|
if nodeID != nil {
|
||||||
|
body["nodeId"] = *nodeID
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func vlessClient(email string) map[string]any {
|
||||||
|
return map[string]any{"email": email, "enable": true, "id": newUUID(email)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUUID derives a stable, valid UUID from a label so failures are reproducible.
|
||||||
|
func newUUID(label string) string {
|
||||||
|
var b [16]byte
|
||||||
|
copy(b[:], []byte(label+"________________"))
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package nodee2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNodeSync walks one master/node pair per enrollment scope through every
|
||||||
|
// operation that must converge onto the node. Each subtest names its invariant.
|
||||||
|
func TestNodeSync(t *testing.T) {
|
||||||
|
bin := panelBinary(t)
|
||||||
|
for _, scope := range []string{"admin", "node-sync"} {
|
||||||
|
t.Run("enrolled with "+scope+" token", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
runNodeSyncScenarios(t, bin, scope)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pair struct {
|
||||||
|
t *testing.T
|
||||||
|
master *panel
|
||||||
|
node *panel
|
||||||
|
nodeID int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *pair) nodeInbound(port int) (inboundView, bool) { return pr.node.inboundOnPort(port) }
|
||||||
|
|
||||||
|
func (pr *pair) masterInbound(port int) (inboundView, bool) {
|
||||||
|
for _, ib := range pr.master.inbounds() {
|
||||||
|
if ib.Port == port && ib.NodeID != nil && *ib.NodeID == pr.nodeID {
|
||||||
|
return ib, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inboundView{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *pair) waitNode(what string, port int, ok func(inboundView) bool) {
|
||||||
|
pr.t.Helper()
|
||||||
|
eventually(pr.t, settleTimeout, what, func() (bool, string) {
|
||||||
|
ib, found := pr.nodeInbound(port)
|
||||||
|
if !found {
|
||||||
|
return ok(inboundView{}) && false, fmt.Sprintf("node has no inbound on %d", port)
|
||||||
|
}
|
||||||
|
return ok(ib), fmt.Sprintf("node inbound %d: enable=%v remark=%q emails=%v", port, ib.Enable, ib.Remark, ib.emails())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *pair) waitNodeAbsent(what string, port int) {
|
||||||
|
pr.t.Helper()
|
||||||
|
eventually(pr.t, settleTimeout, what, func() (bool, string) {
|
||||||
|
ib, found := pr.nodeInbound(port)
|
||||||
|
return !found, fmt.Sprintf("node still has inbound %d with %v", port, ib.emails())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *pair) bulkAttach(emails []string, masterInboundID int) {
|
||||||
|
pr.t.Helper()
|
||||||
|
var res struct {
|
||||||
|
Attached []string `json:"attached"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
obj := pr.master.call(http.MethodPost, "/panel/api/clients/bulkAttach", map[string]any{"emails": emails, "inboundIds": []int{masterInboundID}})
|
||||||
|
if err := json.Unmarshal(obj, &res); err != nil {
|
||||||
|
pr.t.Fatalf("decode bulkAttach: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Errors) != 0 || len(res.Attached) != len(emails) {
|
||||||
|
pr.t.Fatalf("bulkAttach attached=%d/%d errors=%v", len(res.Attached), len(emails), res.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailRange(prefix string, from, to int) []string {
|
||||||
|
out := make([]string, 0, to-from+1)
|
||||||
|
for i := from; i <= to; i++ {
|
||||||
|
out = append(out, prefix+strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAll(have []string, want ...string) bool {
|
||||||
|
for _, w := range want {
|
||||||
|
if !slices.Contains(have, w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNodeSyncScenarios(t *testing.T, bin, scope string) {
|
||||||
|
master := newPanel(t, bin, "master")
|
||||||
|
node := newPanel(t, bin, "node")
|
||||||
|
linkToken := node.mintToken("master-link", scope)
|
||||||
|
master.start()
|
||||||
|
node.start()
|
||||||
|
pr := &pair{t: t, master: master, node: node}
|
||||||
|
|
||||||
|
var (
|
||||||
|
adoptedPort = freePort(t)
|
||||||
|
madePort = freePort(t)
|
||||||
|
lostPort = freePort(t)
|
||||||
|
droppedPort = freePort(t)
|
||||||
|
offlinePort = freePort(t)
|
||||||
|
unmanagedPort = freePort(t)
|
||||||
|
localPort = freePort(t)
|
||||||
|
)
|
||||||
|
node.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("pre-existing", adoptedPort, nil))
|
||||||
|
|
||||||
|
local := master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("local-pool", localPort, nil))
|
||||||
|
var localIb inboundView
|
||||||
|
_ = json.Unmarshal(local, &localIb)
|
||||||
|
for _, email := range emailRange("p", 1, 45) {
|
||||||
|
master.call(http.MethodPost, "/panel/api/clients/add", map[string]any{
|
||||||
|
"client": map[string]any{"email": email, "enable": true}, "inboundIds": []int{localIb.Id},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeView struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
}
|
||||||
|
obj := master.call(http.MethodPost, "/panel/api/nodes/add", map[string]any{
|
||||||
|
"name": "n1", "scheme": "http", "address": "127.0.0.1", "port": node.port, "basePath": "/",
|
||||||
|
"apiToken": linkToken, "enable": true, "allowPrivateAddress": true,
|
||||||
|
})
|
||||||
|
if err := json.Unmarshal(obj, &nodeView); err != nil || nodeView.Id == 0 {
|
||||||
|
t.Fatalf("decode node add: %v (%s)", err, obj)
|
||||||
|
}
|
||||||
|
pr.nodeID = nodeView.Id
|
||||||
|
|
||||||
|
var adoptedID, madeID int
|
||||||
|
t.Run("an inbound already on the node is adopted by the master", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
eventually(t, settleTimeout, "master adopts the node inbound", func() (bool, string) {
|
||||||
|
ib, ok := pr.masterInbound(adoptedPort)
|
||||||
|
adoptedID = ib.Id
|
||||||
|
return ok, "not adopted yet"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if adoptedID == 0 {
|
||||||
|
t.Fatal("no adopted inbound; later scenarios depend on it")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("an inbound created on the master for the node lands there with its clients", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
obj := master.call(http.MethodPost, "/panel/api/inbounds/add",
|
||||||
|
vlessInbound("made-on-master", madePort, &pr.nodeID, vlessClient("m1"), vlessClient("m2")))
|
||||||
|
var ib inboundView
|
||||||
|
_ = json.Unmarshal(obj, &ib)
|
||||||
|
madeID = ib.Id
|
||||||
|
pr.waitNode("node holds the master-made inbound", madePort, func(ib inboundView) bool {
|
||||||
|
return hasAll(ib.emails(), "m1", "m2")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("editing the inbound on the master updates the node and keeps its clients", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
body := vlessInbound("renamed-on-master", madePort, &pr.nodeID)
|
||||||
|
body["settings"] = `{"decryption":"none"}`
|
||||||
|
master.call(http.MethodPost, "/panel/api/inbounds/update/"+strconv.Itoa(madeID), body)
|
||||||
|
pr.waitNode("node shows the new remark with both clients", madePort, func(ib inboundView) bool {
|
||||||
|
return ib.Remark == "renamed-on-master" && hasAll(ib.emails(), "m1", "m2")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("attaching a few existing clients reaches the node", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
pr.bulkAttach([]string{"p1", "p2", "p3"}, madeID)
|
||||||
|
pr.waitNode("node holds p1..p3", madePort, func(ib inboundView) bool {
|
||||||
|
return hasAll(ib.emails(), "m1", "m2", "p1", "p2", "p3")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("attaching more clients than the per-client push limit reaches the node", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
emails := emailRange("p", 4, 43)
|
||||||
|
pr.bulkAttach(emails, adoptedID)
|
||||||
|
pr.waitNode("node holds all 40", adoptedPort, func(ib inboundView) bool {
|
||||||
|
return hasAll(ib.emails(), emails...)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disabling a client on the master disables it on the node", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
mib, _ := pr.masterInbound(madePort)
|
||||||
|
entry := mib.client("p1")
|
||||||
|
if entry == nil {
|
||||||
|
t.Fatalf("master inbound has no p1: %v", mib.emails())
|
||||||
|
}
|
||||||
|
entry["enable"] = false
|
||||||
|
master.call(http.MethodPost, "/panel/api/clients/update/p1", entry)
|
||||||
|
pr.waitNode("node p1 disabled", madePort, func(ib inboundView) bool {
|
||||||
|
c := ib.client("p1")
|
||||||
|
return c != nil && c["enable"] == false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("detaching a client from the node inbound removes it there", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
master.call(http.MethodPost, "/panel/api/clients/p2/detach", map[string]any{"inboundIds": []int{madeID}})
|
||||||
|
pr.waitNode("node drops p2", madePort, func(ib inboundView) bool {
|
||||||
|
return ib.client("p2") == nil && ib.client("p3") != nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deleting a client on the master removes it from the node", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
master.call(http.MethodPost, "/panel/api/clients/del/p4", nil)
|
||||||
|
pr.waitNode("node drops p4", adoptedPort, func(ib inboundView) bool {
|
||||||
|
return ib.client("p4") == nil && ib.client("p5") != nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("switching the inbound off on the master switches it off on the node", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
master.call(http.MethodPost, "/panel/api/inbounds/setEnable/"+strconv.Itoa(madeID), map[string]any{"enable": false})
|
||||||
|
pr.waitNode("node inbound disabled", madePort, func(ib inboundView) bool { return !ib.Enable })
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("node traffic reaches the master and a master reset clears the node", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
node.call(http.MethodPost, "/panel/api/clients/updateTraffic/p5", map[string]any{"upload": 1000, "download": 2000})
|
||||||
|
usage := func(p *panel) int64 {
|
||||||
|
var tr struct{ Up, Down int64 }
|
||||||
|
_ = json.Unmarshal(p.call(http.MethodGet, "/panel/api/clients/traffic/p5", nil), &tr)
|
||||||
|
return tr.Up + tr.Down
|
||||||
|
}
|
||||||
|
eventually(t, settleTimeout, "master sees p5's node traffic", func() (bool, string) {
|
||||||
|
u := usage(master)
|
||||||
|
return u == 3000, fmt.Sprintf("master p5 usage %d", u)
|
||||||
|
})
|
||||||
|
master.call(http.MethodPost, "/panel/api/clients/resetTraffic/p5", nil)
|
||||||
|
eventually(t, settleTimeout, "node p5 usage reset", func() (bool, string) {
|
||||||
|
u := usage(node)
|
||||||
|
return u == 0, fmt.Sprintf("node p5 usage %d", u)
|
||||||
|
})
|
||||||
|
time.Sleep(12 * time.Second)
|
||||||
|
if u := usage(master); u != 0 {
|
||||||
|
t.Fatalf("master p5 usage %d after reset settled, want 0", u)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an inbound deleted on the node is removed from the master too", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("deleted-on-node", lostPort, &pr.nodeID, vlessClient("l1")))
|
||||||
|
pr.waitNode("node holds the inbound", lostPort, func(ib inboundView) bool { return ib.client("l1") != nil })
|
||||||
|
nib, _ := pr.nodeInbound(lostPort)
|
||||||
|
node.call(http.MethodPost, "/panel/api/inbounds/del/"+strconv.Itoa(nib.Id), nil)
|
||||||
|
eventually(t, settleTimeout, "master mirrors the node-side delete (#6219)", func() (bool, string) {
|
||||||
|
_, still := pr.masterInbound(lostPort)
|
||||||
|
return !still, "master still has the inbound"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deleting the inbound on the master removes it from the node", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
obj := master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("deleted-on-master", droppedPort, &pr.nodeID, vlessClient("d1")))
|
||||||
|
var ib inboundView
|
||||||
|
_ = json.Unmarshal(obj, &ib)
|
||||||
|
pr.waitNode("node holds the inbound", droppedPort, func(ib inboundView) bool { return ib.client("d1") != nil })
|
||||||
|
master.call(http.MethodPost, "/panel/api/inbounds/del/"+strconv.Itoa(ib.Id), nil)
|
||||||
|
pr.waitNodeAbsent("node drops the inbound", droppedPort)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a change made while the node is down reaches it once it is back", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
node.stop()
|
||||||
|
pr.bulkAttach([]string{"p44"}, adoptedID)
|
||||||
|
node.start()
|
||||||
|
pr.waitNode("node holds p44 after restart", adoptedPort, func(ib inboundView) bool {
|
||||||
|
return ib.client("p44") != nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an inbound the node lost while down is re-created with the master's pending change", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
nib, ok := pr.nodeInbound(adoptedPort)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("node has no adopted inbound to lose")
|
||||||
|
}
|
||||||
|
node.stop()
|
||||||
|
node.deleteInboundRow(nib.Id)
|
||||||
|
pr.bulkAttach([]string{"p45"}, adoptedID)
|
||||||
|
node.start()
|
||||||
|
pr.waitNode("node re-creates the inbound with p44 and p45", adoptedPort, func(ib inboundView) bool {
|
||||||
|
return ib.client("p44") != nil && ib.client("p45") != nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an inbound created and edited while the node is down lands once it is back", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
node.stop()
|
||||||
|
obj := master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("made-while-down", offlinePort, &pr.nodeID, vlessClient("o1")))
|
||||||
|
var ib inboundView
|
||||||
|
_ = json.Unmarshal(obj, &ib)
|
||||||
|
body := vlessInbound("edited-while-down", offlinePort, &pr.nodeID)
|
||||||
|
body["settings"] = `{"decryption":"none"}`
|
||||||
|
master.call(http.MethodPost, "/panel/api/inbounds/update/"+strconv.Itoa(ib.Id), body)
|
||||||
|
node.start()
|
||||||
|
pr.waitNode("node holds the edited inbound with o1", offlinePort, func(ib inboundView) bool {
|
||||||
|
return ib.Remark == "edited-while-down" && ib.client("o1") != nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a change made while the node is disabled on the master lands once it is re-enabled", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
nodePath := "/panel/api/nodes/setEnable/" + strconv.Itoa(pr.nodeID)
|
||||||
|
master.call(http.MethodPost, nodePath, map[string]any{"enable": false})
|
||||||
|
pr.bulkAttach([]string{"p6"}, madeID)
|
||||||
|
time.Sleep(6 * time.Second)
|
||||||
|
if ib, _ := pr.nodeInbound(madePort); ib.client("p6") != nil {
|
||||||
|
t.Fatal("a disabled node received a push")
|
||||||
|
}
|
||||||
|
master.call(http.MethodPost, nodePath, map[string]any{"enable": true})
|
||||||
|
pr.waitNode("node holds p6 after re-enable", madePort, func(ib inboundView) bool { return ib.client("p6") != nil })
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("selected sync mode leaves the node's unselected inbounds alone", func(t *testing.T) {
|
||||||
|
pr.t = t
|
||||||
|
var selected []string
|
||||||
|
for _, ib := range master.inbounds() {
|
||||||
|
if ib.NodeID != nil && *ib.NodeID == pr.nodeID {
|
||||||
|
selected = append(selected, ib.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
master.call(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(pr.nodeID), map[string]any{
|
||||||
|
"name": "n1", "scheme": "http", "address": "127.0.0.1", "port": node.port, "basePath": "/",
|
||||||
|
"enable": true, "allowPrivateAddress": true, "inboundSyncMode": "selected", "inboundTags": selected,
|
||||||
|
})
|
||||||
|
node.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("node-only", unmanagedPort, nil, vlessClient("u1")))
|
||||||
|
pr.bulkAttach([]string{"p7"}, madeID)
|
||||||
|
pr.waitNode("selected inbound still converges", madePort, func(ib inboundView) bool { return ib.client("p7") != nil })
|
||||||
|
time.Sleep(12 * time.Second)
|
||||||
|
if _, adopted := pr.masterInbound(unmanagedPort); adopted {
|
||||||
|
t.Fatal("master adopted an unselected node inbound")
|
||||||
|
}
|
||||||
|
if ib, ok := pr.nodeInbound(unmanagedPort); !ok || ib.client("u1") == nil {
|
||||||
|
t.Fatal("reconcile swept or rewrote an unselected node inbound")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #6738: without the host's Description on hysteria(2):// links, Happ falls back
|
||||||
|
// to its own "Hysteria | hysteria | TLS" caption on a host that also serves VLESS.
|
||||||
|
func TestGenHysteriaLinkAppendsHostServerDescription(t *testing.T) {
|
||||||
|
tests := map[string]struct {
|
||||||
|
version int
|
||||||
|
scheme string
|
||||||
|
}{
|
||||||
|
"hysteria v1": {version: 1, scheme: "hysteria://"},
|
||||||
|
"hysteria v2": {version: 2, scheme: "hysteria2://"},
|
||||||
|
}
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
host := &model.Host{
|
||||||
|
Address: "hy.example.com", Port: 443,
|
||||||
|
Remark: "Poland", ServerDescription: "Wi-Fi",
|
||||||
|
}
|
||||||
|
stream := map[string]any{
|
||||||
|
"security": "tls",
|
||||||
|
"externalProxy": []any{hostToExternalProxyMap(host, "hy.example.com", 443)},
|
||||||
|
}
|
||||||
|
rawStream, err := json.Marshal(stream)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal stream settings: %v", err)
|
||||||
|
}
|
||||||
|
// The inbound's own `version` picks the hysteria vs hysteria2 scheme.
|
||||||
|
rawSettings, err := json.Marshal(map[string]any{
|
||||||
|
"version": tc.version,
|
||||||
|
"clients": []any{map[string]any{"auth": "secret", "email": "user"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal inbound settings: %v", err)
|
||||||
|
}
|
||||||
|
in := &model.Inbound{
|
||||||
|
Id: 920010, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
|
||||||
|
Remark: "hy", StreamSettings: string(rawStream), Settings: string(rawSettings),
|
||||||
|
}
|
||||||
|
got := (&SubService{}).genHysteriaLink(in, "user")
|
||||||
|
if !strings.HasPrefix(got, tc.scheme) {
|
||||||
|
t.Fatalf("link scheme changed.\n got: %s\nwant prefix: %s", got, tc.scheme)
|
||||||
|
}
|
||||||
|
// base64("Wi-Fi"), matching the reporter's subscription output.
|
||||||
|
if !strings.HasSuffix(got, "?serverDescription=V2ktRmk=") {
|
||||||
|
t.Fatalf("host serverDescription missing from fragment.\n got: %s\nwant suffix: ?serverDescription=V2ktRmk=", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1535,7 +1535,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
|||||||
applyExternalProxyHysteriaParams(ep, epParams)
|
applyExternalProxyHysteriaParams(ep, epParams)
|
||||||
|
|
||||||
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
|
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
|
||||||
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "quic")))
|
// VLESS/Trojan/SS get the host's description through buildEndpointLinks;
|
||||||
|
// this loop renders the fragment itself, so add it here too (#6738).
|
||||||
|
remark := s.endpointRemark(inbound, email, ep, "quic")
|
||||||
|
remark = appendHappServerDescription(remark, externalProxyToEndpoint(ep).ServerDescription)
|
||||||
|
links = append(links, buildLinkWithParams(link, epParams, remark))
|
||||||
}
|
}
|
||||||
return strings.Join(links, "\n")
|
return strings.Join(links, "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,9 +96,7 @@ func TestAudit3ManagerEnsureActualSendersWithPersistentTraffic(t *testing.T) {
|
|||||||
if _, err := stream.Write(frame.Bytes()); err != nil {
|
if _, err := stream.Write(frame.Bytes()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := stream.Close(); err != nil {
|
closeUniStream(t, stream)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
response, err := p.client.AcceptUniStream(ctx)
|
response, err := p.client.AcceptUniStream(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -164,9 +162,7 @@ func TestAudit3ManagerEnsureActualSendersWithPersistentTraffic(t *testing.T) {
|
|||||||
if _, err := auth.Write(authBytes); err != nil {
|
if _, err := auth.Write(authBytes); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := auth.Close(); err != nil {
|
closeUniStream(t, auth)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForClientCongestionSender(t, server, client, served)
|
waitForClientCongestionSender(t, server, client, served)
|
||||||
var serverConn *quic.Conn
|
var serverConn *quic.Conn
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ func audit3LogsStart(t *testing.T, level, marker, relayAddr string) (*Server, *c
|
|||||||
if _, err := auth.Write(frame); err != nil {
|
if _, err := auth.Write(frame); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := auth.Close(); err != nil {
|
closeUniStream(t, auth)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, _ = authenticatedServerConnection(t, s, id)
|
_, _ = authenticatedServerConnection(t, s, id)
|
||||||
return s, c, id, password, token
|
return s, c, id, password, token
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
serverquic "github.com/apernet/quic-go"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/quic-go/quic-go"
|
"github.com/quic-go/quic-go"
|
||||||
)
|
)
|
||||||
@@ -196,12 +198,51 @@ func testServerTCPConnectE2E(t *testing.T, controller string) {
|
|||||||
t.Fatalf("expected active email alice@example.com, got %v", activeEmails)
|
t.Fatalf("expected active email alice@example.com, got %v", activeEmails)
|
||||||
}
|
}
|
||||||
|
|
||||||
deltas := server.CollectClientTraffic()
|
waitForClientTraffic(t, server, "alice@example.com", int64(len(testMsg)))
|
||||||
if len(deltas) == 0 {
|
}
|
||||||
t.Fatalf("expected traffic deltas, got none")
|
|
||||||
|
// closeUniStream tolerates only the server's STOP_SENDING: it cancels the read side of a
|
||||||
|
// uni stream once the command is parsed, which can land before the client's FIN.
|
||||||
|
func closeUniStream(t *testing.T, stream interface {
|
||||||
|
Close() error
|
||||||
|
Context() context.Context
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
err := stream.Close()
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if deltas[0].Email != "alice@example.com" || deltas[0].Up < int64(len(testMsg)) || deltas[0].Down < int64(len(testMsg)) {
|
cause := context.Cause(stream.Context())
|
||||||
t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
|
var clientErr *quic.StreamError
|
||||||
|
var serverErr *serverquic.StreamError
|
||||||
|
if (errors.As(cause, &clientErr) && clientErr.Remote) || (errors.As(cause, &serverErr) && serverErr.Remote) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("close uni stream: %v (cause %v)", err, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForClientTraffic accumulates drained deltas because the up and down counters are
|
||||||
|
// bumped on different relay goroutines, so the echo can arrive before the upload is counted.
|
||||||
|
func waitForClientTraffic(t *testing.T, server *Server, email string, minBytes int64) {
|
||||||
|
t.Helper()
|
||||||
|
var up, down int64
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
for {
|
||||||
|
for _, delta := range server.CollectClientTraffic() {
|
||||||
|
if delta.Email != email {
|
||||||
|
t.Fatalf("unexpected traffic delta for %q: %+v", delta.Email, delta)
|
||||||
|
}
|
||||||
|
up += delta.Up
|
||||||
|
down += delta.Down
|
||||||
|
}
|
||||||
|
if up >= minBytes && down >= minBytes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("traffic for %s = up %d, down %d; want both >= %d", email, up, down, minBytes)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,13 +397,7 @@ func testServerUDPDatagramE2E(t *testing.T, controller string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Verify traffic
|
// 4. Verify traffic
|
||||||
deltas := server.CollectClientTraffic()
|
waitForClientTraffic(t, server, "bob@example.com", int64(len(udpMsg)))
|
||||||
if len(deltas) == 0 {
|
|
||||||
t.Fatalf("expected traffic deltas, got none")
|
|
||||||
}
|
|
||||||
if deltas[0].Email != "bob@example.com" || deltas[0].Up < int64(len(udpMsg)) || deltas[0].Down < int64(len(udpMsg)) {
|
|
||||||
t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerUDPStreamE2E(t *testing.T) {
|
func TestServerUDPStreamE2E(t *testing.T) {
|
||||||
@@ -433,9 +468,7 @@ func testServerUDPStreamE2E(t *testing.T, controller string) {
|
|||||||
if _, err := authStream.Write(authPayload); err != nil {
|
if _, err := authStream.Write(authPayload); err != nil {
|
||||||
t.Fatalf("write authentication payload failed: %v", err)
|
t.Fatalf("write authentication payload failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := authStream.Close(); err != nil {
|
closeUniStream(t, authStream)
|
||||||
t.Fatalf("close authentication stream failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53}
|
target := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53}
|
||||||
udpMsg := bytes.Repeat([]byte("s"), 8500)
|
udpMsg := bytes.Repeat([]byte("s"), 8500)
|
||||||
@@ -458,9 +491,7 @@ func testServerUDPStreamE2E(t *testing.T, controller string) {
|
|||||||
if _, err := packetStream.Write(frame.Bytes()); err != nil {
|
if _, err := packetStream.Write(frame.Bytes()); err != nil {
|
||||||
t.Fatalf("write packet frame failed: %v", err)
|
t.Fatalf("write packet frame failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := packetStream.Close(); err != nil {
|
closeUniStream(t, packetStream)
|
||||||
t.Fatalf("close packet stream failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replyReassembler := newPacketReassembler(maxUdpRelayPacketSize)
|
replyReassembler := newPacketReassembler(maxUdpRelayPacketSize)
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *
|
|||||||
if _, err := stream.Write(auth); err != nil {
|
if _, err := stream.Write(auth); err != nil {
|
||||||
t.Fatalf("write authentication: %v", err)
|
t.Fatalf("write authentication: %v", err)
|
||||||
}
|
}
|
||||||
if err := stream.Close(); err != nil {
|
closeUniStream(t, stream)
|
||||||
t.Fatalf("close authentication stream: %v", err)
|
|
||||||
}
|
|
||||||
return server, client, clientID, password
|
return server, client, clientID, password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const (
|
|||||||
HashHeader = "X-Config-Sha256"
|
HashHeader = "X-Config-Sha256"
|
||||||
// CapsHeader is set by a node on its API responses to advertise support.
|
// CapsHeader is set by a node on its API responses to advertise support.
|
||||||
CapsHeader = "X-3x-Node-Caps"
|
CapsHeader = "X-3x-Node-Caps"
|
||||||
|
// MasterPushHeader marks a request as a master's push, whatever its token scope.
|
||||||
|
MasterPushHeader = "X-3x-Master-Push"
|
||||||
// EncodingZstd is the Content-Encoding value for a zstd-compressed body.
|
// EncodingZstd is the Content-Encoding value for a zstd-compressed body.
|
||||||
EncodingZstd = "zstd"
|
EncodingZstd = "zstd"
|
||||||
// CapZstd is the capability token advertised in CapsHeader.
|
// CapZstd is the capability token advertised in CapsHeader.
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
|||||||
"/inbounds/add": {http.MethodPost: {}},
|
"/inbounds/add": {http.MethodPost: {}},
|
||||||
"/inbounds/del/:id": {http.MethodPost: {}},
|
"/inbounds/del/:id": {http.MethodPost: {}},
|
||||||
"/inbounds/update/:id": {http.MethodPost: {}},
|
"/inbounds/update/:id": {http.MethodPost: {}},
|
||||||
|
"/inbounds/:id/subSortIndex": {http.MethodPost: {}},
|
||||||
"/clients/add": {http.MethodPost: {}},
|
"/clients/add": {http.MethodPost: {}},
|
||||||
"/clients/del/:email": {http.MethodPost: {}},
|
"/clients/del/:email": {http.MethodPost: {}},
|
||||||
"/clients/:email/detach": {http.MethodPost: {}},
|
"/clients/:email/detach": {http.MethodPost: {}},
|
||||||
@@ -106,11 +107,13 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
|||||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||||
"/server/descendants": {http.MethodGet: {}},
|
"/server/descendants": {http.MethodGet: {}},
|
||||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
||||||
|
"/clients/bulkResetTraffic": {http.MethodPost: {}},
|
||||||
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
||||||
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
||||||
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
||||||
"/clients/onlines": {http.MethodPost: {}},
|
"/clients/onlines": {http.MethodPost: {}},
|
||||||
"/clients/lastOnline": {http.MethodPost: {}},
|
"/clients/lastOnline": {http.MethodPost: {}},
|
||||||
|
"/clients/activeInbounds": {http.MethodPost: {}},
|
||||||
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
||||||
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
||||||
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -138,39 +137,6 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
|
|
||||||
expected := map[string]map[string]struct{}{
|
|
||||||
"/server/status": {http.MethodGet: {}},
|
|
||||||
"/inbounds/list": {http.MethodGet: {}},
|
|
||||||
"/inbounds/add": {http.MethodPost: {}},
|
|
||||||
"/inbounds/del/:id": {http.MethodPost: {}},
|
|
||||||
"/inbounds/update/:id": {http.MethodPost: {}},
|
|
||||||
"/clients/add": {http.MethodPost: {}},
|
|
||||||
"/clients/del/:email": {http.MethodPost: {}},
|
|
||||||
"/clients/:email/detach": {http.MethodPost: {}},
|
|
||||||
"/clients/update/:email": {http.MethodPost: {}},
|
|
||||||
"/server/restartXrayService": {http.MethodPost: {}},
|
|
||||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
|
||||||
"/server/descendants": {http.MethodGet: {}},
|
|
||||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
|
||||||
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
|
||||||
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
|
||||||
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
|
||||||
"/clients/onlines": {http.MethodPost: {}},
|
|
||||||
"/clients/lastOnline": {http.MethodPost: {}},
|
|
||||||
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
|
||||||
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
|
||||||
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
|
||||||
"/hosts/list": {http.MethodGet: {}},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(nodeSyncScopeAllow, expected) {
|
|
||||||
t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected)
|
|
||||||
}
|
|
||||||
if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok {
|
|
||||||
t.Fatal("node-sync must not include /server/updatePanel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
|
func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
|
||||||
engine, _ := newAPIAuthTestEngine(t)
|
engine, _ := newAPIAuthTestEngine(t)
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||||
@@ -64,7 +65,9 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
|||||||
func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
|
func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
|
||||||
svc := a.inboundService
|
svc := a.inboundService
|
||||||
scope, _ := c.Get("api_token_scope")
|
scope, _ := c.Get("api_token_scope")
|
||||||
svc.FromNodeSync = scope == model.ApiScopeNodeSync
|
// A master enrolled with an admin token (the -getApiToken default) has no
|
||||||
|
// node-sync scope, so it marks every request it sends instead.
|
||||||
|
svc.FromNodeSync = scope == model.ApiScopeNodeSync || c.GetHeader(wirecodec.MasterPushHeader) != ""
|
||||||
return &svc
|
return &svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,22 @@ package job
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultTuicSpeedSampleInterval = 10 * time.Second
|
||||||
|
|
||||||
type TuicJob struct {
|
type TuicJob struct {
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
|
runMu sync.Mutex
|
||||||
|
lastSpeedSample time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTuicJob() *TuicJob {
|
func NewTuicJob() *TuicJob {
|
||||||
@@ -19,6 +25,9 @@ func NewTuicJob() *TuicJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *TuicJob) Run() {
|
func (j *TuicJob) Run() {
|
||||||
|
j.runMu.Lock()
|
||||||
|
defer j.runMu.Unlock()
|
||||||
|
|
||||||
tuicJournalMu.Lock()
|
tuicJournalMu.Lock()
|
||||||
journalErr := j.replayTuicJournal()
|
journalErr := j.replayTuicJournal()
|
||||||
tuicJournalMu.Unlock()
|
tuicJournalMu.Unlock()
|
||||||
@@ -44,20 +53,28 @@ func (j *TuicJob) Run() {
|
|||||||
onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
|
onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
|
||||||
|
|
||||||
clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails)
|
clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails)
|
||||||
|
sampledAt := time.Now()
|
||||||
|
sampleInterval := tuicSpeedSampleInterval(j.lastSpeedSample, sampledAt)
|
||||||
|
|
||||||
// Inbound total traffic is already metered through the loopback SOCKS relay
|
// Inbound total traffic is already metered through the loopback SOCKS relay
|
||||||
// by xray_traffic_job (matching mtproto); only per-client deltas are submitted here.
|
// by xray_traffic_job (matching mtproto); only per-client deltas are submitted here.
|
||||||
|
persisted := true
|
||||||
if len(clientTraffics) > 0 {
|
if len(clientTraffics) > 0 {
|
||||||
needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics)
|
needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("tuic job: add traffic failed:", err)
|
logger.Warning("tuic job: add traffic failed:", err)
|
||||||
mgr.RequeueClientTraffic(clientDeltas)
|
mgr.RequeueClientTraffic(clientDeltas)
|
||||||
|
persisted = false
|
||||||
} else if needRestart {
|
} else if needRestart {
|
||||||
if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
|
if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
|
||||||
mgr.Reconcile(desired)
|
mgr.Reconcile(desired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if persisted {
|
||||||
|
websocket.BroadcastTraffic(tuicSpeedPayload(clientTraffics, sampleInterval))
|
||||||
|
j.lastSpeedSample = sampledAt
|
||||||
|
}
|
||||||
|
|
||||||
if len(onlineEmails) > 0 {
|
if len(onlineEmails) > 0 {
|
||||||
if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil {
|
if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil {
|
||||||
@@ -68,6 +85,25 @@ func (j *TuicJob) Run() {
|
|||||||
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
|
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tuicSpeedSampleInterval(previous, current time.Time) time.Duration {
|
||||||
|
if previous.IsZero() || !current.After(previous) {
|
||||||
|
return defaultTuicSpeedSampleInterval
|
||||||
|
}
|
||||||
|
return current.Sub(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tuicSpeedPayload(clientTraffics []*xray.ClientTraffic, sampleInterval time.Duration) map[string]any {
|
||||||
|
intervalMs := sampleInterval.Milliseconds()
|
||||||
|
if intervalMs < 1 {
|
||||||
|
intervalMs = 1
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"clientTraffics": clientTraffics,
|
||||||
|
"clientTrafficSource": "tuic",
|
||||||
|
"clientTrafficIntervalMs": intervalMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FlushStoppedTraffic persists counters drained when the TUIC manager stops its
|
// FlushStoppedTraffic persists counters drained when the TUIC manager stops its
|
||||||
// listeners. Call it after scheduled jobs have stopped and before the traffic
|
// listeners. Call it after scheduled jobs have stopped and before the traffic
|
||||||
// writer shuts down.
|
// writer shuts down.
|
||||||
|
|||||||
@@ -180,3 +180,14 @@ func TestAggregateTuicClientTrafficPreservesStableIdentityAcrossEmailRename(t *t
|
|||||||
t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down)
|
t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTuicSpeedSampleIntervalUsesElapsedPollTime(t *testing.T) {
|
||||||
|
current := time.Date(2026, time.October, 3, 12, 0, 10, 0, time.UTC)
|
||||||
|
previous := current.Add(-12 * time.Second)
|
||||||
|
if got := tuicSpeedSampleInterval(previous, current); got != 12*time.Second {
|
||||||
|
t.Fatalf("sample interval = %s, want 12s", got)
|
||||||
|
}
|
||||||
|
if got := tuicSpeedSampleInterval(time.Time{}, current); got != defaultTuicSpeedSampleInterval {
|
||||||
|
t.Fatalf("initial sample interval = %s, want %s", got, defaultTuicSpeedSampleInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nodeUnderContract serves the production router as a node and records every
|
||||||
|
// request the node refused for auth or scope.
|
||||||
|
type nodeUnderContract struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
refused []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func startContractNode(t *testing.T) *nodeUnderContract {
|
||||||
|
t.Helper()
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
||||||
|
prevMgr := runtime.GetManager()
|
||||||
|
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||||
|
t.Cleanup(func() { runtime.SetManager(prevMgr) })
|
||||||
|
|
||||||
|
previous := global.GetWebServer()
|
||||||
|
s := NewServer()
|
||||||
|
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
|
||||||
|
global.SetWebServer(s)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
s.cancel()
|
||||||
|
global.SetWebServer(previous)
|
||||||
|
})
|
||||||
|
engine, err := s.initRouter()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initRouter: %v", err)
|
||||||
|
}
|
||||||
|
n := &nodeUnderContract{}
|
||||||
|
n.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
engine.ServeHTTP(rec, r)
|
||||||
|
if rec.status == http.StatusUnauthorized || rec.status == http.StatusForbidden {
|
||||||
|
n.mu.Lock()
|
||||||
|
n.refused = append(n.refused, r.Method+" "+r.URL.Path+" -> "+strconv.Itoa(rec.status))
|
||||||
|
n.mu.Unlock()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(n.srv.Close)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *statusRecorder) WriteHeader(code int) {
|
||||||
|
r.status = code
|
||||||
|
r.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *nodeUnderContract) takeRefused() []string {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
out := n.refused
|
||||||
|
n.refused = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *nodeUnderContract) masterWithToken(t *testing.T, scope string) *runtime.Remote {
|
||||||
|
t.Helper()
|
||||||
|
token := "contract-" + scope
|
||||||
|
if err := database.GetDB().Create(&model.ApiToken{
|
||||||
|
Name: "master-" + scope, Token: crypto.HashTokenSHA256(token), Enabled: true, Scope: scope,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed %s token: %v", scope, err)
|
||||||
|
}
|
||||||
|
u, _ := url.Parse(n.srv.URL)
|
||||||
|
port, _ := strconv.Atoi(u.Port())
|
||||||
|
return runtime.NewRemote(&model.Node{
|
||||||
|
Id: 1, Name: "contract-node", Scheme: "http", Address: u.Hostname(), Port: port,
|
||||||
|
BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeRow(t *testing.T, tag string) (*model.Inbound, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var ib model.Inbound
|
||||||
|
err := database.GetDB().Where("tag = ?", tag).First(&ib).Error
|
||||||
|
return &ib, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeTraffic(t *testing.T, email string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var ct xray.ClientTraffic
|
||||||
|
if err := database.GetDB().Where("email = ?", email).First(&ct).Error; err != nil {
|
||||||
|
t.Fatalf("client_traffics %s: %v", email, err)
|
||||||
|
}
|
||||||
|
return ct.Up + ct.Down
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedNodeTraffic(t *testing.T, emails ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, e := range emails {
|
||||||
|
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", e).
|
||||||
|
Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
|
||||||
|
t.Fatalf("seed traffic %s: %v", e, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", contractTag).
|
||||||
|
Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound traffic: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractTag = "in-51001-tcp"
|
||||||
|
|
||||||
|
func masterInbound(remark string, enable bool, clients ...string) *model.Inbound {
|
||||||
|
entries := make([]string, 0, len(clients))
|
||||||
|
for i, email := range clients {
|
||||||
|
entries = append(entries, `{"email":"`+email+`","enable":true,"subId":"s-`+email+
|
||||||
|
`","id":"0b6d5c2e-7c1a-4f4e-9d3b-00000000000`+strconv.Itoa(i)+`"}`)
|
||||||
|
}
|
||||||
|
return &model.Inbound{
|
||||||
|
Tag: contractTag, Remark: remark, Enable: enable, Port: 51001, Protocol: model.VLESS,
|
||||||
|
Settings: `{"clients":[` + strings.Join(entries, ",") + `],"decryption":"none"}`,
|
||||||
|
StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`,
|
||||||
|
Sniffing: `{}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeEmails(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
ib, ok := nodeRow(t, contractTag)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("node has no contract inbound")
|
||||||
|
}
|
||||||
|
clients, err := (&service.InboundService{}).GetClients(ib)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse node clients: %v", err)
|
||||||
|
}
|
||||||
|
emails := make([]string, 0, len(clients))
|
||||||
|
for _, c := range clients {
|
||||||
|
emails = append(emails, c.Email)
|
||||||
|
}
|
||||||
|
return emails
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMasterNodeContract sends every node call the master makes through the production
|
||||||
|
// router, once per enrollment scope; UpdatePanel is excluded from node-sync on purpose.
|
||||||
|
func TestMasterNodeContract(t *testing.T) {
|
||||||
|
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} {
|
||||||
|
t.Run(scope, func(t *testing.T) {
|
||||||
|
node := startContractNode(t)
|
||||||
|
master := node.masterWithToken(t, scope)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cells := []struct {
|
||||||
|
name string
|
||||||
|
covers []string
|
||||||
|
run func() error
|
||||||
|
check func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{"AddInbound creates the inbound with its clients", []string{"AddInbound"}, func() error {
|
||||||
|
return master.AddInbound(ctx, masterInbound("added", true, "c0", "c1"))
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1" {
|
||||||
|
t.Fatalf("node clients = %v, want c0,c1", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"UpdateInbound applies remark, clients and enable", []string{"UpdateInbound", "AddUser", "RemoveUser", "ReconcileInbound"}, func() error {
|
||||||
|
ib := masterInbound("updated", false, "c0", "c1", "c2")
|
||||||
|
if err := master.AddUser(ctx, ib, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := master.RemoveUser(ctx, ib, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := master.ReconcileInbound(ctx, ib, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return master.UpdateInbound(ctx, ib, ib)
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
ib, _ := nodeRow(t, contractTag)
|
||||||
|
if ib.Remark != "updated" || ib.Enable {
|
||||||
|
t.Fatalf("node remark=%q enable=%v, want updated/false", ib.Remark, ib.Enable)
|
||||||
|
}
|
||||||
|
if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1,c2" {
|
||||||
|
t.Fatalf("node clients = %v, want c0,c1,c2", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"SetInboundSubSortIndex reaches the node", []string{"SetInboundSubSortIndex"}, func() error {
|
||||||
|
return master.SetInboundSubSortIndex(ctx, masterInbound("updated", false), 7)
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if ib, _ := nodeRow(t, contractTag); ib.SubSortIndex != 7 {
|
||||||
|
t.Fatalf("node subSortIndex = %d, want 7", ib.SubSortIndex)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"AddClient attaches one client", []string{"AddClient"}, func() error {
|
||||||
|
return master.AddClient(ctx, masterInbound("updated", false), model.Client{
|
||||||
|
Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true,
|
||||||
|
})
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if got := nodeEmails(t); !strings.Contains(strings.Join(got, ","), "c3") {
|
||||||
|
t.Fatalf("node clients = %v, want c3 among them", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"UpdateUser changes the client's limits", []string{"UpdateUser"}, func() error {
|
||||||
|
return master.UpdateUser(ctx, masterInbound("updated", false), "c3", model.Client{
|
||||||
|
Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true, TotalGB: 5 << 30,
|
||||||
|
})
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
var ct xray.ClientTraffic
|
||||||
|
database.GetDB().Where("email = ?", "c3").First(&ct)
|
||||||
|
if ct.Total != 5<<30 {
|
||||||
|
t.Fatalf("node c3 total = %d, want %d", ct.Total, int64(5<<30))
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"ResetClientTraffic zeroes one client", []string{"ResetClientTraffic"}, func() error {
|
||||||
|
seedNodeTraffic(t, "c0")
|
||||||
|
return master.ResetClientTraffic(ctx, nil, "c0")
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if u := nodeTraffic(t, "c0"); u != 0 {
|
||||||
|
t.Fatalf("node c0 usage = %d, want 0", u)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"ResetClientTraffics zeroes several clients", []string{"ResetClientTraffics"}, func() error {
|
||||||
|
seedNodeTraffic(t, "c1", "c2")
|
||||||
|
return master.ResetClientTraffics(ctx, []string{"c1", "c2"})
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if u := nodeTraffic(t, "c1") + nodeTraffic(t, "c2"); u != 0 {
|
||||||
|
t.Fatalf("node c1+c2 usage = %d, want 0", u)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"ResetInboundTraffic zeroes the inbound", []string{"ResetInboundTraffic"}, func() error {
|
||||||
|
seedNodeTraffic(t)
|
||||||
|
return master.ResetInboundTraffic(ctx, masterInbound("updated", false))
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
|
||||||
|
t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"ResetAllTraffics zeroes every inbound's counters", []string{"ResetAllTraffics"}, func() error {
|
||||||
|
seedNodeTraffic(t)
|
||||||
|
return master.ResetAllTraffics(ctx)
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
|
||||||
|
t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"FetchTrafficSnapshot reads every part of the snapshot", []string{"FetchTrafficSnapshot"}, func() error {
|
||||||
|
_, err := master.FetchTrafficSnapshot(ctx)
|
||||||
|
return err
|
||||||
|
}, nil},
|
||||||
|
{"PushGlobalClientTraffics is accepted", []string{"PushGlobalClientTraffics"}, func() error {
|
||||||
|
return master.PushGlobalClientTraffics(ctx, "master-guid", []*xray.ClientTraffic{{Email: "c0", Up: 1, Down: 2}})
|
||||||
|
}, nil},
|
||||||
|
{"client IP sync is accepted both ways", []string{"FetchAllClientIps", "PushAllClientIps", "FetchClientIpsByGuid"}, func() error {
|
||||||
|
ips, err := master.FetchAllClientIps(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := master.PushAllClientIps(ctx, ips); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = master.FetchClientIpsByGuid(ctx)
|
||||||
|
return err
|
||||||
|
}, nil},
|
||||||
|
{"host groups, descendants and web cert files are readable", []string{"FetchHostGroups", "GetDescendants", "GetWebCertFiles", "ListInboundOptions", "ListRemoteTags"}, func() error {
|
||||||
|
if _, err := master.FetchHostGroups(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := master.GetDescendants(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := master.GetWebCertFiles(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := master.ListInboundOptions(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := master.ListRemoteTags(ctx)
|
||||||
|
return err
|
||||||
|
}, nil},
|
||||||
|
{"RestartXray is accepted by the node", []string{"RestartXray"}, func() error {
|
||||||
|
// No core binary here: only the node's own restart failure may come back.
|
||||||
|
if err := master.RestartXray(ctx); err != nil && !strings.Contains(err.Error(), "rebooting the Xray") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, nil},
|
||||||
|
{"DeleteUser detaches the client from the inbound", []string{"DeleteUser"}, func() error {
|
||||||
|
return master.DeleteUser(ctx, masterInbound("updated", false), "c3")
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c3") {
|
||||||
|
t.Fatalf("node clients = %v, want c3 gone", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"DeleteClient removes the client everywhere", []string{"DeleteClient"}, func() error {
|
||||||
|
return master.DeleteClient(ctx, "c2")
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c2") {
|
||||||
|
t.Fatalf("node clients = %v, want c2 gone", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"DelInbound removes the inbound", []string{"DelInbound"}, func() error {
|
||||||
|
return master.DelInbound(ctx, masterInbound("updated", false))
|
||||||
|
}, func(t *testing.T) {
|
||||||
|
if _, ok := nodeRow(t, contractTag); ok {
|
||||||
|
t.Fatal("node still has the inbound")
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
covered := map[string]bool{}
|
||||||
|
for _, c := range cells {
|
||||||
|
for _, m := range c.covers {
|
||||||
|
covered[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEveryRemoteCallCovered(t, covered)
|
||||||
|
for _, c := range cells {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
node.takeRefused()
|
||||||
|
if err := c.run(); err != nil {
|
||||||
|
t.Fatalf("master call failed: %v", err)
|
||||||
|
}
|
||||||
|
if refused := node.takeRefused(); len(refused) != 0 {
|
||||||
|
t.Fatalf("node refused master requests: %v", refused)
|
||||||
|
}
|
||||||
|
if c.check != nil {
|
||||||
|
c.check(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote methods that never reach the node, or that this table must not run.
|
||||||
|
var remoteMethodsOutsideContract = map[string]string{
|
||||||
|
"Name": "local label",
|
||||||
|
"RecordAdoptedInbound": "local fingerprint bookkeeping",
|
||||||
|
"AdoptInboundAlias": "local alias bookkeeping",
|
||||||
|
"AdoptedInboundAliases": "local alias bookkeeping",
|
||||||
|
"AdvancePushedInbound": "local fingerprint bookkeeping",
|
||||||
|
"ForgetPushedInbound": "local fingerprint bookkeeping",
|
||||||
|
"UpdatePanel": "replaces the node binary; node-sync is denied it on purpose (#6201)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Remote method with no cell is how activeInbounds and bulkResetTraffic
|
||||||
|
// drifted out of the node-sync allowlist unnoticed.
|
||||||
|
func assertEveryRemoteCallCovered(t *testing.T, covered map[string]bool) {
|
||||||
|
t.Helper()
|
||||||
|
rt := reflect.TypeOf(&runtime.Remote{})
|
||||||
|
for i := 0; i < rt.NumMethod(); i++ {
|
||||||
|
name := rt.Method(i).Name
|
||||||
|
if _, skip := remoteMethodsOutsideContract[name]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !covered[name] {
|
||||||
|
t.Errorf("runtime.Remote.%s has no cell in TestMasterNodeContract", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ import (
|
|||||||
func TestReconcileInbound_SkipsUnchanged(t *testing.T) {
|
func TestReconcileInbound_SkipsUnchanged(t *testing.T) {
|
||||||
var pushes atomic.Int32
|
var pushes atomic.Int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/panel/api/inbounds/update/") {
|
if r.Method == http.MethodPost && (strings.Contains(r.URL.Path, "/panel/api/inbounds/update/") ||
|
||||||
|
strings.Contains(r.URL.Path, "/panel/api/inbounds/add")) {
|
||||||
pushes.Add(1)
|
pushes.Add(1)
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -283,7 +284,7 @@ func TestDelInboundDropsReconcileFingerprint(t *testing.T) {
|
|||||||
ib := &model.Inbound{Tag: "in-del", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
ib := &model.Inbound{Tag: "in-del", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||||
r.cacheSet(ib.Tag, 7)
|
r.cacheSet(ib.Tag, 7)
|
||||||
|
|
||||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||||
t.Fatalf("initial reconcile: pushed=%v err=%v, want push", pushed, err)
|
t.Fatalf("initial reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||||
}
|
}
|
||||||
if err := r.DelInbound(context.Background(), ib); err != nil {
|
if err := r.DelInbound(context.Background(), ib); err != nil {
|
||||||
@@ -317,3 +318,36 @@ func TestUpdateInboundFallbackAddSeedsReconcileFingerprint(t *testing.T) {
|
|||||||
t.Fatalf("reconcile sent %d full inbound updates, want 0", got)
|
t.Fatalf("reconcile sent %d full inbound updates, want 0", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An inbound deleted on the node must be re-created by the next reconcile; a
|
||||||
|
// cached tag→id from before the delete used to send update/<gone id> forever.
|
||||||
|
func TestReconcileInbound_RecreatesInboundTheNodeLost(t *testing.T) {
|
||||||
|
var adds, staleUpdates atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/panel/api/inbounds/list"):
|
||||||
|
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/panel/api/inbounds/update/"):
|
||||||
|
staleUpdates.Add(1)
|
||||||
|
_, _ = w.Write([]byte(`{"success":false,"msg":"record not found"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/panel/api/inbounds/add"):
|
||||||
|
adds.Add(1)
|
||||||
|
_, _ = w.Write([]byte(`{"success":true,"obj":{"id":9,"tag":"in-1"}}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"success":true}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||||
|
ib := &model.Inbound{Tag: "n1-in-1", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||||
|
r.cacheSet("in-1", 7)
|
||||||
|
|
||||||
|
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
||||||
|
t.Fatalf("reconcile of a lost inbound: pushed=%v err=%v, want a re-create", pushed, err)
|
||||||
|
}
|
||||||
|
if staleUpdates.Load() != 0 || adds.Load() != 1 {
|
||||||
|
t.Fatalf("updates to the stale id=%d adds=%d, want 0 and 1", staleUpdates.Load(), adds.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
|
|||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set(wirecodec.MasterPushHeader, "1")
|
||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
req.Header.Set("Content-Type", contentType)
|
req.Header.Set("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
@@ -359,6 +360,15 @@ func (r *Remote) cacheDel(tag string) {
|
|||||||
delete(r.pushedFP, tag)
|
delete(r.pushedFP, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forgetTag drops every tag form cacheGetTag would match, once the node reports
|
||||||
|
// none of them, so the next resolve re-reads the node instead of a deleted id.
|
||||||
|
func (r *Remote) forgetTag(tag string) {
|
||||||
|
prefix := nodeInboundTagPrefix(r.node.Id)
|
||||||
|
bare := strings.TrimPrefix(tag, prefix)
|
||||||
|
r.cacheDel(bare)
|
||||||
|
r.cacheDel(prefix + bare)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
|
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
|
||||||
if err := r.refreshRemoteIDs(ctx); err != nil {
|
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -494,6 +504,8 @@ func (r *Remote) ReconcileInbound(ctx context.Context, ib *model.Inbound, exists
|
|||||||
if ok && prev == fp {
|
if ok && prev == fp {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
r.forgetTag(ib.Tag)
|
||||||
}
|
}
|
||||||
if err := r.UpdateInbound(ctx, ib, ib); err != nil {
|
if err := r.UpdateInbound(ctx, ib, ib); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -516,6 +528,17 @@ func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
|
|||||||
r.recordPushedInbound(ib)
|
r.recordPushedInbound(ib)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForgetPushedInbound drops the reconcile-skip fingerprint once the node is seen
|
||||||
|
// without the payload it stamped, so the next reconcile re-sends the inbound.
|
||||||
|
func (r *Remote) ForgetPushedInbound(tag string) {
|
||||||
|
prefix := nodeInboundTagPrefix(r.node.Id)
|
||||||
|
bare := strings.TrimPrefix(tag, prefix)
|
||||||
|
r.mu.Lock()
|
||||||
|
delete(r.pushedFP, bare)
|
||||||
|
delete(r.pushedFP, prefix+bare)
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// AdoptInboundAlias records a deployed alias without mutating either panel.
|
// AdoptInboundAlias records a deployed alias without mutating either panel.
|
||||||
// The runtime association is rediscovered after a master restart.
|
// The runtime association is rediscovered after a master restart.
|
||||||
func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) {
|
func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The merge must soft-orphan, not delete: everything stays recoverable until
|
// A partial snapshot (node alive, still serving another client) authoritatively drops one;
|
||||||
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
|
// the merge soft-orphans, recoverable until the grace elapses and the reaper confirms it.
|
||||||
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
||||||
db := initTrafficTestDB(t)
|
db := initTrafficTestDB(t)
|
||||||
svc := &InboundService{}
|
svc := &InboundService{}
|
||||||
@@ -40,16 +40,20 @@ func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
|||||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||||
|
|
||||||
const email = "gone@x"
|
const email = "gone@x"
|
||||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
const keep = "keep@x"
|
||||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
|
||||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
|
||||||
|
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
|
||||||
|
xray.ClientTraffic{Email: keep, Enable: true},
|
||||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||||
|
|
||||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||||
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
|
||||||
|
xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
|
||||||
t.Fatalf("orphaning merge: %v", err)
|
t.Fatalf("orphaning merge: %v", err)
|
||||||
}
|
}
|
||||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||||
@@ -84,7 +88,7 @@ func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A client the node reports again was never gone: clearing the mark is what
|
// A client the node reports again (partial snapshot) was never gone: clearing the mark
|
||||||
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
||||||
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
||||||
db := initTrafficTestDB(t)
|
db := initTrafficTestDB(t)
|
||||||
@@ -94,19 +98,24 @@ func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
|||||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||||
|
|
||||||
const email = "flaky@x"
|
const email = "flaky@x"
|
||||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
const keep = "keep@x"
|
||||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
|
||||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
|
||||||
|
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
|
||||||
|
xray.ClientTraffic{Email: keep, Enable: true},
|
||||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||||
|
|
||||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
|
||||||
|
xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
|
||||||
t.Fatalf("orphaning merge: %v", err)
|
t.Fatalf("orphaning merge: %v", err)
|
||||||
}
|
}
|
||||||
if readOrphanMark(t, db, email) <= 0 {
|
if readOrphanMark(t, db, email) <= 0 {
|
||||||
t.Fatal("setup: expected the merge to mark the client")
|
t.Fatal("setup: expected the merge to mark the client")
|
||||||
}
|
}
|
||||||
|
|
||||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
|
||||||
|
xray.ClientTraffic{Email: keep, Enable: true},
|
||||||
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
||||||
|
|
||||||
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
||||||
|
|||||||
@@ -287,6 +287,9 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
|
func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
|
||||||
|
// GetAmneziaWGLogs appends peer handshake activity, which reads the DB;
|
||||||
|
// own a throwaway one so -shuffle can't leave us the global nil DB.
|
||||||
|
setupConflictDB(t)
|
||||||
logger.InitLogger(logging.DEBUG)
|
logger.InitLogger(logging.DEBUG)
|
||||||
logger.Info("amneziawg: started interface awg1 for inbound 1")
|
logger.Info("amneziawg: started interface awg1 for inbound 1")
|
||||||
logger.Info("xray: unrelated line that must never show up here")
|
logger.Info("xray: unrelated line that must never show up here")
|
||||||
|
|||||||
@@ -427,6 +427,20 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
|
|||||||
return &a
|
return &a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshotDropsEveryHubClient reports a node that lists no clients where the hub
|
||||||
|
// still links some: a reset or half-started node, never an authoritative removal.
|
||||||
|
func snapshotDropsEveryHubClient(tx *gorm.DB, inboundID int, wireSettings string) bool {
|
||||||
|
clients, err := ParseInboundSettingsClients(wireSettings)
|
||||||
|
if err != nil || len(clients) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var links int64
|
||||||
|
if err := tx.Table("client_inbounds").Where("inbound_id = ?", inboundID).Count(&links).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return links > 0
|
||||||
|
}
|
||||||
|
|
||||||
// clientEmailsOwnedElsewhere returns the emails attached only to inbounds of
|
// clientEmailsOwnedElsewhere returns the emails attached only to inbounds of
|
||||||
// other nodes: email is unique, so adopting one would overwrite a client this
|
// other nodes: email is unique, so adopting one would overwrite a client this
|
||||||
// node does not serve. Attached nowhere means soft-orphaned, hence adoptable.
|
// node does not serve. Attached nowhere means soft-orphaned, hence adoptable.
|
||||||
@@ -644,6 +658,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
wireSettings string
|
wireSettings string
|
||||||
}
|
}
|
||||||
var pendingAdopts []pendingAdopt
|
var pendingAdopts []pendingAdopt
|
||||||
|
degradedInbounds := map[int]string{}
|
||||||
|
|
||||||
newInboundIDs := make(map[int]struct{})
|
newInboundIDs := make(map[int]struct{})
|
||||||
|
|
||||||
@@ -782,7 +797,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
adoptedSettings = deduped
|
adoptedSettings = deduped
|
||||||
}
|
}
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
if !dirty {
|
if !dirty && snapshotDropsEveryHubClient(tx, c.Id, adoptedSettings) {
|
||||||
|
degradedInbounds[c.Id] = c.Tag
|
||||||
|
} else if !dirty {
|
||||||
// Defer lifecycle lift until after client_traffics absorbs this tick's
|
// Defer lifecycle lift until after client_traffics absorbs this tick's
|
||||||
// deltas so quota stale-disable matches SQL (#6228).
|
// deltas so quota stale-disable matches SQL (#6228).
|
||||||
pendingAdopts = append(pendingAdopts, pendingAdopt{
|
pendingAdopts = append(pendingAdopts, pendingAdopt{
|
||||||
@@ -1121,6 +1138,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
if k.inboundID != c.Id {
|
if k.inboundID != c.Id {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if _, degraded := degradedInbounds[c.Id]; degraded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, kept := snapEmails[k.email]; kept {
|
if _, kept := snapEmails[k.email]; kept {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1228,6 +1248,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
applyMasterClientLifecycle(&clients[i], existing, csPtr)
|
applyMasterClientLifecycle(&clients[i], existing, csPtr)
|
||||||
filtered = append(filtered, clients[i])
|
filtered = append(filtered, clients[i])
|
||||||
}
|
}
|
||||||
|
// A degraded node (reset/restart/removal) reports zero clients for an inbound the
|
||||||
|
// hub populates; adopting it empties links and ReapSyncOrphans deletes shared clients (#6734).
|
||||||
|
if _, degraded := degradedInbounds[c.Id]; degraded {
|
||||||
|
logger.Warningf("setRemoteTraffic: node %d reported zero clients for tag %q while the hub has %d attached — keeping them and re-pushing", nodeID, snapIb.Tag, len(oldEmailsRows))
|
||||||
|
syncFailedInbounds[c.Id] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
localEmails := make([]string, 0, len(filtered))
|
localEmails := make([]string, 0, len(filtered))
|
||||||
for i := range filtered {
|
for i := range filtered {
|
||||||
if filtered[i].Email != "" {
|
if filtered[i].Email != "" {
|
||||||
@@ -1337,6 +1364,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
committed = true
|
committed = true
|
||||||
|
|
||||||
|
if len(degradedInbounds) > 0 {
|
||||||
|
if mgr := runtime.GetManager(); mgr != nil {
|
||||||
|
if rt, rtErr := mgr.RuntimeFor(&nodeID); rtErr == nil {
|
||||||
|
if rem, ok := rt.(*runtime.Remote); ok {
|
||||||
|
for _, tag := range degradedInbounds {
|
||||||
|
rem.ForgetPushedInbound(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := (&NodeService{}).MarkNodeDirty(nodeID); err != nil {
|
||||||
|
logger.Warningf("setRemoteTraffic: mark node %d dirty after an empty snapshot failed: %v", nodeID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if lifecycleLifted && !dirty {
|
if lifecycleLifted && !dirty {
|
||||||
var already model.Node
|
var already model.Node
|
||||||
if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty {
|
if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty {
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// linkCount returns how many client_inbounds links a client currently has,
|
||||||
|
// across every inbound — the value ReapSyncOrphans checks before deleting.
|
||||||
|
func linkCount(t *testing.T, db *gorm.DB, email string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := db.Table("client_inbounds").
|
||||||
|
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||||
|
Where("clients.email = ?", email).
|
||||||
|
Count(&n).Error; err != nil {
|
||||||
|
t.Fatalf("count links for %q: %v", email, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// A degraded node reporting zero clients for an inbound the hub populates must
|
||||||
|
// keep its links and never orphan-mark, or SyncInbound/ReapSyncOrphans delete the row.
|
||||||
|
func TestSetRemoteTraffic_EmptySnapshotKeepsClients(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||||
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "svc@x")
|
||||||
|
|
||||||
|
settings := `{"clients":[{"email":"svc@x","enable":true}]}`
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", settings,
|
||||||
|
xray.ClientTraffic{Email: "svc@x", Enable: true}), false, false); err != nil {
|
||||||
|
t.Fatalf("seed sync: %v", err)
|
||||||
|
}
|
||||||
|
if n := linkCount(t, db, "svc@x"); n != 1 {
|
||||||
|
t.Fatalf("setup: svc@x links=%d, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The node returns an empty snapshot — the trigger that deleted real clients.
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
||||||
|
t.Fatalf("empty-snapshot sync: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec, _ := countClientRows(t, db, "svc@x"); rec != 1 {
|
||||||
|
t.Fatalf("empty snapshot deleted the client row: clients=%d, want 1", rec)
|
||||||
|
}
|
||||||
|
if n := linkCount(t, db, "svc@x"); n != 1 {
|
||||||
|
t.Fatalf("empty snapshot stripped the client link: links=%d, want 1", n)
|
||||||
|
}
|
||||||
|
if at := readOrphanMark(t, db, "svc@x"); at != 0 {
|
||||||
|
t.Fatalf("empty snapshot orphan-marked a live client: sync_orphaned_at=%d, want 0", at)
|
||||||
|
}
|
||||||
|
// The hub must keep the client in the inbound's settings, or reconcile re-pushes
|
||||||
|
// an empty blob to the node and the clients never come back (#6734).
|
||||||
|
var ib model.Inbound
|
||||||
|
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||||
|
t.Fatalf("read central inbound: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ib.Settings, "svc@x") {
|
||||||
|
t.Fatalf("empty snapshot blanked the inbound settings: %q", ib.Settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guard is narrow: a snapshot still carrying a client is authoritative, so a
|
||||||
|
// client the node really dropped is unlinked and orphan-marked; only all-empty is degraded.
|
||||||
|
func TestSetRemoteTraffic_PartialSnapshotStillPrunes(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||||
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "keep@x")
|
||||||
|
|
||||||
|
bothSettings := `{"clients":[{"email":"keep@x","enable":true},{"email":"drop@x","enable":true}]}`
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", bothSettings,
|
||||||
|
xray.ClientTraffic{Email: "keep@x", Enable: true},
|
||||||
|
xray.ClientTraffic{Email: "drop@x", Enable: true}), false, false); err != nil {
|
||||||
|
t.Fatalf("seed sync: %v", err)
|
||||||
|
}
|
||||||
|
if n := linkCount(t, db, "drop@x"); n != 1 {
|
||||||
|
t.Fatalf("setup: drop@x links=%d, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node now reports only keep@x — drop@x was genuinely removed there.
|
||||||
|
keepOnlySettings := `{"clients":[{"email":"keep@x","enable":true}]}`
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnlySettings,
|
||||||
|
xray.ClientTraffic{Email: "keep@x", Enable: true}), false, false); err != nil {
|
||||||
|
t.Fatalf("partial-snapshot sync: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := linkCount(t, db, "keep@x"); n != 1 {
|
||||||
|
t.Fatalf("partial snapshot dropped a reported client: keep@x links=%d, want 1", n)
|
||||||
|
}
|
||||||
|
if n := linkCount(t, db, "drop@x"); n != 0 {
|
||||||
|
t.Fatalf("partial snapshot kept an unreported client linked: drop@x links=%d, want 0", n)
|
||||||
|
}
|
||||||
|
if at := readOrphanMark(t, db, "drop@x"); at <= 0 {
|
||||||
|
t.Fatalf("partial snapshot did not orphan-mark the removed client: sync_orphaned_at=%d, want >0", at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeping the hub's settings is not recovery: the node is only healed once the
|
||||||
|
// hub actually re-pushes them, which needs a dirty node and a stale fingerprint.
|
||||||
|
func TestSetRemoteTraffic_EmptySnapshotRepushesHubClients(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var pushed []string
|
||||||
|
writeOK := func(w http.ResponseWriter, obj any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeOK(w, []map[string]any{{"id": 7, "tag": "deg-in", "port": 41001, "protocol": "vless"}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
pushed = append(pushed, r.PostForm.Get("settings"))
|
||||||
|
mu.Unlock()
|
||||||
|
writeOK(w, nil)
|
||||||
|
})
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
node := reconcileTestNode(t, ts, "deg-node", "all", nil)
|
||||||
|
settings := `{"clients":[{"email":"svc@x","enable":true,"id":"11111111-1111-1111-1111-111111111111"}]}`
|
||||||
|
nid := node.Id
|
||||||
|
if err := db.Create(&model.Inbound{UserId: 1, Tag: "deg-in", Enable: true, Port: 41001, Protocol: model.VLESS, NodeID: &nid, Settings: settings}).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
rt := runtime.NewRemote(node, nil)
|
||||||
|
mgr := runtime.NewManager(runtime.LocalDeps{})
|
||||||
|
mgr.SetRuntimeOverride(node.Id, rt)
|
||||||
|
runtime.SetManager(mgr)
|
||||||
|
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||||
|
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(node.Id, snapshotWithClients(t, "deg-in", settings,
|
||||||
|
xray.ClientTraffic{Email: "svc@x", Enable: true}), false, false); err != nil {
|
||||||
|
t.Fatalf("seed sync: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
|
||||||
|
t.Fatalf("first reconcile: %v", err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
pushed = nil
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(node.Id, snapshotWithoutClients(t, "deg-in"), false, false); err != nil {
|
||||||
|
t.Fatalf("empty-snapshot sync: %v", err)
|
||||||
|
}
|
||||||
|
var after model.Node
|
||||||
|
if err := db.Where("id = ?", node.Id).First(&after).Error; err != nil {
|
||||||
|
t.Fatalf("reload node: %v", err)
|
||||||
|
}
|
||||||
|
if !after.ConfigDirty {
|
||||||
|
t.Fatal("empty snapshot left the node clean: the job never reconciles it, so the node stays without its clients")
|
||||||
|
}
|
||||||
|
if err := svc.ReconcileNode(context.Background(), rt, &after); err != nil {
|
||||||
|
t.Fatalf("reconcile after empty snapshot: %v", err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(pushed) != 1 || !strings.Contains(pushed[0], "svc@x") {
|
||||||
|
t.Fatalf("reconcile after empty snapshot pushed %d settings payload(s) %q, want one carrying svc@x", len(pushed), pushed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The traffic a client used while its node reported nothing must still count
|
||||||
|
// once the node reports it again.
|
||||||
|
func TestSetRemoteTraffic_EmptySnapshotKeepsTrafficBaseline(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||||
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "svc@x")
|
||||||
|
settings := `{"clients":[{"email":"svc@x","enable":true}]}`
|
||||||
|
for _, used := range []int64{100, 200} {
|
||||||
|
syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: "svc@x", Up: used, Down: used, Enable: true})
|
||||||
|
}
|
||||||
|
before := readTraffic(t, db, "svc@x")
|
||||||
|
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
||||||
|
t.Fatalf("empty-snapshot sync: %v", err)
|
||||||
|
}
|
||||||
|
syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: "svc@x", Up: 250, Down: 250, Enable: true})
|
||||||
|
|
||||||
|
assertUpDown(t, readTraffic(t, db, "svc@x"), before.Up+50, before.Down+50, "after the node recovered")
|
||||||
|
}
|
||||||
@@ -94,10 +94,12 @@ func NewHub() *Hub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Traffic messages carry independent partial updates from Xray, TUIC, and node
|
||||||
|
// polling jobs. Throttling by message type would silently discard one source
|
||||||
|
// when two jobs publish within the throttle window.
|
||||||
var throttledMessageTypes = map[MessageType]struct{}{
|
var throttledMessageTypes = map[MessageType]struct{}{
|
||||||
MessageTypeInbounds: {},
|
MessageTypeInbounds: {},
|
||||||
MessageTypeOutbounds: {},
|
MessageTypeOutbounds: {},
|
||||||
MessageTypeTraffic: {},
|
|
||||||
MessageTypeClientStats: {},
|
MessageTypeClientStats: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,21 +81,45 @@ func TestHub_BroadcastDeliversToClient(t *testing.T) {
|
|||||||
waitClientCount(t, h, 1)
|
waitClientCount(t, h, 1)
|
||||||
|
|
||||||
h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"})
|
h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"})
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case raw := <-c.Send:
|
case raw := <-c.Send:
|
||||||
var m Message
|
var message Message
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
if err := json.Unmarshal(raw, &message); err != nil {
|
||||||
t.Fatalf("payload is not valid JSON: %v\n%s", err, raw)
|
t.Fatalf("payload is not valid JSON: %v", err)
|
||||||
}
|
}
|
||||||
if m.Type != MessageTypeStatus {
|
if message.Type != MessageTypeStatus {
|
||||||
t.Fatalf("Type = %q, want %q", m.Type, MessageTypeStatus)
|
t.Fatalf("message type = %q, want %q", message.Type, MessageTypeStatus)
|
||||||
}
|
}
|
||||||
if m.Time == 0 {
|
if message.Time == 0 {
|
||||||
t.Fatal("Time should be set to a non-zero unix-millis value")
|
t.Fatal("Time should be set to a non-zero unix-millis value")
|
||||||
}
|
}
|
||||||
case <-time.After(500 * time.Millisecond):
|
case <-time.After(500 * time.Millisecond):
|
||||||
t.Fatal("timed out waiting for broadcast to reach client")
|
t.Fatal("timed out waiting for status broadcast to reach client")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, source := range []string{"tuic", "xray"} {
|
||||||
|
h.Broadcast(MessageTypeTraffic, map[string]string{"source": source})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, wantSource := range []string{"tuic", "xray"} {
|
||||||
|
select {
|
||||||
|
case raw := <-c.Send:
|
||||||
|
var message struct {
|
||||||
|
Type MessageType `json:"type"`
|
||||||
|
Payload map[string]string `json:"payload"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &message); err != nil {
|
||||||
|
t.Fatalf("traffic event is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if message.Type != MessageTypeTraffic {
|
||||||
|
t.Fatalf("message type = %q, want %q", message.Type, MessageTypeTraffic)
|
||||||
|
}
|
||||||
|
if got := message.Payload["source"]; got != wantSource {
|
||||||
|
t.Fatalf("traffic source = %q, want %q", got, wantSource)
|
||||||
|
}
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatalf("timed out waiting for %q traffic event", wantSource)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,23 +180,28 @@ func TestHub_ShouldThrottle(t *testing.T) {
|
|||||||
t.Fatal("non-gated message type should never throttle on second call")
|
t.Fatal("non-gated message type should never throttle on second call")
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.shouldThrottle(MessageTypeTraffic) {
|
if h.shouldThrottle(MessageTypeInbounds) {
|
||||||
t.Fatal("first call for gated type should not throttle")
|
t.Fatal("first call for gated type should not throttle")
|
||||||
}
|
}
|
||||||
if !h.shouldThrottle(MessageTypeTraffic) {
|
if !h.shouldThrottle(MessageTypeInbounds) {
|
||||||
t.Fatal("immediate second call for gated type should throttle")
|
t.Fatal("immediate second call for gated type should throttle")
|
||||||
}
|
}
|
||||||
|
for i := range 2 {
|
||||||
|
if h.shouldThrottle(MessageTypeTraffic) {
|
||||||
|
t.Fatalf("traffic event %d must not be throttled", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
|
func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
|
||||||
h := NewHub()
|
h := NewHub()
|
||||||
defer h.Stop()
|
defer h.Stop()
|
||||||
|
|
||||||
if h.shouldThrottle(MessageTypeTraffic) {
|
|
||||||
t.Fatal("first Traffic call should not throttle")
|
|
||||||
}
|
|
||||||
if h.shouldThrottle(MessageTypeInbounds) {
|
if h.shouldThrottle(MessageTypeInbounds) {
|
||||||
t.Fatal("first Inbounds call should not throttle even after Traffic")
|
t.Fatal("first Inbounds call should not throttle")
|
||||||
|
}
|
||||||
|
if h.shouldThrottle(MessageTypeOutbounds) {
|
||||||
|
t.Fatal("first Outbounds call should not throttle even after Inbounds")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -940,6 +940,180 @@ setup_fail2ban() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The hardened unit makes /usr, /boot, /efi and /etc read-only. The panel's own
|
||||||
|
# updater is expected to escape that sandbox by running this script through a
|
||||||
|
# transient systemd-run unit; when systemd-run is unavailable it starts this
|
||||||
|
# script as a plain child instead, and that child inherits the sandbox and then
|
||||||
|
# cannot write anything this update needs. Say so once, up front, instead of
|
||||||
|
# dying partway through with "Failed to download x-ui".
|
||||||
|
require_writable_update_paths() {
|
||||||
|
local dir probe
|
||||||
|
for dir in "${xui_folder%/*}" "/usr/bin"; do
|
||||||
|
[[ -n "$dir" && -d "$dir" ]] || continue
|
||||||
|
probe="${dir}/.x-ui-write-test.$$"
|
||||||
|
# A real write test rather than [[ -w ]]: this runs as root, where a
|
||||||
|
# permission bit means little and the test only reflects the file mode
|
||||||
|
# and the mount flags, not an immutable attribute or a full filesystem.
|
||||||
|
if ! : > "$probe" 2> /dev/null; then
|
||||||
|
_fail "ERROR: ${dir} is not writable for this process (read-only mount, attribute or full filesystem). The panel's fallback updater cannot run inside the hardened systemd sandbox; update from the panel UI (which uses systemd-run) or run 'x-ui update' in a shell."
|
||||||
|
fi
|
||||||
|
rm -f "$probe"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Major version of the local systemd, 0 when it cannot be determined. The
|
||||||
|
# SystemCallFilter=@system-service group only exists from systemd 239 on (other
|
||||||
|
# @-named groups exist since 231); on older versions an unknown group is not
|
||||||
|
# ignored safely, the filter stays in force and leaves a whitelist the panel
|
||||||
|
# cannot run under.
|
||||||
|
_xui_systemd_major_version() {
|
||||||
|
local version=""
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')"
|
||||||
|
fi
|
||||||
|
if [[ ! "$version" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo 0
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The shipped units list hardening that older systemd does not know: the
|
||||||
|
# directive is logged and ignored at load time rather than rejected, so the
|
||||||
|
# panel still starts, only without that protection. Each entry is the systemd
|
||||||
|
# release that introduced the directive (systemd.exec(5)); everything else in
|
||||||
|
# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219).
|
||||||
|
# SystemCallFilter= is listed because the drop-in only writes it from 239 on.
|
||||||
|
_xui_warn_unsupported_hardening() {
|
||||||
|
local version entry missing=""
|
||||||
|
version="$(_xui_systemd_major_version)"
|
||||||
|
[[ "$version" -gt 0 ]] || return 0
|
||||||
|
for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \
|
||||||
|
ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \
|
||||||
|
SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \
|
||||||
|
ProtectKernelLogs:244 ProtectClock:245; do
|
||||||
|
if [[ "$version" -lt "${entry##*:}" ]]; then
|
||||||
|
missing="${missing:+$missing, }${entry%%:*} (${entry##*:})"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ -n "$missing" ]] || return 0
|
||||||
|
echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}"
|
||||||
|
echo " Not applied, needs a newer systemd: ${missing}."
|
||||||
|
if [[ "$version" -lt 231 ]]; then
|
||||||
|
echo " The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs."
|
||||||
|
fi
|
||||||
|
echo " The rest of the hardening is in force. Upgrade systemd to apply the above."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem=
|
||||||
|
# strict would make the whole hierarchy read-only (only the kernel API
|
||||||
|
# filesystems stay as they are), and that would break the panel's own use of
|
||||||
|
# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER,
|
||||||
|
# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place
|
||||||
|
# the files in -- the unit's WorkingDirectory on a stock install, and what a
|
||||||
|
# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit
|
||||||
|
# either misses a relocated store -- the panel then cannot write its own SQLite
|
||||||
|
# database and sits in a Restart=on-failure loop -- or forces the operator to
|
||||||
|
# edit a file that every install/update overwrites from the release tarball.
|
||||||
|
# install.sh and update.sh therefore regenerate the drop-in from the folders
|
||||||
|
# actually in use, and the unit's own ReadWritePaths only carry the
|
||||||
|
# plain-install defaults. A relocated store means re-running install or update:
|
||||||
|
# the drop-in is only written here.
|
||||||
|
_xui_service_write_paths_dropin() {
|
||||||
|
# $1 is the env file to resolve the XUI_* folders from; callers pass nothing
|
||||||
|
# and get the OS-specific path the unit itself uses.
|
||||||
|
local env_file="${1:-}"
|
||||||
|
local dropin_dir dropin temp_file
|
||||||
|
local db_folder log_folder bin_folder main_folder
|
||||||
|
local path line="" whitespace_paths="" seen_paths="" escaped_path
|
||||||
|
|
||||||
|
if [[ -z "$env_file" ]]; then
|
||||||
|
env_file="$(xui_env_file_path)"
|
||||||
|
fi
|
||||||
|
if [[ -r "$env_file" ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$env_file"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# XUI_* wins over the script's own default: the unit hands that same env
|
||||||
|
# file to the panel through EnvironmentFile=, so these are the folders it
|
||||||
|
# will actually use.
|
||||||
|
main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}"
|
||||||
|
db_folder="${XUI_DB_FOLDER:-/etc/x-ui}"
|
||||||
|
log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||||
|
# An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working
|
||||||
|
# directory, which the unit sets to the main folder.
|
||||||
|
bin_folder="${XUI_BIN_FOLDER:-bin}"
|
||||||
|
if [[ "$bin_folder" != /* ]]; then
|
||||||
|
bin_folder="${main_folder%/}/${bin_folder#./}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do
|
||||||
|
[[ "$path" == /* ]] || continue
|
||||||
|
# ReadWritePaths= is a whitespace-separated list, and a folder whose
|
||||||
|
# name contains whitespace cannot be written into it without relying on
|
||||||
|
# quoting. A wrong entry makes systemd reject the whole drop-in and the
|
||||||
|
# panel would not start, so leave such a folder out and say so instead.
|
||||||
|
if [[ "$path" != "${path//[[:space:]]/}" ]]; then
|
||||||
|
whitespace_paths="${whitespace_paths:+$whitespace_paths }$path"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case " $seen_paths " in
|
||||||
|
*" $path "*) continue ;;
|
||||||
|
esac
|
||||||
|
seen_paths="${seen_paths}${seen_paths:+ }$path"
|
||||||
|
# systemd expands %-specifiers in unit files, so a folder name carrying
|
||||||
|
# a literal % has to be written as %%, or the entry stops naming the
|
||||||
|
# folder systemd is meant to keep writable.
|
||||||
|
escaped_path="${path//%/%%}"
|
||||||
|
line="${line} -${escaped_path}"
|
||||||
|
done
|
||||||
|
if [[ -n "$whitespace_paths" ]]; then
|
||||||
|
echo "Warning: these folders contain whitespace and were left out of" >&2
|
||||||
|
echo " 10-xui-sandbox.conf: $whitespace_paths" >&2
|
||||||
|
echo " The panel cannot write to them under the unit's sandbox." >&2
|
||||||
|
fi
|
||||||
|
line="${line# }"
|
||||||
|
[[ -n "$line" ]] || return 1
|
||||||
|
|
||||||
|
dropin_dir="${xui_service}/x-ui.service.d"
|
||||||
|
dropin="${dropin_dir}/10-xui-sandbox.conf"
|
||||||
|
temp_file="${dropin}.tmp.$$"
|
||||||
|
|
||||||
|
mkdir -p "$dropin_dir" || return 1
|
||||||
|
cat > "$temp_file" << EOF
|
||||||
|
# Regenerated by install.sh/update.sh on every install and update: edits here
|
||||||
|
# are lost, and the list only reflects the XUI_* variables read from
|
||||||
|
# ${env_file} at that moment. Re-run install/update after moving a store.
|
||||||
|
# It lists the folders the panel writes to. Put local additions in their own
|
||||||
|
# drop-in, for example 20-x-ui-local.conf, which nothing here touches.
|
||||||
|
[Service]
|
||||||
|
ReadWritePaths=${line}
|
||||||
|
ReadWriteDirectories=${line}
|
||||||
|
EOF
|
||||||
|
if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then
|
||||||
|
cat >> "$temp_file" << 'EOF'
|
||||||
|
# @system-service needs systemd >= 239; on older versions the unknown group
|
||||||
|
# would leave the panel with a filter it cannot start under (x-ui.service.*).
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$temp_file" ]]; then
|
||||||
|
rm -f "$temp_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
chmod 644 "$temp_file"
|
||||||
|
mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; }
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
|
# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
|
||||||
# atomic mv, so a failed cp/curl or an interrupted mv never leaves a
|
# atomic mv, so a failed cp/curl or an interrupted mv never leaves a
|
||||||
# truncated unit file at the live path -- systemd would then fail to parse
|
# truncated unit file at the live path -- systemd would then fail to parse
|
||||||
@@ -971,6 +1145,11 @@ _install_xui_service_unit() {
|
|||||||
rm -f "$temp_file"
|
rm -f "$temp_file"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
if ! _xui_service_write_paths_dropin; then
|
||||||
|
echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}"
|
||||||
|
echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}"
|
||||||
|
fi
|
||||||
|
_xui_warn_unsupported_hardening
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1171,6 +1350,11 @@ update_x-ui() {
|
|||||||
chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1
|
chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Finish the schema/data migrations before the service starts, so the service and
|
||||||
|
# config_after_update's CLI calls never run them on the same database at once (#6728).
|
||||||
|
echo -e "${green}Migrating database...${plain}"
|
||||||
|
"${xui_folder}/x-ui" migrate
|
||||||
|
|
||||||
if [[ $release == "alpine" ]]; then
|
if [[ $release == "alpine" ]]; then
|
||||||
echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
|
echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
|
||||||
xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
|
xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
|
||||||
@@ -1287,5 +1471,6 @@ update_x-ui() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo -e "${green}Running...${plain}"
|
echo -e "${green}Running...${plain}"
|
||||||
|
require_writable_update_paths
|
||||||
install_base
|
install_base
|
||||||
update_x-ui $1
|
update_x-ui $1
|
||||||
|
|||||||
@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|
||||||
|
# The panel intentionally stays root: it supervises the Xray child processes,
|
||||||
|
# edits netfilter state and reads TLS private keys. These settings only bound
|
||||||
|
# what a panel-level flaw can reach.
|
||||||
|
#
|
||||||
|
# PrivateTmp=yes is deliberately absent: the web updater writes its script into
|
||||||
|
# /tmp and hands the absolute path to a "systemd-run" transient unit, which
|
||||||
|
# does not share this service's private /tmp (the download would vanish).
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=full
|
||||||
|
# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
|
||||||
|
# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
|
||||||
|
# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
|
||||||
|
# floor, not the whole list: install.sh and update.sh regenerate a drop-in
|
||||||
|
# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
|
||||||
|
# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
|
||||||
|
# the list survives an update instead of being reset to these defaults. Changing
|
||||||
|
# one of those variables in the env file is not enough by itself: the drop-in has
|
||||||
|
# to be refreshed as well, i.e. install or update the panel again.
|
||||||
|
# Add local extras in your own drop-in (e.g. 20-local.conf).
|
||||||
|
# The leading '-' keeps the unit startable if a path does not exist yet.
|
||||||
|
# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
|
||||||
|
# read-only, everything else stays writable. So this list matters for stores
|
||||||
|
# under those trees -- the default main folder under /usr/local is one.
|
||||||
|
#
|
||||||
|
# The in-panel updater is expected to leave this sandbox: it runs update.sh
|
||||||
|
# through a transient systemd-run unit, which does not inherit these settings.
|
||||||
|
# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
|
||||||
|
# here -- update.sh stages the release archive beside the main folder, replaces
|
||||||
|
# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
|
||||||
|
# message instead of failing halfway, and the sandbox deliberately does not
|
||||||
|
# grant /usr or /etc to accommodate it.
|
||||||
|
# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
|
||||||
|
# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
|
||||||
|
ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
|
||||||
|
ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectHostname=yes
|
||||||
|
# read-only rather than yes: installs keep TLS certs under /root/cert, and the
|
||||||
|
# panel must still be able to read them.
|
||||||
|
ProtectHome=read-only
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
UMask=0077
|
||||||
|
# AF_NETLINK for interface/route lookups and the ip(8) child used by the
|
||||||
|
# AmneziaWG IPv6-alias feature.
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||||
|
# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
|
||||||
|
# for raw sockets and SO_BINDTODEVICE.
|
||||||
|
#
|
||||||
|
# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
|
||||||
|
# subtracted from root's own privileges too: without it root can only read a
|
||||||
|
# file when the owner/group/other bits let uid 0 through, and any TLS private
|
||||||
|
# key belonging to another account becomes unreadable -- a certificate issued to
|
||||||
|
# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
|
||||||
|
# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
|
||||||
|
# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
|
||||||
|
# reads worked before the sandbox because the panel is root.
|
||||||
|
# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
|
||||||
|
# DAC_OVERRIDE, so it would only widen the set without adding anything.
|
||||||
|
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
|
||||||
|
# (other @-named groups exist since 231), and older systemd does not ignore an
|
||||||
|
# unknown group name gracefully: on
|
||||||
|
# <231 the name fails to resolve and the filter stays the built-in whitelist of
|
||||||
|
# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
|
||||||
|
# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
|
||||||
|
# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
|
||||||
|
# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
|
||||||
|
# "systemctl --version" reports 239 or newer.
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|
||||||
|
# The panel intentionally stays root: it supervises the Xray child processes,
|
||||||
|
# edits netfilter state and reads TLS private keys. These settings only bound
|
||||||
|
# what a panel-level flaw can reach.
|
||||||
|
#
|
||||||
|
# PrivateTmp=yes is deliberately absent: the web updater writes its script into
|
||||||
|
# /tmp and hands the absolute path to a "systemd-run" transient unit, which
|
||||||
|
# does not share this service's private /tmp (the download would vanish).
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=full
|
||||||
|
# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
|
||||||
|
# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
|
||||||
|
# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
|
||||||
|
# floor, not the whole list: install.sh and update.sh regenerate a drop-in
|
||||||
|
# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
|
||||||
|
# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
|
||||||
|
# the list survives an update instead of being reset to these defaults. Changing
|
||||||
|
# one of those variables in the env file is not enough by itself: the drop-in has
|
||||||
|
# to be refreshed as well, i.e. install or update the panel again.
|
||||||
|
# Add local extras in your own drop-in (e.g. 20-local.conf).
|
||||||
|
# The leading '-' keeps the unit startable if a path does not exist yet.
|
||||||
|
# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
|
||||||
|
# read-only, everything else stays writable. So this list matters for stores
|
||||||
|
# under those trees -- the default main folder under /usr/local is one.
|
||||||
|
#
|
||||||
|
# The in-panel updater is expected to leave this sandbox: it runs update.sh
|
||||||
|
# through a transient systemd-run unit, which does not inherit these settings.
|
||||||
|
# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
|
||||||
|
# here -- update.sh stages the release archive beside the main folder, replaces
|
||||||
|
# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
|
||||||
|
# message instead of failing halfway, and the sandbox deliberately does not
|
||||||
|
# grant /usr or /etc to accommodate it.
|
||||||
|
# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
|
||||||
|
# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
|
||||||
|
ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
|
||||||
|
ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectHostname=yes
|
||||||
|
# read-only rather than yes: installs keep TLS certs under /root/cert, and the
|
||||||
|
# panel must still be able to read them.
|
||||||
|
ProtectHome=read-only
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
UMask=0077
|
||||||
|
# AF_NETLINK for interface/route lookups and the ip(8) child used by the
|
||||||
|
# AmneziaWG IPv6-alias feature.
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||||
|
# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
|
||||||
|
# for raw sockets and SO_BINDTODEVICE.
|
||||||
|
#
|
||||||
|
# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
|
||||||
|
# subtracted from root's own privileges too: without it root can only read a
|
||||||
|
# file when the owner/group/other bits let uid 0 through, and any TLS private
|
||||||
|
# key belonging to another account becomes unreadable -- a certificate issued to
|
||||||
|
# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
|
||||||
|
# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
|
||||||
|
# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
|
||||||
|
# reads worked before the sandbox because the panel is root.
|
||||||
|
# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
|
||||||
|
# DAC_OVERRIDE, so it would only widen the set without adding anything.
|
||||||
|
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
|
||||||
|
# (other @-named groups exist since 231), and older systemd does not ignore an
|
||||||
|
# unknown group name gracefully: on
|
||||||
|
# <231 the name fails to resolve and the filter stays the built-in whitelist of
|
||||||
|
# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
|
||||||
|
# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
|
||||||
|
# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
|
||||||
|
# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
|
||||||
|
# "systemctl --version" reports 239 or newer.
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|
||||||
|
# The panel intentionally stays root: it supervises the Xray child processes,
|
||||||
|
# edits netfilter state and reads TLS private keys. These settings only bound
|
||||||
|
# what a panel-level flaw can reach.
|
||||||
|
#
|
||||||
|
# PrivateTmp=yes is deliberately absent: the web updater writes its script into
|
||||||
|
# /tmp and hands the absolute path to a "systemd-run" transient unit, which
|
||||||
|
# does not share this service's private /tmp (the download would vanish).
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=full
|
||||||
|
# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
|
||||||
|
# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
|
||||||
|
# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
|
||||||
|
# floor, not the whole list: install.sh and update.sh regenerate a drop-in
|
||||||
|
# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
|
||||||
|
# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
|
||||||
|
# the list survives an update instead of being reset to these defaults. Changing
|
||||||
|
# one of those variables in the env file is not enough by itself: the drop-in has
|
||||||
|
# to be refreshed as well, i.e. install or update the panel again.
|
||||||
|
# Add local extras in your own drop-in (e.g. 20-local.conf).
|
||||||
|
# The leading '-' keeps the unit startable if a path does not exist yet.
|
||||||
|
# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
|
||||||
|
# read-only, everything else stays writable. So this list matters for stores
|
||||||
|
# under those trees -- the default main folder under /usr/local is one.
|
||||||
|
#
|
||||||
|
# The in-panel updater is expected to leave this sandbox: it runs update.sh
|
||||||
|
# through a transient systemd-run unit, which does not inherit these settings.
|
||||||
|
# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
|
||||||
|
# here -- update.sh stages the release archive beside the main folder, replaces
|
||||||
|
# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
|
||||||
|
# message instead of failing halfway, and the sandbox deliberately does not
|
||||||
|
# grant /usr or /etc to accommodate it.
|
||||||
|
# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
|
||||||
|
# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
|
||||||
|
ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
|
||||||
|
ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectHostname=yes
|
||||||
|
# read-only rather than yes: installs keep TLS certs under /root/cert, and the
|
||||||
|
# panel must still be able to read them.
|
||||||
|
ProtectHome=read-only
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
UMask=0077
|
||||||
|
# AF_NETLINK for interface/route lookups and the ip(8) child used by the
|
||||||
|
# AmneziaWG IPv6-alias feature.
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||||
|
# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
|
||||||
|
# for raw sockets and SO_BINDTODEVICE.
|
||||||
|
#
|
||||||
|
# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
|
||||||
|
# subtracted from root's own privileges too: without it root can only read a
|
||||||
|
# file when the owner/group/other bits let uid 0 through, and any TLS private
|
||||||
|
# key belonging to another account becomes unreadable -- a certificate issued to
|
||||||
|
# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
|
||||||
|
# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
|
||||||
|
# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
|
||||||
|
# reads worked before the sandbox because the panel is root.
|
||||||
|
# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
|
||||||
|
# DAC_OVERRIDE, so it would only widen the set without adding anything.
|
||||||
|
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
|
||||||
|
# (other @-named groups exist since 231), and older systemd does not ignore an
|
||||||
|
# unknown group name gracefully: on
|
||||||
|
# <231 the name fails to resolve and the filter stays the built-in whitelist of
|
||||||
|
# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
|
||||||
|
# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
|
||||||
|
# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
|
||||||
|
# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
|
||||||
|
# "systemctl --version" reports 239 or newer.
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -294,6 +294,12 @@ uninstall() {
|
|||||||
systemctl stop x-ui
|
systemctl stop x-ui
|
||||||
systemctl disable x-ui
|
systemctl disable x-ui
|
||||||
rm ${xui_service}/x-ui.service -f
|
rm ${xui_service}/x-ui.service -f
|
||||||
|
# The sandbox drop-in generated by install.sh/update.sh lives beside the
|
||||||
|
# unit; leaving it behind would keep an empty x-ui.service.d around and
|
||||||
|
# silently re-apply on a later install of another unit of the same name.
|
||||||
|
# Local drop-ins the operator added go with it, which is what an
|
||||||
|
# uninstall is expected to do.
|
||||||
|
rm -rf -- "${xui_service}/x-ui.service.d"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl reset-failed
|
systemctl reset-failed
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user