mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
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:
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) — домен-фронт,
|
||||
|
||||
@@ -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 主机名。
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+579
-570
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -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"
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -431,7 +431,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"enable": false,
|
||||
"expiryTime": 0,
|
||||
"flow": "",
|
||||
"forwardedPorts": "",
|
||||
"forwardedPorts": null,
|
||||
"group": "",
|
||||
"id": "",
|
||||
"keepAlive": 0,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
} {
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user