Merge pull request #7040 from MHSanaei/fix/hardening-2026-11

Hardening and bug fixes: node sync, subscriptions, web, outbounds, TUIC/AmneziaWG/MTProto, client lifecycle; xray-core v26.10.31
This commit is contained in:
Sanaei
2026-11-01 15:44:17 +01:00
committed by GitHub
294 changed files with 13661 additions and 2092 deletions
+2 -2
View File
@@ -115,7 +115,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -283,7 +283,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
+1 -1
View File
@@ -52,7 +52,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
fi
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/Xray-linux-${XRAY_ZIP}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/Xray-linux-${XRAY_ZIP}.zip"
unzip "Xray-linux-${XRAY_ZIP}.zip"
rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat
mv xray "${XRAY_BIN}"
+4 -3
View File
@@ -23,7 +23,7 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`,
| **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. |
| **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. |
| **Hysteria** | `hysteriaSettings` | QUIC-based transport — only for the **Hysteria2** protocol. |
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, a local folder or any HTTP storage API). |
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, S3, a local folder or any HTTP storage API). |
| **MASQUE** | `masqueSettings` | CONNECT-IP over HTTP/3 or HTTP/2 — only for the **MASQUE** protocol. See [MASQUE](/docs/config/masque). |
<Callout type="info">
@@ -132,10 +132,11 @@ enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound with
| Field | Default | Meaning |
| -------------- | -------------- | ----------------------------------------------------------------------- |
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) or `template` (any HTTP storage API). The old `Google Drive` spelling is still read. |
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) `template` (any HTTP storage API) or `s3` (S3-compatible storage). The old `Google Drive` spelling is still read. |
| `remoteFolder` | — | Folder that holds the session files. |
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. |
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey and an optional SessionToken. |
| `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. |
| `s3` | — | S3 service only: `endpoint` (an http(s) URL), `region`, `bucket` and `pathStyle` (default `true`; `false` puts the bucket in the host name). `remoteFolder` is the object prefix. |
| tuning | core defaults | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
The optional **Front address/port** (the stream-level `address`/`port`) is a domain
+25 -19
View File
@@ -182,10 +182,11 @@ _openapi:
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running'
- depth: 2
title: Zero up/down counters for many clients in one call. Loops the
single-reset path so each client is re-enabled across its attached
inbounds and pushed to Xray/remote nodes. Returns the count of
successfully reset clients.
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients'
single-reset path so each client its quota had disabled is re-enabled
across its attached inbounds and pushed to Xray/remote nodes; a client
disabled by hand stays disabled. Returns the count of successfully reset
clients.
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients'
- depth: 2
title: List all client groups with their member counts. Merges persisted groups
(rows in client_groups, including empty placeholders) with the distinct
@@ -223,10 +224,11 @@ _openapi:
client_groups row if the group exists only as a derived label.
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
- depth: 2
title: Zero out a single client’s up/down counters. Re-enables the client across
every attached inbound and pushes the change to Xray (or the remote
node) so depleted users can connect again immediately.
url: '#zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately'
title: Zero out a single client’s up/down counters. Re-enables a client its
quota had disabled across every attached inbound and pushes the change
to Xray (or the remote node) so depleted users can connect again
immediately; a client disabled by hand stays disabled.
url: '#zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled'
- depth: 2
title: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems.
@@ -457,10 +459,11 @@ _openapi:
any target inbound was running."
id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running
- content: Zero up/down counters for many clients in one call. Loops the
single-reset path so each client is re-enabled across its attached
inbounds and pushed to Xray/remote nodes. Returns the count of
successfully reset clients.
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients
single-reset path so each client its quota had disabled is re-enabled
across its attached inbounds and pushed to Xray/remote nodes; a client
disabled by hand stays disabled. Returns the count of successfully
reset clients.
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients
- content: List all client groups with their member counts. Merges persisted
groups (rows in client_groups, including empty placeholders) with the
distinct group_name values currently set on clients. Sorted
@@ -492,10 +495,11 @@ _openapi:
Creates the client_groups row if the group exists only as a derived
label.
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
- content: Zero out a single client’s up/down counters. Re-enables the client
across every attached inbound and pushes the change to Xray (or the
remote node) so depleted users can connect again immediately.
id: zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately
- content: Zero out a single client’s up/down counters. Re-enables a client its
quota had disabled across every attached inbound and pushes the change
to Xray (or the remote node) so depleted users can connect again
immediately; a client disabled by hand stays disabled.
id: zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled
- content: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems.
id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems
@@ -609,9 +613,11 @@ _openapi:
different client of that same inbound holds, or `... used by a client
on <inbound>` when the holder sits on another WireGuard or AmneziaWG
inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides
with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no
address. Allocation likewise skips every address inside a prefix
another client holds. The same validation runs on POST
with `10.0.0.5/32`; a `/0` entry collides with every address of its
family another client holds, and only a `0.0.0.0/0` or `::/0` default
route that is already stored (legacy peers) keeps nobody out.
Allocation likewise skips every address inside a prefix another client
holds. The same validation runs on POST
/panel/api/clients/{email}/attach, where a client that already carries
an address brings it along.
+3 -2
View File
@@ -133,10 +133,11 @@ inbound به مقدار `decryption` نیاز دارد و outbound به `encrypt
| فیلد | پیش‌فرض | معنا |
| -------------- | -------------- | ---- |
| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. |
| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک)، `template` (هر API ذخیره‌سازی HTTP) یا `s3` (ذخیره‌ساز سازگار با S3). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. |
| `remoteFolder` | — | پوشه‌ای که فایل‌های نشست در آن قرار می‌گیرند. |
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. |
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. S3: ‏AccessKeyID، SecretAccessKey و در صورت نیاز SessionToken. |
| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیره‌ساز. |
| `s3` | — | فقط برای سرویس s3: `endpoint` (یک URL از نوع http(s))، `region`، `bucket` و `pathStyle` (پیش‌فرض `true`؛ با `false` نام باکت در نام میزبان می‌آید). `remoteFolder` پیشوند اشیاء است. |
| تنظیمات دقیق | پیش‌فرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). |
**آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
+3 -2
View File
@@ -135,10 +135,11 @@ Mux. Панель предлагает XDRIVE только для VLESS, не с
| Поле | По умолчанию | Значение |
| -------------- | -------------- | -------- |
| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. |
| `service` | `google-drive` | `google-drive`, `local` (папка на диске), `template` (любой HTTP API хранилища) или `s3` (S3-совместимое хранилище). Старое написание `Google Drive` по-прежнему читается. |
| `remoteFolder` | — | Папка с файлами сессий. |
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. |
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey и при необходимости SessionToken. |
| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. |
| `s3` | — | Только для s3: `endpoint` (URL http(s)), `region`, `bucket` и `pathStyle` (по умолчанию `true`; `false` переносит бакет в имя хоста). `remoteFolder` — префикс объектов. |
| тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
+3 -2
View File
@@ -124,10 +124,11 @@ XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服
| 字段 | 默认值 | 含义 |
| -------------- | -------------- | ---- |
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 |
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)、`template`(任意 HTTP 存储 API)或 `s3`(S3 兼容存储)。旧写法 `Google Drive` 仍可读取。 |
| `remoteFolder` | — | 存放会话文件的文件夹。 |
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 |
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。S3:AccessKeyID、SecretAccessKey,以及可选的 SessionToken。 |
| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 |
| `s3` | — | 仅用于 s3 服务:`endpoint`(http(s) URL)、`region`、`bucket` 和 `pathStyle`(默认 `true`;为 `false` 时存储桶名放在主机名中)。`remoteFolder` 为对象前缀。 |
| 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 |
可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
+5 -4
View File
@@ -1785,7 +1785,8 @@
"type": "string"
},
"forwardedPorts": {
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
"nullable": true,
"type": "string"
},
"group": {
@@ -8846,7 +8847,7 @@
],
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"requestBody": {
"required": true,
"content": {
@@ -10163,7 +10164,7 @@
"tags": [
"Clients"
],
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
"operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": {
"required": true,
@@ -10504,7 +10505,7 @@
"tags": [
"Clients"
],
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
"operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [
{
+2 -1
View File
@@ -5,6 +5,7 @@ import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { buildAntdThemeConfig } from '@/hooks/useTheme';
import { INTERPOLATION } from '@/i18n/react';
import enUS from '../../internal/web/translation/en-US.json';
if (!i18next.isInitialized) {
@@ -12,7 +13,7 @@ if (!i18next.isInitialized) {
lng: 'en-US',
fallbackLng: 'en-US',
resources: { 'en-US': { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
interpolation: INTERPOLATION,
returnNull: false,
});
}
+579 -570
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -38,30 +38,30 @@
"@ant-design/icons": "^6.3.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^6.0.0",
"@hookform/resolvers": "^7.0.1",
"@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.104.5",
"@tanstack/react-query-devtools": "^5.104.5",
"antd": "^6.6.7",
"@tanstack/react-query": "^5.105.1",
"@tanstack/react-query-devtools": "^5.105.1",
"antd": "^6.6.8",
"codemirror": "^6.0.2",
"dayjs": "^1.11.23",
"i18next": "^26.5.6",
"i18next": "^26.6.0",
"otpauth": "^9.5.2",
"persian-calendar-suite": "^1.5.6",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
"react-i18next": "^17.1.3",
"react-router": "^8.5.0",
"swagger-ui-react": "^5.34.0",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.10",
"react-router": "^8.5.1",
"swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32",
"zod": "^4.6.7"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.6.3",
"@storybook/addon-docs": "^10.6.3",
"@storybook/addon-vitest": "^10.6.3",
"@storybook/react-vite": "^10.6.3",
"@storybook/addon-a11y": "^10.6.5",
"@storybook/addon-docs": "^10.6.5",
"@storybook/addon-vitest": "^10.6.5",
"@storybook/react-vite": "^10.6.5",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0",
@@ -74,11 +74,11 @@
"jsdom": "^30.1.3",
"lint-staged": "^17.6.0",
"msw": "^3.0.3",
"oxfmt": "0.74.0",
"oxlint": "1.89.0",
"oxlint-tsgolint": "^7.0.2004",
"oxfmt": "0.75.0",
"oxlint": "1.90.0",
"oxlint-tsgolint": "^7.0.2005",
"playwright": "^1.64.1",
"storybook": "^10.6.3",
"storybook": "^10.6.5",
"typescript": "7.0.2",
"vite": "8.3.5",
"vitest": "^5.0.5"
+5 -4
View File
@@ -1785,7 +1785,8 @@
"type": "string"
},
"forwardedPorts": {
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
"nullable": true,
"type": "string"
},
"group": {
@@ -8846,7 +8847,7 @@
],
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"requestBody": {
"required": true,
"content": {
@@ -10163,7 +10164,7 @@
"tags": [
"Clients"
],
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
"operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": {
"required": true,
@@ -10504,7 +10505,7 @@
"tags": [
"Clients"
],
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
"operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [
{
+8 -3
View File
@@ -20,14 +20,19 @@ export function useHostMutations() {
const updateMut = useMutation({
mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) =>
HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS),
HttpUtil.post(
`/panel/api/hosts/update/${encodeURIComponent(groupId)}`,
payload,
JSON_HEADERS,
),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const removeMut = useMutation({
mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`),
mutationFn: (groupId: string) =>
HttpUtil.post(`/panel/api/hosts/del/${encodeURIComponent(groupId)}`),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
@@ -35,7 +40,7 @@ export function useHostMutations() {
const setEnableMut = useMutation({
mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) =>
HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }),
HttpUtil.post(`/panel/api/hosts/setEnable/${encodeURIComponent(groupId)}`, { enable }),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
+1 -1
View File
@@ -431,7 +431,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": false,
"expiryTime": 0,
"flow": "",
"forwardedPorts": "",
"forwardedPorts": null,
"group": "",
"id": "",
"keepAlive": 0,
+2 -1
View File
@@ -1759,7 +1759,8 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string"
},
"forwardedPorts": {
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
"nullable": true,
"type": "string"
},
"group": {
+1 -1
View File
@@ -426,7 +426,7 @@ export interface Client {
enable: boolean;
expiryTime: number;
flow?: string;
forwardedPorts?: string;
forwardedPorts?: string | null;
group?: string;
id?: string;
keepAlive?: number | string;
+1 -1
View File
@@ -450,7 +450,7 @@ export const ClientSchema = z.object({
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string().optional(),
forwardedPorts: z.string().optional(),
forwardedPorts: z.string().nullable().optional(),
group: z.string().optional(),
id: z.string().optional(),
keepAlive: z.union([z.number().int(), z.string()]).optional(),
+12 -1
View File
@@ -1,4 +1,5 @@
import i18next from 'i18next';
import type { InterpolationOptions } from 'i18next';
import { initReactI18next } from 'react-i18next';
import { LanguageManager } from '@/utils';
@@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json';
const FALLBACK = 'en-US';
// No locale uses $t(...) nesting, and its regex backtracks exponentially on an unclosed
// "$t(" in untrusted text (node-reported names, remarks); no off switch, so never match.
export const INTERPOLATION: InterpolationOptions = {
escapeValue: false,
prefix: '{',
suffix: '}',
nestingPrefix: '',
nestingPrefixEscaped: '(?!)',
};
const lazyModules = import.meta.glob([
'../../../internal/web/translation/*.json',
'!../../../internal/web/translation/en-US.json',
@@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') {
lng: active,
fallbackLng: FALLBACK,
resources: { [FALLBACK]: { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
interpolation: INTERPOLATION,
returnNull: false,
});
if (active !== FALLBACK) {
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Typography } from 'antd';
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { JsonEditor } from '@/components/form';
@@ -13,7 +13,7 @@ const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
local: 'pages.inbounds.form.xdrive.serviceLocal',
template: 'pages.inbounds.form.xdrive.serviceTemplate',
};
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive' };
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive', s3: 'S3' };
// Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an
// empty field keeps meaning "use the core's value".
@@ -29,6 +29,11 @@ const TUNING_FIELDS = [
] as const;
const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const;
const S3_SECRETS = [
['accessKeyId', true],
['secretAccessKey', true],
['sessionToken', false],
] as const;
function TemplateEditor({
value,
@@ -90,7 +95,11 @@ export default function XDriveForm() {
name={[...BASE, 'remoteFolder']}
required
>
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
<Input
placeholder={
service === 'local' ? '/var/lib/xdrive' : service === 's3' ? 'xdrive' : 'xray-tunnel'
}
/>
</FormField>
{service === 'google-drive' &&
GOOGLE_DRIVE_SECRETS.map((key, index) => (
@@ -103,6 +112,48 @@ export default function XDriveForm() {
{key === 'clientId' ? <Input /> : <Input.Password />}
</FormField>
))}
{service === 's3' && (
<>
{S3_SECRETS.map(([key, required], index) => (
<FormField
key={key}
label={t(`pages.inbounds.form.xdrive.${key}`)}
name={[...BASE, 'secrets', index]}
required={required}
>
{key === 'accessKeyId' ? <Input /> : <Input.Password />}
</FormField>
))}
<FormField
label={t('pages.inbounds.form.xdrive.s3Endpoint')}
name={[...BASE, 's3', 'endpoint']}
required
>
<Input placeholder="https://s3.amazonaws.com" />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.s3Region')}
name={[...BASE, 's3', 'region']}
>
<Input placeholder="us-east-1" />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.s3Bucket')}
name={[...BASE, 's3', 'bucket']}
required
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.s3PathStyle')}
tooltip={t('pages.inbounds.form.xdrive.s3PathStyleDesc')}
name={[...BASE, 's3', 'pathStyle']}
transform={{ input: (value) => value !== false }}
>
<Switch />
</FormField>
</>
)}
{service === 'template' && (
<>
<FormField
+22 -3
View File
@@ -859,12 +859,31 @@ export function parseWireguardLink(link: string): Raw | null {
export function parseOutboundLink(link: string): Raw | null {
const trimmed = link.trim();
if (!trimmed) return null;
return (
const outbound =
parseVmessLink(trimmed) ??
parseVlessLink(trimmed) ??
parseTrojanLink(trimmed) ??
parseShadowsocksLink(trimmed) ??
parseHysteria2Link(trimmed) ??
parseWireguardLink(trimmed)
);
parseWireguardLink(trimmed);
if (outbound) dropHostLocalKeys(outbound);
return outbound;
}
// Keys that redirect the core's dial or name files on the importing host; extra=, fm= and
// vmess JSON copy provider objects verbatim, and the core binds keys case-folded.
const HOST_LOCAL_KEYS = new Set(['dialerproxy', 'certificatefile', 'keyfile', 'masterkeylog']);
function dropHostLocalKeys(value: unknown): void {
if (Array.isArray(value)) {
value.forEach(dropHostLocalKeys);
return;
}
if (!value || typeof value !== 'object') return;
const obj = value as Raw;
for (const key of Object.keys(obj)) {
// Upper-casing first folds the long s and the Kelvin sign the way Go's decoder does.
if (HOST_LOCAL_KEYS.has(key.toUpperCase().toLowerCase())) delete obj[key];
else dropHostLocalKeys(obj[key]);
}
}
+3 -3
View File
@@ -1195,7 +1195,7 @@ export const sections: readonly Section[] = [
summary:
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
description:
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
params: [
{
name: 'client',
@@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/bulkResetTraffic',
summary:
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.',
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.',
body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
},
@@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/resetTraffic/:email',
summary:
'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
'Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.',
params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
},
{
@@ -294,6 +294,7 @@ export default function InboundsPage() {
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds) {
if (candidate.id === dbInbound.id) continue;
if ((candidate.nodeId ?? 0) !== (dbInbound.nodeId ?? 0)) continue;
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
if (candStream.network !== 'tcp') continue;
@@ -46,9 +46,10 @@ function StringList({ name, label, tooltip, placeholder }: StringListProps) {
);
}
// xray-core 26.9.30 accepts exactly these autoSystemWfpBlockLeak values.
// Named autoSystemWfpBlockLeak values; xray-core 26.10.31 also takes typed "tcp/<port>" or "udp/<port>".
const WFP_BLOCK_LEAK_OPTIONS = [
{ value: 'dns', label: 'dns' },
{ value: 'windnsclient', label: 'windnsclient' },
{ value: 'misconfigtun', label: 'misconfigtun' },
];
@@ -110,7 +111,8 @@ export default function TunFields() {
>
<Select
id="tun-wfp-block-leak"
mode="multiple"
mode="tags"
tokenSeparators={[',', ' ']}
allowClear
options={WFP_BLOCK_LEAK_OPTIONS}
/>
@@ -69,7 +69,9 @@ export default function InboundInfoModal({
if (!clientStats?.email) return;
setRefreshing(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`);
const msg = await HttpUtil.post(
`/panel/api/clients/ips/${encodeURIComponent(clientStats.email)}`,
);
if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record');
setClientIpsArray([]);
@@ -101,7 +103,9 @@ export default function InboundInfoModal({
const clearClientIps = useCallback(async () => {
if (!clientStats?.email) return;
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`);
const msg = await HttpUtil.post(
`/panel/api/clients/clearIps/${encodeURIComponent(clientStats.email)}`,
);
if (msg?.success) {
setClientIpsArray([]);
setClientIpsText(t('tgbot.noIpRecord'));
@@ -223,7 +227,8 @@ export default function InboundInfoModal({
setClientIpsText('');
if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) {
void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => {
const email = encodeURIComponent(stats.email);
void HttpUtil.post(`/panel/api/clients/ips/${email}`).then((msg) => {
if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record');
return;
+2 -2
View File
@@ -19,8 +19,8 @@ interface GeodataSectionProps {
}
const DEFAULT_CRON = '0 4 * * *';
// Xray resolves `file` inside its asset directory; plain file names only.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
// Xray resolves `file` inside its asset directory; since 26.10.29 a scheduled one must be .dat.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.dat$/i;
function fileNameFromUrl(url: string): string {
try {
+11 -4
View File
@@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) {
</span>
);
}
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
// A sub-node's status is free text from its parent node: show it as unknown, not as a key.
return (
<span>
{t(`pages.nodes.statusValues.${status || 'unknown'}`, {
defaultValue: t('pages.nodes.statusValues.unknown'),
})}
</span>
);
}
function formatPct(p?: number, available = true): string {
@@ -188,11 +195,11 @@ export default function NodeList({
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
// Map a node GUID to its display name so a transitive sub-node can show which
// parent it is reached through (#4983).
// Map a direct node's GUID to its display name so a transitive sub-node can show
// which parent it is reached through (#4983); the master walks one level only.
const nameByGuid = useMemo(() => {
const m = new Map<string, string>();
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
for (const n of nodes) if (n.guid && !n.transitive) m.set(n.guid, n.name || n.guid);
return m;
}, [nodes]);
@@ -54,7 +54,7 @@ export const AmneziawgServerSchema = z.object({
publicKey: z.string().optional(),
subnetIp: z.string().default('10.8.1.0'),
subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)),
mtu: optionalClearedInt(z.number().int().min(1)),
mtu: optionalClearedInt(z.number().int().min(1).max(65535)),
primaryDns: z.string().default('8.8.8.8'),
secondaryDns: z.string().default('8.8.4.4'),
externalInterface: z.string().default(''),
@@ -71,10 +71,10 @@ export const AmneziawgServerSchema = z.object({
// embedded userspace stack -- no Xray routing, sniffing, IPv6 or port forwarding.
kernelMode: z.boolean().default(false).optional(),
// Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go):
// jc/jmin/jmax are uint32, s1-s4 uint16. Wider values make IpcSet fail.
jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)),
jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)),
// Every handshake allocates jc packets of jmin..jmax bytes; s1-s4 are uint16.
jc: clearedToDefault(z.number().int().min(0).max(128).default(5)),
jmin: clearedToDefault(z.number().int().min(0).max(65535).default(10)),
jmax: clearedToDefault(z.number().int().min(0).max(65535).default(50)),
s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)),
s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)),
s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)),
@@ -10,7 +10,7 @@ export const TunInboundSettingsSchema = z.object({
userLevel: z.number().int().min(0).default(0),
autoSystemRoutingTable: z.array(z.string()).default([]),
autoOutboundsInterface: z.string().default('auto'),
// xray-core 26.9.30: the first acts on Linux, the second ("dns", "misconfigtun") on Windows.
// xray-core 26.9.30: the first acts on Linux, the second (named values or "tcp/<port>") on Windows.
autoSystemDnsToGateway: z.boolean().optional(),
autoSystemWfpBlockLeak: z.array(z.string()).optional(),
});
@@ -14,14 +14,14 @@ export type AmneziaWGOutboundPeer = z.infer<typeof AmneziaWGOutboundPeerSchema>;
export const AmneziaWGOutboundSettingsSchema = z.object({
// 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU);
// a pinned 1420 fragments every full-size packet once S4 exceeds 20.
mtu: z.number().int().min(0).default(0),
mtu: z.number().int().min(0).max(65535).default(0),
secretKey: z.string().default(''),
address: z.array(z.string()).default([]),
listenPort: z.number().int().min(0).max(65535).default(0),
dns: z.string().default(''),
jc: z.number().int().min(0).default(0),
jmin: z.number().int().min(0).default(40),
jmax: z.number().int().min(0).default(100),
jc: z.number().int().min(0).max(128).default(0),
jmin: z.number().int().min(0).max(65535).default(40),
jmax: z.number().int().min(0).max(65535).default(100),
// The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here.
s1: z.number().int().min(0).max(65535).default(15),
s2: z.number().int().min(0).max(65535).default(80),
@@ -2,7 +2,7 @@ import { z } from 'zod';
// Both XDRIVE ends carry the same service, folder and secrets; an absent tuning knob takes
// the core's default (segmentBytes and concurrency are capped there at 16 MiB / 64).
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template']);
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template', 's3']);
export type XDriveService = z.infer<typeof XDriveServiceSchema>;
// xray-core 26.10.17 (#7182) renamed "Google Drive" and reads the old name only as a deprecated alias.
@@ -22,10 +22,19 @@ export function liftLegacyXDriveService(stream: Record<string, unknown>): void {
const MillisSchema = z.number().int().min(0);
// S3-compatible storage (xray-core 26.10.31, #7231); an unset pathStyle means path-style addressing.
export const XDriveS3Schema = z.object({
endpoint: z.string().optional(),
region: z.string().optional(),
bucket: z.string().optional(),
pathStyle: z.boolean().optional(),
});
export const XDriveStreamSettingsSchema = z.object({
service: XDriveServiceInputSchema.default('google-drive'),
remoteFolder: z.string().default(''),
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken.
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken; S3 takes
// AccessKeyID, SecretAccessKey and an optional SessionToken.
secrets: z.array(z.string()).default([]),
segmentBytes: z.number().int().min(0).optional(),
flushIntervalMs: MillisSchema.optional(),
@@ -36,6 +45,7 @@ export const XDriveStreamSettingsSchema = z.object({
eagerWindowMs: MillisSchema.optional(),
holeTimeoutMs: MillisSchema.optional(),
template: z.record(z.string(), z.unknown()).optional(),
s3: XDriveS3Schema.optional(),
// Client XMUX (xray-core 26.10.21); left unset, the core sizes it per service, so no
// defaults are filled in here. A JSON subscription hands an inbound's value to clients.
xmux: z.record(z.string(), z.unknown()).optional(),
@@ -108,6 +108,8 @@ export const XHttpStreamSettingsSchema = z.preprocess(
serverMaxHeaderBytes: z.number().int().min(0).default(0),
uplinkHTTPMethod: z.string().default(''),
headers: WsHeaderMapSchema.default({}),
// Server-only Go net/http HTTP2Config (xray-core 26.10.31); never put into share links.
h2Params: z.record(z.string(), z.unknown()).optional(),
// Client-side fields stored on inbound for subscription propagation.
// The server listener ignores them at runtime, but the panel embeds
// them in share-link `extra` blobs so the same xhttp config can
@@ -42,9 +42,10 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
['s2', 1609],
['s3', 1637],
['s4', 33],
['jc', 4294967296],
['jmin', 4294967296],
['jmax', 5000000000],
['jc', 129],
['jmin', 65536],
['jmax', 65536],
['mtu', 65536],
];
it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => {
@@ -56,7 +57,9 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
['s2', 1608],
['s3', 1636],
['s4', 32],
['jc', 4294967295],
['jc', 128],
['jmax', 65535],
['mtu', 65535],
];
it.each(atLimit)('accepts %s exactly at its limit', (field, value) => {
@@ -76,4 +76,33 @@ describe('GeodataSection', () => {
}
});
});
// xray-core 26.10.29 refuses a scheduled asset whose file is not .dat when it starts.
it('refuses to save a scheduled asset whose file is not .dat', async () => {
const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
new Msg(
true,
'',
JSON.stringify({
xraySetting: {
outbounds: [],
geodata: { cron: '0 4 * * *', assets: [CUSTOM_SOURCE] },
},
geodataSources: [],
}),
),
);
const user = userEvent.setup();
render(<GeodataSection active onBusy={vi.fn()} onClose={vi.fn()} />);
const file = await screen.findByDisplayValue(CUSTOM_SOURCE.file);
await user.clear(file);
await user.type(file, 'geosite_custom.mmdb');
await user.click(screen.getByRole('button', { name: 'Save & Restart Xray' }));
expect(await screen.findByText(/File names must be/)).toBeTruthy();
expect(screen.queryByText('Save geodata settings?')).toBeNull();
expect(post).toHaveBeenCalledTimes(1);
});
});
@@ -228,6 +228,21 @@ describe('formValuesToWirePayload', () => {
expect(payload.sniffing).toBe('');
});
// xray-core 26.10.31 (#7295): the XHTTP server reads h2Params; the form must not strip it.
it('keeps an XHTTP inbound h2Params set through JSON', () => {
const h2Params = { MaxConcurrentStreams: 512, MaxReadFrameSize: 1048576 };
const values = rawInboundToFormValues({
...vlessRow,
streamSettings: {
network: 'xhttp',
security: 'none',
xhttpSettings: { path: '/x', mode: 'auto', h2Params },
},
});
const payload = formValuesToWirePayload(values);
expect(JSON.parse(payload.streamSettings).xhttpSettings.h2Params).toEqual(h2Params);
});
it('omits nodeId when null', () => {
const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
const payload = formValuesToWirePayload(values);
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import i18next from 'i18next';
import NodeList from '@/pages/nodes/NodeList';
import type { NodeRecord } from '@/schemas/node';
import { renderWithProviders } from './test-utils';
const noop = () => {};
// A closed reference: an unclosed "$t(" would hang a regressed run instead of failing it.
const reported = '$t(pages.nodes.subNode)';
function renderSubNode(fields: Partial<NodeRecord>) {
const nodes: NodeRecord[] = [
{ id: 1, name: 'parent', guid: 'p1', enable: true, status: 'online' },
// parentGuid is its own GUID: the sub-node tries to put its name into the parent tooltip.
{ id: 0, name: 'child', guid: 'c1', parentGuid: 'c1', transitive: true, ...fields },
];
renderWithProviders(
<NodeList
nodes={nodes}
isMobile={false}
selectedIds={[]}
onSelectionChange={noop}
onAdd={noop}
onMtls={noop}
onEdit={noop}
onDelete={noop}
onProbe={noop}
onToggleEnable={noop}
onUpdateNode={noop}
onUpdateSelected={noop}
/>,
);
}
describe('NodeList sub-node rows', () => {
afterEach(() => vi.restoreAllMocks());
it('shows a node-reported status it does not know as Unknown, not as an i18next key', () => {
renderSubNode({ status: reported });
const row = screen.getByText('child').closest('tr') as HTMLElement;
expect(row.textContent).toContain('Unknown');
expect(row.textContent).not.toContain('statusValues');
});
it('never names a sub-node as the parent it is reached through', () => {
const translate = vi.spyOn(i18next, 't');
renderSubNode({ name: reported, status: 'online' });
expect(translate).toHaveBeenCalledWith(
'pages.nodes.subNodeTip',
expect.objectContaining({ parent: '-' }),
);
expect(JSON.stringify(translate.mock.calls)).not.toContain(reported);
});
});
describe('i18next nesting', () => {
afterEach(() => vi.restoreAllMocks());
// Closed references only: an unclosed "$t(" would hang a regressed run instead of failing it.
it('is off, so "$t(...)" in a missing key or an interpolated value stays plain text', async () => {
const utils = await import('@/utils');
vi.spyOn(utils.LanguageManager, 'getLanguage').mockReturnValue('en-US');
const { readyI18n } = await import('@/i18n/react');
const i18n = await readyI18n();
expect(i18n.t('$t(pages.nodes.subNode)')).toBe('$t(pages.nodes.subNode)');
expect(i18n.t('pages.nodes.subNodeTip', { parent: '$t(pages.nodes.subNode)' })).toBe(
"Read-only: a downstream node reached through $t(pages.nodes.subNode). Manage it from $t(pages.nodes.subNode)'s own panel.",
);
});
});
@@ -0,0 +1,63 @@
import type { ReactNode } from 'react';
import { act, render, renderHook } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useHostMutations } from '@/api/queries/useHostMutations';
import { BulkAddHostSchema } from '@/schemas/api/host';
import { DBInbound } from '@/models/dbinbound';
import InboundInfoModal from '@/pages/inbounds/info/InboundInfoModal';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil } from '@/utils';
// Client emails and host group ids adopted from a node skip the panel's own
// character rules: unencoded, "../" and "?" re-target the admin's POST.
const hostile = '../../setting/update?tgBotEnable=true';
const encoded = encodeURIComponent(hostile);
describe('node-reported values stay one URL path segment', () => {
const post = vi.mocked(HttpUtil.post);
beforeEach(() => {
post.mockClear();
});
it('encodes the client email in the inbound info IP-log request', () => {
const dbInbound = new DBInbound({
id: 2,
protocol: 'http',
port: 44301,
settings: JSON.stringify({ clients: [{ email: hostile, enable: true, limitIp: 1 }] }),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: '',
clientStats: [{ email: hostile, enable: true, up: 1, down: 1, total: 0, expiryTime: 0 }],
});
render(<InboundInfoModal open onClose={() => {}} dbInbound={dbInbound} ipLimitEnable />);
expect(post).toHaveBeenCalledWith(`/panel/api/clients/ips/${encoded}`);
});
it('encodes the host group id in the host mutations', async () => {
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useHostMutations(), { wrapper });
await act(async () => {
await result.current.update(
hostile,
BulkAddHostSchema.parse({ inboundIds: [1], remark: 'cdn' }),
);
await result.current.setEnable(hostile, false);
await result.current.remove(hostile);
});
expect(post.mock.calls.map(([url]) => url)).toEqual([
`/panel/api/hosts/update/${encoded}`,
`/panel/api/hosts/setEnable/${encoded}`,
`/panel/api/hosts/del/${encoded}`,
]);
});
});
@@ -563,6 +563,69 @@ describe('OutboundFormModal XDRIVE service name', () => {
});
});
// xray-core 26.10.31 (#7231): every S3 key needs a registered field or the editor drops it on save.
describe('OutboundFormModal XDRIVE S3 storage', () => {
it('opens and saves a stored S3 outbound unchanged', async () => {
const onConfirm = vi.fn();
const queryClient = makeTestQueryClient();
const xdriveSettings = {
service: 's3',
remoteFolder: 'xdrive',
secrets: ['AKID', 'SECRET', 'TOKEN'],
s3: {
endpoint: 'https://storage.yandexcloud.net',
region: 'ru-central1',
bucket: 'example-bucket',
pathStyle: false,
},
};
const outbound = {
protocol: 'vless',
tag: 's3-out',
settings: {
address: 'example.com',
port: 443,
id: 'c9f0c2d0-0000-4000-8000-000000000000',
encryption: 'mlkem768x25519plus.native.0rtt.key',
},
mux: { enabled: true, concurrency: 8 },
streamSettings: { network: 'xdrive', security: 'none', xdriveSettings },
};
const tree = (open: boolean) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<OutboundFormModal
open={open}
outbound={outbound}
existingTags={[]}
onClose={() => {}}
onConfirm={onConfirm}
/>
</ThemeProvider>
</QueryClientProvider>
);
const { rerender } = render(tree(false));
rerender(tree(true));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect((screen.getByLabelText('Bucket') as HTMLInputElement).value).toBe('example-bucket');
expect((screen.getByLabelText('Access key ID') as HTMLInputElement).value).toBe('AKID');
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
await act(async () => {
fireEvent.click(ok);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(onConfirm).toHaveBeenCalledTimes(1);
const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record<string, unknown> };
expect(payload.streamSettings.xdriveSettings).toMatchObject(xdriveSettings);
});
});
// xray-core 26.10.10 dials the XDRIVE storage API with the outbound's tlsSettings (SNI,
// uTLS fingerprint), so switching a TLS outbound to XDRIVE must keep them.
describe('OutboundFormModal XDRIVE TLS', () => {
@@ -1124,3 +1124,32 @@ describe('obfs=gecko packetSize validation', () => {
expect(packetSizeOf(link)).toBeUndefined();
});
});
describe('parseOutboundLink — host-local keys', () => {
it('drops keys that redirect the dial or name host files, however they are cased', () => {
const extra = encodeURIComponent(
JSON.stringify({
downloadSettings: {
address: '8.8.8.8',
port: 443,
sockopt: { dialerProxy: 'api', tcpFastOpen: true },
tlsSettings: {
serverName: 'cdn.example.com',
certificates: [{ CERTIFICATEFILE: '/dev/zero', keyFile: '/etc/shadow' }],
maſterKeyLog: '/root/keys',
},
},
}),
);
const fm = encodeURIComponent(
JSON.stringify({ tcp: [{ type: 'header-custom', settings: { DialerProxy: 'api' } }] }),
);
const out = parseOutboundLink(
`vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=${extra}&fm=${fm}#node`,
);
const json = JSON.stringify(out);
expect(json).not.toMatch(/dialerproxy|certificatefile|keyfile|maſterkeylog/i);
expect(json).toContain('"tcpFastOpen":true');
expect(json).toContain('"serverName":"cdn.example.com"');
});
});
+2 -1
View File
@@ -3,6 +3,7 @@ import { cleanup } from '@testing-library/react';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { INTERPOLATION } from '@/i18n/react';
import enUS from '../../../internal/web/translation/en-US.json';
// RTL sets this from a global beforeAll, which never runs with `globals: false`.
@@ -75,7 +76,7 @@ if (!i18next.isInitialized) {
lng: 'en-US',
fallbackLng: 'en-US',
resources: { 'en-US': { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
interpolation: INTERPOLATION,
returnNull: false,
});
}
+11
View File
@@ -56,6 +56,17 @@ describe('NetworkSettingsSchema xray-core 26.10.21 keys', () => {
expect(parsed.xdriveSettings.service).toBe('google-drive');
});
// xray-core 26.10.31 (#7231): the "s3" service reads its storage from an "s3" object.
it('keeps the XDRIVE s3 service and its storage', () => {
const s3 = { endpoint: 'https://s3.example', region: 'r', bucket: 'b', pathStyle: false };
const parsed = NetworkSettingsSchema.parse({
network: 'xdrive',
xdriveSettings: { service: 's3', remoteFolder: 'x', secrets: ['AKID', 'SECRET'], s3 },
}) as { xdriveSettings: { service: string; s3?: unknown } };
expect(parsed.xdriveSettings.service).toBe('s3');
expect(parsed.xdriveSettings.s3).toEqual(s3);
});
it('keeps the XDRIVE and MASQUE xmux', () => {
const xmux = { maxConnections: 2, hMaxReusableSecs: '1800-3000' };
const xdrive = NetworkSettingsSchema.parse({
+19 -19
View File
@@ -5,33 +5,33 @@ go 1.27.3
require (
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e
github.com/gin-contrib/gzip v1.2.8
github.com/gin-contrib/sessions v1.1.2
github.com/gin-contrib/gzip v1.2.9
github.com/gin-contrib/sessions v1.1.3
github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-playground/validator/v10 v10.30.5
github.com/goccy/go-json v0.11.2
github.com/goccy/go-json v0.12.0
github.com/goccy/go-yaml v1.19.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.11.0
github.com/jackc/pgx/v5 v5.12.0
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.20.3
github.com/mattn/go-sqlite3 v1.14.52
github.com/mymmrac/telego v1.12.1
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/nicksnyder/go-i18n/v2 v2.7.0
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/quic-go/quic-go v0.64.1
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.9
github.com/shirou/gopsutil/v4 v4.26.10
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.76.0
github.com/valyala/fasthttp v1.77.0
github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf
github.com/xtls/xray-core v1.260327.1-0.20261031132019-a4a3dff5b20c
go.uber.org/atomic v1.12.0
golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0
golang.org/x/net v0.62.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/text v0.43.0
@@ -47,14 +47,14 @@ require (
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/andybalholm/brotli v1.2.6 // indirect
github.com/andybalholm/brotli v1.2.7 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.4 // indirect
github.com/bytedance/sonic v1.15.5 // indirect
github.com/bytedance/sonic/loader v0.5.3 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/ebitengine/purego v0.11.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect
github.com/gin-contrib/sse v1.2.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.2 // indirect
@@ -78,18 +78,18 @@ require (
github.com/leodido/go-urn v1.5.0 // indirect
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect
github.com/libp2p/go-netroute v0.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/lufia/plan9stats v0.0.0-20261101121723-be84dd9509a0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/miekg/dns v1.1.73 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/molecule-man/go-brrr v1.3.0 // indirect
github.com/molecule-man/go-brrr v1.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pion/dtls/v3 v3.1.15 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.7 // indirect
github.com/pion/transport/v4 v4.1.1 // indirect
github.com/pion/transport/v5 v5.1.1 // indirect
github.com/pion/transport/v4 v4.1.3 // indirect
github.com/pion/transport/v5 v5.1.4 // indirect
github.com/pires/go-proxyproto v0.16.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
@@ -109,13 +109,13 @@ require (
go.mongodb.org/mongo-driver/v2 v2.9.2 // indirect
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
golang.org/x/arch v0.32.0 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20261030153154-30a33513ff6d // indirect
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
golang.org/x/time v0.16.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
golang.zx2c4.com/wireguard/windows v1.1.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e // indirect
layeh.com/gopher-luar v1.0.11 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
)
+38 -38
View File
@@ -6,14 +6,14 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 h1:D8d8gGvwXcTxUIsE4z6F6vjy4/VZddu95vMNtOygh1c=
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg=
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8=
github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic v1.15.5 h1:gKWTBfYCIQpQHZ7LpwGf9Gj3N88ZN0G/GaB3EQhiGXE=
github.com/bytedance/sonic v1.15.5/go.mod h1:nn7CoK5W+LKbl22XGJrXg6SwzaFAcVs89miqzkqDwOU=
github.com/bytedance/sonic/loader v0.5.3 h1:ziVGAbXQtLoaot/M6YQxntGkekxz6B/DyuJVwnTbMAA=
github.com/bytedance/sonic/loader v0.5.3/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -30,12 +30,12 @@ github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDm
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
github.com/gin-contrib/gzip v1.2.8 h1:wDb1thtVsSUe+126xHdgLiWcZqHdWXiBzdZj2yhdUM8=
github.com/gin-contrib/gzip v1.2.8/go.mod h1:OiNBR7FxAwHN3iwoDtjj2RlypuONCk48zmtagvASfrc=
github.com/gin-contrib/sessions v1.1.2 h1:utWRqOHT/kbqxxqF/mtutV9wfGLi1+uu5YuvWUhj/Ow=
github.com/gin-contrib/sessions v1.1.2/go.mod h1:qRxYwA/TYJw0wv28oFzs4gY/8G8z69id862SNgX9Gmw=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-contrib/gzip v1.2.9 h1:MPyIubgLF/o5A4ni90weaQV0Oji7JTQto7QBVqWtmmY=
github.com/gin-contrib/gzip v1.2.9/go.mod h1:vLBq0o7y73JpE7m1pngAK/BPaVB7EU56R2gQP1yCgbo=
github.com/gin-contrib/sessions v1.1.3 h1:jTd6RietfxB/0brE36W6Q7XTl9die/gwD5orFZQZfh0=
github.com/gin-contrib/sessions v1.1.3/go.mod h1:4As481wI7g3rsRf6RnQYc0pS/IqBjp8vpz1Y4wudHek=
github.com/gin-contrib/sse v1.2.0 h1:AeYPE9hukQvAU1y7+eNvTC67PuBLK4BOduG08yv7L28=
github.com/gin-contrib/sse v1.2.0/go.mod h1:9ivB3pROCxcwnwHvAoYaHLwNQMGmzCjgriA04TuNIAc=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
@@ -53,8 +53,8 @@ github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dm
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
@@ -88,8 +88,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds=
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1EdCs=
@@ -132,8 +132,8 @@ github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcTh
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8=
github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q=
github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20261101121723-be84dd9509a0 h1:bGmNmV0W+HWMJyDOXmbY2InjV33LK59X9ClO7AU/8e8=
github.com/lufia/plan9stats v0.0.0-20261101121723-be84dd9509a0/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
@@ -145,12 +145,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/molecule-man/go-brrr v1.3.0 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjRB7GeU=
github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/molecule-man/go-brrr v1.3.1 h1:EwaJUzRIlp/dFmIpzq4J17CA5Zi5OHo2vOGmu09hF1w=
github.com/molecule-man/go-brrr v1.3.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/mymmrac/telego v1.12.1 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88=
github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
@@ -163,10 +163,10 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo=
github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE=
github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8=
github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0=
github.com/pion/transport/v4 v4.1.3 h1:Qf6b/NJRn8J0yJK/Ac/qq9oY6XcKiWiLf9Kxsi0x05o=
github.com/pion/transport/v4 v4.1.3/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE=
github.com/pion/transport/v5 v5.1.4 h1:j0E5QIPyv28YnWxa8yE9AyDmrxVnwhZ109c7Dpt48K4=
github.com/pion/transport/v5 v5.1.4/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0=
github.com/pires/go-proxyproto v0.16.0 h1:9KYT/fM3wpjT8bdMEj/7FBcoiUG1KyzEjRYnPu+vz7A=
github.com/pires/go-proxyproto v0.16.0/go.mod h1:n7yhg8yIfNl3KgYDCwBzmeUHNpKTVnUhcWpc7rV4RW4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -184,8 +184,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig=
github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg=
github.com/shirou/gopsutil/v4 v4.26.10 h1:MJN6euesJx53MVEjscYeyRSRcrdIcy3sz5k9nosjldg=
github.com/shirou/gopsutil/v4 v4.26.10/go.mod h1:XpkIrcUMQhwte/dGxu57KtnMMFsbKelI7PP23zweKBk=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -210,8 +210,8 @@ github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.76.0 h1:QitrT4foDQuqvUnr/hzmQUDbYzV12Zz4vkLOlipU4Cw=
github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o=
github.com/valyala/fasthttp v1.77.0 h1:6mxBJdHabRmU4Cmw79Uqhgvjzsp6pjOaQk2t8COflzo=
github.com/valyala/fasthttp v1.77.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@@ -224,8 +224,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs=
github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY=
github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf h1:Eqz6sj4Up2NtymyVY2UtXNpHZqJGrMwKsXnGOySyUNI=
github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf/go.mod h1:rbK8dL0A1f4uYpNa3ljvB1U5qrcb4KQ4Pxb8yGdWZXo=
github.com/xtls/xray-core v1.260327.1-0.20261031132019-a4a3dff5b20c h1:XijTasBJ2fImHZo1GoAgSfHuDqloQGYHEADLy9CgrJ8=
github.com/xtls/xray-core v1.260327.1-0.20261031132019-a4a3dff5b20c/go.mod h1:/Umx7vpU+A78gOOuKl6ESU6eQJ5EBRhwr5cl7+9/drw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
@@ -247,12 +247,12 @@ golang.org/x/arch v0.32.0 h1:NjGf8/xGf5JgAJ4dw4NtME5R1L7xatagqAg95T3LgII=
golang.org/x/arch v0.32.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a h1:V5oicZXS0oqZx30gY/apgSVD7YvNBfrIOdcMnlelJY4=
golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/crypto/x509roots/fallback v0.0.0-20261030153154-30a33513ff6d h1:5NBkmd8MXirajj6AAEpi4HFVJnVkBNPTL1ggYAKqaws=
golang.org/x/crypto/x509roots/fallback v0.0.0-20261030153154-30a33513ff6d/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
@@ -272,14 +272,14 @@ golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc=
golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE=
golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e h1:w3fy/G8OrJHYexn+u5y7hFyc8YRRIr7DCevtIOwAcGY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.86.0-dev h1:FLq7hcApuJeJ70J9+k0FvmXsNCwS8B2/MeAfYdy3GHY=
google.golang.org/grpc v1.86.0-dev/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+1 -1
View File
@@ -34,7 +34,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
PublicKey: c.PublicKey,
PresharedKey: c.PreSharedKey,
AllowedIPs: c.AllowedIPs,
ForwardedPorts: c.ForwardedPorts,
ForwardedPorts: c.ForwardedPortsSpec(),
})
}
if len(peers) == 0 {
+3
View File
@@ -282,6 +282,9 @@ func ValidateAmneziaWGOutbound(tag string, raw []byte) error {
if err := ValidateObfuscation(parsed.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
}
if err := ValidateMTU(parsed.MTU); err != nil {
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
}
for n, iv := range map[string]string{
"i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5,
} {
+70 -13
View File
@@ -37,11 +37,23 @@ func randInt(min, max int) int {
// link, before AmneziaWG's own S4 transport junk is prepended.
const DefaultMTU = 1420
// MaxMTU bounds the tunnel MTU: amneziawg-go keeps it as an int32, and a wider value wraps.
const MaxMTU = 65535
// ValidateMTU refuses an MTU past MaxMTU before it is saved; 0 means the default.
func ValidateMTU(mtu int) error {
if mtu < 0 || mtu > MaxMTU {
return fmt.Errorf("invalid mtu %d (must be 0..%d)", mtu, MaxMTU)
}
return nil
}
// EffectiveMTU is the admin's value when set, else DefaultMTU minus S4: s4 junk
// is prepended to every transport packet and never clamped against the MTU.
func EffectiveMTU(configuredMTU, s4 int) int {
if configuredMTU > 0 {
return configuredMTU
// A row saved before ValidateMTU existed may still carry a wider value.
return min(configuredMTU, MaxMTU)
}
return max(DefaultMTU-max(s4, 0), 1280)
}
@@ -159,6 +171,35 @@ func ValidateServerObfuscation(o Obfuscation31) error {
return nil
}
const (
// maxJunkPackets is the Jc range AmneziaWG documents.
maxJunkPackets = 128
// maxPreamblePacket is amneziawg-go's MaxSegmentSize: each junk and signature packet is one datagram.
maxPreamblePacket = 65535
)
// ValidateHandshakePreamble bounds what every handshake initiation allocates: Jc junk
// packets of Jmin..Jmax bytes and the I1-I5 signature packets. Device setup runs it too.
func ValidateHandshakePreamble(o Obfuscation31) error {
if o.Jmin > o.Jmax {
return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
}
for _, f := range []struct {
name string
v, max int
}{{"Jc", o.Jc, maxJunkPackets}, {"Jmin", o.Jmin, maxPreamblePacket}, {"Jmax", o.Jmax, maxPreamblePacket}} {
if f.v < 0 || f.v > f.max {
return fmt.Errorf("invalid %s value %d (must be 0..%d)", f.name, f.v, f.max)
}
}
for i, spec := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} {
if err := validateObfChain(spec); err != nil {
return fmt.Errorf("invalid I%d: %w", i+1, err)
}
}
return nil
}
// ValidateObfuscation rejects malformed parameters before they are saved, so
// a bad manual entry can't break the embedded amneziawg-go device's own
// UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
@@ -166,19 +207,16 @@ func ValidateServerObfuscation(o Obfuscation31) error {
// are allowed (they fall back to a default); each accepts an integer or a
// "100-800" range.
func ValidateObfuscation(o Obfuscation31) error {
if o.Jmin > o.Jmax {
return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
if err := ValidateHandshakePreamble(o); err != nil {
return err
}
// amneziawg-go parses jc/jmin/jmax as uint32 and s1-s3 as uint16 (device/uapi.go);
// a wider value makes IpcSet reject the whole device.
// amneziawg-go parses s1-s3 as uint16 (device/uapi.go); a wider value makes IpcSet
// reject the whole device.
for _, f := range []struct {
name string
v int
max int64
}{
{"Jc", o.Jc, math.MaxUint32},
{"Jmin", o.Jmin, math.MaxUint32},
{"Jmax", o.Jmax, math.MaxUint32},
{"S1", o.S1, math.MaxUint16},
{"S2", o.S2, math.MaxUint16},
{"S3", o.S3, math.MaxUint16},
@@ -187,11 +225,6 @@ func ValidateObfuscation(o Obfuscation31) error {
return fmt.Errorf("invalid %s value %d (must be 0..%d)", f.name, f.v, f.max)
}
}
for i, spec := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} {
if err := validateObfChain(spec); err != nil {
return fmt.Errorf("invalid I%d: %w", i+1, err)
}
}
if o.S4 < 0 || o.S4 > 32 {
return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
}
@@ -263,6 +296,7 @@ func validateObfChain(spec string) error {
return nil
}
remaining := spec
total := 0
for {
start := strings.IndexByte(remaining, '<')
if start == -1 {
@@ -279,6 +313,29 @@ func validateObfChain(spec string) error {
if !obfChainTags[fields[0]] {
return fmt.Errorf("spec %q uses unknown tag <%s>", spec, fields[0])
}
// The packet is built once per handshake initiation, so what it allocates is bounded.
switch fields[0] {
case "r", "rc", "rd", "dz":
n := -1
if len(fields) == 2 {
if v, err := strconv.Atoi(fields[1]); err == nil {
n = v
}
}
if n < 0 || n > maxPreamblePacket {
return fmt.Errorf("spec %q: <%s> needs a length of 0..%d", spec, fields[0], maxPreamblePacket)
}
total += n
case "b":
if len(fields) == 2 {
total += len(strings.TrimPrefix(fields[1], "0x")) / 2
}
case "t":
total += 4
}
if total > maxPreamblePacket {
return fmt.Errorf("spec %q builds more than %d bytes", spec, maxPreamblePacket)
}
remaining = remaining[start+end+1:]
}
}
+33
View File
@@ -529,3 +529,36 @@ func TestValidateObfuscationRejectsMalformedSignaturePackets(t *testing.T) {
})
}
}
// Each handshake initiation sends Jc junk packets and the I1-I5 packets, allocated from
// these values; unbounded, one connection (an unauthenticated peer) killed the panel.
func TestValidateObfuscationBoundsWhatAHandshakeAllocates(t *testing.T) {
for name, mutate := range map[string]func(*Obfuscation31){
"jc past the documented range": func(o *Obfuscation31) { o.Jc = maxJunkPackets + 1 },
"jmax past a datagram": func(o *Obfuscation31) { o.Jmax = maxPreamblePacket + 1 },
"random bytes past a datagram": func(o *Obfuscation31) { o.I1 = "<r 70000>" },
"negative length": func(o *Obfuscation31) { o.I2 = "<r -1>" },
"spec past a datagram": func(o *Obfuscation31) { o.I3 = "<r 40000><rc 40000>" },
} {
o := validObfuscation()
mutate(&o)
if err := ValidateObfuscation(o); err == nil {
t.Errorf("%s: accepted", name)
}
}
o := validObfuscation()
o.I1 = "<b 0xf6ab3267fa><r 1200><t>"
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("an ordinary I1 was refused: %v", err)
}
}
// amneziawg-go stores the MTU as an int32; a wider value wraps and the device panics.
func TestMTUIsBoundedWhereItIsSavedAndWhereItIsRead(t *testing.T) {
if err := ValidateMTU(MaxMTU + 1); err == nil {
t.Fatal("an MTU past 65535 was accepted")
}
if got := EffectiveMTU(1<<31, 0); got != MaxMTU {
t.Fatalf("EffectiveMTU(2^31) = %d, want a stored oversized value capped at %d", got, MaxMTU)
}
}
+2 -2
View File
@@ -134,7 +134,7 @@ func newBenchTunnel(b *testing.B, listenPort int, serverAddr, clientAddr string)
if err != nil {
b.Fatalf("newUnconfiguredDevice: %v", err)
}
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, dest netip.AddrPort) {
defer conn.Close()
switch dest.Port() {
case benchDiscardPort:
@@ -210,7 +210,7 @@ func (t *benchTunnel) dial(b *testing.B, dest netip.AddrPort) *gonet.TCPConn {
func BenchmarkTunnelThroughput(b *testing.B) {
const chunkSize = 64 << 10
const serverAddr = "10.203.0.1"
tun := newBenchTunnel(b, 58714, serverAddr, "10.203.0.2")
tun := newBenchTunnel(b, freeUDPPort(b), serverAddr, "10.203.0.2")
defer tun.closeFn()
b.Run("upload", func(b *testing.B) {
+4
View File
@@ -25,6 +25,10 @@ func buildClientUAPIConfig(inst amneziawg.OutboundInstance, opts DeviceOptions)
}
b.WriteString("replace_peers=true\n")
// Rows saved before these bounds existed would allocate without limit per handshake.
if err := amneziawg.ValidateHandshakePreamble(inst.Obfuscation); err != nil {
return "", err
}
o := inst.Obfuscation
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
+4
View File
@@ -199,6 +199,10 @@ func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error
// dropped instead of lingering from a previous IpcSet call.
b.WriteString("replace_peers=true\n")
// Rows saved before these bounds existed would allocate without limit per handshake.
if err := amneziawg.ValidateHandshakePreamble(inst.Obfuscation); err != nil {
return "", err
}
o := inst.Obfuscation
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
+22 -14
View File
@@ -39,7 +39,7 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach
listenPort := freeUDPPort(t)
const wantEmail = "test-peer@example.com"
inst := amneziawg.Instance{
@@ -80,14 +80,9 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
// purely from the decapsulated packet, not from any routing table.
wantDest := netip.MustParseAddrPort("10.201.9.9:9999")
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) {
defer conn.Close()
srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String())
var peer amneziawg.Peer
var ok bool
if parseErr == nil {
peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
}
peer, ok := idx.Lookup(src.Addr().Unmap())
got <- recovered{email: peer.Email, ok: ok, dest: dest}
io.Copy(io.Discard, conn)
})
@@ -248,7 +243,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
t.Fatalf("generate header protection key: %v", err)
}
const listenPort = 58713 // fixed loopback test port, distinct from the handshake test above
listenPort := freeUDPPort(t)
const contentPaddingAddition = "20-40"
inst := amneziawg.Instance{
@@ -283,7 +278,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
const wantRequest = "hello from client"
const wantReply = "hello from server"
serverDone := make(chan error, 1)
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, dest netip.AddrPort) {
defer conn.Close()
buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil {
@@ -434,7 +429,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58721 // fixed loopback test port, distinct from every other test in this package
listenPort := freeUDPPort(t)
inst := amneziawg.Instance{
Id: 3,
@@ -461,7 +456,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
const wantRequest = "hello from client, with a trailer"
const wantReply = "hello from server, with a trailer"
serverDone := make(chan error, 1)
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, dest netip.AddrPort) {
defer conn.Close()
buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil {
@@ -596,7 +591,7 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
}},
}
for i, tc := range cases {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
o := base
tc.mut(&o)
@@ -604,7 +599,7 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
return // rejected before saving, which is the whole point
}
inst := amneziawg.Instance{
Id: 88, InterfaceName: "awgcontract", ListenPort: 58900 + i,
Id: 88, InterfaceName: "awgcontract", ListenPort: 0,
PrivateKey: priv, PublicKey: pub,
Address: []string{"10.198.0.1/24"}, MTU: 1420,
Obfuscation: o,
@@ -679,3 +674,16 @@ func TestBuildUAPIConfigClearedHeaderProtectionKeyIsSentAsZero(t *testing.T) {
t.Fatalf("a configured key must not be emitted as zero, got:\n%s", withKey)
}
}
// A row stored before the handshake bounds existed must fail its own device setup instead
// of allocating without limit at the next handshake, which ended the whole panel.
func TestBuildUAPIConfigRefusesAStoredUnboundedPreamble(t *testing.T) {
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
inst := amneziawg.Instance{PrivateKey: priv, Obfuscation: amneziawg.Obfuscation31{Jc: 1 << 31, Jmin: 10, Jmax: 20}}
if _, err := buildUAPIConfig(inst, DeviceOptions{}); err == nil {
t.Fatal("a stored Jc of 2^31 reached the device")
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) {
t.Fatalf("generate idle-peer keypair: %v", err)
}
const listenPort = 58713 // distinct from device_test.go's fixed port
listenPort := freeUDPPort(t)
const activeEmail = "active@example.com"
const idleEmail = "idle@example.com"
@@ -77,7 +77,7 @@ func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) {
// Registering the forwarder doesn't require any peer to be configured
// yet, so this ordering is free; it's Configure's IpcSet that must
// never run before the forwarder is registered.
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _ netip.AddrPort) {
AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, _ netip.AddrPort) {
defer conn.Close()
io.Copy(io.Discard, conn)
})
+32 -9
View File
@@ -71,26 +71,49 @@ var tunnelDNSCache = struct {
const (
tunnelDNSCacheTTL = 60 * time.Second
tunnelDNSCacheMaxSize = 1024
tunnelDNSMaxInFlight = 1024
)
// tunnelDNSSlots caps lookups in flight across all outbounds: each one holds a
// netstack port until the resolver answers or the lookup times out.
var tunnelDNSSlots = make(chan struct{}, tunnelDNSMaxInFlight)
// dnsCacheKey computes cache key scoped by outbound tag, server, and host.
func dnsCacheKey(tag, dnsServer, host string) string {
return tag + "|" + dnsServer + "|" + host
}
func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) {
normDNS := amneziawg.NormalizeDNSServer(dnsServer)
if normDNS == "" {
normDNS = defaultDNSFor(dev.LocalAddresses())
// tunnelDNSServerFor normalizes dnsServer, empty meaning the family default.
func tunnelDNSServerFor(dev *Device, dnsServer string) string {
if normDNS := amneziawg.NormalizeDNSServer(dnsServer); normDNS != "" {
return normDNS
}
return defaultDNSFor(dev.LocalAddresses())
}
// cachedTunnelAddr returns host's unexpired cache entry; it never queries.
func cachedTunnelAddr(dev *Device, tag string, dnsServer string, host string) (netip.Addr, bool) {
key := dnsCacheKey(tag, tunnelDNSServerFor(dev, dnsServer), host)
tunnelDNSCache.mu.Lock()
defer tunnelDNSCache.mu.Unlock()
e, ok := tunnelDNSCache.m[key]
return e.addr, ok && time.Now().Before(e.exp)
}
func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) {
if addr, ok := cachedTunnelAddr(dev, tag, dnsServer, host); ok {
return addr, nil
}
normDNS := tunnelDNSServerFor(dev, dnsServer)
key := dnsCacheKey(tag, normDNS, host)
now := time.Now()
tunnelDNSCache.mu.Lock()
if e, ok := tunnelDNSCache.m[key]; ok && now.Before(e.exp) {
tunnelDNSCache.mu.Unlock()
return e.addr, nil
select {
case tunnelDNSSlots <- struct{}{}:
defer func() { <-tunnelDNSSlots }()
case <-ctx.Done():
return netip.Addr{}, fmt.Errorf("dns lookup %q: too many lookups in flight: %w", host, ctx.Err())
}
tunnelDNSCache.mu.Unlock()
server, err := netip.ParseAddrPort(normDNS)
if err != nil {
+69 -1
View File
@@ -11,6 +11,7 @@ import (
"net"
"net/netip"
"sync"
"sync/atomic"
"time"
"gvisor.dev/gvisor/pkg/tcpip"
@@ -218,6 +219,9 @@ func (s *socks5EgressServer) acceptLoop(ln net.Listener, closing chan struct{})
default:
}
logger.Warningf("amneziawgnet: egress accept: %v", err)
if !waitAcceptRetry(closing) {
return
}
continue
}
select {
@@ -479,8 +483,56 @@ type udpControl struct{ conn net.Conn }
type egressUDPSession struct {
dst netip.AddrPort
conn *gonet.UDPConn
// Unix nanoseconds of the last datagram either way; the budget evicts by it.
lastActive atomic.Int64
}
func (s *egressUDPSession) touch() { s.lastActive.Store(time.Now().UnixNano()) }
// egressMaxUDPSessions bounds the sessions of all associations together: each
// pins a goroutine, a port and 64 KiB, and a Mux frame opens an association.
var egressMaxUDPSessions = 4096
// egressUDPBudget is process-wide, like the egress server: the socks bridge
// carries no user identity, so every user draws on the same budget.
type egressUDPBudget struct {
mu sync.Mutex
live map[*egressUDPSession]struct{}
}
var egressUDPSessions = egressUDPBudget{live: map[*egressUDPSession]struct{}{}}
// admit counts sess in. A spent budget closes the longest-idle session rather
// than refusing sess, so a flood's unanswered flows go before live ones.
func (b *egressUDPBudget) admit(sess *egressUDPSession) {
var idlest *egressUDPSession
b.mu.Lock()
if len(b.live) >= egressMaxUDPSessions {
for s := range b.live {
if idlest == nil || s.lastActive.Load() < idlest.lastActive.Load() {
idlest = s
}
}
delete(b.live, idlest)
}
b.live[sess] = struct{}{}
b.mu.Unlock()
if idlest != nil {
idlest.conn.Close()
}
}
func (b *egressUDPBudget) release(sess *egressUDPSession) {
b.mu.Lock()
delete(b.live, sess)
b.mu.Unlock()
}
// maxPendingUDPResolves caps the domain datagrams one association may have
// waiting on tunnel DNS; each holds a goroutine and a copy of the datagram.
const maxPendingUDPResolves = 32
// relayUDP answers the associate request and relays datagrams to
// per-destination tunnel endpoints until the control connection closes.
func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _ socksTarget) {
@@ -503,6 +555,7 @@ func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _
}
sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}}
pendingResolves := make(chan struct{}, maxPendingUDPResolves)
// Reader: strip per-datagram SOCKS5 headers and forward into the tunnel;
// only the associated client's address is accepted.
@@ -532,10 +585,20 @@ func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _
if perr != nil {
continue
}
dnsSrv := s.currentDNSServer(tag)
if daddr, ok := cachedTunnelAddr(dev, tag, dnsSrv, name); ok {
s.deliverUDPDatagram(dev, tag, udpConn, client, sessions, netip.AddrPortFrom(daddr, port), data[hdrLen:])
continue
}
select {
case pendingResolves <- struct{}{}:
default:
continue // enough lookups pending; UDP may drop
}
// Resolve off reader loop so slow tunnel DNS lookups do not
// stall other destinations on this association.
go func(client netip.AddrPort, name string, port uint16, hdrLen int, datagram []byte) {
dnsSrv := s.currentDNSServer(tag)
defer func() { <-pendingResolves }()
rctx, rcancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
daddr, rerr := resolveTunnelVia(rctx, dev, tag, dnsSrv, name)
rcancel()
@@ -601,6 +664,8 @@ func (s *udpEgressSessions) getOrDial(dev *Device, tag string, udpConn *net.UDPC
return nil
}
sess := &egressUDPSession{dst: dst, conn: conn}
sess.touch() // before admit: a zero stamp is the next one evicted
egressUDPSessions.admit(sess)
s.m[dst] = sess
go pumpUDPEgress(udpConn, client, sess, s)
return sess
@@ -624,6 +689,7 @@ func (s *socks5EgressServer) deliverUDPDatagram(dev *Device, tag string, udpConn
if sess == nil {
return
}
sess.touch()
if _, werr := sess.conn.Write(payload); werr != nil {
logger.Warningf("amneziawgnet: egress %q: send udp to %s: %v", tag, dst, werr)
}
@@ -636,6 +702,7 @@ func pumpUDPEgress(udpConn *net.UDPConn, client netip.AddrPort, sess *egressUDPS
sessions.mu.Lock()
delete(sessions.m, sess.dst)
sessions.mu.Unlock()
egressUDPSessions.release(sess)
sess.conn.Close()
}()
buf := make([]byte, 65536)
@@ -646,6 +713,7 @@ func pumpUDPEgress(udpConn *net.UDPConn, client netip.AddrPort, sess *egressUDPS
if err != nil {
return
}
sess.touch()
hdr := make([]byte, 0, 3+1+16+2+n)
hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0)
if sess.dst.Addr().Is4() {
+116 -12
View File
@@ -2,11 +2,14 @@ package amneziawgnet
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/netip"
"runtime"
"strconv"
"strings"
"testing"
@@ -54,12 +57,7 @@ func newPairedTunnelForTest(t *testing.T) *pairedTunnel {
if err != nil {
t.Fatal(err)
}
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listenPort := pc.LocalAddr().(*net.UDPAddr).Port
pc.Close()
listenPort := freeUDPPort(t)
obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
serverInst := amneziawg.Instance{
@@ -130,12 +128,7 @@ func newPairedTunnelV6ForTest(t *testing.T) *pairedTunnel {
if err != nil {
t.Fatal(err)
}
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listenPort := pc.LocalAddr().(*net.UDPAddr).Port
pc.Close()
listenPort := freeUDPPort(t)
obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
serverInst := amneziawg.Instance{
@@ -643,6 +636,117 @@ func TestEgressUDPDatagramDomainInterleavedClients(t *testing.T) {
}
}
// TestEgressUDPDatagramDomainFloodBoundsPendingLookups ensures datagrams to a
// name whose lookup never completes do not pin a goroutine and a port each.
func TestEgressUDPDatagramDomainFloodBoundsPendingLookups(t *testing.T) {
tun := newPairedTunnelForTest(t)
registerEgressDeviceForTest(t, tun.client)
srv := GetEgressServer()
prev := srv.currentDNSServer()
srv.SetDNSServer(net.JoinHostPort(tun.serverIP.String(), "53"))
t.Cleanup(func() { srv.SetDNSServer(prev) })
resetTunnelDNSCacheForTest()
silentDNS, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 53}, nil, ipv4.ProtocolNumber)
if err != nil {
t.Fatal(err)
}
defer silentDNS.Close()
ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(srv.Port())))
if err != nil {
t.Fatal(err)
}
defer ctl.Close()
socksAuth(t, ctl)
if _, err := ctl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
t.Fatal(err)
}
reply := make([]byte, 10)
if _, err := io.ReadFull(ctl, reply); err != nil || reply[1] != 0x00 {
t.Fatalf("associate failed: %v %v", err, reply)
}
bindPort := binary.BigEndian.Uint16(reply[8:10])
udp, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: int(bindPort)})
if err != nil {
t.Fatal(err)
}
defer udp.Close()
name := "never-answers.internal"
dgram := make([]byte, 0, 5+len(name)+2+1)
dgram = append(dgram, 0x00, 0x00, 0x00, 0x03, byte(len(name)))
dgram = append(dgram, name...)
dgram = append(dgram, 0x27, 0x0f, 'x')
const flood = 2000
before := runtime.NumGoroutine()
for i := 0; i < flood; i++ {
if _, err := udp.Write(dgram); err != nil {
t.Fatal(err)
}
if i%50 == 49 {
time.Sleep(time.Millisecond)
}
}
time.Sleep(100 * time.Millisecond)
if grown := runtime.NumGoroutine() - before; grown > maxPendingUDPResolves+8 {
t.Fatalf("%d datagrams left %d goroutines behind, want at most %d pending lookups", flood, grown, maxPendingUDPResolves)
}
// A cached name takes no lookup slot, so it still gets through.
in, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 9999}, nil, ipv4.ProtocolNumber)
if err != nil {
t.Fatal(err)
}
defer in.Close()
cached := "cached.internal"
tunnelDNSCache.mu.Lock()
tunnelDNSCache.m[dnsCacheKey("awg-dom-test", srv.currentDNSServer(), cached)] = tunnelDNSCacheEntry{addr: tun.serverIP, exp: time.Now().Add(time.Hour)}
tunnelDNSCache.mu.Unlock()
dgram = append([]byte{0x00, 0x00, 0x00, 0x03, byte(len(cached))}, cached...)
dgram = append(dgram, 0x27, 0x0f, 'p', 'i', 'n', 'g')
rcv := make([]byte, 64)
var nr int
var rerr error
for attempt := 0; attempt < 3; attempt++ {
if _, err := udp.Write(dgram); err != nil {
t.Fatal(err)
}
in.SetReadDeadline(time.Now().Add(3 * time.Second))
nr, _, rerr = in.ReadFrom(rcv)
if rerr == nil {
break
}
}
if rerr != nil || string(rcv[:nr]) != "ping" {
t.Fatalf("cached-name datagram behind the flood: payload = %q, err = %v", rcv[:nr], rerr)
}
}
// TestResolveTunnelViaWaitsForLookupSlot ensures a cache miss opens no tunnel
// endpoint (the nil Stack would panic) while every lookup slot is taken.
func TestResolveTunnelViaWaitsForLookupSlot(t *testing.T) {
resetTunnelDNSCacheForTest()
for i := 0; i < cap(tunnelDNSSlots); i++ {
tunnelDNSSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < cap(tunnelDNSSlots); i++ {
<-tunnelDNSSlots
}
})
dev := &Device{localAddrs: []netip.Addr{netip.MustParseAddr(tunnelTestClientAddr)}}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := resolveTunnelVia(ctx, dev, "awg-dom-test", "", "slots.internal"); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want context.DeadlineExceeded", err)
}
}
func TestDefaultDNSFor(t *testing.T) {
v4 := netip.MustParseAddr("10.8.0.2")
v6 := netip.MustParseAddr("2001:db8::2")
+85
View File
@@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"net"
"net/netip"
"strconv"
"testing"
"time"
)
// holdEgressBasePort occupies EgressBasePort the way another service would; a
@@ -112,3 +114,86 @@ func TestEgressFallbackStaysBelowThePanelRanges(t *testing.T) {
t.Fatalf("Port() = %d, inside the panel's reserved range", port)
}
}
// TestEgressUDPSessionsShareOneBudget: a proxy user opens an association per
// Mux frame, so the session ceiling must hold across all of them together.
func TestEgressUDPSessionsShareOneBudget(t *testing.T) {
saved := egressMaxUDPSessions
egressMaxUDPSessions = 64
t.Cleanup(func() { egressMaxUDPSessions = saved })
local := netip.MustParseAddr("10.211.4.1")
dev := &Device{Stack: newTestStack(t, local.String()), localAddrs: []netip.Addr{local}}
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("listen udp: %v", err)
}
defer udpConn.Close()
client := netip.MustParseAddrPort("127.0.0.1:9")
dst := func(port int) netip.AddrPort {
return netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(port))
}
var associations []*udpEgressSessions
associate := func() *udpEgressSessions {
sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}}
associations = append(associations, sessions)
return sessions
}
session := func(sessions *udpEgressSessions, port int) *egressUDPSession {
sessions.mu.Lock()
defer sessions.mu.Unlock()
return sessions.m[dst(port)]
}
liveSessions := func() int {
n := 0
for _, sessions := range associations {
sessions.mu.Lock()
n += len(sessions.m)
sessions.mu.Unlock()
}
return n
}
slotsTaken := func() int {
egressUDPSessions.mu.Lock()
defer egressUDPSessions.mu.Unlock()
return len(egressUDPSessions.live)
}
waitFor := func(what string, cond func() bool) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); !cond(); time.Sleep(time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s (%d live sessions)", what, liveSessions())
}
}
}
before := slotsTaken()
others := associate()
busy := others.getOrDial(dev, "t", udpConn, client, dst(1))
if busy == nil || others.getOrDial(dev, "t", udpConn, client, dst(2)) == nil {
t.Fatal("no session with the budget unspent")
}
// Three times the budget, spread over associations that stay small.
for range 3 * egressMaxUDPSessions / 16 {
// The oldest session of all, but never the longest idle one.
GetEgressServer().deliverUDPDatagram(dev, "t", udpConn, client, others, dst(1), []byte("x"))
flood := associate()
for port := 1; port <= 16; port++ {
if flood.getOrDial(dev, "t", udpConn, client, dst(port)) == nil {
t.Fatalf("destination %d refused: the budget must evict, not drop", port)
}
}
}
waitFor("evicted sessions to retire", func() bool { return liveSessions() <= egressMaxUDPSessions })
if session(others, 2) != nil {
t.Error("the longest-idle session outlived a spent budget")
}
if session(others, 1) != busy {
t.Error("a session that kept relaying was evicted ahead of idle ones")
}
for _, sessions := range associations {
sessions.closeAll()
}
waitFor("closeAll to hand every session back", func() bool { return liveSessions() == 0 && slotsTaken() <= before })
}
+59 -3
View File
@@ -2,6 +2,7 @@ package amneziawgnet
import (
"net/netip"
"sync"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/stack"
@@ -9,22 +10,76 @@ import (
"gvisor.dev/gvisor/pkg/waiter"
)
// One datagram or bare SYN from a fresh source port buys a flow its sockets, goroutine and
// buffers, so each peer gets a share of an interface instead of the whole of it.
const (
maxTCPFlowsPerPeer = 4096
maxUDPSessionsPerPeer = 1024
)
// peerSlots counts the flows each peer holds; every successful acquire needs one release.
type peerSlots struct {
mu sync.Mutex
limit int
held map[string]int
}
func newPeerSlots(limit int) *peerSlots {
return &peerSlots{limit: limit, held: map[string]int{}}
}
func (s *peerSlots) acquire(peer string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.held[peer] >= s.limit {
return false
}
s.held[peer]++
return true
}
func (s *peerSlots) release(peer string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.held[peer]--; s.held[peer] <= 0 {
delete(s.held, peer)
}
}
// AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous +
// spoofing mode, so it accepts connections addressed to any destination --
// not just the stack's own configured local address -- and hands the
// handler both the accepted connection and the tunnel client's real,
// handler the accepted connection, the tunnel client's source and its real,
// dynamically-arbitrary destination (recovered from the connection's own
// TransportEndpointID, not from any preconfigured routing table). This is
// the mechanism the whole embedded-AmneziaWG design depends on: what the
// handler does with that destination (dial it directly, relay it into
// Xray's SOCKS5 inbound, ...) is entirely up to the caller.
//
// Handlers must identify the peer by src, never conn.RemoteAddr(): that asks
// the live endpoint and is nil once a RST is processed, possibly before the call.
//
// owner names the peer a tunnel source belongs to; it is asked on the SYN, before anything
// is allocated, and refuses a source nobody owns or one whose owner holds its full share.
//
// Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT).
func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, dest netip.AddrPort)) {
func AttachTCPForwarder(gstack *stack.Stack, owner func(src netip.Addr) (string, bool), handler func(conn *gonet.TCPConn, src, dest netip.AddrPort)) {
enablePromiscuousRouting(gstack)
slots := newPeerSlots(maxTCPFlowsPerPeer)
fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) {
peer, ok := owner(addrFromTcpip(r.ID().RemoteAddress).Unmap())
if !ok {
r.Complete(true)
return
}
// CreateEndpoint holds the request until the handshake ends, so a bare SYN costs a slot.
if !slots.acquire(peer) {
r.Complete(false)
return
}
go func(r *tcp.ForwarderRequest) {
defer slots.release(peer)
var wq waiter.Queue
id := r.ID()
@@ -33,8 +88,9 @@ func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, d
r.Complete(true)
return
}
src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort)
dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
handler(gonet.NewTCPConn(&wq, ep), dest)
handler(gonet.NewTCPConn(&wq, ep), src, dest)
ep.Close()
r.Complete(false)
}(r)
+162
View File
@@ -0,0 +1,162 @@
package amneziawgnet
import (
"net"
"net/netip"
"testing"
"time"
awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
func rawTCPSegment(src, dst netip.AddrPort, seq, ack uint32, flags header.TCPFlags) []byte {
srcIP, dstIP := tcpip.AddrFromSlice(src.Addr().AsSlice()), tcpip.AddrFromSlice(dst.Addr().AsSlice())
packet := make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize)
ip := header.IPv4(packet)
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(packet)),
TTL: 64,
Protocol: uint8(header.TCPProtocolNumber),
SrcAddr: srcIP,
DstAddr: dstIP,
})
ip.SetChecksum(^ip.CalculateChecksum())
tcp := header.TCP(packet[header.IPv4MinimumSize:])
tcp.Encode(&header.TCPFields{
SrcPort: src.Port(),
DstPort: dst.Port(),
SeqNum: seq,
AckNum: ack,
DataOffset: header.TCPMinimumSize,
Flags: flags,
WindowSize: 65535,
})
xsum := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcIP, dstIP, header.TCPMinimumSize)
tcp.SetChecksum(^tcp.CalculateChecksum(checksum.Checksum(nil, xsum)))
return packet
}
func injectFromPeer(t *testing.T, tun awgtun.Device, packet []byte) {
t.Helper()
if _, err := tun.Write([][]byte{packet}, 0); err != nil {
t.Fatalf("inject packet: %v", err)
}
}
// A peer's RST can beat the handler, and a reset endpoint has no RemoteAddr:
// deriving the source from it panicked the panel on a goroutine with no recover.
func TestTCPForwarderHandsOverSourceOfAnAlreadyResetConnection(t *testing.T) {
tun, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.9.0.1")}, 1420)
if err != nil {
t.Fatalf("createNetTUNWithStack: %v", err)
}
defer tun.Close()
peer := netip.MustParseAddrPort("10.9.0.2:40000")
target := netip.MustParseAddrPort("203.0.113.7:443")
type handedOver struct {
src, dest netip.AddrPort
remoteAddr net.Addr
}
accepted, resetSent := make(chan struct{}), make(chan struct{})
got := make(chan handedOver, 1)
AttachTCPForwarder(gstack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) {
defer conn.Close()
close(accepted)
<-resetSent
// Returns only once the stack has processed the RST.
_, _ = conn.Read(make([]byte, 1))
got <- handedOver{src: src, dest: dest, remoteAddr: conn.RemoteAddr()}
})
const peerISN = 1000
injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN, 0, header.TCPFlagSyn))
bufs, sizes := [][]byte{make([]byte, 2048)}, make([]int, 1)
if _, err := tun.Read(bufs, sizes, 0); err != nil {
t.Fatalf("read SYN-ACK: %v", err)
}
synAck := header.TCP(bufs[0][header.IPv4MinimumSize:sizes[0]])
if synAck.Flags() != header.TCPFlagSyn|header.TCPFlagAck {
t.Fatalf("first reply has flags %s, want SYN-ACK", synAck.Flags())
}
injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN+1, synAck.SequenceNumber()+1, header.TCPFlagAck))
select {
case <-accepted:
case <-time.After(5 * time.Second):
t.Fatal("handler never ran")
}
injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN+1, 0, header.TCPFlagRst))
close(resetSent)
select {
case h := <-got:
if h.remoteAddr != nil {
t.Fatalf("RemoteAddr() = %v after the RST, want nil: the test no longer reaches the reset state", h.remoteAddr)
}
if h.src != peer {
t.Errorf("src = %s, want %s", h.src, peer)
}
if h.dest != target {
t.Errorf("dest = %s, want %s", h.dest, target)
}
case <-time.After(5 * time.Second):
t.Fatal("handler never saw the reset")
}
}
// ownerBySource counts every tunnel source address as a peer of its own.
func ownerBySource(src netip.Addr) (string, bool) {
return src.String(), true
}
// The forwarder's in-flight limit is shared by the interface, so one peer's bare SYNs used
// to take every slot and leave the other clients' connections unanswered.
func TestTCPForwarderPeerCannotStarveOthers(t *testing.T) {
tun, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.8.1.1")}, 1420)
if err != nil {
t.Fatalf("createNetTUNWithStack: %v", err)
}
defer tun.Close()
flooderRange := netip.MustParsePrefix("10.8.2.0/24")
AttachTCPForwarder(gstack, func(src netip.Addr) (string, bool) {
if flooderRange.Contains(src) {
return "flooder", true
}
return "other", true
}, func(conn *gonet.TCPConn, _, _ netip.AddrPort) { conn.Close() })
other := netip.MustParseAddrPort("10.8.1.3:40000")
answered := make(chan struct{})
go func() {
bufs, sizes := [][]byte{make([]byte, 2048)}, []int{0}
for {
if _, err := tun.Read(bufs, sizes, 0); err != nil {
return
}
reply := header.IPv4(bufs[0][:sizes[0]])
seg := header.TCP(reply.Payload())
if reply.DestinationAddress() == tcpip.AddrFrom4(other.Addr().As4()) && seg.Flags() == header.TCPFlagSyn|header.TCPFlagAck {
close(answered)
return
}
}
}()
dst := netip.MustParseAddrPort("10.8.1.1:80")
for n := range 70000 {
src := netip.AddrPortFrom(netip.AddrFrom4([4]byte{10, 8, 2, byte(n)}), uint16(1024+n/256))
injectFromPeer(t, tun, rawTCPSegment(src, dst, 1000, 0, header.TCPFlagSyn))
}
injectFromPeer(t, tun, rawTCPSegment(other, dst, 1000, 0, header.TCPFlagSyn))
select {
case <-answered:
case <-time.After(5 * time.Second):
t.Fatalf("no SYN-ACK for %s; dropped at the in-flight limit: %d", other, gstack.Stats().TCP.ForwardMaxInFlightDrop.Value())
}
}
+3 -1
View File
@@ -49,7 +49,9 @@ func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) {
bestBits := -1
var bestPeer amneziawg.Peer
for _, e := range idx.entries {
if e.prefix.Bits() <= bestBits || !e.prefix.Contains(addr) {
// Equal length: the later peer wins, as in the device's allowed-IPs
// table, which buildUAPIConfig fills from the same peers in this order.
if e.prefix.Bits() < bestBits || !e.prefix.Contains(addr) {
continue
}
bestBits = e.prefix.Bits()
+72
View File
@@ -0,0 +1,72 @@
package amneziawgnet
import (
"net/netip"
"slices"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestPeerIndexLookupMatchesDeviceAllowedIPs catches Lookup attributing a
// prefix two peers both carry to a peer the device accepts no such source from.
func TestPeerIndexLookupMatchesDeviceAllowedIPs(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
var peers []amneziawg.Peer
for _, p := range []amneziawg.Peer{
{Email: "first@example.com", AllowedIPs: []string{"10.202.0.2/32", "fd00:202::2/128", "0.0.0.0/0", "::/0"}},
{Email: "last@example.com", AllowedIPs: []string{"10.202.0.3/32", "fd00:202::3/128", "0.0.0.0/0", "::/0"}},
} {
if _, p.PublicKey, err = wireguard.GenerateWireguardKeypair(); err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
peers = append(peers, p)
}
dev, err := NewDevice(amneziawg.Instance{
Id: 1,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.202.0.1/24"},
MTU: 1420,
Peers: peers,
}, DeviceOptions{})
if err != nil {
t.Fatalf("NewDevice: %v", err)
}
defer dev.Close()
dump, err := dev.IpcGet()
if err != nil {
t.Fatalf("IpcGet: %v", err)
}
_, states := parseUAPIDump(dump)
idx := NewPeerIndex(peers)
for _, tc := range []struct{ addr, prefix string }{
{"10.99.0.1", "0.0.0.0/0"},
{"fd00:99::1", "::/0"},
{"10.202.0.2", "10.202.0.2/32"},
{"10.202.0.3", "10.202.0.3/32"},
{"fd00:202::2", "fd00:202::2/128"},
{"fd00:202::3", "fd00:202::3/128"},
} {
t.Run(tc.addr, func(t *testing.T) {
got, ok := idx.Lookup(netip.MustParseAddr(tc.addr))
if !ok {
t.Fatalf("Lookup(%s) found no peer", tc.addr)
}
gotHex, err := wireguard.KeyToHex(got.PublicKey)
if err != nil {
t.Fatalf("KeyToHex: %v", err)
}
if !slices.Contains(states[gotHex].allowedIPs, tc.prefix) {
t.Errorf("Lookup(%s) = %s, but the device holds %s for another peer", tc.addr, got.Email, tc.prefix)
}
})
}
}
+39 -14
View File
@@ -62,6 +62,11 @@ func (m *managed) lookupPeer(addr netip.Addr) (amneziawg.Peer, bool) {
return peers.Lookup(addr)
}
func (m *managed) peerEmail(addr netip.Addr) (string, bool) {
peer, ok := m.lookupPeer(addr)
return peer.Email, ok
}
func (m *managed) handleUDP(src, dst netip.AddrPort, payload []byte) {
peer, ok := m.lookupPeer(src.Addr())
if !ok {
@@ -70,6 +75,27 @@ func (m *managed) handleUDP(src, dst netip.AddrPort, payload []byte) {
m.udpRelay.Handle(src, dst, peer.Email, payload)
}
// dropReassignedFlows aborts every stack flow pinned to a tunnel address its
// peer in before lost (removed or re-keyed): the next client inherits the address.
func (m *managed) dropReassignedFlows(before *PeerIndex) {
reassigned := func(addr netip.Addr) bool {
was, ok := before.Lookup(addr)
if !ok {
return false
}
now, ok := m.lookupPeer(addr)
return !ok || now.PublicKey != was.PublicKey
}
// Abort, not Close: a closed endpoint keeps retransmitting unacknowledged
// data, and that would reach whoever holds the address next.
for _, ep := range m.dev.Stack.RegisteredEndpoints() {
if remote, ok := endpointRemoteAddr(ep); ok && reassigned(remote) {
ep.Abort()
}
}
m.udpRelay.CloseSessions(reassigned)
}
func (m *managed) close() {
m.portForwards.Close()
// Stop packet delivery before closing the relay so an in-flight handler
@@ -90,6 +116,9 @@ func (m *managed) close() {
type Manager struct {
mu sync.Mutex
ifaces map[int]*managed
// v6Owned: IPv6 aliases this Manager put on the host, the only ones it
// may delete again (see applyV6Aliases).
v6Owned map[v6Alias]struct{}
}
var (
@@ -163,7 +192,7 @@ func (m *Manager) ensureLocked(d Desired) error {
if conf == cur.uapiConfig {
cur.peers.Store(NewPeerIndex(inst.Peers))
cur.inst = inst
applyV6Aliases(diffV6Aliases(oldInst, inst))
m.applyV6Aliases(diffV6Aliases(oldInst, inst))
// buildUAPIConfig never reads ForwardedPorts (it's a panel-level
// concept, not a WireGuard UAPI field), so a ForwardedPorts-only
// edit renders byte-identical here and takes this exact no-op
@@ -177,11 +206,12 @@ func (m *Manager) ensureLocked(d Desired) error {
if err := cur.dev.IpcSet(conf); err != nil {
return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
}
cur.peers.Store(NewPeerIndex(inst.Peers))
oldPeers := cur.peers.Swap(NewPeerIndex(inst.Peers))
cur.inst = inst
cur.uapiConfig = conf
applyV6Aliases(diffV6Aliases(oldInst, inst))
m.applyV6Aliases(diffV6Aliases(oldInst, inst))
cur.portForwards.Reconcile(inst)
cur.dropReassignedFlows(oldPeers)
return nil
}
@@ -205,15 +235,10 @@ func (m *Manager) ensureLocked(d Desired) error {
structFP: structFP,
}
next.peers.Store(NewPeerIndex(inst.Peers))
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
if err != nil {
conn.Close()
return
}
AttachTCPForwarder(dev.Stack, next.peerEmail, func(conn *gonet.TCPConn, src, dest netip.AddrPort) {
// Reload for every connection: in-place reconfiguration swaps the peer
// index without reattaching handlers and may hold the lifecycle lock.
peer, ok := next.lookupPeer(srcAddrPort.Addr().Unmap())
peer, ok := next.lookupPeer(src.Addr().Unmap())
if !ok {
conn.Close()
return
@@ -240,7 +265,7 @@ func (m *Manager) ensureLocked(d Desired) error {
conf, _ := buildUAPIConfig(inst, opts)
next.uapiConfig = conf
m.ifaces[inst.Id] = next
applyV6Aliases(diffV6Aliases(oldInst, inst))
m.applyV6Aliases(diffV6Aliases(oldInst, inst))
portForwards.Reconcile(inst)
logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
return nil
@@ -280,7 +305,7 @@ func (m *Manager) Reconcile(desired []Desired) {
if _, ok := want[id]; ok {
continue
}
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.close()
delete(m.ifaces, id)
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
@@ -303,7 +328,7 @@ func (m *Manager) Remove(id int) {
if !exists {
return
}
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.close()
delete(m.ifaces, id)
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
@@ -314,7 +339,7 @@ func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, cur := range m.ifaces {
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.close()
delete(m.ifaces, id)
}
+117 -5
View File
@@ -1,6 +1,7 @@
package amneziawgnet
import (
"context"
"fmt"
"net"
"net/netip"
@@ -8,6 +9,7 @@ import (
"time"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -27,7 +29,7 @@ func TestManagerLifecycle(t *testing.T) {
inst := amneziawg.Instance{
Id: 3,
InterfaceName: "awgtest3",
ListenPort: 58714,
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.203.0.1/24"},
@@ -115,7 +117,7 @@ func TestEnsureRebuildsWhenS4ChangesTheDerivedMTU(t *testing.T) {
inst := amneziawg.Instance{
Id: 9 + i,
InterfaceName: fmt.Sprintf("awgtest%d", 9+i),
ListenPort: 58719 + i,
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.209.0.1/24"},
@@ -212,7 +214,7 @@ func TestEnsureUnchangedInstanceDoesNotResetLivePeers(t *testing.T) {
inst := amneziawg.Instance{
Id: 4,
InterfaceName: "awgtest4",
ListenPort: 58715,
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.204.0.1/24"},
@@ -330,6 +332,116 @@ func TestForwardedPortsOnlyChangeStillReconcilesPortForwards(t *testing.T) {
conn.Close()
}
// The stack survives an in-place reconfiguration, so a flow pinned to a removed
// peer's address would otherwise reach whichever client gets that address next.
func TestEnsureDropsFlowsOfReassignedTunnelAddress(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
var peerPubs [3]string
for i := range peerPubs {
if _, peerPubs[i], err = wireguard.GenerateWireguardKeypair(); err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
}
reused, kept := netip.MustParseAddr("10.206.0.2"), netip.MustParseAddr("10.206.0.3")
m := &Manager{ifaces: map[int]*managed{}}
inst := amneziawg.Instance{
Id: 9,
InterfaceName: "awgtest9",
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.206.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{
{Email: "old@test", PublicKey: peerPubs[0], AllowedIPs: []string{reused.String() + "/32"}},
{Email: "other@test", PublicKey: peerPubs[1], AllowedIPs: []string{kept.String() + "/32"}},
},
}
defer m.StopAll()
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (create): %v", err)
}
cur := m.ifaces[inst.Id]
endpointsTo := func(addr netip.Addr) int {
n := 0
for _, ep := range cur.dev.Stack.RegisteredEndpoints() {
if remote, ok := endpointRemoteAddr(ep); ok && remote == addr {
n++
}
}
return n
}
waitEndpointsTo := func(addr netip.Addr, want int) {
t.Helper()
for deadline := time.Now().Add(2 * time.Second); endpointsTo(addr) != want; time.Sleep(5 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("endpoints connected to %s = %d, want %d", addr, endpointsTo(addr), want)
}
}
}
for _, addr := range []netip.Addr{reused, kept} {
raddr := tunnelFullAddress(addr, 8080)
udpConn, err := gonet.DialUDP(cur.dev.Stack, nil, &raddr, tunnelNetwork(addr))
if err != nil {
t.Fatalf("DialUDP %s: %v", addr, err)
}
defer udpConn.Close()
ctrl, _ := net.Pipe()
relayConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("ListenUDP: %v", err)
}
cur.udpRelay.sessions[netip.AddrPortFrom(addr, 1234)] = &socks5UDPSession{ctrl: ctrl, udpConn: relayConn}
}
dialCtx, cancelDial := context.WithCancel(context.Background())
dialed := make(chan struct{})
go func() {
defer close(dialed)
if conn, err := gonet.DialContextTCP(dialCtx, cur.dev.Stack, tunnelFullAddress(reused, 8080), tunnelNetwork(reused)); err == nil {
conn.Close()
}
}()
defer func() { cancelDial(); <-dialed }()
waitEndpointsTo(reused, 2)
waitEndpointsTo(kept, 1)
next := inst
next.Peers = []amneziawg.Peer{
{Email: "new@test", PublicKey: peerPubs[2], AllowedIPs: []string{reused.String() + "/32"}},
inst.Peers[1],
}
if err := m.Ensure(Desired{Instance: next}); err != nil {
t.Fatalf("Ensure (address reassigned): %v", err)
}
if m.ifaces[inst.Id] != cur {
t.Fatal("peer change rebuilt the interface instead of reconfiguring it in place")
}
waitEndpointsTo(reused, 0)
waitEndpointsTo(kept, 1)
cur.udpRelay.mu.Lock()
_, reusedSession := cur.udpRelay.sessions[netip.AddrPortFrom(reused, 1234)]
_, keptSession := cur.udpRelay.sessions[netip.AddrPortFrom(kept, 1234)]
cur.udpRelay.mu.Unlock()
if reusedSession {
t.Error("the removed peer's UDP relay session survived its address being reassigned")
}
if !keptSession {
t.Error("an unaffected peer's UDP relay session was torn down")
}
}
// TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace is a regression
// test for the Phase 3.7 AWG 3.0 wiring: proves that populating
// Desired.Options with a real HeaderProtectionKey/ContentPaddingAddition
@@ -353,7 +465,7 @@ func TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace(t *testing.T) {
inst := amneziawg.Instance{
Id: 7,
InterfaceName: "awgtest7",
ListenPort: 58717,
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.207.0.1/24"},
@@ -413,7 +525,7 @@ func TestEnsureRejectsHeaderProtectionKeyWithLowS1S4(t *testing.T) {
inst := amneziawg.Instance{
Id: 8,
InterfaceName: "awgtest8",
ListenPort: 58718,
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.208.0.1/24"},
+30
View File
@@ -8,6 +8,7 @@ import (
"os"
"sync"
"syscall"
"time"
awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
@@ -27,6 +28,10 @@ import (
// 1024 starved simultaneous TCP slow-starts; channel.Endpoint drops silently when full.
const tunQueueDepth = 8192
// tunQueueMaxWait bounds how long WriteNotify parks the goroutine that made the stack
// emit a packet; past it the packet is dropped, as channel.Endpoint drops when full.
const tunQueueMaxWait = 5 * time.Millisecond
// maxReceiveBuffer caps per-connection receive autotuning; gVisor's own 4 MB
// cap held a single client upload near 310 Mbit/s at 50 ms RTT.
const maxReceiveBuffer = 8 << 20
@@ -191,10 +196,21 @@ func (t *stackTun) WriteNotify() {
view := pkt.ToView()
pkt.DecRef()
select {
case t.incomingPacket <- view:
return
default:
}
// Echo replies and RSTs are emitted on amneziawg-go's per-peer receiver, which the
// reader of this queue itself waits on during a rebind: never park here for good.
timer := time.NewTimer(tunQueueMaxWait)
defer timer.Stop()
// Select against done so racing dispatch abandons packet on close
// without blocking Close or panicking on closed channel.
select {
case t.incomingPacket <- view:
case <-timer.C:
view.Release()
case <-t.done:
view.Release()
}
@@ -226,6 +242,20 @@ func enablePromiscuousRouting(gstack *stack.Stack) {
gstack.SetSpoofing(1, true)
}
// endpointRemoteAddr returns the address ep is connected to; false for an
// endpoint that has none (listening, unconnected).
func endpointRemoteAddr(ep stack.TransportEndpoint) (netip.Addr, bool) {
tep, ok := ep.(tcpip.Endpoint)
if !ok {
return netip.Addr{}, false
}
info, ok := tep.Info().(*stack.TransportEndpointInfo)
if !ok || info.ID.RemoteAddress.Len() == 0 {
return netip.Addr{}, false
}
return addrFromTcpip(info.ID.RemoteAddress), true
}
// addrFromTcpip converts a gVisor tcpip.Address to netip.Addr.
func addrFromTcpip(a tcpip.Address) netip.Addr {
if a.Len() == 4 {
+46
View File
@@ -1,9 +1,14 @@
package amneziawgnet
import (
"net/netip"
"testing"
"time"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
)
@@ -128,3 +133,44 @@ func TestStackTunReadReturnsViewsToPool(t *testing.T) {
t.Fatalf("Read allocates %v times per packet, want <=1: drained views are not being released", allocs)
}
}
// TestStackTunWriteSurvivesFullOutboundQueue: Write runs on amneziawg-go's per-peer
// receiver, so a reply the stack cannot queue must be dropped rather than wait for Read.
func TestStackTunWriteSurvivesFullOutboundQueue(t *testing.T) {
tun, _, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.9.0.1")}, 1420)
if err != nil {
t.Fatalf("createNetTUNWithStack: %v", err)
}
defer tun.Close()
ping := make([]byte, header.IPv4MinimumSize+header.ICMPv4MinimumSize)
ip := header.IPv4(ping)
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(ping)),
TTL: 64,
Protocol: uint8(header.ICMPv4ProtocolNumber),
SrcAddr: tcpip.AddrFrom4([4]byte{10, 9, 0, 2}),
DstAddr: tcpip.AddrFrom4([4]byte{10, 9, 0, 1}),
})
ip.SetChecksum(^ip.CalculateChecksum())
icmp := header.ICMPv4(ping[header.IPv4MinimumSize:])
icmp.SetType(header.ICMPv4Echo)
icmp.SetChecksum(^checksum.Checksum(icmp, 0))
// Nobody calls Read, so the echo replies fill incomingPacket and overflow it.
done := make(chan struct{})
go func() {
defer close(done)
for range tunQueueDepth + 8 {
_, _ = tun.Write([][]byte{ping}, 0)
}
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Write is still waiting for room in the outbound queue: the receiver would deadlock a rebind")
}
if got := len(tun.(*stackTun).incomingPacket); got != tunQueueDepth {
t.Fatalf("incomingPacket holds %d replies, want %d: the queue never filled", got, tunQueueDepth)
}
}
+68 -14
View File
@@ -270,6 +270,43 @@ func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForward
const portForwardDialTimeout = 10 * time.Second
// portForwardMaxFlows caps live TCP relays, and separately UDP sessions, over every
// listener: senders are unauthenticated, and each flow pins a goroutine and a buffer.
const portForwardMaxFlows = 2048
// Process-wide on purpose: a per-listener cap still multiplies by the forwarded ports
// and by the number of forwarding peers.
var (
portForwardTCPSlots = make(chan struct{}, portForwardMaxFlows)
portForwardUDPSlots = make(chan struct{}, portForwardMaxFlows)
)
// acquirePortForwardSlot never blocks the accept or read loop; false means the flow is
// refused. The owner gives the slot back by receiving from slots.
func acquirePortForwardSlot(slots chan struct{}) bool {
select {
case slots <- struct{}{}:
return true
default:
return false
}
}
// acceptRetryDelay paces an accept loop's retries, so an error that lasts
// (EMFILE, until descriptors free up) neither spins it nor floods the log.
const acceptRetryDelay = 100 * time.Millisecond
// waitAcceptRetry sleeps acceptRetryDelay after a failed Accept; false means
// closing fired meanwhile and the loop should return instead of retrying.
func waitAcceptRetry(closing <-chan struct{}) bool {
select {
case <-closing:
return false
case <-time.After(acceptRetryDelay):
return true
}
}
// tunnelNetwork returns the gVisor network protocol number matching addr's
// address family, for dialing toward it inside the embedded stack.
func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber {
@@ -290,10 +327,11 @@ func tunnelFullAddress(addr netip.Addr, port int) tcpip.FullAddress {
}
// tcpForwardListener is one open host-facing TCP listener for a single
// portForwardKey.
// portForwardKey; ctx ends with it and takes every relay it started along.
type tcpForwardListener struct {
ln net.Listener
closing chan struct{}
ln net.Listener
ctx context.Context
cancel context.CancelFunc
}
// listenPortForwardTCP opens a host-facing TCP listener on key.port and
@@ -309,7 +347,8 @@ func listenPortForwardTCP(gstack *stack.Stack, inboundID int, key portForwardKey
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err)
return nil
}
l := &tcpForwardListener{ln: ln, closing: make(chan struct{})}
l := &tcpForwardListener{ln: ln}
l.ctx, l.cancel = context.WithCancel(context.Background())
logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening tcp :%d", inboundID, key.email, key.port)
go l.acceptLoop(gstack, inboundID, key, target)
return l
@@ -320,24 +359,36 @@ func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key
conn, err := l.ln.Accept()
if err != nil {
select {
case <-l.closing:
case <-l.ctx.Done():
return // intentional shutdown, not a real accept error
default:
}
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err)
return
// Keep accepting: returning would strand a bound socket nobody
// serves, under a key Reconcile still sees as open and never reopens.
if !waitAcceptRetry(l.ctx.Done()) {
return
}
continue
}
go relayTCPForward(gstack, conn, inboundID, key, target)
if !acquirePortForwardSlot(portForwardTCPSlots) {
conn.Close()
continue
}
go func() {
defer func() { <-portForwardTCPSlots }()
l.relayTCPForward(gstack, conn, inboundID, key, target)
}()
}
}
func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) {
func (l *tcpForwardListener) relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) {
defer conn.Close()
addr, ok := target(key.email)
if !ok {
return
}
ctx, cancel := context.WithTimeout(context.Background(), portForwardDialTimeout)
ctx, cancel := context.WithTimeout(l.ctx, portForwardDialTimeout)
defer cancel()
tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr))
if err != nil {
@@ -346,14 +397,17 @@ func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key port
}
defer tunnelConn.Close()
stop := context.AfterFunc(l.ctx, func() {
conn.Close()
tunnelConn.Close()
})
defer stop()
pipeBothWays(conn, tunnelConn)
}
// Close stops accepting new connections. Already-relaying connections are
// left to finish on their own -- there's no shared state to tear down early
// for, and an abrupt cut would just look like a network error to whichever
// external client was mid-transfer.
// Close stops accepting and cuts the connections already relaying, as
// udpForwardListener.Close does: a withdrawn forward must not stay usable.
func (l *tcpForwardListener) Close() {
close(l.closing)
l.cancel()
l.ln.Close()
}
+170 -6
View File
@@ -2,11 +2,14 @@ package amneziawgnet
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/netip"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -142,7 +145,7 @@ func TestPortForwardSetReconcileOpensAndClosesListeners(t *testing.T) {
gs := newTestStack(t, "10.211.0.1")
set := NewPortForwardSet(gs, 501)
const port = 58910
port := freeTCPUDPPort(t)
inst := amneziawg.Instance{Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", fmt.Sprintf("%d", port), "10.211.0.2/32"),
}}
@@ -187,8 +190,7 @@ func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) {
gs := newTestStack(t, "10.211.1.1")
set := NewPortForwardSet(gs, 502)
const collidingPort = 58911
const okPort = 58912
collidingPort, okPort := freeTCPUDPPort(t), freeTCPUDPPort(t)
blocker, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", collidingPort))
if err != nil {
t.Fatalf("pre-bind test port: %v", err)
@@ -222,6 +224,44 @@ func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) {
set.Close()
}
// failFirstAccept fails the first Accept the way the kernel does once the
// process is at RLIMIT_NOFILE, then behaves like the listener it wraps.
type failFirstAccept struct {
net.Listener
failed atomic.Bool
}
func (l *failFirstAccept) Accept() (net.Conn, error) {
if l.failed.CompareAndSwap(false, true) {
return nil, &net.OpError{Op: "accept", Net: "tcp", Addr: l.Addr(), Err: syscall.EMFILE}
}
return l.Listener.Accept()
}
func TestTCPForwardAcceptLoopSurvivesTransientError(t *testing.T) {
host, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
l := &tcpForwardListener{ln: &failFirstAccept{Listener: host}}
l.ctx, l.cancel = context.WithCancel(context.Background())
defer l.Close()
noTarget := func(string) (netip.Addr, bool) { return netip.Addr{}, false }
go l.acceptLoop(newTestStack(t, "10.211.4.1"), 505, portForwardKey{email: "a@x", proto: tcpForward}, noTarget)
conn, err := net.DialTimeout("tcp", host.Addr().String(), time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// relayTCPForward closes a connection it has no target for, so EOF proves
// the loop accepted it; a dead loop leaves it queued in the backlog.
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Fatalf("read after a transient accept error = %v, want io.EOF from an accepted connection", err)
}
}
// --- Real round trip: a genuine amneziawg-go client handshakes against a
// real server Device, PortForwardSet opens a real host listener, and a real
// external-side dial (this test's own process) round-trips bytes through
@@ -239,9 +279,8 @@ func TestPortForwardRoundTripTCPAndUDP(t *testing.T) {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58920 // fixed loopback test port, matches this package's existing test convention
const tcpPort = 58921
const udpPort = 58922
listenPort := freeUDPPort(t)
tcpPort, udpPort := freeTCPUDPPort(t), freeTCPUDPPort(t)
const clientAddr = "10.202.0.2"
inst := amneziawg.Instance{
@@ -423,4 +462,129 @@ primed:
if string(gotUDP) != wantUDP {
t.Errorf("UDP round trip = %q, want %q", gotUDP, wantUDP)
}
// Withdrawing the forward must end the connection already relaying over
// it, not just refuse new ones.
withdrawn := inst
withdrawn.Peers = []amneziawg.Peer{
{Email: "client@test", PublicKey: clientPub, AllowedIPs: []string{clientAddr + "/32"}},
}
set.Reconcile(withdrawn)
tcpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := tcpConn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Errorf("read on the relayed connection after its forward was withdrawn = %v, want io.EOF", err)
}
}
// leavePortForwardSlots fills slots until only free remain, so a test hits the
// ceiling with a handful of flows; returns how many it holds (cleanup releases).
func leavePortForwardSlots(t *testing.T, slots chan struct{}, free int) int {
t.Helper()
held := 0
for acquirePortForwardSlot(slots) {
held++
}
for range free {
<-slots
held--
}
t.Cleanup(func() {
for range held {
<-slots
}
})
return held
}
func waitPortForwardSlots(t *testing.T, slots chan struct{}, want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for len(slots) > want {
if time.Now().After(deadline) {
t.Fatalf("slots still taken = %d, want at most %d", len(slots), want)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestPortForwardUDPSessionsAreCapped(t *testing.T) {
port := freeUDPPort(t)
const free, sources = 3, 10
held := leavePortForwardSlots(t, portForwardUDPSlots, free)
gs := newTestStack(t, "10.211.2.1")
l := listenPortForwardUDP(gs, 503, portForwardKey{email: "a@x", port: port, proto: udpForward}, func(string) (netip.Addr, bool) {
return netip.MustParseAddr("10.211.2.2"), true
})
if l == nil {
t.Fatal("listenPortForwardUDP returned nil")
}
defer l.Close()
for range sources {
conn, err := net.Dial("udp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatalf("dial udp: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte("x")); err != nil {
t.Fatalf("write udp: %v", err)
}
}
// Long enough for readLoop to have drained every datagram sent above.
most := 0
for settle := time.Now().Add(500 * time.Millisecond); time.Now().Before(settle); time.Sleep(10 * time.Millisecond) {
l.mu.Lock()
most = max(most, len(l.sessions))
l.mu.Unlock()
}
if most < free || most > portForwardMaxFlows-held {
t.Fatalf("sessions opened by %d sources = %d, want the %d free slots", sources, most, free)
}
// A slot that outlived its session would shrink the ceiling for good.
l.Close()
waitPortForwardSlots(t, portForwardUDPSlots, held)
}
func TestPortForwardTCPConnectionsAreCapped(t *testing.T) {
port := freePort(t)
const free, clients = 3, 10
held := leavePortForwardSlots(t, portForwardTCPSlots, free)
// Parks every relayed connection at its first step, holding its slot.
release := make(chan struct{})
gs := newTestStack(t, "10.211.3.1")
l := listenPortForwardTCP(gs, 504, portForwardKey{email: "a@x", port: port, proto: tcpForward}, func(string) (netip.Addr, bool) {
<-release
return netip.Addr{}, false
})
if l == nil {
t.Fatal("listenPortForwardTCP returned nil")
}
defer l.Close()
relayed := 0
for range clients {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
if err != nil {
t.Fatalf("dial tcp: %v", err)
}
defer conn.Close()
// A refused connection is closed at once; a relayed one stays silent.
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
if _, err := conn.Read(make([]byte, 1)); err != nil {
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
relayed++
}
}
}
if relayed < free || relayed > portForwardMaxFlows-held {
t.Fatalf("connections relayed out of %d = %d, want the %d free slots", clients, relayed, free)
}
close(release)
waitPortForwardSlots(t, portForwardTCPSlots, held)
}
+6
View File
@@ -78,9 +78,14 @@ func (l *udpForwardListener) readLoop(gstack *stack.Stack, inboundID int, key po
if !ok {
continue
}
// Dropped silently: a flood of fresh sources is when a per-datagram log line hurts.
if !acquirePortForwardSlot(portForwardUDPSlots) {
continue
}
raddr := tunnelFullAddress(addr, key.port)
conn, err := gonet.DialUDP(gstack, nil, &raddr, tunnelNetwork(addr))
if err != nil {
<-portForwardUDPSlots
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
continue
}
@@ -109,6 +114,7 @@ func (l *udpForwardListener) pump(src netip.AddrPort, sess *udpForwardSession) {
delete(l.sessions, src)
l.mu.Unlock()
sess.conn.Close()
<-portForwardUDPSlots
}()
buf := make([]byte, 65536)
for {
+24 -2
View File
@@ -386,11 +386,12 @@ type UDPRelay struct {
// session map: src.String() would allocate on every relayed datagram.
mu sync.Mutex
sessions map[netip.AddrPort]*socks5UDPSession
slots *peerSlots
}
// NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack.
func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay {
return &UDPRelay{relay: relay, gstack: gstack, sessions: map[netip.AddrPort]*socks5UDPSession{}}
return &UDPRelay{relay: relay, gstack: gstack, sessions: map[netip.AddrPort]*socks5UDPSession{}, slots: newPeerSlots(maxUDPSessionsPerPeer)}
}
// Handle relays one packet from src (the peer's tunnel-internal source) to
@@ -404,8 +405,12 @@ func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte)
u.mu.Unlock()
if !ok {
if !u.slots.acquire(email) {
return
}
fresh, err := newSocks5UDPSession(u.relay.Addr, email, u.relay.Password)
if err != nil {
u.slots.release(email)
logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err)
return
}
@@ -415,12 +420,16 @@ func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte)
if existing, dup := u.sessions[src]; dup {
u.mu.Unlock()
fresh.Close()
u.slots.release(email)
sess = existing
} else {
u.sessions[src] = fresh
u.mu.Unlock()
sess = fresh
go u.pump(src, fresh)
go func() {
defer u.slots.release(email)
u.pump(src, fresh)
}()
}
}
if err := sess.sendTo(dst, payload); err != nil {
@@ -455,6 +464,19 @@ func (u *UDPRelay) pump(src netip.AddrPort, sess *socks5UDPSession) {
}
}
// CloseSessions tears down the sessions whose tunnel-internal source address
// matches; pump retires each one's goroutine on the resulting read error.
func (u *UDPRelay) CloseSessions(match func(netip.Addr) bool) {
u.mu.Lock()
defer u.mu.Unlock()
for k, s := range u.sessions {
if match(k.Addr()) {
s.Close()
delete(u.sessions, k)
}
}
}
// Close tears down every open session. Call when the owning Device is
// closed.
func (u *UDPRelay) Close() {
+37 -9
View File
@@ -77,7 +77,7 @@ func TestSocksRelayAgainstRealXray(t *testing.T) {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58715
listenPort := freeUDPPort(t)
inst := amneziawg.Instance{
Id: 4,
InterfaceName: "awgtest4",
@@ -161,13 +161,8 @@ func TestSocksRelayAgainstRealXray(t *testing.T) {
udpRelay := NewUDPRelay(relay, dev.Stack)
defer udpRelay.Close()
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
if err != nil {
conn.Close()
return
}
peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap())
AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) {
peer, ok := idx.Lookup(src.Addr().Unmap())
if !ok {
conn.Close()
return
@@ -310,7 +305,7 @@ func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
}
const wantEmail = "manager-e2e-peer@example.com"
const listenPort = 58716
listenPort := freeUDPPort(t)
const inboundID = 5
tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
@@ -600,6 +595,39 @@ func freePort(t *testing.T) int {
return l.Addr().(*net.TCPAddr).Port
}
// freeUDPPort asks the OS for a free loopback UDP port: a fixed one sits in the
// Linux ephemeral range, where any concurrent :0 socket can already hold it.
func freeUDPPort(tb testing.TB) int {
tb.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
tb.Fatal(err)
}
defer pc.Close()
return pc.LocalAddr().(*net.UDPAddr).Port
}
// freeTCPUDPPort finds a loopback port free for both protocols, since a port
// forward binds a TCP and a UDP listener on the same number.
func freeTCPUDPPort(t *testing.T) int {
t.Helper()
for range 20 {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := l.Addr().(*net.TCPAddr).Port
pc, err := net.ListenPacket("udp", fmt.Sprintf("127.0.0.1:%d", port))
l.Close()
if err == nil {
pc.Close()
return port
}
}
t.Fatal("no loopback port free for both TCP and UDP")
return 0
}
func waitForPort(t *testing.T, port int) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
+73
View File
@@ -297,3 +297,76 @@ func TestNewSocks5UDPSessionGivesUpOnSilentServer(t *testing.T) {
t.Fatal("newSocks5UDPSession still blocked 2s past the associate deadline: a silent SOCKS5 server parks the tunnel's delivery path")
}
}
// startFakeSocks5UDP serves just enough no-auth SOCKS5 for UDP ASSOCIATE to
// succeed, pointing every association at one UDP socket nobody reads.
func startFakeSocks5UDP(t *testing.T) string {
t.Helper()
sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("listen udp: %v", err)
}
t.Cleanup(func() { _ = sink.Close() })
port := sink.LocalAddr().(*net.UDPAddr).Port
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen tcp: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
var greeting [4]byte
var associate [10]byte
if _, err := io.ReadFull(c, greeting[:]); err != nil {
return
}
_, _ = c.Write([]byte{0x05, 0x00})
if _, err := io.ReadFull(c, associate[:]); err != nil {
return
}
_, _ = c.Write([]byte{0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, byte(port >> 8), byte(port)})
_, _ = io.Copy(io.Discard, c)
}()
}
}()
return ln.Addr().String()
}
// Each fresh tunnel source port bought a SOCKS5 association and a UDP socket with no
// per-peer bound, so one client could take the host's UDP ports from everyone.
func TestUDPRelayCapsSessionsPerPeer(t *testing.T) {
relay := NewUDPRelay(SocksRelay{Addr: startFakeSocks5UDP(t), Password: "x"}, nil)
defer relay.Close()
dst := netip.MustParseAddrPort("192.0.2.1:53")
for n := 1; n < maxUDPSessionsPerPeer; n++ {
relay.slots.acquire("greedy")
}
relay.Handle(netip.MustParseAddrPort("10.8.1.5:1000"), dst, "greedy", []byte("x"))
relay.Handle(netip.MustParseAddrPort("10.8.1.5:1001"), dst, "greedy", []byte("x"))
relay.Handle(netip.MustParseAddrPort("10.8.1.6:1000"), dst, "modest", []byte("x"))
relay.mu.Lock()
_, last := relay.sessions[netip.MustParseAddrPort("10.8.1.5:1000")]
_, excess := relay.sessions[netip.MustParseAddrPort("10.8.1.5:1001")]
_, other := relay.sessions[netip.MustParseAddrPort("10.8.1.6:1000")]
relay.mu.Unlock()
if !last || excess || !other {
t.Fatalf("sessions: greedy's last allowed = %v, greedy's excess = %v, modest's = %v; want true, false, true", last, excess, other)
}
relay.Close()
deadline := time.Now().Add(2 * time.Second)
for !relay.slots.acquire("greedy") {
if time.Now().After(deadline) {
t.Fatal("closing greedy's session never released its slot")
}
time.Sleep(10 * time.Millisecond)
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ func TestNewDeviceUDPHandlerAndReply(t *testing.T) {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58713 // distinct from the TCP test's port
listenPort := freeUDPPort(t)
const wantEmail = "udp-test-peer@example.com"
const echoPayload = "hello-from-client"
+23 -10
View File
@@ -117,16 +117,30 @@ const ipCommandTimeout = 3 * time.Second
// aliased at once is harmless). Never surfaces an error — an alias failing
// only narrows that one peer's own outbound-source-identity feature, never
// a reason to fail the tunnel or its SOCKS5 relay.
func applyV6Aliases(add, remove []v6Alias) {
for _, a := range add {
addV6Alias(a)
func (m *Manager) applyV6Aliases(add, remove []v6Alias) {
if m.v6Owned == nil {
m.v6Owned = map[v6Alias]struct{}{}
}
for _, a := range add {
if addV6Alias(a) {
m.v6Owned[a] = struct{}{}
}
}
// A peer may name an address the host already owns (::1 on lo, its own
// public /128): only what this Manager itself added is ever deleted.
for _, a := range remove {
if _, ours := m.v6Owned[a]; !ours {
logger.Debugf("amneziawgnet: leaving IPv6 address %s on %s, not aliased by this process", a.Addr, a.Iface)
continue
}
delete(m.v6Owned, a)
removeV6Alias(a)
}
}
func addV6Alias(a v6Alias) {
// addV6Alias reports whether the alias is on the host because of this call;
// an address that was already there is the host's own, not ours to remove.
func addV6Alias(a v6Alias) bool {
ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
defer cancel()
// nodad: this address is a specific peer's own admin-assigned identity,
@@ -136,16 +150,15 @@ func addV6Alias(a v6Alias) {
stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad")
if err == nil {
logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface)
return
return true
}
if strings.Contains(stderr, "File exists") {
// Already the desired end state -- most commonly hit once, harmlessly,
// right after an ungraceful panel restart (the OS-level alias from
// before the crash outlives the process; the in-memory managed map
// doesn't).
return
// Already there: the host's own address, or an alias an ungraceful
// restart left behind -- indistinguishable, so neither is ours.
return false
}
logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
return false
}
func removeV6Alias(a v6Alias) {
+63 -5
View File
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func peerWithIPs(email string, ips ...string) amneziawg.Peer {
@@ -264,14 +265,71 @@ func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) {
}
return "", nil
})
applyV6Aliases(
m := &Manager{ifaces: map[int]*managed{}}
m.applyV6Aliases([]v6Alias{{Addr: "fd86::2", Iface: "eth0"}}, nil)
m.applyV6Aliases(
[]v6Alias{{Addr: "fd86::99", Iface: "eth0"}},
[]v6Alias{{Addr: "fd86::2", Iface: "eth0"}},
)
if len(*calls) != 2 {
t.Fatalf("expected exactly 2 calls, got %d", len(*calls))
if len(*calls) != 3 {
t.Fatalf("expected exactly 3 calls, got %d", len(*calls))
}
if order[0] != "add" || order[1] != "del" {
t.Fatalf("expected add before del, got order=%v", order)
if order[1] != "add" || order[2] != "del" {
t.Fatalf("expected add before del, got order=%v", order[1:])
}
}
// An address the host already held (::1 on lo, its own /128) answers the add
// with EEXIST; tearing the inbound down must leave it in place.
func TestManagerRemoveLeavesHostOwnedAddressInPlace(t *testing.T) {
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
if args[2] == "add" && args[3] == "::1/128" {
return "RTNETLINK answers: File exists", errors.New("exit status 2")
}
return "", nil
})
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
_, foreignPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
_, hostOwnPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
m := &Manager{ifaces: map[int]*managed{}}
defer m.StopAll()
inst := amneziawg.Instance{
Id: 7,
InterfaceName: "awgtest7",
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.207.0.1/24", "fd86:ea04:1115::1/64"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20},
IPv6Enabled: true,
IPv6ExternalInterface: "lo",
Peers: []amneziawg.Peer{
{Email: "foreign", PublicKey: foreignPub, AllowedIPs: []string{"10.207.0.2/32", "2001:db8:beef::1/128"}},
{Email: "host-own", PublicKey: hostOwnPub, AllowedIPs: []string{"10.207.0.3/32", "::1/128"}},
},
}
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure: %v", err)
}
m.Remove(inst.Id)
var deleted []string
for _, c := range *calls {
if c[2] == "del" {
deleted = append(deleted, c[3])
}
}
if len(deleted) != 1 || deleted[0] != "2001:db8:beef::1/128" {
t.Fatalf("deleted %v, want only the alias this process added (2001:db8:beef::1/128)", deleted)
}
}
+119 -2
View File
@@ -405,7 +405,10 @@ func migrateSyncOrphanColumns() error {
if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") {
return nil
}
return db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error
if err := db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error; err != nil {
return err
}
return db.Exec("UPDATE clients SET sync_orphaned_by = 0 WHERE sync_orphaned_by IS NULL").Error
}
// The client identity checks match emails case-insensitively; without an
@@ -1527,7 +1530,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim", "OwnerlessClientHwidPurge", "ClientForwardedPortsBackfill"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -1638,6 +1641,12 @@ func runSeeders(isUsersEmpty bool) error {
}
}
if !slices.Contains(seedersHistory, "SessionSecretRotation") {
if err := dropPreUpgradeSessionSecret(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "OutboundRemovedKeysFix") {
if err := migrateOutboundRemovedKeys(); err != nil {
return err
@@ -1704,6 +1713,24 @@ func runSeeders(isUsersEmpty bool) error {
}
}
if !slices.Contains(seedersHistory, "ClientGroupNameTrim") {
if err := trimClientGroupNames(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "OwnerlessClientHwidPurge") {
if err := purgeOwnerlessClientHwids(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "ClientForwardedPortsBackfill") {
if err := backfillClientForwardedPorts(); err != nil {
return err
}
}
if err := seedHostsFromExternalProxy(); err != nil {
return err
}
@@ -1899,6 +1926,83 @@ func clearLegacyProxySettings() error {
})
}
// The client editor fills forwardedPorts from the record, which kept the value it was created
// with; an emptied field now withdraws the forwards, so the record must hold the live spec.
func backfillClientForwardedPorts() error {
return db.Transaction(func(tx *gorm.DB) error {
var inbounds []model.Inbound
if err := tx.Select("id", "settings").Where("protocol = ?", model.AmneziaWG).Find(&inbounds).Error; err != nil {
return err
}
for _, ib := range inbounds {
var parsed struct {
Clients []struct {
Email string `json:"email"`
ForwardedPorts string `json:"forwardedPorts"`
} `json:"clients"`
}
if json.Unmarshal([]byte(ib.Settings), &parsed) != nil {
continue
}
for _, c := range parsed.Clients {
spec := strings.TrimSpace(c.ForwardedPorts)
if c.Email == "" || spec == "" {
continue
}
if err := tx.Model(&model.ClientRecord{}).
Where("email = ? AND (wg_forwarded_ports IS NULL OR wg_forwarded_ports = '')", c.Email).
UpdateColumn("wg_forwarded_ports", spec).Error; err != nil {
return err
}
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientForwardedPortsBackfill"}).Error
})
}
// The HWID gate stored a row for any subId a request named, owned or not; those rows
// belong to no subscription and only grow the database.
func purgeOwnerlessClientHwids() error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("sub_id NOT IN (SELECT sub_id FROM clients WHERE sub_id IS NOT NULL)").
Delete(&model.ClientHwid{}).Error; err != nil {
return err
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "OwnerlessClientHwidPurge"}).Error
})
}
// The groups page addresses a group by its trimmed name, so a padded stored name listed as
// its own group while every action on it hit the trimmed one.
func trimClientGroupNames() error {
return db.Transaction(func(tx *gorm.DB) error {
var names []string
if err := tx.Model(&model.ClientRecord{}).Distinct().Pluck("group_name", &names).Error; err != nil {
return err
}
for _, name := range names {
if trimmed := strings.TrimSpace(name); trimmed != name {
if err := tx.Model(&model.ClientRecord{}).Where("group_name = ?", name).
UpdateColumn("group_name", trimmed).Error; err != nil {
return err
}
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientGroupNameTrim"}).Error
})
}
// Secrets stored before v2.8.3 came from math/rand (a 31-bit clock seed up to v2.1.3);
// GetSecret draws a crypto/rand one on the next start.
func dropPreUpgradeSessionSecret() error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("key = ?", "secret").Delete(&model.Setting{}).Error; err != nil {
return err
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "SessionSecretRotation"}).Error
})
}
func migrateOutboundRemovedKeys() error {
var setting model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
@@ -3133,6 +3237,11 @@ func seedClientsFromInboundJSON() error {
continue
}
model.NormalizeLegacyClientFields(obj)
// Releases before 3.1.0 served a client whose JSON carries no
// switch; only an explicit false means disabled.
if obj["enable"] == nil {
obj["enable"] = true
}
blob, err := json.Marshal(obj)
if err != nil {
continue
@@ -3154,6 +3263,14 @@ func seedClientsFromInboundJSON() error {
if err := tx.Create(incoming).Error; err != nil {
return err
}
// gorm default:true drops enable=false on Create — restate (#6478).
if !c.Enable {
if err := tx.Model(&model.ClientRecord{}).Where("id = ?", incoming.Id).
UpdateColumn("enable", false).Error; err != nil {
return err
}
incoming.Enable = false
}
byEmail[email] = incoming
row = incoming
} else {
+197
View File
@@ -2,6 +2,7 @@ package database
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"testing"
@@ -107,6 +108,56 @@ func TestEnsureFirstUserCreatesOneWorkingCredential(t *testing.T) {
}
}
// Old releases drew the cookie-signing secret from a clock-seeded math/rand, and a
// stored value cannot tell its generator, so an upgrade must replace it exactly once.
func TestSessionSecretRotatesOnceOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
reopen := func() []string {
t.Helper()
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var secrets []string
if err := db.Model(&model.Setting{}).Where("key = ?", "secret").Pluck("value", &secrets).Error; err != nil {
t.Fatalf("read secret: %v", err)
}
return secrets
}
storeSecret := func(value string) {
t.Helper()
if err := db.Where("key = ?", "secret").Delete(&model.Setting{}).Error; err != nil {
t.Fatalf("clear secret: %v", err)
}
if err := db.Create(&model.Setting{Key: "secret", Value: value}).Error; err != nil {
t.Fatalf("store secret: %v", err)
}
}
storeSecret("kept-on-a-fresh-install")
if got := reopen(); len(got) != 1 || got[0] != "kept-on-a-fresh-install" {
t.Fatalf("fresh install secret = %q after restart, want it kept", got)
}
storeSecret("rfBd56ti2SMtYvSgD5xAV0YU99zampta")
if err := db.Where("seeder_name = ?", "SessionSecretRotation").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if got := reopen(); len(got) != 0 {
t.Fatalf("secret = %q after upgrade, want the pre-upgrade secret dropped", got)
}
storeSecret("drawn-after-upgrade")
if got := reopen(); len(got) != 1 || got[0] != "drawn-after-upgrade" {
t.Fatalf("secret = %q, want the rotated secret kept so restarts do not log everyone out", got)
}
}
func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
@@ -169,6 +220,47 @@ func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testin
}
}
func TestSeedClientsFromInboundJSON_KeepsTheManualSwitch(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
inbound := model.Inbound{
UserId: 1,
Port: 12345,
Protocol: model.VLESS,
Settings: `{"clients": [
{"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001", "email": "paying", "enable": true},
{"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c002", "email": "banned", "enable": false},
{"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003", "email": "no-switch"}
]}`,
Tag: "test-inbound",
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear ClientsTable history: %v", err)
}
if err := seedClientsFromInboundJSON(); err != nil {
t.Fatalf("seedClientsFromInboundJSON: %v", err)
}
for email, want := range map[string]bool{"paying": true, "banned": false, "no-switch": true} {
var row model.ClientRecord
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("read client %s: %v", email, err)
}
if row.Enable != want {
t.Errorf("client %s migrated with enable = %v, want %v", email, row.Enable, want)
}
}
}
func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
@@ -295,3 +387,108 @@ func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
}
}
}
// A padded group name was listed as its own group while every action on it hit the trimmed
// one, so the upgrade folds stored names onto their trimmed form.
func TestClientGroupNamesTrimmedOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
for email, group := range map[string]string{"a@x": "vip ", "b@x": "\tvip", "c@x": "vip"} {
if err := db.Create(&model.ClientRecord{Email: email, Group: group, Enable: true}).Error; err != nil {
t.Fatalf("seed %s: %v", email, err)
}
}
if err := db.Where("seeder_name = ?", "ClientGroupNameTrim").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var groups []string
if err := db.Model(&model.ClientRecord{}).Distinct().Pluck("group_name", &groups).Error; err != nil {
t.Fatalf("read groups: %v", err)
}
if len(groups) != 1 || groups[0] != "vip" {
t.Fatalf("groups after upgrade = %q, want [vip]", groups)
}
}
// Requests naming a subId no client owns stored HWID rows that nothing ever reads; the
// upgrade drops them and keeps the rows of real subscriptions.
func TestOwnerlessClientHwidsPurgedOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.ClientRecord{Email: "a@x", SubID: "owned", Enable: true}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
for i, sub := range []string{"owned", "junk-1", "junk-2"} {
if err := db.Create(&model.ClientHwid{SubID: sub, HwidHash: fmt.Sprintf("h%d", i), FirstSeen: 1, LastSeen: 1}).Error; err != nil {
t.Fatalf("seed hwid %s: %v", sub, err)
}
}
if err := db.Where("seeder_name = ?", "OwnerlessClientHwidPurge").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var subs []string
if err := db.Model(&model.ClientHwid{}).Pluck("sub_id", &subs).Error; err != nil {
t.Fatalf("read hwids: %v", err)
}
if len(subs) != 1 || subs[0] != "owned" {
t.Fatalf("hwid rows after upgrade = %q, want [owned]", subs)
}
}
// The editor fills forwardedPorts from the record, and an emptied field now withdraws the
// forwards: a record left at its creation value would drop live forwards on the next save.
func TestClientForwardedPortsBackfilledOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.ClientRecord{Email: "fwd@x", Enable: true}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.ClientRecord{Email: "kept@x", Enable: true, ForwardedPorts: "9000"}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.Inbound{
Tag: "awg", Port: 51820, Protocol: model.AmneziaWG, Enable: true,
Settings: `{"clients":[{"email":"fwd@x","forwardedPorts":"8022"},{"email":"kept@x","forwardedPorts":"9001"}]}`,
}).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := db.Where("seeder_name = ?", "ClientForwardedPortsBackfill").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
for email, want := range map[string]string{"fwd@x": "8022", "kept@x": "9000"} {
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("read %s: %v", email, err)
}
if rec.ForwardedPorts != want {
t.Errorf("%s forwardedPorts = %q after upgrade, want %q", email, rec.ForwardedPorts, want)
}
}
}
+41 -5
View File
@@ -4,6 +4,7 @@ package model
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
@@ -356,7 +357,9 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
if listen == "" {
listen = "0.0.0.0"
}
listen = fmt.Sprintf("\"%v\"", listen)
// Encoded, not quoted by hand: an escaped "env:" prefix must stay literal text.
encodedListen, _ := json.Marshal(listen)
listen = string(encodedListen)
protocol := string(i.Protocol)
settings := i.Settings
switch i.Protocol {
@@ -705,6 +708,16 @@ func ValidMtprotoAdTag(tag string) bool {
return err == nil
}
// ValidMtprotoSecret mirrors mtg's secret parser: hex, else unpadded URL-safe
// base64, of the 0xee FakeTLS marker, a 16-byte key and a non-empty host name.
func ValidMtprotoSecret(secret string) bool {
decoded, err := hex.DecodeString(secret)
if err != nil {
decoded, err = base64.RawURLEncoding.DecodeString(secret)
}
return err == nil && len(decoded) > 17 && decoded[0] == 0xee
}
// StripMtprotoInboundSecret removes the vestigial inbound-level `secret` from an
// mtproto inbound's settings JSON. MTProto is multi-client: every secret lives on
// a client, and mtg's [secrets] config plus every share link read only the
@@ -953,7 +966,7 @@ type Client struct {
AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
PreSharedKey string `json:"preSharedKey,omitempty"`
KeepAlive *KeepAliveValue `json:"keepAlive,omitempty"` // Numeric values or AmneziaWG ranges; 0 disables, omission preserves.
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
ForwardedPorts *string `json:"forwardedPorts,omitempty"` // AmneziaWG port-forwarding spec, e.g. "80,443,8000-8100"; "" withdraws the forwards, omit to keep them
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
Email string `json:"email"` // Client email identifier
@@ -1016,6 +1029,8 @@ type ClientRecord struct {
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
// deleting; orphans from any other cause stay at zero and are never reaped.
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
// The node whose merge orphaned the record: only that node may take it back.
SyncOrphanedBy int `json:"-" gorm:"column:sync_orphaned_by;default:0"`
}
func (ClientRecord) TableName() string { return "clients" }
@@ -1269,6 +1284,27 @@ func keepAliveValue(value *KeepAliveValue) KeepAliveValue {
return *value
}
// ForwardedPortsSpec is the client's port-forwarding spec, "" when unset.
func (c Client) ForwardedPortsSpec() string {
if c.ForwardedPorts == nil {
return ""
}
return strings.TrimSpace(*c.ForwardedPorts)
}
// ForwardedPortsPtr wraps an explicit spec, "" included: distinct from a nil
// ForwardedPorts, which means the field was never sent.
func ForwardedPortsPtr(spec string) *string { return &spec }
// nonEmptyForwardedPorts keeps a stored "" nil: a record re-saved as a client must not
// read as a request to withdraw the forwards.
func nonEmptyForwardedPorts(spec string) *string {
if spec == "" {
return nil
}
return ForwardedPortsPtr(spec)
}
func (c *Client) ToRecord() *ClientRecord {
rec := &ClientRecord{
Email: c.Email,
@@ -1284,7 +1320,7 @@ func (c *Client) ToRecord() *ClientRecord {
ExpiryTime: c.ExpiryTime,
Enable: c.Enable,
TgID: c.TgID,
Group: c.Group,
Group: strings.TrimSpace(c.Group),
Comment: c.Comment,
Reset: c.Reset,
ResetDay: c.ResetDay,
@@ -1300,7 +1336,7 @@ func (c *Client) ToRecord() *ClientRecord {
AllowedIPs: strings.Join(c.AllowedIPs, ","),
PreSharedKey: c.PreSharedKey,
KeepAlive: keepAliveValue(c.KeepAlive),
ForwardedPorts: c.ForwardedPorts,
ForwardedPorts: c.ForwardedPortsSpec(),
Secret: c.Secret,
AdTag: c.AdTag,
}
@@ -1360,7 +1396,7 @@ func (r *ClientRecord) ToClient() *Client {
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
PreSharedKey: r.PreSharedKey,
KeepAlive: keepAlivePtr(r.KeepAlive),
ForwardedPorts: r.ForwardedPorts,
ForwardedPorts: nonEmptyForwardedPorts(r.ForwardedPorts),
Secret: r.Secret,
AdTag: r.AdTag,
}
@@ -25,6 +25,20 @@ func TestGenerateFakeTLSSecret(t *testing.T) {
}
}
func TestValidMtprotoSecret(t *testing.T) {
const good = "ee0123456789abcdef0123456789abcdef6578616d706c652e636f6d"
for _, s := range []string{good, strings.ToUpper(good), "ee0123456789abcdef0123456789abcdef61", "7gEjRWeJq83vASNFZ4mrze9leGFtcGxlLmNvbQ"} {
if !ValidMtprotoSecret(s) {
t.Fatalf("mtg parses %q, so it must be valid", s)
}
}
for _, s := range []string{"", "zz", "ee00", "dd0123456789abcdef0123456789abcdef6578616d706c652e636f6d", "ee0123456789abcdef0123456789abcdef", "7gEjRWeJq83vASNFZ4mrze9leGFtcGxlLmNvbQ==", "not a secret"} {
if ValidMtprotoSecret(s) {
t.Fatalf("mtg refuses %q, so it must be invalid", s)
}
}
}
func TestStripMtprotoInboundAdTag(t *testing.T) {
in := `{"adTag":"0123456789abcdef0123456789abcdef","clients":[{"email":"a","adTag":"fedcba9876543210fedcba9876543210"}]}`
out, changed := StripMtprotoInboundAdTag(in)
@@ -16,6 +16,8 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) {
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=",
KeepAlive: KeepAlivePtr(25),
ForwardedPorts: ForwardedPortsPtr("80,8000-8100"),
}
rec := c.ToRecord()
@@ -32,6 +34,7 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) {
{"PublicKey", c.PublicKey, got.PublicKey},
{"PreSharedKey", c.PreSharedKey, got.PreSharedKey},
{"KeepAlive", c.KeepAliveSeconds(), got.KeepAliveSeconds()},
{"ForwardedPorts", c.ForwardedPortsSpec(), got.ForwardedPortsSpec()},
} {
if f.a != f.b {
t.Errorf("%s round-trip = %v, want %v", f.name, f.b, f.a)
@@ -59,6 +62,15 @@ func TestClientToClientOmitsUnsetKeepAlive(t *testing.T) {
}
}
// An explicit "" withdraws the forwards, which a re-saved record must never ask for.
func TestClientToClientOmitsUnsetForwardedPorts(t *testing.T) {
rec := &ClientRecord{Email: "awg@example.test", Enable: true}
if got := rec.ToClient().ForwardedPorts; got != nil {
t.Fatalf("ForwardedPorts for a record that never set any = %q, want nil", *got)
}
}
func TestClientRecordEmptyAllowedIPs(t *testing.T) {
rec := &ClientRecord{Email: "bob@example.test", AllowedIPs: ""}
if got := rec.ToClient().AllowedIPs; got != nil {
+13 -5
View File
@@ -169,8 +169,9 @@ func GetManager() *Manager {
// InstanceFromInbound derives a desired Instance from an mtproto inbound,
// building one named secret per active client. Secrets are healed on save (see
// normalizeMtprotoSecret) and by the migration, so they are read as-is here to
// keep the fingerprint stable across reconciles. Returns false when the inbound
// is not a usable mtproto inbound or has no active client secret to serve.
// keep the fingerprint stable across reconciles; a client whose stored values
// mtg could not parse is left out (see usableAdTag). Returns false when the
// inbound is not a usable mtproto inbound or has no active client secret to serve.
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.MTProto {
return Instance{}, false
@@ -205,15 +206,15 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
}
secrets := make([]SecretEntry, 0, len(parsed.Clients))
for _, c := range parsed.Clients {
if !c.Enable || c.Secret == "" || c.Email == "" {
if !c.Enable || c.Email == "" || !model.ValidMtprotoSecret(c.Secret) {
continue
}
entry := SecretEntry{Name: c.Email, Secret: c.Secret, AdTag: usableAdTag(c.AdTag)}
if c.TotalGB > 0 {
entry.QuotaBytes = c.TotalGB
entry.QuotaBytes = min(c.TotalGB, maxQuotaBytes)
}
if c.ExpiryTime > 0 {
entry.ExpiresUnix = c.ExpiryTime / 1000
entry.ExpiresUnix = min(c.ExpiryTime/1000, maxExpiresUnix)
}
secrets = append(secrets, entry)
}
@@ -253,6 +254,13 @@ func usableAdTag(tag string) string {
return tag
}
// The largest limits mtg parses: its byte parser overflows just short of
// MaxInt64 and RFC 3339 ends with the year 9999. Larger stored values are capped.
const (
maxQuotaBytes = int64(9_000_000_000_000_000_000)
maxExpiresUnix = int64(253402300799)
)
// Ensure starts the mtg process for an instance, or restarts it when its
// configuration changed. A no-op when the desired process is already running.
func (m *Manager) Ensure(inst Instance) error {
+37 -1
View File
@@ -76,7 +76,7 @@ func TestInstanceFromInbound(t *testing.T) {
t.Fatal("an inbound with no active secret should not produce an instance")
}
badTags := &model.Inbound{Protocol: model.MTProto, Settings: `{"clients":[{"email":"x","secret":"ee00","adTag":"deadbeef","enable":true}]}`}
badTags := &model.Inbound{Protocol: model.MTProto, Settings: `{"clients":[{"email":"x","secret":"` + aliceSecret + `","adTag":"deadbeef","enable":true}]}`}
badInst, ok := InstanceFromInbound(badTags)
if !ok {
t.Fatal("expected a usable instance despite a malformed ad tag")
@@ -86,6 +86,42 @@ func TestInstanceFromInbound(t *testing.T) {
}
}
// mtg refuses its whole config over one secret, quota or expiry it cannot
// parse, which would take every client of the inbound down with the one holding it.
func TestInstanceFromInboundLeavesOutWhatMtgCannotParse(t *testing.T) {
const good = "ee0123456789abcdef0123456789abcdef6578616d706c652e636f6d"
inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.MTProto, Port: 443, Settings: `{"clients":[` +
`{"email":"hex","enable":true,"secret":"` + good + `","totalGB":1000,"expiryTime":4102444800000},` +
`{"email":"base64","enable":true,"secret":"7gEjRWeJq83vASNFZ4mrze9leGFtcGxlLmNvbQ"},` +
`{"email":"truncated","enable":true,"secret":"zz"},` +
`{"email":"not-faketls","enable":true,"secret":"dd0123456789abcdef0123456789abcdef6578616d706c652e636f6d"},` +
`{"email":"no-host","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"},` +
`{"email":"year-10000","enable":true,"secret":"` + good + `","expiryTime":253402300800000},` +
`{"email":"huge-quota","enable":true,"secret":"` + good + `","totalGB":9223372036854775806}]}`})
if !ok {
t.Fatal("expected a usable instance")
}
got := map[string]SecretEntry{}
for _, e := range inst.Secrets {
got[e.Name] = e
}
if len(got) != 4 {
t.Fatalf("only the clients mtg can parse should be served, got %+v", inst.Secrets)
}
if e := got["hex"]; e.Secret != good || e.QuotaBytes != 1000 || e.ExpiresUnix != 4102444800 {
t.Fatalf("parsable values must be kept as stored, got %+v", e)
}
if _, served := got["base64"]; !served {
t.Fatal("mtg parses a base64 secret, so its client must be served")
}
if e := got["year-10000"]; expiresString(e.ExpiresUnix) != "9999-12-31T23:59:59Z" {
t.Fatalf("an expiry past RFC 3339 must be capped, got %q", expiresString(e.ExpiresUnix))
}
if e := got["huge-quota"]; e.QuotaBytes != 9_000_000_000_000_000_000 {
t.Fatalf("a quota mtg's parser overflows on must be capped, got %d", e.QuotaBytes)
}
}
func TestRenderConfig(t *testing.T) {
// A bare instance emits only the required keys, api-bind-to, and the
// [secrets] section, with no optional keys and no [domain-fronting].
+19 -29
View File
@@ -15,7 +15,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
@@ -90,7 +89,9 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
variants, closed := subReq.clientHostVariants(inbound, client, "clash")
hasBoundClosed = hasBoundClosed || closed
for _, variant := range variants {
render := func() []map[string]any { return s.getProxies(subReq, variant.inbound(subReq, inbound), client, host) }
render := func() []map[string]any {
return s.budgetedProxies(subReq, variant.inbound(subReq, inbound), client, host)
}
ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
}
continue
@@ -106,7 +107,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
injectExternalProxy(&clone, effectiveEps)
effective = &clone
}
proxies = append(proxies, s.getProxies(subReq, effective, client, host)...)
proxies = append(proxies, s.budgetedProxies(subReq, effective, client, host)...)
}
}
if subReq.hostsFirst {
@@ -668,8 +669,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
return nil
}
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
inboundSettings := subReq.linkSettings(inbound)
secretKey, _ := inboundSettings["secretKey"].(string)
proxy := map[string]any{
@@ -721,21 +721,18 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
}
func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
inst, ok := tuic.InstanceFromInbound(inbound)
if !ok {
inst := subReq.tuicInstanceForLink(inbound)
if inst == nil {
return nil
}
uuid := client.ID
password := client.Password
for _, c := range inst.Clients {
if c.Email == client.Email {
if uuid == "" {
uuid = c.UUID
}
if password == "" {
password = c.Password
}
break
if c := inst.byEmail[client.Email]; c != nil {
if uuid == "" {
uuid = c.UUID
}
if password == "" {
password = c.Password
}
}
if uuid == "" || password == "" {
@@ -777,15 +774,9 @@ func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbo
// amneziaWGClientAddresses prefers this inbound's own settings entry over the
// shared clients.wg_allowed_ips column, which for an identity attached to both
// a wireguard and an amneziawg inbound holds the other one's address.
func amneziaWGClientAddresses(settingsClients []model.Client, client model.Client) []string {
for i := range settingsClients {
if !strings.EqualFold(settingsClients[i].Email, client.Email) {
continue
}
if len(settingsClients[i].AllowedIPs) > 0 {
return settingsClients[i].AllowedIPs
}
break
func amneziaWGClientAddresses(subReq *SubService, inbound *model.Inbound, client model.Client) []string {
if own, ok := subReq.clientForLink(inbound, client.Email); ok && len(own.AllowedIPs) > 0 {
return own.AllowedIPs
}
return client.AllowedIPs
}
@@ -809,11 +800,10 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model
return nil
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
server := subReq.amneziaWGServerForLink(inbound)
if server == nil {
return nil
}
server := parsed.Server
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, ""),
@@ -834,7 +824,7 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model
proxy["persistent-keepalive"] = ka
}
for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) {
for _, addr := range amneziaWGClientAddresses(subReq, inbound, client) {
ip := stripCIDR(addr)
if ip == "" {
continue
+44 -5
View File
@@ -3,7 +3,10 @@ package sub
import (
"reflect"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"github.com/goccy/go-yaml"
)
@@ -41,30 +44,66 @@ func yamlScalarIsAmbiguous(s string) bool {
return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s)
}
// yamlEscapedString is written double-quoted on one line: the encoder's block scalars
// mishandle CR and the Unicode line breaks, so the text could continue as YAML.
type yamlEscapedString string
func (s yamlEscapedString) MarshalYAML() ([]byte, error) {
return []byte(strconv.Quote(string(s))), nil
}
func yamlNeedsEscaping(s string) bool {
if !utf8.ValidString(s) {
return true
}
return strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsPrint(r) })
}
func quoteYAMLString(s string) any {
switch {
case yamlNeedsEscaping(s):
return yamlEscapedString(s)
case yamlScalarIsAmbiguous(s):
return yamlQuotedString(s)
}
return s
}
func quoteAmbiguousYAMLScalars(v any) any {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
if yamlScalarIsAmbiguous(s) {
return yamlQuotedString(s)
}
return s
return quoteYAMLString(s)
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Map:
out := make(map[string]any, rv.Len())
escapedKeys := false
iter := rv.MapRange()
for iter.Next() {
key, ok := iter.Key().Interface().(string)
if !ok {
return v
}
escapedKeys = escapedKeys || yamlNeedsEscaping(key)
out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface())
}
return out
if !escapedKeys {
return out
}
// Only marshaler-typed keys escape, and only a map[any]any key keeps its type.
keyed := make(map[any]any, len(out))
for key, value := range out {
if yamlNeedsEscaping(key) {
keyed[yamlEscapedString(key)] = value
} else {
keyed[key] = value
}
}
return keyed
case reflect.Slice, reflect.Array:
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
return v
+29
View File
@@ -127,3 +127,32 @@ func TestQuotedScalarEscapesQuotes(t *testing.T) {
t.Errorf("password round-tripped to %#v\n%s", got, out)
}
}
// Node-reported paths, remarks and header names reach the profile; a line break the
// encoder writes bare would start a top-level key of the subscriber's Clash config.
func TestLineBreaksStayInsideTheirScalar(t *testing.T) {
for _, value := range []string{
"/ws\r\n\r\nmode:\r direct", "/ws\n\n\n\n\n\n\rmode:\r direct", "a\nb\nc\u0085mode: direct",
"a\nb\nc
mode: direct", "a\nb\nc
mode: direct", "2026-01-01\n\n\rmode: direct",
} {
profile := map[string]any{"proxies": []any{map[string]any{
"name": "n",
"ws-opts": map[string]any{"path": value, "headers": map[string]any{value: "v"}},
}}}
out, err := marshalClashYAML(profile)
if err != nil {
t.Fatalf("marshal %q: %v", value, err)
}
if strings.ContainsAny(string(out), "\r\u0085

") || strings.Contains(string(out), "\nmode:") {
t.Fatalf("value %q escaped its scalar:\n%q", value, out)
}
var back map[string]any
if err := yaml.Unmarshal(out, &back); err != nil {
t.Fatalf("read back %q: %v\n%q", value, err, out)
}
opts := back["proxies"].([]any)[0].(map[string]any)["ws-opts"].(map[string]any)
if opts["path"] != value || opts["headers"].(map[string]any)[value] != "v" {
t.Fatalf("value %q did not round-trip:\n%q", value, out)
}
}
}
+73
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"testing"
@@ -181,3 +182,75 @@ func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) {
t.Fatalf("vmess fm = %q, want the host mask", obj["fm"])
}
}
// An endpoint address is mirrored from the node as reported: whatever it holds, one
// endpoint stays one line of the subscription body, inside its own link's authority.
func TestSub_EndpointAddressCannotAddLines(t *testing.T) {
seedSubDB(t)
dest, _ := json.Marshal("::1]:443#a\nincy://routing/onadd/e30=\r\nvless://x@[::2")
seedSubInbound(t, "s1", "n", 4490, 1, fmt.Sprintf(
`{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":%s,"port":443,"remark":""}]}`, dest))
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
want := "vless://11111111-2222-4333-8444-000000004490@" +
"[::1%5D:443%23a%0Aincy:%2F%2Frouting%2Fonadd%2Fe30=%0D%0Avless:%2F%2Fx%40%5B::2]:443" +
"?encryption=none&security=none&type=tcp#n-n%40e"
if len(links) != 1 || links[0] != want {
t.Fatalf("links = %q, want [%q]", links, want)
}
}
func TestJoinHostPortEscapesNonHostCharacters(t *testing.T) {
for host, want := range map[string]string{
"cdn.example.com": "cdn.example.com:443",
"203.0.113.5": "203.0.113.5:443",
"[2001:db8::1]": "[2001:db8::1]:443",
"fe80::1%eth0": "[fe80::1%eth0]:443",
"пример.рф": "пример.рф:443",
"a b\tc": "a%20b%09c:443",
"a\u0085b\u2028c": "a%C2%85b%E2%80%A8c:443",
"h?x=1#f": "h%3Fx=1%23f:443",
"u@h/p": "u%40h%2Fp:443",
} {
if got := joinHostPort(host, 443); got != want {
t.Errorf("joinHostPort(%q) = %q, want %q", host, got, want)
}
}
}
// The VLESS id is the userinfo of its link and is escaped like every other protocol's.
func TestGenVlessLinkEscapesID(t *testing.T) {
in := &model.Inbound{
Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n",
Settings: `{"clients":[{"id":"a@b#c\nd","email":"user"}],"decryption":"none"}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
got := NewSubService("").genVlessLink(in, "user")
if !strings.HasPrefix(got, "vless://a%40b%23c%0Ad@203.0.113.5:443?") {
t.Fatalf("vless id not escaped: %q", got)
}
}
// The panel builds its own link and QR through the browser's URL parser, which escapes
// userinfo with the WHATWG userinfo set; a subscription escaping more serves another id.
func TestGenVlessLinkEscapesIDLikeThePanel(t *testing.T) {
for id, want := range map[string]string{
"team+1": "team+1",
"a=b": "a%3Db",
"x:y;z": "x%3Ay%3Bz",
"ok!$,'()*~": "ok!$,'()*~",
"é": "%C3%A9",
} {
in := &model.Inbound{
Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n",
Settings: `{"clients":[{"id":` + strconv.Quote(id) + `,"email":"user"}],"decryption":"none"}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
got := NewSubService("").genVlessLink(in, "user")
if !strings.HasPrefix(got, "vless://"+want+"@203.0.113.5:443?") {
t.Errorf("id %q: link %q, want userinfo %q", id, got, want)
}
}
}
+5 -1
View File
@@ -36,6 +36,9 @@ type expandedLink struct {
// getClientExternalLinksBySubId returns active rows with owner state attached.
// Consumers keep inactive owners as metadata but omit their link values.
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
if strings.TrimSpace(subId) == "" {
return nil, nil
}
db := database.GetDB()
var recs []model.ClientRecord
if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil {
@@ -203,7 +206,8 @@ func parsedExternalOutbound(rawLink string) json_util.RawMessage {
return nil
}
ob["tag"] = "proxy"
b, err := json.MarshalIndent(ob, "", " ")
// Compact: the response is indented once at the end, and each extra pass costs per level.
b, err := json.Marshal(ob)
if err != nil {
return nil
}
+13
View File
@@ -3,6 +3,7 @@ package sub
import (
"encoding/base64"
"net/url"
"slices"
"strings"
"testing"
"time"
@@ -176,3 +177,15 @@ func TestClashProxyFromExternalTrojanReality(t *testing.T) {
t.Fatalf("expected reality→tls true, got %v", proxy["tls"])
}
}
// Only server links pass: an app deeplink or a plain URL from the provider would reach the
// subscriber's app as a directive (a routing profile, an import) the operator never chose.
func TestDecodeSubscriptionBodyKeepsOnlyServerShareLinks(t *testing.T) {
body := []byte("VLESS://u@a:1#a\nincy://autorouting/onadd/https://p.example/r.json\nhttps://p.example/page\n" +
"happ://routing/add/x\nvpn://payload\ntuic://u:p@a:1#t\nhy2://p@a:1#h\nnot a link://x\n")
got := decodeSubscriptionBody(body)
want := []string{"VLESS://u@a:1#a", "vpn://payload", "tuic://u:p@a:1#t", "hy2://p@a:1#h"}
if !slices.Equal(got, want) {
t.Fatalf("decoded links = %q, want %q", got, want)
}
}
+1
View File
@@ -13,6 +13,7 @@ import (
// #6559: the Master panel must send a stable X-HWID when fetching external
// subscriptions, otherwise an HWID-limited donor answers 404.
func TestFetchSendsStableHwid(t *testing.T) {
allowLoopbackProvider(t)
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
var gotHwid string
+72 -10
View File
@@ -4,7 +4,10 @@ import (
"context"
"encoding/base64"
"io"
"net"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
@@ -12,6 +15,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
@@ -21,10 +26,50 @@ import (
const (
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
subscriptionMaxLinks = 1000
subscriptionCacheCapacity = 256
)
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
// The provider picks every redirect target, so each hop is resolved, checked and dialed by
// the SSRF guard; its headers are bounded like its body.
var subscriptionHTTPClient = &http.Client{
Timeout: 6 * time.Second,
Transport: &http.Transport{
DialContext: netsafe.SSRFGuardedDialContext,
TLSHandshakeTimeout: 5 * time.Second,
MaxResponseHeaderBytes: 64 << 10,
IdleConnTimeout: 90 * time.Second,
},
}
// subscriptionProxy is the host's proxy setting: the proxy is the operator's and does the
// dialling, so a proxied fetch goes through it as the default transport did.
var subscriptionProxy = http.ProxyFromEnvironment
var proxiedSubscriptionHTTPClient = &http.Client{
Timeout: 6 * time.Second,
Transport: &http.Transport{
Proxy: func(r *http.Request) (*url.URL, error) { return subscriptionProxy(r) },
TLSHandshakeTimeout: 5 * time.Second,
MaxResponseHeaderBytes: 64 << 10,
IdleConnTimeout: 90 * time.Second,
},
}
// subscriptionClientFor picks the proxied client when the host's proxy setting covers req.
func subscriptionClientFor(req *http.Request) *http.Client {
if proxyURL, err := subscriptionProxy(req); err == nil && proxyURL != nil {
return proxiedSubscriptionHTTPClient
}
return subscriptionHTTPClient
}
// operatorChosePrivateHost reports a URL naming a private address itself: the operator picked
// it. A hostname does not count, since the provider controls what its DNS answers.
func operatorChosePrivateHost(u *url.URL) bool {
ip := net.ParseIP(u.Hostname())
return ip != nil && netsafe.IsBlockedIP(ip)
}
type subscriptionCacheEntry struct {
links []string
@@ -129,7 +174,7 @@ func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
}
lastFetchError := ""
if fetchErr != nil {
lastFetchError = fetchErr.Error()
lastFetchError = common.TruncateStatusText(service.WithoutRequestURL(fetchErr).Error())
}
if err := database.GetDB().
Model(&model.ClientExternalLink{}).
@@ -138,24 +183,27 @@ func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
"last_fetch_at": time.Now().UnixMilli(),
"last_fetch_error": lastFetchError,
}).Error; err != nil {
logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err)
logger.Warningf("sub: recording fetch status for an external subscription: %v", err)
}
}
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
return nil, service.WithoutRequestURL(err)
}
// A provider on the operator's own network is fetched; the hops a public provider picks by
// redirecting stay held to public addresses.
req = req.WithContext(netsafe.ContextWithAllowPrivate(req.Context(), operatorChosePrivateHost(req.URL)))
// Some providers gate the link body on a known client User-Agent.
req.Header.Set("User-Agent", externalSubUserAgent())
// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
if hwid := service.ExternalSubscriptionHwid(); hwid != "" {
req.Header.Set("X-HWID", hwid)
}
resp, err := subscriptionHTTPClient.Do(req)
resp, err := subscriptionClientFor(req).Do(req)
if err != nil {
return nil, err
return nil, service.WithoutRequestURL(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
@@ -168,7 +216,12 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
if len(body) > subscriptionMaxBytes {
return nil, errSubscriptionBodyTooLarge
}
return decodeSubscriptionBody(body), nil
links := decodeSubscriptionBody(body)
if len(links) > subscriptionMaxLinks {
logger.Warningf("sub: an external subscription lists %d share links; serving the first %d", len(links), subscriptionMaxLinks)
links = links[:subscriptionMaxLinks]
}
return links, nil
}
var (
@@ -193,8 +246,16 @@ type subError struct{ msg string }
func (e *subError) Error() string { return e.msg }
// decodeSubscriptionBody handles the common base64-encoded newline list as well
// as a plain-text body, returning only the lines that look like share links.
// serverShareLinkSchemes are the server links a raw subscription carries on: what the link
// parser reads, what the panel itself writes, and common third-party server schemes.
var serverShareLinkSchemes = []string{
"vmess", "vless", "trojan", "ss", "ssr", "hysteria", "hysteria2", "hy2", "tuic", "wireguard", "wg",
"vpn", "socks", "socks5", "anytls", "juicity", "naive+https", "tg",
}
// decodeSubscriptionBody handles the common base64-encoded newline list as well as a
// plain-text body, keeping only server share links: app deeplinks and plain URLs would
// reach the subscriber's app as directives the operator never chose.
func decodeSubscriptionBody(body []byte) []string {
text := strings.TrimSpace(string(body))
if text == "" {
@@ -210,7 +271,8 @@ func decodeSubscriptionBody(body []byte) []string {
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
if strings.Contains(ln, "://") {
scheme, _, found := strings.Cut(ln, "://")
if found && slices.ContainsFunc(serverShareLinkSchemes, func(s string) bool { return strings.EqualFold(s, scheme) }) {
out = append(out, ln)
}
}
+116
View File
@@ -2,8 +2,10 @@ package sub
import (
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
@@ -17,6 +19,17 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// allowLoopbackProvider lets a test serve its provider from httptest on 127.0.0.1, which
// the guarded production dialer refuses; every other transport limit stays.
func allowLoopbackProvider(t *testing.T) {
t.Helper()
previous := subscriptionHTTPClient
transport := previous.Transport.(*http.Transport).Clone()
transport.DialContext = (&net.Dialer{}).DialContext
subscriptionHTTPClient = &http.Client{Timeout: previous.Timeout, Transport: transport}
t.Cleanup(func() { subscriptionHTTPClient = previous })
}
func resetSubscriptionCache(t *testing.T) {
t.Helper()
subscriptionCache.Lock()
@@ -34,6 +47,7 @@ func resetSubscriptionCache(t *testing.T) {
}
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
allowLoopbackProvider(t)
resetSubscriptionCache(t)
var requests atomic.Int32
release := make(chan struct{})
@@ -69,6 +83,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
}
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
allowLoopbackProvider(t)
resetSubscriptionCache(t)
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -96,6 +111,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
}
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
allowLoopbackProvider(t)
resetSubscriptionCache(t)
stale := []string{"vless://stale@example.com:443"}
release := make(chan struct{})
@@ -150,6 +166,7 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T
}
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
allowLoopbackProvider(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
}))
@@ -165,6 +182,7 @@ func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
}
func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
allowLoopbackProvider(t)
link := "vless://example"
body := link + "\n" + strings.Repeat("#", subscriptionMaxBytes-len(link)-1)
if len(body) != subscriptionMaxBytes {
@@ -185,6 +203,7 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
}
func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) {
allowLoopbackProvider(t)
initMutDB(t)
resetSubscriptionCache(t)
db := database.GetDB()
@@ -302,6 +321,7 @@ func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
}
func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
allowLoopbackProvider(t)
resetSubscriptionCache(t)
dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua.db"))
@@ -330,6 +350,7 @@ func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
}
func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
allowLoopbackProvider(t)
resetSubscriptionCache(t)
dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua-default.db"))
@@ -348,3 +369,98 @@ func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
t.Fatalf("User-Agent = %q, want default v2rayNG/1.8.5", gotUA)
}
}
// The provider chooses where the fetch goes, every redirect included, so the dial itself
// must refuse this host and its network.
// A hostname is the provider's to resolve, so one answering with a private address is refused.
func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("vless://uuid@example.com:443#internal"))
}))
defer srv.Close()
viaName := strings.Replace(srv.URL, "127.0.0.1", "localhost", 1)
if links, err := doFetchSubscriptionLinks(viaName + "/admin"); err == nil || links != nil {
t.Fatalf("fetch of a loopback target = %v, %v; want it refused", links, err)
}
if hits.Load() != 0 {
t.Fatalf("the loopback target received %d request(s)", hits.Load())
}
}
// Fetch errors are stored on every row holding the URL and shown in the panel: they may
// name the host, never the URL (a credential), and a provider must not size them.
func TestExternalSubscriptionFetchErrorIsBoundedAndKeepsTheURLSecret(t *testing.T) {
initMutDB(t)
resetSubscriptionCache(t)
allowLoopbackProvider(t)
const token = "s3cr3t-token"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Junk", strings.Repeat("j", 128<<10))
_, _ = w.Write([]byte("vless://uuid@example.com:443#Node"))
}))
defer srv.Close()
rawURL := srv.URL + "/sub/" + token
if err := database.GetDB().Create(&model.ClientExternalLink{Kind: model.ExternalLinkKindSubscription, Value: rawURL}).Error; err != nil {
t.Fatalf("seed link: %v", err)
}
result := fetchSubscriptionLinks(rawURL)
if result.err == nil {
t.Fatal("a provider with 128 KiB of headers was accepted")
}
recordExternalSubscriptionFetch(rawURL, result.err)
var row model.ClientExternalLink
if err := database.GetDB().Where("value = ?", rawURL).First(&row).Error; err != nil {
t.Fatalf("load link: %v", err)
}
if row.LastFetchError == "" || strings.Contains(row.LastFetchError, token) || len(row.LastFetchError) > 1100 {
t.Fatalf("last_fetch_error = %.200q (%d bytes), want a short error without the URL", row.LastFetchError, len(row.LastFetchError))
}
}
// A provider listing past the cap is cut to it, not refused: refusing dropped every link
// the subscription served before the cap existed.
func TestDoFetchSubscriptionLinksCapsTooManyLinks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("trojan://p@a:1\n", subscriptionMaxLinks+1)))
}))
defer srv.Close()
if links, err := doFetchSubscriptionLinks(srv.URL); err != nil || len(links) != subscriptionMaxLinks {
t.Fatalf("fetch = %d links, %v; want the first %d", len(links), err, subscriptionMaxLinks)
}
}
// An operator merging a panel on their own network names its address in the URL; refusing
// it lost those links from every subscriber's response after the SSRF guard went in.
func TestDoFetchSubscriptionLinksFetchesOperatorChosenPrivateAddress(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("vless://uuid@example.com:443#lan"))
}))
defer srv.Close()
if links, err := doFetchSubscriptionLinks(srv.URL + "/sub"); err != nil || len(links) != 1 {
t.Fatalf("fetch of an operator-chosen loopback URL = %v, %v; want its link", links, err)
}
}
// A panel that reaches the Internet only through the host's proxy fetched through it before
// the guarded transport, which had no proxy at all.
func TestDoFetchSubscriptionLinksUsesTheHostProxy(t *testing.T) {
var proxied atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxied.Add(1)
_, _ = w.Write([]byte("vless://uuid@example.com:443#via-proxy"))
}))
defer proxy.Close()
proxyURL, _ := url.Parse(proxy.URL)
previous := subscriptionProxy
subscriptionProxy = http.ProxyURL(proxyURL)
t.Cleanup(func() { subscriptionProxy = previous })
if links, err := doFetchSubscriptionLinks("http://provider.example/sub"); err != nil || len(links) != 1 {
t.Fatalf("proxied fetch = %v, %v; want the provider's link", links, err)
}
if proxied.Load() != 1 {
t.Fatalf("proxy saw %d requests, want 1", proxied.Load())
}
}
+275
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/url"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -17,6 +18,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func seedSubDB(t *testing.T) {
@@ -233,6 +236,57 @@ func TestSub_HostOverFallback(t *testing.T) {
}
}
// The legacy "@socket" lookup stays on the child's own host: an older inbound
// mirrored from another node that names the same socket is never the master.
func TestSub_LegacyFallbackMasterStaysOnChildHost(t *testing.T) {
foreign, own := 7, 8
for _, tc := range []struct {
name string
nodeID *int
}{
{"local child", nil},
{"node child", &own},
} {
t.Run(tc.name, func(t *testing.T) {
seedSubDB(t)
db := database.GetDB()
for _, master := range []*model.Inbound{
{
UserId: 1, Tag: "foreign", Enable: true, Port: 44301, Protocol: model.VLESS, NodeID: &foreign,
Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@vless-ws"}]}`,
StreamSettings: `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"none","dest":"evil.example","port":8080}]}`,
},
{
UserId: 1, Tag: "front", Enable: true, Port: 9443, Protocol: model.VLESS, NodeID: tc.nodeID,
Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@vless-ws"}]}`,
StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"front.sni"}}`,
},
} {
if err := db.Create(master).Error; err != nil {
t.Fatalf("seed %s: %v", master.Tag, err)
}
}
child := seedSubInbound(t, "s1", "child", 4441, 1, `{"network":"ws","security":"none"}`)
if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).
Updates(map[string]any{"listen": "@vless-ws", "node_id": tc.nodeID}).Error; err != nil {
t.Fatalf("place child: %v", err)
}
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
joined := strings.Join(links, "\n")
if strings.Contains(joined, "evil.example") {
t.Fatalf("another node's inbound was used as fallback master: %s", joined)
}
if !strings.Contains(joined, ":9443") || !strings.Contains(joined, "sni=front.sni") {
t.Fatalf("the front on the child's host must be projected: %s", joined)
}
})
}
}
// #8 — a client only gets hosts for inbounds it is actually on (the
// clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds.
func TestSub_HostsResolveViaClientInbounds(t *testing.T) {
@@ -1960,3 +2014,224 @@ func TestSub_HostMultiSNI_Overrides(t *testing.T) {
t.Errorf("expected blank sni: %s", parts[1])
}
}
// The "@socket" front lookup parses every inbound's settings, so a request runs it
// once, not per child; the lower-id front still wins and unreadable settings are skipped.
func TestSub_LegacyFallbackMasterLookedUpOncePerRequest(t *testing.T) {
seedSubDB(t)
db := database.GetDB()
fronts := []*model.Inbound{
{
UserId: 1, Tag: "front", Enable: true, Port: 9443, Protocol: model.VLESS,
Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":80},{"dest":"@child-1"},"not-an-object"]}`,
StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"front.sni"}}`,
},
{
UserId: 1, Tag: "newer-front", Enable: true, Port: 9444, Protocol: model.VLESS,
Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@child-1"},{"dest":"@child-2"}]}`,
StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"newer.sni"}}`,
},
{UserId: 1, Tag: "broken", Enable: true, Port: 9445, Protocol: model.VLESS, Settings: `{not json`, StreamSettings: `{}`},
}
for _, ib := range fronts {
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed %s: %v", ib.Tag, err)
}
}
for i := 1; i <= 3; i++ {
child := seedSubInbound(t, "s1", fmt.Sprintf("child-%d", i), 4450+i, i, `{"network":"ws","security":"none"}`)
child.Listen = fmt.Sprintf("@child-%d", i)
if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", child.Listen).Error; err != nil {
t.Fatalf("set child listen: %v", err)
}
}
scans := 0
count := func(tx *gorm.DB) {
for _, v := range tx.Statement.Vars {
if v == "%fallbacks%" {
scans++
}
}
}
_ = db.Callback().Query().After("gorm:query").Register("test:count_front_scans", count)
_ = db.Callback().Row().After("gorm:row").Register("test:count_front_scans", count)
t.Cleanup(func() {
_ = db.Callback().Query().Remove("test:count_front_scans")
_ = db.Callback().Row().Remove("test:count_front_scans")
})
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if scans != 1 {
t.Fatalf("inbound settings were scanned %d times in one request, want 1", scans)
}
if len(links) != 3 {
t.Fatalf("links = %d, want 3: %v", len(links), links)
}
for i, want := range []string{"req.example.com:9443?", "req.example.com:9444?", "req.example.com:4453?"} {
if !strings.Contains(links[i], want) {
t.Fatalf("child-%d link should advertise %s: %s", i+1, want, links[i])
}
}
if !strings.Contains(links[0], "sni=front.sni") {
t.Fatalf("child-1 must carry its front's TLS state: %s", links[0])
}
}
// seedSharedSubIdInbound stores one inbound whose n clients all carry subId, the
// shape a node merge produces (the admin API keeps a subId unique per client).
func seedSharedSubIdInbound(t *testing.T, protocol model.Protocol, subId string, n int, serverJSON string, clientJSON func(i int) string, record func(i int) model.ClientRecord) *model.Inbound {
t.Helper()
entries := make([]string, n)
records := make([]model.ClientRecord, n)
for i := range entries {
entries[i] = clientJSON(i)
records[i] = record(i)
records[i].SubID = subId
records[i].Enable = true
}
db := database.GetDB()
ib := &model.Inbound{
UserId: 1, Tag: string(protocol), Enable: true, Listen: "203.0.113.5", Port: 443, Protocol: protocol,
Remark: string(protocol),
Settings: fmt.Sprintf(`{%s,"clients":[%s]}`, serverJSON, strings.Join(entries, ",")),
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create %s inbound: %v", protocol, err)
}
if err := db.CreateInBatches(&records, 100).Error; err != nil {
t.Fatalf("create clients: %v", err)
}
links := make([]model.ClientInbound, n)
for i := range records {
links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: ib.Id}
}
if err := db.CreateInBatches(&links, 500).Error; err != nil {
t.Fatalf("link clients: %v", err)
}
return ib
}
// A subscription of n clients of one inbound must decode that inbound's settings
// once, not once per link: with the clients array inside, n decodes grow with n².
func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) {
const subId = "shared-sub-id"
// Growth, not an absolute byte count, so the race detector or a Go upgrade cannot move
// it: tripling n triples linear work and multiplies a per-link decode by nine.
const small, scale, maxGrowth = 300, 3, 5.0
serverPriv, serverPub := mustWireguardKeypair(t)
clientPriv, _ := mustWireguardKeypair(t)
email := func(i int) string { return fmt.Sprintf("user-%04d@sub", i) }
uuid := func(i int) string { return fmt.Sprintf("11111111-2222-4333-8444-%012d", i) }
seed := map[model.Protocol]func(t *testing.T, n int) *model.Inbound{
model.TUIC: func(t *testing.T, n int) *model.Inbound {
return seedSharedSubIdInbound(t, model.TUIC, subId, n, `"server":{"certificate":"/c","private_key":"/k"}`,
func(i int) string {
return fmt.Sprintf(`{"uuid":%q,"password":"pw","email":%q,"enable":true}`, uuid(i), email(i))
},
func(i int) model.ClientRecord {
return model.ClientRecord{Email: email(i), UUID: uuid(i), Password: "pw"}
})
},
model.AmneziaWG: func(t *testing.T, n int) *model.Inbound {
return seedSharedSubIdInbound(t, model.AmneziaWG, subId, n, fmt.Sprintf(`"server":{"privateKey":%q,"publicKey":%q,"mtu":1420}`, serverPriv, serverPub),
func(i int) string {
return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256)
},
func(i int) model.ClientRecord {
return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)}
})
},
model.WireGuard: func(t *testing.T, n int) *model.Inbound {
return seedSharedSubIdInbound(t, model.WireGuard, subId, n, fmt.Sprintf(`"secretKey":%q,"mtu":1420`, serverPriv),
func(i int) string {
return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256)
},
func(i int) model.ClientRecord {
return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)}
})
},
model.Hysteria: func(t *testing.T, n int) *model.Inbound {
return seedSharedSubIdInbound(t, model.Hysteria, subId, n, `"version":2`,
func(i int) string {
return fmt.Sprintf(`{"email":%q,"auth":"auth","enable":true}`, email(i))
},
func(i int) model.ClientRecord { return model.ClientRecord{Email: email(i), Auth: "auth"} })
},
}
raw := func(t *testing.T) int {
links, _, _, _, err := NewSubService("").GetSubs(subId, "sub.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
for _, link := range links {
if link == "" {
t.Fatal("GetSubs returned an empty link")
}
}
return len(links)
}
clash := func(t *testing.T) int {
out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash(subId, "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
var doc struct {
Proxies []map[string]any `yaml:"proxies"`
}
if err := yaml.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("parse clash yaml: %v", err)
}
return len(doc.Proxies)
}
jsonSub := func(t *testing.T) int {
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson(subId, "sub.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
var configs []json.RawMessage
if err := json.Unmarshal([]byte(out), &configs); err != nil {
t.Fatalf("parse json subscription: %v", err)
}
return len(configs)
}
cases := []struct {
name string
protocol model.Protocol
fetch func(t *testing.T) int
}{
{"raw tuic", model.TUIC, raw},
{"raw amneziawg", model.AmneziaWG, raw},
{"clash tuic", model.TUIC, clash},
{"clash amneziawg", model.AmneziaWG, clash},
{"clash wireguard", model.WireGuard, clash},
{"json wireguard", model.WireGuard, jsonSub},
{"json hysteria", model.Hysteria, jsonSub},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
allocated := func(n int) float64 {
initSubDB(t)
seed[tc.protocol](t, n)
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
got := tc.fetch(t)
runtime.ReadMemStats(&after)
if got != n {
t.Fatalf("entries = %d, want %d", got, n)
}
return float64(after.TotalAlloc - before.TotalAlloc)
}
base, grown := allocated(small), allocated(small*scale)
if growth := grown / base; growth > maxGrowth {
t.Fatalf("%dx the entries allocated %.1fx the memory (%d MiB), want linear growth", scale, growth, int64(grown)>>20)
}
})
}
}
+21
View File
@@ -218,6 +218,27 @@ func TestSubscriptionHwidStatusDoesNotRegisterDevice(t *testing.T) {
}
}
// A 404 for a subId nobody was issued must not persist anything: the gate runs
// before the lookup, and an attacker can send any number of distinct subIds.
func TestSubscriptionHwidGateIgnoresUnknownSubID(t *testing.T) {
router, _ := initHwidSubRouter(t, 0)
for _, path := range []string{"/sub/never-issued", "/json/never-issued", "/clash/never-issued"} {
rec := requestSub(t, router, http.MethodGet, path, "device-ghost", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("%s unknown subId status = %d, want 404", path, rec.Code)
}
}
var count int64
if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", "never-issued").Count(&count).Error; err != nil {
t.Fatalf("count hwids: %v", err)
}
if count != 0 {
t.Fatalf("client_hwids rows for an unknown subId = %d, want 0", count)
}
}
func TestSubscriptionHwidStatusWithoutLimit(t *testing.T) {
router, subID := initHwidSubRouter(t, 0)
@@ -113,3 +113,25 @@ func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) {
})
}
}
// Clients stored without a subId share no subscription; a blank key must not
// select all of them. The routes never pass one, in-process callers can.
func TestBlankSubIdResolvesToNothing(t *testing.T) {
for _, subId := range []string{"", " "} {
seedSubDB(t)
seedSubInbound(t, subId, "one", 4431, 1, `{"network":"tcp","security":"none"}`)
seedSubInbound(t, subId, "two", 4432, 2, `{"network":"tcp","security":"none"}`)
seedInactiveExternalOnlySub(t, subId, "ext@e", true, time.Now().Add(time.Hour).UnixMilli())
links, emails, _, _, err := NewSubService("").GetSubs(subId, "req.example.com")
if err != nil || len(links) != 0 || len(emails) != 0 {
t.Errorf("GetSubs(%q) = %v, %v, %v; want nothing", subId, links, emails, err)
}
if out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson(subId, "req.example.com", false); err != nil || out != "" {
t.Errorf("GetJson(%q) = %q, %v; want nothing", subId, out, err)
}
if out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash(subId, "req.example.com"); err != nil || out != "" {
t.Errorf("GetClash(%q) = %q, %v; want nothing", subId, out, err)
}
}
}
+4 -1
View File
@@ -210,7 +210,10 @@ func buildJsonRoutingSpec(object map[string]any) (jsonRoutingSpec, error) {
for _, segment := range strings.Split(order, "-") {
switch segment {
case "block", "proxy", "direct":
spec.RouteOrder = append(spec.RouteOrder, segment)
// A repeat can never match (the first rule wins) and would copy every list again.
if !slices.Contains(spec.RouteOrder, segment) {
spec.RouteOrder = append(spec.RouteOrder, segment)
}
}
}
return spec, nil
+12
View File
@@ -94,6 +94,18 @@ func TestParseJsonRoutingSpecRouteOrderUnknownSegments(t *testing.T) {
}
}
// Every group copy repeats the profile's whole site lists in the rendered routing, and a
// later copy can never match, so a remote profile repeating groups must not multiply them.
func TestParseJsonRoutingSpecRouteOrderKeepsEachGroupOnce(t *testing.T) {
spec, _, err := parseJsonRoutingSpec(`{"RouteOrder":"` + strings.Repeat("proxy-direct-block-", 500) + `proxy"}`)
if err != nil {
t.Fatalf("parse: %v", err)
}
if strings.Join(spec.RouteOrder, ",") != "proxy,direct,block" {
t.Fatalf("RouteOrder = %d segments, want proxy,direct,block once each", len(spec.RouteOrder))
}
}
func TestParseJsonRoutingSpecDeeplinks(t *testing.T) {
payload := mustMarshal(t, fullRoutingPayload())
cases := []string{
+17 -13
View File
@@ -173,7 +173,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
hasBoundClosed = hasBoundClosed || closed
for _, variant := range variants {
render := func() subConfigEntry {
configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host)
configs := s.budgetedConfig(subReq, variant.inbound(subReq, inbound), client, host)
return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order}
}
ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render})
@@ -191,7 +191,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
injectExternalProxy(&clone, effectiveEps)
effective = &clone
}
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, effective, client, host)...)
inboundConfigs = append(inboundConfigs, s.budgetedConfig(subReq, effective, client, host)...)
}
if len(inboundConfigs) > 0 {
entries = append(entries, subConfigEntry{
@@ -268,7 +268,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
maps.Copy(newConfigJson, s.bakedTemplate())
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = remark
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
newConfig, _ := json.Marshal(newConfigJson)
configArray = append(configArray, newConfig)
}
}
@@ -531,6 +531,9 @@ func leastLoadCosts(balancer *model.SubBalancer, members []balMember) []any {
func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage {
prefix := fmt.Sprintf("bal-%d-", balancer.Id)
usedTags := make(map[string]bool)
// Next suffix to try per base: tags are only ever added, so every lower one
// is taken and resuming there keeps a node-sized member list linear.
nextSuffix := make(map[string]int)
var proxies []json_util.RawMessage
// Members in emission order with their owning inbound, so costs[] can
// reference the exact retagged tags assigned here.
@@ -546,9 +549,11 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
protocol, _ := outbound["protocol"].(string)
base := prefix + balancerMemberSuffix(protocol)
tag := base
for suffix := 2; usedTags[tag]; suffix++ {
suffix := max(nextSuffix[base], 2)
for ; usedTags[tag]; suffix++ {
tag = fmt.Sprintf("%s-%d", base, suffix)
}
nextSuffix[base] = suffix
usedTags[tag] = true
member := maps.Clone(outbound)
member["tag"] = tag
@@ -731,11 +736,11 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
case "trojan", "shadowsocks":
newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
case "hysteria":
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux)))
newOutbounds = append(newOutbounds, s.genHy(subReq, inbound, newStream, client, jsonMux(mux, hostMux)))
case "masque":
newOutbounds = append(newOutbounds, s.genMasque(inbound, newStream, client))
case "wireguard":
wgOutbound := s.genWireguard(inbound, client)
wgOutbound := s.genWireguard(subReq, inbound, client)
if wgOutbound == nil {
continue
}
@@ -804,6 +809,7 @@ func (s *SubJsonService) streamData(stream string, clientKey string) map[string]
delete(xhttp, "scMaxBufferedPosts")
delete(xhttp, "scStreamUpServerSecs")
delete(xhttp, "serverMaxHeaderBytes")
delete(xhttp, "h2Params")
// Values matching xray-core's own defaults stay off the wire:
// old panels seeded them into every stored config and the
// literal scMinPostsIntervalMs=30 is a DPI fingerprint (#5141).
@@ -1037,7 +1043,7 @@ func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, s
return result
}
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
func (s *SubJsonService) genHy(subReq *SubService, inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
Tag: "proxy",
@@ -1047,9 +1053,8 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
outbound.Mux = json_util.RawMessage(mux)
}
var settings, stream map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
version, _ := settings["version"].(float64)
var stream map[string]any
version, _ := subReq.linkSettings(inbound)["version"].(float64)
outbound.Settings = map[string]any{
"version": int(version),
"address": inbound.Listen,
@@ -1115,13 +1120,12 @@ func (s *SubJsonService) genMasque(inbound *model.Inbound, newStream map[string]
// mirroring genWireguardLink: the peer public key is derived from the inbound
// secretKey, the client owns the private key / tunnel address / pre-shared key,
// and the peer routes the full tunnel. Returns nil when the client has no key.
func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Client) json_util.RawMessage {
func (s *SubJsonService) genWireguard(subReq *SubService, inbound *model.Inbound, client model.Client) json_util.RawMessage {
if client.PrivateKey == "" {
return nil
}
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
inboundSettings := subReq.linkSettings(inbound)
secretKey, _ := inboundSettings["secretKey"].(string)
peer := map[string]any{
+15 -2
View File
@@ -155,6 +155,19 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
}
}
// h2Params (xray-core 26.10.31, #7295) tunes the XHTTP server's HTTP/2; clients must not get it.
func TestSubJsonServiceDropsXhttpServerOnlyH2Params(t *testing.T) {
svc := NewSubJsonService("", "", "", "", nil)
stream := svc.streamData(`{"network":"xhttp","security":"none","xhttpSettings":{"path":"/x","mode":"auto","h2Params":{"MaxConcurrentStreams":512}}}`, "")
xhttp, _ := stream["xhttpSettings"].(map[string]any)
if xhttp == nil || xhttp["path"] != "/x" {
t.Fatalf("xhttpSettings = %#v, want the inbound's path kept", stream["xhttpSettings"])
}
if h2, ok := xhttp["h2Params"]; ok {
t.Fatalf("xhttpSettings.h2Params = %#v, want the server-only key dropped", h2)
}
}
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", "", finalMask, "", nil)
@@ -520,7 +533,7 @@ func TestSubJsonServiceWireguard(t *testing.T) {
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
}
raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client)
raw := NewSubJsonService("", "", "", "", nil).genWireguard(&SubService{}, inbound, client)
if raw == nil {
t.Fatal("genWireguard returned nil for a valid wireguard client")
}
@@ -564,7 +577,7 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) {
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
client := model.Client{Email: "user"}
if raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client); raw != nil {
if raw := NewSubJsonService("", "", "", "", nil).genWireguard(&SubService{}, inbound, client); raw != nil {
t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
}
}
+7
View File
@@ -423,6 +423,13 @@ func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
refJDN := 2460311
days := int64(jdn - refJDN)
jy, jm, jd = 1402, 10, 11
// Leap years follow y % 33, so whole 33-year blocks are skipped: a node-reported expiry
// can lie hundreds of millions of years away, and the walk below goes month by month.
const jalaliCycleDays = 33*365 + 8
if cycles := days / jalaliCycleDays; cycles != 0 {
jy += int(cycles) * 33
days -= cycles * jalaliCycleDays
}
// Walk forward
for days > 0 {
+16
View File
@@ -4,6 +4,7 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -529,6 +530,8 @@ func TestGregorianToJalali(t *testing.T) {
{2024, 1, 1, 1402, 10, 11},
{2000, 3, 20, 1379, 1, 1},
{1979, 2, 11, 1357, 11, 22},
// 2124-01-01 lies three 33-year blocks and a year past the reference.
{2124, 1, 1, 1502, 10, 11},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
@@ -539,6 +542,19 @@ func TestGregorianToJalali(t *testing.T) {
}
}
// A node reports the expiry the remark renders, so a date hundreds of millions of years
// out must not walk the calendar month by month on every subscription fetch.
func TestGregorianToJalaliFarDatesTakeConstantTime(t *testing.T) {
start := time.Now()
if jy, _, _ := gregorianToJalali(292000000, 1, 1); jy < 290000000 {
t.Fatalf("year 292000000 converted to Jalali year %d", jy)
}
_, _, _ = gregorianToJalali(-292000000, 1, 1)
if took := time.Since(start); took > 100*time.Millisecond {
t.Fatalf("converting far dates took %v", took)
}
}
func TestJalaliExpireDateLabel(t *testing.T) {
if got := jalaliExpireDateLabel(0); got != "" {
t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
+48 -1
View File
@@ -39,6 +39,11 @@ const (
remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header
remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value
remoteRoutingClashMaxBody = 2 << 20 // 2 MiB
// What a document may marshal to, aliases copied out; the encoder re-indents a node's text
// once per enclosing level, hence the depth bound.
remoteRoutingClashMaxWritten = 4 << 20
remoteRoutingClashMaxDepth = 16
)
// isHappPayloadKind reports whether the kind carries a happ-payload source:
@@ -448,6 +453,9 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) {
if len(document) == 0 {
return "", nil, errors.New("Clash routing response must be a YAML map")
}
if budget := remoteRoutingClashMaxWritten; !clashFitsWritten(document, 0, &budget) {
return "", nil, errors.New("Clash routing response is too large or too deeply nested once written out")
}
hasSupportedKey := false
for key := range document {
if remoteClashKeyClasses[key] == remoteClashRouteGraph {
@@ -471,6 +479,43 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) {
return text, document, nil
}
// clashFitsWritten charges what the encoder writes for v: every string per line at its
// indentation, every node its indentation. It stops as soon as the budget is spent.
func clashFitsWritten(v any, depth int, budget *int) bool {
if depth > remoteRoutingClashMaxDepth {
return false
}
indent := 2*depth + 2
switch v := v.(type) {
case string:
*budget -= len(v) + (strings.Count(v, "\n")+1)*indent
case []byte:
// The encoder writes !!binary values as a list with one item per byte.
*budget -= len(v) * (indent + 6)
case map[string]any:
for key, child := range v {
if *budget -= len(key) + indent; *budget < 0 || !clashFitsWritten(child, depth+1, budget) {
return false
}
}
case map[any]any:
for key, child := range v {
if *budget -= indent; *budget < 0 || !clashFitsWritten(key, depth+1, budget) || !clashFitsWritten(child, depth+1, budget) {
return false
}
}
case []any:
for _, child := range v {
if *budget -= indent; *budget < 0 || !clashFitsWritten(child, depth+1, budget) {
return false
}
}
default:
*budget -= indent + 24
}
return *budget >= 0
}
func resolveIncyRoutingSource(raw string) (string, bool, error) {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil || !remote {
@@ -486,7 +531,9 @@ func newRemoteRoutingHTTPClient() *http.Client {
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 4 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
// Downloads are minutes apart, and a parked connection lives as long as the host likes.
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
return &http.Client{
Timeout: remoteRoutingHTTPTimeout,
+30
View File
@@ -3,6 +3,7 @@ package sub
import (
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -748,3 +749,32 @@ rules:
t.Fatalf("requests=%d, want 1", requests.Load())
}
}
// Aliases decode to one shared value but the encoder writes every copy out, on each Clash
// request, so a small remote document must not be accepted if it marshals out of bounds.
func TestNormalizeClashRoutingRefusesWhatMarshalsOutOfBounds(t *testing.T) {
var laughs strings.Builder
laughs.WriteString("a0: &a0 [\"" + strings.Repeat("x", 64) + "\"]\n")
for i := 1; i <= 9; i++ {
fmt.Fprintf(&laughs, "a%d: &a%d [", i, i)
for j := range 10 {
if j > 0 {
laughs.WriteString(",")
}
fmt.Fprintf(&laughs, "*a%d", i-1)
}
laughs.WriteString("]\n")
}
bomb := laughs.String() + "rule-providers:\n p: {type: inline, behavior: domain, payload: *a9}\nrules:\n - RULE-SET,p,DIRECT\n - MATCH,DIRECT\n"
if _, _, err := normalizeClashRouting([]byte(bomb)); err == nil || !strings.Contains(err.Error(), "too large") {
t.Fatalf("alias bomb = %v, want it refused as too large", err)
}
deep := "rules:\n - MATCH,DIRECT\nrule-providers:\n p: {type: inline, behavior: domain, payload: " + strings.Repeat("[", 40) + strings.Repeat("]", 40) + "}\n"
if _, _, err := normalizeClashRouting([]byte(deep)); err == nil {
t.Fatal("a document nested 40 levels deep was accepted")
}
anchored := "x: &common {type: http, behavior: domain, interval: 86400}\nrule-providers:\n ads: {<<: *common, url: \"https://example.com/ads.yaml\"}\nrules:\n - RULE-SET,ads,REJECT\n - MATCH,DIRECT\n"
if _, _, err := normalizeClashRouting([]byte(anchored)); err != nil {
t.Fatalf("an ordinary anchored document was refused: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package sub
import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
)
// maxRenderedBytes bounds what one subscription response renders: every client and
// inbound of the subId repeats the remark and endpoints, so the size is a product.
const maxRenderedBytes = 8 << 20
// renderBudget counts rendered bytes against maxRenderedBytes. The zero value
// is a fresh budget.
type renderBudget int
// take books n more bytes and reports whether they still fit. The caller drops
// what does not fit and stops rendering: a spent budget admits nothing else.
func (b *renderBudget) take(n int) bool {
if b.spent() {
return false
}
*b += renderBudget(n)
if b.spent() {
logger.Warningf("sub: rendering stopped at %d bytes, the remaining links are dropped", maxRenderedBytes)
return false
}
return true
}
func (b *renderBudget) spent() bool {
return *b > maxRenderedBytes
}
// keepLink books a rendered raw link, and its line break, against the request's budget.
func (s *SubService) keepLink(link string) bool {
return s.rendered.take(len(link) + 1)
}
// budgetedConfig renders a client's JSON configs, keeping those the request's budget admits.
func (s *SubJsonService) budgetedConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
if subReq.rendered.spent() {
return nil
}
configs := s.getConfig(subReq, inbound, client, host)
for i, config := range configs {
if !subReq.rendered.take(len(config)) {
return configs[:i]
}
}
return configs
}
// budgetedProxies renders a client's Clash proxies, keeping those the request's budget admits;
// the YAML is written only at the end, and its JSON form is about as long.
func (s *SubClashService) budgetedProxies(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []map[string]any {
if subReq.rendered.spent() {
return nil
}
proxies := s.getProxies(subReq, inbound, client, host)
for i, proxy := range proxies {
text, _ := json.Marshal(proxy)
if !subReq.rendered.take(len(text)) {
return proxies[:i]
}
}
return proxies
}

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