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

Hardening and bug fixes: node sync, subscriptions, web, outbounds, TUIC/AmneziaWG/MTProto, client lifecycle; xray-core v26.10.31
This commit is contained in:
Sanaei
2026-11-01 15:44:17 +01:00
committed by GitHub
294 changed files with 13661 additions and 2092 deletions
+2 -2
View File
@@ -115,7 +115,7 @@ jobs:
cd x-ui/bin cd x-ui/bin
# Download dependencies # 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 if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip unzip Xray-linux-64.zip
@@ -283,7 +283,7 @@ jobs:
cd x-ui\bin cd x-ui\bin
# Download Xray for Windows # 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" Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip" Remove-Item "Xray-windows-64.zip"
+1 -1
View File
@@ -52,7 +52,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
fi fi
mkdir -p build/bin mkdir -p build/bin
cd 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" unzip "Xray-linux-${XRAY_ZIP}.zip"
rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat
mv xray "${XRAY_BIN}" mv xray "${XRAY_BIN}"
+4 -3
View File
@@ -23,7 +23,7 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`,
| **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. | | **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. |
| **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. | | **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. |
| **Hysteria** | `hysteriaSettings` | QUIC-based transport — only for the **Hysteria2** protocol. | | **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). | | **MASQUE** | `masqueSettings` | CONNECT-IP over HTTP/3 or HTTP/2 — only for the **MASQUE** protocol. See [MASQUE](/docs/config/masque). |
<Callout type="info"> <Callout type="info">
@@ -132,10 +132,11 @@ enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound with
| Field | Default | Meaning | | 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. | | `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. | | `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). | | 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 The optional **Front address/port** (the stream-level `address`/`port`) is a domain
+25 -19
View File
@@ -182,10 +182,11 @@ _openapi:
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running' 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 - depth: 2
title: Zero up/down counters for many clients in one call. Loops the 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 single-reset path so each client its quota had disabled is re-enabled
inbounds and pushed to Xray/remote nodes. Returns the count of across its attached inbounds and pushed to Xray/remote nodes; a client
successfully reset clients. disabled by hand stays disabled. Returns the count of successfully reset
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' 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 - depth: 2
title: List all client groups with their member counts. Merges persisted groups title: List all client groups with their member counts. Merges persisted groups
(rows in client_groups, including empty placeholders) with the distinct (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. 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' 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 - depth: 2
title: Zero out a single client’s up/down counters. Re-enables the client across title: Zero out a single client’s up/down counters. Re-enables a client its
every attached inbound and pushes the change to Xray (or the remote quota had disabled across every attached inbound and pushes the change
node) so depleted users can connect again immediately. to Xray (or the remote node) so depleted users can connect again
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' 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 - depth: 2
title: Manually adjust a client’s upload + download counters. Useful for title: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems. migrations from external accounting systems.
@@ -457,10 +459,11 @@ _openapi:
any target inbound was running." 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 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 - 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 single-reset path so each client its quota had disabled is re-enabled
inbounds and pushed to Xray/remote nodes. Returns the count of across its attached inbounds and pushed to Xray/remote nodes; a client
successfully reset clients. disabled by hand stays disabled. Returns the count of successfully
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 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 - content: List all client groups with their member counts. Merges persisted
groups (rows in client_groups, including empty placeholders) with the groups (rows in client_groups, including empty placeholders) with the
distinct group_name values currently set on clients. Sorted 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 Creates the client_groups row if the group exists only as a derived
label. 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 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 - content: Zero out a single client’s up/down counters. Re-enables a client its
across every attached inbound and pushes the change to Xray (or the quota had disabled across every attached inbound and pushes the change
remote node) so depleted users can connect again immediately. to Xray (or the remote node) so depleted users can connect again
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 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 - content: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems. migrations from external accounting systems.
id: manually-adjust-a-clients-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 different client of that same inbound holds, or `... used by a client
on <inbound>` when the holder sits on another WireGuard or AmneziaWG on <inbound>` when the holder sits on another WireGuard or AmneziaWG
inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides 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 with `10.0.0.5/32`; a `/0` entry collides with every address of its
address. Allocation likewise skips every address inside a prefix family another client holds, and only a `0.0.0.0/0` or `::/0` default
another client holds. The same validation runs on POST 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 /panel/api/clients/{email}/attach, where a client that already carries
an address brings it along. an address brings it along.
+3 -2
View File
@@ -133,10 +133,11 @@ inbound به مقدار `decryption` نیاز دارد و outbound به `encrypt
| فیلد | پیش‌فرض | معنا | | فیلد | پیش‌فرض | معنا |
| -------------- | -------------- | ---- | | -------------- | -------------- | ---- |
| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. | | `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک)، `template` (هر API ذخیره‌سازی HTTP) یا `s3` (ذخیره‌ساز سازگار با S3). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. |
| `remoteFolder` | — | پوشه‌ای که فایل‌های نشست در آن قرار می‌گیرند. | | `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 ذخیره‌ساز. | | `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). | | تنظیمات دقیق | پیش‌فرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). |
**آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front **آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
+3 -2
View File
@@ -135,10 +135,11 @@ Mux. Панель предлагает XDRIVE только для VLESS, не с
| Поле | По умолчанию | Значение | | Поле | По умолчанию | Значение |
| -------------- | -------------- | -------- | | -------------- | -------------- | -------- |
| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. | | `service` | `google-drive` | `google-drive`, `local` (папка на диске), `template` (любой HTTP API хранилища) или `s3` (S3-совместимое хранилище). Старое написание `Google Drive` по-прежнему читается. |
| `remoteFolder` | — | Папка с файлами сессий. | | `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 хранилища. | | `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). | | тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт, Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
+3 -2
View File
@@ -124,10 +124,11 @@ XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服
| 字段 | 默认值 | 含义 | | 字段 | 默认值 | 含义 |
| -------------- | -------------- | ---- | | -------------- | -------------- | ---- |
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 | | `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)、`template`(任意 HTTP 存储 API)或 `s3`(S3 兼容存储)。旧写法 `Google Drive` 仍可读取。 |
| `remoteFolder` | — | 存放会话文件的文件夹。 | | `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` 操作。 | | `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)。 | | 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 |
可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。 可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
+5 -4
View File
@@ -1785,7 +1785,8 @@
"type": "string" "type": "string"
}, },
"forwardedPorts": { "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" "type": "string"
}, },
"group": { "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.", "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", "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": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -10163,7 +10164,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -10504,7 +10505,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [ "parameters": [
{ {
+2 -1
View File
@@ -5,6 +5,7 @@ import i18next from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import { buildAntdThemeConfig } from '@/hooks/useTheme'; import { buildAntdThemeConfig } from '@/hooks/useTheme';
import { INTERPOLATION } from '@/i18n/react';
import enUS from '../../internal/web/translation/en-US.json'; import enUS from '../../internal/web/translation/en-US.json';
if (!i18next.isInitialized) { if (!i18next.isInitialized) {
@@ -12,7 +13,7 @@ if (!i18next.isInitialized) {
lng: 'en-US', lng: 'en-US',
fallbackLng: 'en-US', fallbackLng: 'en-US',
resources: { 'en-US': { translation: enUS } }, resources: { 'en-US': { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, interpolation: INTERPOLATION,
returnNull: false, returnNull: false,
}); });
} }
+579 -570
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -38,30 +38,30 @@
"@ant-design/icons": "^6.3.4", "@ant-design/icons": "^6.3.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^6.0.0", "@hookform/resolvers": "^7.0.1",
"@noble/hashes": "^2.4.0", "@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.104.5", "@tanstack/react-query": "^5.105.1",
"@tanstack/react-query-devtools": "^5.104.5", "@tanstack/react-query-devtools": "^5.105.1",
"antd": "^6.6.7", "antd": "^6.6.8",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"i18next": "^26.5.6", "i18next": "^26.6.0",
"otpauth": "^9.5.2", "otpauth": "^9.5.2",
"persian-calendar-suite": "^1.5.6", "persian-calendar-suite": "^1.5.6",
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.90.0", "react-hook-form": "^7.91.0",
"react-i18next": "^17.1.3", "react-i18next": "^17.1.10",
"react-router": "^8.5.0", "react-router": "^8.5.1",
"swagger-ui-react": "^5.34.0", "swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32", "uplot": "^1.6.32",
"zod": "^4.6.7" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-a11y": "^10.6.3", "@storybook/addon-a11y": "^10.6.5",
"@storybook/addon-docs": "^10.6.3", "@storybook/addon-docs": "^10.6.5",
"@storybook/addon-vitest": "^10.6.3", "@storybook/addon-vitest": "^10.6.5",
"@storybook/react-vite": "^10.6.3", "@storybook/react-vite": "^10.6.5",
"@testing-library/dom": "^10.4.2", "@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
@@ -74,11 +74,11 @@
"jsdom": "^30.1.3", "jsdom": "^30.1.3",
"lint-staged": "^17.6.0", "lint-staged": "^17.6.0",
"msw": "^3.0.3", "msw": "^3.0.3",
"oxfmt": "0.74.0", "oxfmt": "0.75.0",
"oxlint": "1.89.0", "oxlint": "1.90.0",
"oxlint-tsgolint": "^7.0.2004", "oxlint-tsgolint": "^7.0.2005",
"playwright": "^1.64.1", "playwright": "^1.64.1",
"storybook": "^10.6.3", "storybook": "^10.6.5",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.3.5", "vite": "8.3.5",
"vitest": "^5.0.5" "vitest": "^5.0.5"
+5 -4
View File
@@ -1785,7 +1785,8 @@
"type": "string" "type": "string"
}, },
"forwardedPorts": { "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" "type": "string"
}, },
"group": { "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.", "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", "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": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -10163,7 +10164,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -10504,7 +10505,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [ "parameters": [
{ {
+8 -3
View File
@@ -20,14 +20,19 @@ export function useHostMutations() {
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) => 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) => { onSuccess: (msg) => {
if (msg?.success) invalidate(); if (msg?.success) invalidate();
}, },
}); });
const removeMut = useMutation({ 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) => { onSuccess: (msg) => {
if (msg?.success) invalidate(); if (msg?.success) invalidate();
}, },
@@ -35,7 +40,7 @@ export function useHostMutations() {
const setEnableMut = useMutation({ const setEnableMut = useMutation({
mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) => 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) => { onSuccess: (msg) => {
if (msg?.success) invalidate(); if (msg?.success) invalidate();
}, },
+1 -1
View File
@@ -431,7 +431,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": false, "enable": false,
"expiryTime": 0, "expiryTime": 0,
"flow": "", "flow": "",
"forwardedPorts": "", "forwardedPorts": null,
"group": "", "group": "",
"id": "", "id": "",
"keepAlive": 0, "keepAlive": 0,
+2 -1
View File
@@ -1759,7 +1759,8 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string" "type": "string"
}, },
"forwardedPorts": { "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" "type": "string"
}, },
"group": { "group": {
+1 -1
View File
@@ -426,7 +426,7 @@ export interface Client {
enable: boolean; enable: boolean;
expiryTime: number; expiryTime: number;
flow?: string; flow?: string;
forwardedPorts?: string; forwardedPorts?: string | null;
group?: string; group?: string;
id?: string; id?: string;
keepAlive?: number | string; keepAlive?: number | string;
+1 -1
View File
@@ -450,7 +450,7 @@ export const ClientSchema = z.object({
enable: z.boolean(), enable: z.boolean(),
expiryTime: z.number().int(), expiryTime: z.number().int(),
flow: z.string().optional(), flow: z.string().optional(),
forwardedPorts: z.string().optional(), forwardedPorts: z.string().nullable().optional(),
group: z.string().optional(), group: z.string().optional(),
id: z.string().optional(), id: z.string().optional(),
keepAlive: z.union([z.number().int(), z.string()]).optional(), keepAlive: z.union([z.number().int(), z.string()]).optional(),
+12 -1
View File
@@ -1,4 +1,5 @@
import i18next from 'i18next'; import i18next from 'i18next';
import type { InterpolationOptions } from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import { LanguageManager } from '@/utils'; import { LanguageManager } from '@/utils';
@@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json';
const FALLBACK = 'en-US'; 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([ const lazyModules = import.meta.glob([
'../../../internal/web/translation/*.json', '../../../internal/web/translation/*.json',
'!../../../internal/web/translation/en-US.json', '!../../../internal/web/translation/en-US.json',
@@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') {
lng: active, lng: active,
fallbackLng: FALLBACK, fallbackLng: FALLBACK,
resources: { [FALLBACK]: { translation: enUS } }, resources: { [FALLBACK]: { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, interpolation: INTERPOLATION,
returnNull: false, returnNull: false,
}); });
if (active !== FALLBACK) { if (active !== FALLBACK) {
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useFormContext, useWatch } from 'react-hook-form';
import { JsonEditor } from '@/components/form'; import { JsonEditor } from '@/components/form';
@@ -13,7 +13,7 @@ const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
local: 'pages.inbounds.form.xdrive.serviceLocal', local: 'pages.inbounds.form.xdrive.serviceLocal',
template: 'pages.inbounds.form.xdrive.serviceTemplate', 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 // Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an
// empty field keeps meaning "use the core's value". // empty field keeps meaning "use the core's value".
@@ -29,6 +29,11 @@ const TUNING_FIELDS = [
] as const; ] as const;
const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const; const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const;
const S3_SECRETS = [
['accessKeyId', true],
['secretAccessKey', true],
['sessionToken', false],
] as const;
function TemplateEditor({ function TemplateEditor({
value, value,
@@ -90,7 +95,11 @@ export default function XDriveForm() {
name={[...BASE, 'remoteFolder']} name={[...BASE, 'remoteFolder']}
required required
> >
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} /> <Input
placeholder={
service === 'local' ? '/var/lib/xdrive' : service === 's3' ? 'xdrive' : 'xray-tunnel'
}
/>
</FormField> </FormField>
{service === 'google-drive' && {service === 'google-drive' &&
GOOGLE_DRIVE_SECRETS.map((key, index) => ( GOOGLE_DRIVE_SECRETS.map((key, index) => (
@@ -103,6 +112,48 @@ export default function XDriveForm() {
{key === 'clientId' ? <Input /> : <Input.Password />} {key === 'clientId' ? <Input /> : <Input.Password />}
</FormField> </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' && ( {service === 'template' && (
<> <>
<FormField <FormField
+22 -3
View File
@@ -859,12 +859,31 @@ export function parseWireguardLink(link: string): Raw | null {
export function parseOutboundLink(link: string): Raw | null { export function parseOutboundLink(link: string): Raw | null {
const trimmed = link.trim(); const trimmed = link.trim();
if (!trimmed) return null; if (!trimmed) return null;
return ( const outbound =
parseVmessLink(trimmed) ?? parseVmessLink(trimmed) ??
parseVlessLink(trimmed) ?? parseVlessLink(trimmed) ??
parseTrojanLink(trimmed) ?? parseTrojanLink(trimmed) ??
parseShadowsocksLink(trimmed) ?? parseShadowsocksLink(trimmed) ??
parseHysteria2Link(trimmed) ?? parseHysteria2Link(trimmed) ??
parseWireguardLink(trimmed) parseWireguardLink(trimmed);
); if (outbound) dropHostLocalKeys(outbound);
return outbound;
}
// Keys that redirect the core's dial or name files on the importing host; extra=, fm= and
// vmess JSON copy provider objects verbatim, and the core binds keys case-folded.
const HOST_LOCAL_KEYS = new Set(['dialerproxy', 'certificatefile', 'keyfile', 'masterkeylog']);
function dropHostLocalKeys(value: unknown): void {
if (Array.isArray(value)) {
value.forEach(dropHostLocalKeys);
return;
}
if (!value || typeof value !== 'object') return;
const obj = value as Raw;
for (const key of Object.keys(obj)) {
// Upper-casing first folds the long s and the Kelvin sign the way Go's decoder does.
if (HOST_LOCAL_KEYS.has(key.toUpperCase().toLowerCase())) delete obj[key];
else dropHostLocalKeys(obj[key]);
}
} }
+3 -3
View File
@@ -1195,7 +1195,7 @@ export const sections: readonly Section[] = [
summary: 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.', '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: 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: [ params: [
{ {
name: 'client', name: 'client',
@@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/clients/bulkResetTraffic', path: '/panel/api/clients/bulkResetTraffic',
summary: 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}', body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}', response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
}, },
@@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/clients/resetTraffic/:email', path: '/panel/api/clients/resetTraffic/:email',
summary: 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.' }], params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
}, },
{ {
@@ -294,6 +294,7 @@ export default function InboundsPage() {
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound; if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds) { for (const candidate of dbInbounds) {
if (candidate.id === dbInbound.id) continue; if (candidate.id === dbInbound.id) continue;
if ((candidate.nodeId ?? 0) !== (dbInbound.nodeId ?? 0)) continue;
if (!['trojan', 'vless'].includes(candidate.protocol)) continue; if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string }; const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
if (candStream.network !== 'tcp') continue; 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 = [ const WFP_BLOCK_LEAK_OPTIONS = [
{ value: 'dns', label: 'dns' }, { value: 'dns', label: 'dns' },
{ value: 'windnsclient', label: 'windnsclient' },
{ value: 'misconfigtun', label: 'misconfigtun' }, { value: 'misconfigtun', label: 'misconfigtun' },
]; ];
@@ -110,7 +111,8 @@ export default function TunFields() {
> >
<Select <Select
id="tun-wfp-block-leak" id="tun-wfp-block-leak"
mode="multiple" mode="tags"
tokenSeparators={[',', ' ']}
allowClear allowClear
options={WFP_BLOCK_LEAK_OPTIONS} options={WFP_BLOCK_LEAK_OPTIONS}
/> />
@@ -69,7 +69,9 @@ export default function InboundInfoModal({
if (!clientStats?.email) return; if (!clientStats?.email) return;
setRefreshing(true); setRefreshing(true);
try { 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) { if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record'); setClientIpsText((msg?.obj as string) || 'No IP record');
setClientIpsArray([]); setClientIpsArray([]);
@@ -101,7 +103,9 @@ export default function InboundInfoModal({
const clearClientIps = useCallback(async () => { const clearClientIps = useCallback(async () => {
if (!clientStats?.email) return; 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) { if (msg?.success) {
setClientIpsArray([]); setClientIpsArray([]);
setClientIpsText(t('tgbot.noIpRecord')); setClientIpsText(t('tgbot.noIpRecord'));
@@ -223,7 +227,8 @@ export default function InboundInfoModal({
setClientIpsText(''); setClientIpsText('');
if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) { 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) { if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record'); setClientIpsText((msg?.obj as string) || 'No IP record');
return; return;
+2 -2
View File
@@ -19,8 +19,8 @@ interface GeodataSectionProps {
} }
const DEFAULT_CRON = '0 4 * * *'; const DEFAULT_CRON = '0 4 * * *';
// Xray resolves `file` inside its asset directory; plain file names only. // 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._-]+$/; const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.dat$/i;
function fileNameFromUrl(url: string): string { function fileNameFromUrl(url: string): string {
try { try {
+11 -4
View File
@@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) {
</span> </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 { function formatPct(p?: number, available = true): string {
@@ -188,11 +195,11 @@ export default function NodeList({
const [statsNode, setStatsNode] = useState<NodeRow | null>(null); const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
// Map a node GUID to its display name so a transitive sub-node can show which // Map a direct node's GUID to its display name so a transitive sub-node can show
// parent it is reached through (#4983). // which parent it is reached through (#4983); the master walks one level only.
const nameByGuid = useMemo(() => { const nameByGuid = useMemo(() => {
const m = new Map<string, string>(); 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; return m;
}, [nodes]); }, [nodes]);
@@ -54,7 +54,7 @@ export const AmneziawgServerSchema = z.object({
publicKey: z.string().optional(), publicKey: z.string().optional(),
subnetIp: z.string().default('10.8.1.0'), subnetIp: z.string().default('10.8.1.0'),
subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)), 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'), primaryDns: z.string().default('8.8.8.8'),
secondaryDns: z.string().default('8.8.4.4'), secondaryDns: z.string().default('8.8.4.4'),
externalInterface: z.string().default(''), externalInterface: z.string().default(''),
@@ -71,10 +71,10 @@ export const AmneziawgServerSchema = z.object({
// embedded userspace stack -- no Xray routing, sniffing, IPv6 or port forwarding. // embedded userspace stack -- no Xray routing, sniffing, IPv6 or port forwarding.
kernelMode: z.boolean().default(false).optional(), kernelMode: z.boolean().default(false).optional(),
// Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go): // 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. // Every handshake allocates jc packets of jmin..jmax bytes; s1-s4 are uint16.
jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)), jc: clearedToDefault(z.number().int().min(0).max(128).default(5)),
jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)), jmin: clearedToDefault(z.number().int().min(0).max(65535).default(10)),
jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)), jmax: clearedToDefault(z.number().int().min(0).max(65535).default(50)),
s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)), s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)),
s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)), s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)),
s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)), 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), userLevel: z.number().int().min(0).default(0),
autoSystemRoutingTable: z.array(z.string()).default([]), autoSystemRoutingTable: z.array(z.string()).default([]),
autoOutboundsInterface: z.string().default('auto'), 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(), autoSystemDnsToGateway: z.boolean().optional(),
autoSystemWfpBlockLeak: z.array(z.string()).optional(), autoSystemWfpBlockLeak: z.array(z.string()).optional(),
}); });
@@ -14,14 +14,14 @@ export type AmneziaWGOutboundPeer = z.infer<typeof AmneziaWGOutboundPeerSchema>;
export const AmneziaWGOutboundSettingsSchema = z.object({ export const AmneziaWGOutboundSettingsSchema = z.object({
// 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU); // 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU);
// a pinned 1420 fragments every full-size packet once S4 exceeds 20. // 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(''), secretKey: z.string().default(''),
address: z.array(z.string()).default([]), address: z.array(z.string()).default([]),
listenPort: z.number().int().min(0).max(65535).default(0), listenPort: z.number().int().min(0).max(65535).default(0),
dns: z.string().default(''), dns: z.string().default(''),
jc: z.number().int().min(0).default(0), jc: z.number().int().min(0).max(128).default(0),
jmin: z.number().int().min(0).default(40), jmin: z.number().int().min(0).max(65535).default(40),
jmax: z.number().int().min(0).default(100), 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. // 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), s1: z.number().int().min(0).max(65535).default(15),
s2: z.number().int().min(0).max(65535).default(80), 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 // 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). // 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>; 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. // 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); 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({ export const XDriveStreamSettingsSchema = z.object({
service: XDriveServiceInputSchema.default('google-drive'), service: XDriveServiceInputSchema.default('google-drive'),
remoteFolder: z.string().default(''), 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([]), secrets: z.array(z.string()).default([]),
segmentBytes: z.number().int().min(0).optional(), segmentBytes: z.number().int().min(0).optional(),
flushIntervalMs: MillisSchema.optional(), flushIntervalMs: MillisSchema.optional(),
@@ -36,6 +45,7 @@ export const XDriveStreamSettingsSchema = z.object({
eagerWindowMs: MillisSchema.optional(), eagerWindowMs: MillisSchema.optional(),
holeTimeoutMs: MillisSchema.optional(), holeTimeoutMs: MillisSchema.optional(),
template: z.record(z.string(), z.unknown()).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 // 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. // defaults are filled in here. A JSON subscription hands an inbound's value to clients.
xmux: z.record(z.string(), z.unknown()).optional(), 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), serverMaxHeaderBytes: z.number().int().min(0).default(0),
uplinkHTTPMethod: z.string().default(''), uplinkHTTPMethod: z.string().default(''),
headers: WsHeaderMapSchema.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. // Client-side fields stored on inbound for subscription propagation.
// The server listener ignores them at runtime, but the panel embeds // The server listener ignores them at runtime, but the panel embeds
// them in share-link `extra` blobs so the same xhttp config can // them in share-link `extra` blobs so the same xhttp config can
@@ -42,9 +42,10 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
['s2', 1609], ['s2', 1609],
['s3', 1637], ['s3', 1637],
['s4', 33], ['s4', 33],
['jc', 4294967296], ['jc', 129],
['jmin', 4294967296], ['jmin', 65536],
['jmax', 5000000000], ['jmax', 65536],
['mtu', 65536],
]; ];
it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => { 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], ['s2', 1608],
['s3', 1636], ['s3', 1636],
['s4', 32], ['s4', 32],
['jc', 4294967295], ['jc', 128],
['jmax', 65535],
['mtu', 65535],
]; ];
it.each(atLimit)('accepts %s exactly at its limit', (field, value) => { 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(''); 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', () => { it('omits nodeId when null', () => {
const values = rawInboundToFormValues({ ...vlessRow, nodeId: null }); const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
const payload = formValuesToWirePayload(values); 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, // 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. // uTLS fingerprint), so switching a TLS outbound to XDRIVE must keep them.
describe('OutboundFormModal XDRIVE TLS', () => { describe('OutboundFormModal XDRIVE TLS', () => {
@@ -1124,3 +1124,32 @@ describe('obfs=gecko packetSize validation', () => {
expect(packetSizeOf(link)).toBeUndefined(); expect(packetSizeOf(link)).toBeUndefined();
}); });
}); });
describe('parseOutboundLink — host-local keys', () => {
it('drops keys that redirect the dial or name host files, however they are cased', () => {
const extra = encodeURIComponent(
JSON.stringify({
downloadSettings: {
address: '8.8.8.8',
port: 443,
sockopt: { dialerProxy: 'api', tcpFastOpen: true },
tlsSettings: {
serverName: 'cdn.example.com',
certificates: [{ CERTIFICATEFILE: '/dev/zero', keyFile: '/etc/shadow' }],
maſterKeyLog: '/root/keys',
},
},
}),
);
const fm = encodeURIComponent(
JSON.stringify({ tcp: [{ type: 'header-custom', settings: { DialerProxy: 'api' } }] }),
);
const out = parseOutboundLink(
`vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=${extra}&fm=${fm}#node`,
);
const json = JSON.stringify(out);
expect(json).not.toMatch(/dialerproxy|certificatefile|keyfile|maſterkeylog/i);
expect(json).toContain('"tcpFastOpen":true');
expect(json).toContain('"serverName":"cdn.example.com"');
});
});
+2 -1
View File
@@ -3,6 +3,7 @@ import { cleanup } from '@testing-library/react';
import i18next from 'i18next'; import i18next from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import { INTERPOLATION } from '@/i18n/react';
import enUS from '../../../internal/web/translation/en-US.json'; import enUS from '../../../internal/web/translation/en-US.json';
// RTL sets this from a global beforeAll, which never runs with `globals: false`. // RTL sets this from a global beforeAll, which never runs with `globals: false`.
@@ -75,7 +76,7 @@ if (!i18next.isInitialized) {
lng: 'en-US', lng: 'en-US',
fallbackLng: 'en-US', fallbackLng: 'en-US',
resources: { 'en-US': { translation: enUS } }, resources: { 'en-US': { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, interpolation: INTERPOLATION,
returnNull: false, returnNull: false,
}); });
} }
+11
View File
@@ -56,6 +56,17 @@ describe('NetworkSettingsSchema xray-core 26.10.21 keys', () => {
expect(parsed.xdriveSettings.service).toBe('google-drive'); 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', () => { it('keeps the XDRIVE and MASQUE xmux', () => {
const xmux = { maxConnections: 2, hMaxReusableSecs: '1800-3000' }; const xmux = { maxConnections: 2, hMaxReusableSecs: '1800-3000' };
const xdrive = NetworkSettingsSchema.parse({ const xdrive = NetworkSettingsSchema.parse({
+19 -19
View File
@@ -5,33 +5,33 @@ go 1.27.3
require ( require (
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e
github.com/gin-contrib/gzip v1.2.8 github.com/gin-contrib/gzip v1.2.9
github.com/gin-contrib/sessions v1.1.2 github.com/gin-contrib/sessions v1.1.3
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.15 github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-playground/validator/v10 v10.30.5 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/goccy/go-yaml v1.19.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 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/joho/godotenv v1.5.1
github.com/klauspost/compress v1.20.3 github.com/klauspost/compress v1.20.3
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/mymmrac/telego v1.12.1 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/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/quic-go/quic-go v0.64.1 github.com/quic-go/quic-go v0.64.1
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
github.com/robfig/cron/v3 v3.0.1 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/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/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 go.uber.org/atomic v1.12.0
golang.org/x/crypto v0.58.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/sync v0.24.0
golang.org/x/sys v0.49.0 golang.org/x/sys v0.49.0
golang.org/x/text v0.43.0 golang.org/x/text v0.43.0
@@ -47,14 +47,14 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect 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/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/bytedance/sonic/loader v0.5.3 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect github.com/cloudwego/base64x v0.1.7 // indirect
github.com/ebitengine/purego v0.11.2 // indirect github.com/ebitengine/purego v0.11.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.16 // 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-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.2 // 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/leodido/go-urn v1.5.0 // indirect
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect
github.com/libp2p/go-netroute v0.4.0 // 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/mattn/go-isatty v0.0.24 // indirect
github.com/miekg/dns v1.1.73 // indirect github.com/miekg/dns v1.1.73 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pion/dtls/v3 v3.1.15 // indirect github.com/pion/dtls/v3 v3.1.15 // indirect
github.com/pion/logging v0.2.4 // indirect github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.7 // indirect github.com/pion/stun/v3 v3.1.7 // indirect
github.com/pion/transport/v4 v4.1.1 // indirect github.com/pion/transport/v4 v4.1.3 // indirect
github.com/pion/transport/v5 v5.1.1 // indirect github.com/pion/transport/v5 v5.1.4 // indirect
github.com/pires/go-proxyproto v0.16.0 // indirect github.com/pires/go-proxyproto v0.16.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
github.com/quic-go/qpack v0.6.0 // 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 go.mongodb.org/mongo-driver/v2 v2.9.2 // indirect
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
golang.org/x/arch v0.32.0 // 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/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
golang.org/x/time v0.16.0 // indirect golang.org/x/time v0.16.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // 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 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 layeh.com/gopher-luar v1.0.11 // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect
) )
+38 -38
View File
@@ -6,14 +6,14 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/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 h1:D8d8gGvwXcTxUIsE4z6F6vjy4/VZddu95vMNtOygh1c=
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk= 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.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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 h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg=
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= 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 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= 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.5 h1:gKWTBfYCIQpQHZ7LpwGf9Gj3N88ZN0G/GaB3EQhiGXE=
github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= 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 h1:ziVGAbXQtLoaot/M6YQxntGkekxz6B/DyuJVwnTbMAA=
github.com/bytedance/sonic/loader v0.5.3/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.3/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 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/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 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= 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.9 h1:MPyIubgLF/o5A4ni90weaQV0Oji7JTQto7QBVqWtmmY=
github.com/gin-contrib/gzip v1.2.8/go.mod h1:OiNBR7FxAwHN3iwoDtjj2RlypuONCk48zmtagvASfrc= github.com/gin-contrib/gzip v1.2.9/go.mod h1:vLBq0o7y73JpE7m1pngAK/BPaVB7EU56R2gQP1yCgbo=
github.com/gin-contrib/sessions v1.1.2 h1:utWRqOHT/kbqxxqF/mtutV9wfGLi1+uu5YuvWUhj/Ow= github.com/gin-contrib/sessions v1.1.3 h1:jTd6RietfxB/0brE36W6Q7XTl9die/gwD5orFZQZfh0=
github.com/gin-contrib/sessions v1.1.2/go.mod h1:qRxYwA/TYJw0wv28oFzs4gY/8G8z69id862SNgX9Gmw= github.com/gin-contrib/sessions v1.1.3/go.mod h1:4As481wI7g3rsRf6RnQYc0pS/IqBjp8vpz1Y4wudHek=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo= github.com/gin-contrib/sse v1.2.0 h1:AeYPE9hukQvAU1y7+eNvTC67PuBLK4BOduG08yv7L28=
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= 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 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= 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/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 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= 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.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= 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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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= 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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 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.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= 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 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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= 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-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 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q=
github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= 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-20261101121723-be84dd9509a0 h1:bGmNmV0W+HWMJyDOXmbY2InjV33LK59X9ClO7AU/8e8=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= 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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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.1 h1:EwaJUzRIlp/dFmIpzq4J17CA5Zi5OHo2vOGmu09hF1w=
github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= 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 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88=
github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4= 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.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
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/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 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 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= 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/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 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= 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.3 h1:Qf6b/NJRn8J0yJK/Ac/qq9oY6XcKiWiLf9Kxsi0x05o=
github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE= github.com/pion/transport/v4 v4.1.3/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE=
github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8= github.com/pion/transport/v5 v5.1.4 h1:j0E5QIPyv28YnWxa8yE9AyDmrxVnwhZ109c7Dpt48K4=
github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= 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 h1:9KYT/fM3wpjT8bdMEj/7FBcoiUG1KyzEjRYnPu+vz7A=
github.com/pires/go-proxyproto v0.16.0/go.mod h1:n7yhg8yIfNl3KgYDCwBzmeUHNpKTVnUhcWpc7rV4RW4= 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= 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/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 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= 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.10 h1:MJN6euesJx53MVEjscYeyRSRcrdIcy3sz5k9nosjldg=
github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= 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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 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= 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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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.77.0 h1:6mxBJdHabRmU4Cmw79Uqhgvjzsp6pjOaQk2t8COflzo=
github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o= 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 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= 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/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 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs=
github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY= 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.20261031132019-a4a3dff5b20c h1:XijTasBJ2fImHZo1GoAgSfHuDqloQGYHEADLy9CgrJ8=
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/go.mod h1:/Umx7vpU+A78gOOuKl6ESU6eQJ5EBRhwr5cl7+9/drw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 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= 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/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 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= 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-20261030153154-30a33513ff6d h1:5NBkmd8MXirajj6AAEpi4HFVJnVkBNPTL1ggYAKqaws=
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/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 h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= 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.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= 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.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 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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.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 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 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-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20=
golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= 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 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE=
golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= 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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 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-20261026165753-2ffab32bce7e h1:w3fy/G8OrJHYexn+u5y7hFyc8YRRIr7DCevtIOwAcGY=
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/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 h1:FLq7hcApuJeJ70J9+k0FvmXsNCwS8B2/MeAfYdy3GHY=
google.golang.org/grpc v1.86.0-dev/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM= google.golang.org/grpc v1.86.0-dev/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+1 -1
View File
@@ -34,7 +34,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
PublicKey: c.PublicKey, PublicKey: c.PublicKey,
PresharedKey: c.PreSharedKey, PresharedKey: c.PreSharedKey,
AllowedIPs: c.AllowedIPs, AllowedIPs: c.AllowedIPs,
ForwardedPorts: c.ForwardedPorts, ForwardedPorts: c.ForwardedPortsSpec(),
}) })
} }
if len(peers) == 0 { if len(peers) == 0 {
+3
View File
@@ -282,6 +282,9 @@ func ValidateAmneziaWGOutbound(tag string, raw []byte) error {
if err := ValidateObfuscation(parsed.Obfuscation()); err != nil { if err := ValidateObfuscation(parsed.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg outbound %q: %w", tag, err) 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{ for n, iv := range map[string]string{
"i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5, "i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5,
} { } {
+70 -13
View File
@@ -37,11 +37,23 @@ func randInt(min, max int) int {
// link, before AmneziaWG's own S4 transport junk is prepended. // link, before AmneziaWG's own S4 transport junk is prepended.
const DefaultMTU = 1420 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 // 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. // is prepended to every transport packet and never clamped against the MTU.
func EffectiveMTU(configuredMTU, s4 int) int { func EffectiveMTU(configuredMTU, s4 int) int {
if configuredMTU > 0 { 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) return max(DefaultMTU-max(s4, 0), 1280)
} }
@@ -159,6 +171,35 @@ func ValidateServerObfuscation(o Obfuscation31) error {
return nil 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 // ValidateObfuscation rejects malformed parameters before they are saved, so
// a bad manual entry can't break the embedded amneziawg-go device's own // a bad manual entry can't break the embedded amneziawg-go device's own
// UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or // 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 // are allowed (they fall back to a default); each accepts an integer or a
// "100-800" range. // "100-800" range.
func ValidateObfuscation(o Obfuscation31) error { func ValidateObfuscation(o Obfuscation31) error {
if o.Jmin > o.Jmax { if err := ValidateHandshakePreamble(o); err != nil {
return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax) return err
} }
// amneziawg-go parses jc/jmin/jmax as uint32 and s1-s3 as uint16 (device/uapi.go); // amneziawg-go parses s1-s3 as uint16 (device/uapi.go); a wider value makes IpcSet
// a wider value makes IpcSet reject the whole device. // reject the whole device.
for _, f := range []struct { for _, f := range []struct {
name string name string
v int v int
max int64 max int64
}{ }{
{"Jc", o.Jc, math.MaxUint32},
{"Jmin", o.Jmin, math.MaxUint32},
{"Jmax", o.Jmax, math.MaxUint32},
{"S1", o.S1, math.MaxUint16}, {"S1", o.S1, math.MaxUint16},
{"S2", o.S2, math.MaxUint16}, {"S2", o.S2, math.MaxUint16},
{"S3", o.S3, 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) 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 { if o.S4 < 0 || o.S4 > 32 {
return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4) return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
} }
@@ -263,6 +296,7 @@ func validateObfChain(spec string) error {
return nil return nil
} }
remaining := spec remaining := spec
total := 0
for { for {
start := strings.IndexByte(remaining, '<') start := strings.IndexByte(remaining, '<')
if start == -1 { if start == -1 {
@@ -279,6 +313,29 @@ func validateObfChain(spec string) error {
if !obfChainTags[fields[0]] { if !obfChainTags[fields[0]] {
return fmt.Errorf("spec %q uses unknown tag <%s>", spec, 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:] remaining = remaining[start+end+1:]
} }
} }
+33
View File
@@ -529,3 +529,36 @@ func TestValidateObfuscationRejectsMalformedSignaturePackets(t *testing.T) {
}) })
} }
} }
// Each handshake initiation sends Jc junk packets and the I1-I5 packets, allocated from
// these values; unbounded, one connection (an unauthenticated peer) killed the panel.
func TestValidateObfuscationBoundsWhatAHandshakeAllocates(t *testing.T) {
for name, mutate := range map[string]func(*Obfuscation31){
"jc past the documented range": func(o *Obfuscation31) { o.Jc = maxJunkPackets + 1 },
"jmax past a datagram": func(o *Obfuscation31) { o.Jmax = maxPreamblePacket + 1 },
"random bytes past a datagram": func(o *Obfuscation31) { o.I1 = "<r 70000>" },
"negative length": func(o *Obfuscation31) { o.I2 = "<r -1>" },
"spec past a datagram": func(o *Obfuscation31) { o.I3 = "<r 40000><rc 40000>" },
} {
o := validObfuscation()
mutate(&o)
if err := ValidateObfuscation(o); err == nil {
t.Errorf("%s: accepted", name)
}
}
o := validObfuscation()
o.I1 = "<b 0xf6ab3267fa><r 1200><t>"
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("an ordinary I1 was refused: %v", err)
}
}
// amneziawg-go stores the MTU as an int32; a wider value wraps and the device panics.
func TestMTUIsBoundedWhereItIsSavedAndWhereItIsRead(t *testing.T) {
if err := ValidateMTU(MaxMTU + 1); err == nil {
t.Fatal("an MTU past 65535 was accepted")
}
if got := EffectiveMTU(1<<31, 0); got != MaxMTU {
t.Fatalf("EffectiveMTU(2^31) = %d, want a stored oversized value capped at %d", got, MaxMTU)
}
}
+2 -2
View File
@@ -134,7 +134,7 @@ func newBenchTunnel(b *testing.B, listenPort int, serverAddr, clientAddr string)
if err != nil { if err != nil {
b.Fatalf("newUnconfiguredDevice: %v", err) 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() defer conn.Close()
switch dest.Port() { switch dest.Port() {
case benchDiscardPort: case benchDiscardPort:
@@ -210,7 +210,7 @@ func (t *benchTunnel) dial(b *testing.B, dest netip.AddrPort) *gonet.TCPConn {
func BenchmarkTunnelThroughput(b *testing.B) { func BenchmarkTunnelThroughput(b *testing.B) {
const chunkSize = 64 << 10 const chunkSize = 64 << 10
const serverAddr = "10.203.0.1" 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() defer tun.closeFn()
b.Run("upload", func(b *testing.B) { b.Run("upload", func(b *testing.B) {
+4
View File
@@ -25,6 +25,10 @@ func buildClientUAPIConfig(inst amneziawg.OutboundInstance, opts DeviceOptions)
} }
b.WriteString("replace_peers=true\n") 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 o := inst.Obfuscation
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax) 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) fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
+4
View File
@@ -199,6 +199,10 @@ func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error
// dropped instead of lingering from a previous IpcSet call. // dropped instead of lingering from a previous IpcSet call.
b.WriteString("replace_peers=true\n") 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 o := inst.Obfuscation
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax) 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) fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
+22 -14
View File
@@ -39,7 +39,7 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
t.Fatalf("generate client keypair: %v", err) 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" const wantEmail = "test-peer@example.com"
inst := amneziawg.Instance{ inst := amneziawg.Instance{
@@ -80,14 +80,9 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
// purely from the decapsulated packet, not from any routing table. // purely from the decapsulated packet, not from any routing table.
wantDest := netip.MustParseAddrPort("10.201.9.9:9999") 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() defer conn.Close()
srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String()) peer, ok := idx.Lookup(src.Addr().Unmap())
var peer amneziawg.Peer
var ok bool
if parseErr == nil {
peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
}
got <- recovered{email: peer.Email, ok: ok, dest: dest} got <- recovered{email: peer.Email, ok: ok, dest: dest}
io.Copy(io.Discard, conn) io.Copy(io.Discard, conn)
}) })
@@ -248,7 +243,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
t.Fatalf("generate header protection key: %v", err) 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" const contentPaddingAddition = "20-40"
inst := amneziawg.Instance{ inst := amneziawg.Instance{
@@ -283,7 +278,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
const wantRequest = "hello from client" const wantRequest = "hello from client"
const wantReply = "hello from server" const wantReply = "hello from server"
serverDone := make(chan error, 1) 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() defer conn.Close()
buf := make([]byte, len(wantRequest)) buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil { if _, err := io.ReadFull(conn, buf); err != nil {
@@ -434,7 +429,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
t.Fatalf("generate client keypair: %v", err) 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{ inst := amneziawg.Instance{
Id: 3, Id: 3,
@@ -461,7 +456,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
const wantRequest = "hello from client, with a trailer" const wantRequest = "hello from client, with a trailer"
const wantReply = "hello from server, with a trailer" const wantReply = "hello from server, with a trailer"
serverDone := make(chan error, 1) 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() defer conn.Close()
buf := make([]byte, len(wantRequest)) buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil { 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) { t.Run(tc.name, func(t *testing.T) {
o := base o := base
tc.mut(&o) tc.mut(&o)
@@ -604,7 +599,7 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
return // rejected before saving, which is the whole point return // rejected before saving, which is the whole point
} }
inst := amneziawg.Instance{ inst := amneziawg.Instance{
Id: 88, InterfaceName: "awgcontract", ListenPort: 58900 + i, Id: 88, InterfaceName: "awgcontract", ListenPort: 0,
PrivateKey: priv, PublicKey: pub, PrivateKey: priv, PublicKey: pub,
Address: []string{"10.198.0.1/24"}, MTU: 1420, Address: []string{"10.198.0.1/24"}, MTU: 1420,
Obfuscation: o, 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) t.Fatalf("a configured key must not be emitted as zero, got:\n%s", withKey)
} }
} }
// A row stored before the handshake bounds existed must fail its own device setup instead
// of allocating without limit at the next handshake, which ended the whole panel.
func TestBuildUAPIConfigRefusesAStoredUnboundedPreamble(t *testing.T) {
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
inst := amneziawg.Instance{PrivateKey: priv, Obfuscation: amneziawg.Obfuscation31{Jc: 1 << 31, Jmin: 10, Jmax: 20}}
if _, err := buildUAPIConfig(inst, DeviceOptions{}); err == nil {
t.Fatal("a stored Jc of 2^31 reached the device")
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) {
t.Fatalf("generate idle-peer keypair: %v", err) 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 activeEmail = "active@example.com"
const idleEmail = "idle@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 // Registering the forwarder doesn't require any peer to be configured
// yet, so this ordering is free; it's Configure's IpcSet that must // yet, so this ordering is free; it's Configure's IpcSet that must
// never run before the forwarder is registered. // 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() defer conn.Close()
io.Copy(io.Discard, conn) io.Copy(io.Discard, conn)
}) })
+32 -9
View File
@@ -71,26 +71,49 @@ var tunnelDNSCache = struct {
const ( const (
tunnelDNSCacheTTL = 60 * time.Second tunnelDNSCacheTTL = 60 * time.Second
tunnelDNSCacheMaxSize = 1024 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. // dnsCacheKey computes cache key scoped by outbound tag, server, and host.
func dnsCacheKey(tag, dnsServer, host string) string { func dnsCacheKey(tag, dnsServer, host string) string {
return tag + "|" + dnsServer + "|" + host return tag + "|" + dnsServer + "|" + host
} }
func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) { // tunnelDNSServerFor normalizes dnsServer, empty meaning the family default.
normDNS := amneziawg.NormalizeDNSServer(dnsServer) func tunnelDNSServerFor(dev *Device, dnsServer string) string {
if normDNS == "" { if normDNS := amneziawg.NormalizeDNSServer(dnsServer); normDNS != "" {
normDNS = defaultDNSFor(dev.LocalAddresses()) 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) key := dnsCacheKey(tag, normDNS, host)
now := time.Now() now := time.Now()
tunnelDNSCache.mu.Lock()
if e, ok := tunnelDNSCache.m[key]; ok && now.Before(e.exp) { select {
tunnelDNSCache.mu.Unlock() case tunnelDNSSlots <- struct{}{}:
return e.addr, nil 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) server, err := netip.ParseAddrPort(normDNS)
if err != nil { if err != nil {
+69 -1
View File
@@ -11,6 +11,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"sync" "sync"
"sync/atomic"
"time" "time"
"gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip"
@@ -218,6 +219,9 @@ func (s *socks5EgressServer) acceptLoop(ln net.Listener, closing chan struct{})
default: default:
} }
logger.Warningf("amneziawgnet: egress accept: %v", err) logger.Warningf("amneziawgnet: egress accept: %v", err)
if !waitAcceptRetry(closing) {
return
}
continue continue
} }
select { select {
@@ -479,8 +483,56 @@ type udpControl struct{ conn net.Conn }
type egressUDPSession struct { type egressUDPSession struct {
dst netip.AddrPort dst netip.AddrPort
conn *gonet.UDPConn 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 // relayUDP answers the associate request and relays datagrams to
// per-destination tunnel endpoints until the control connection closes. // per-destination tunnel endpoints until the control connection closes.
func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _ socksTarget) { 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{}} sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}}
pendingResolves := make(chan struct{}, maxPendingUDPResolves)
// Reader: strip per-datagram SOCKS5 headers and forward into the tunnel; // Reader: strip per-datagram SOCKS5 headers and forward into the tunnel;
// only the associated client's address is accepted. // 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 { if perr != nil {
continue 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 // Resolve off reader loop so slow tunnel DNS lookups do not
// stall other destinations on this association. // stall other destinations on this association.
go func(client netip.AddrPort, name string, port uint16, hdrLen int, datagram []byte) { 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) rctx, rcancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
daddr, rerr := resolveTunnelVia(rctx, dev, tag, dnsSrv, name) daddr, rerr := resolveTunnelVia(rctx, dev, tag, dnsSrv, name)
rcancel() rcancel()
@@ -601,6 +664,8 @@ func (s *udpEgressSessions) getOrDial(dev *Device, tag string, udpConn *net.UDPC
return nil return nil
} }
sess := &egressUDPSession{dst: dst, conn: conn} 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 s.m[dst] = sess
go pumpUDPEgress(udpConn, client, sess, s) go pumpUDPEgress(udpConn, client, sess, s)
return sess return sess
@@ -624,6 +689,7 @@ func (s *socks5EgressServer) deliverUDPDatagram(dev *Device, tag string, udpConn
if sess == nil { if sess == nil {
return return
} }
sess.touch()
if _, werr := sess.conn.Write(payload); werr != nil { if _, werr := sess.conn.Write(payload); werr != nil {
logger.Warningf("amneziawgnet: egress %q: send udp to %s: %v", tag, dst, werr) 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() sessions.mu.Lock()
delete(sessions.m, sess.dst) delete(sessions.m, sess.dst)
sessions.mu.Unlock() sessions.mu.Unlock()
egressUDPSessions.release(sess)
sess.conn.Close() sess.conn.Close()
}() }()
buf := make([]byte, 65536) buf := make([]byte, 65536)
@@ -646,6 +713,7 @@ func pumpUDPEgress(udpConn *net.UDPConn, client netip.AddrPort, sess *egressUDPS
if err != nil { if err != nil {
return return
} }
sess.touch()
hdr := make([]byte, 0, 3+1+16+2+n) hdr := make([]byte, 0, 3+1+16+2+n)
hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0) hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0)
if sess.dst.Addr().Is4() { if sess.dst.Addr().Is4() {
+116 -12
View File
@@ -2,11 +2,14 @@ package amneziawgnet
import ( import (
"bytes" "bytes"
"context"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/netip" "net/netip"
"runtime"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -54,12 +57,7 @@ func newPairedTunnelForTest(t *testing.T) *pairedTunnel {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pc, err := net.ListenPacket("udp", "127.0.0.1:0") listenPort := freeUDPPort(t)
if err != nil {
t.Fatal(err)
}
listenPort := pc.LocalAddr().(*net.UDPAddr).Port
pc.Close()
obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
serverInst := amneziawg.Instance{ serverInst := amneziawg.Instance{
@@ -130,12 +128,7 @@ func newPairedTunnelV6ForTest(t *testing.T) *pairedTunnel {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pc, err := net.ListenPacket("udp", "127.0.0.1:0") listenPort := freeUDPPort(t)
if err != nil {
t.Fatal(err)
}
listenPort := pc.LocalAddr().(*net.UDPAddr).Port
pc.Close()
obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
serverInst := amneziawg.Instance{ 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) { func TestDefaultDNSFor(t *testing.T) {
v4 := netip.MustParseAddr("10.8.0.2") v4 := netip.MustParseAddr("10.8.0.2")
v6 := netip.MustParseAddr("2001:db8::2") v6 := netip.MustParseAddr("2001:db8::2")
+85
View File
@@ -5,8 +5,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net" "net"
"net/netip"
"strconv" "strconv"
"testing" "testing"
"time"
) )
// holdEgressBasePort occupies EgressBasePort the way another service would; a // 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) t.Fatalf("Port() = %d, inside the panel's reserved range", port)
} }
} }
// TestEgressUDPSessionsShareOneBudget: a proxy user opens an association per
// Mux frame, so the session ceiling must hold across all of them together.
func TestEgressUDPSessionsShareOneBudget(t *testing.T) {
saved := egressMaxUDPSessions
egressMaxUDPSessions = 64
t.Cleanup(func() { egressMaxUDPSessions = saved })
local := netip.MustParseAddr("10.211.4.1")
dev := &Device{Stack: newTestStack(t, local.String()), localAddrs: []netip.Addr{local}}
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("listen udp: %v", err)
}
defer udpConn.Close()
client := netip.MustParseAddrPort("127.0.0.1:9")
dst := func(port int) netip.AddrPort {
return netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(port))
}
var associations []*udpEgressSessions
associate := func() *udpEgressSessions {
sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}}
associations = append(associations, sessions)
return sessions
}
session := func(sessions *udpEgressSessions, port int) *egressUDPSession {
sessions.mu.Lock()
defer sessions.mu.Unlock()
return sessions.m[dst(port)]
}
liveSessions := func() int {
n := 0
for _, sessions := range associations {
sessions.mu.Lock()
n += len(sessions.m)
sessions.mu.Unlock()
}
return n
}
slotsTaken := func() int {
egressUDPSessions.mu.Lock()
defer egressUDPSessions.mu.Unlock()
return len(egressUDPSessions.live)
}
waitFor := func(what string, cond func() bool) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); !cond(); time.Sleep(time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s (%d live sessions)", what, liveSessions())
}
}
}
before := slotsTaken()
others := associate()
busy := others.getOrDial(dev, "t", udpConn, client, dst(1))
if busy == nil || others.getOrDial(dev, "t", udpConn, client, dst(2)) == nil {
t.Fatal("no session with the budget unspent")
}
// Three times the budget, spread over associations that stay small.
for range 3 * egressMaxUDPSessions / 16 {
// The oldest session of all, but never the longest idle one.
GetEgressServer().deliverUDPDatagram(dev, "t", udpConn, client, others, dst(1), []byte("x"))
flood := associate()
for port := 1; port <= 16; port++ {
if flood.getOrDial(dev, "t", udpConn, client, dst(port)) == nil {
t.Fatalf("destination %d refused: the budget must evict, not drop", port)
}
}
}
waitFor("evicted sessions to retire", func() bool { return liveSessions() <= egressMaxUDPSessions })
if session(others, 2) != nil {
t.Error("the longest-idle session outlived a spent budget")
}
if session(others, 1) != busy {
t.Error("a session that kept relaying was evicted ahead of idle ones")
}
for _, sessions := range associations {
sessions.closeAll()
}
waitFor("closeAll to hand every session back", func() bool { return liveSessions() == 0 && slotsTaken() <= before })
}
+59 -3
View File
@@ -2,6 +2,7 @@ package amneziawgnet
import ( import (
"net/netip" "net/netip"
"sync"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/stack" "gvisor.dev/gvisor/pkg/tcpip/stack"
@@ -9,22 +10,76 @@ import (
"gvisor.dev/gvisor/pkg/waiter" "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 + // AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous +
// spoofing mode, so it accepts connections addressed to any destination -- // spoofing mode, so it accepts connections addressed to any destination --
// not just the stack's own configured local address -- and hands the // 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 // dynamically-arbitrary destination (recovered from the connection's own
// TransportEndpointID, not from any preconfigured routing table). This is // TransportEndpointID, not from any preconfigured routing table). This is
// the mechanism the whole embedded-AmneziaWG design depends on: what the // the mechanism the whole embedded-AmneziaWG design depends on: what the
// handler does with that destination (dial it directly, relay it into // handler does with that destination (dial it directly, relay it into
// Xray's SOCKS5 inbound, ...) is entirely up to the caller. // 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). // 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) enablePromiscuousRouting(gstack)
slots := newPeerSlots(maxTCPFlowsPerPeer)
fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) { 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) { go func(r *tcp.ForwarderRequest) {
defer slots.release(peer)
var wq waiter.Queue var wq waiter.Queue
id := r.ID() id := r.ID()
@@ -33,8 +88,9 @@ func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, d
r.Complete(true) r.Complete(true)
return return
} }
src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort)
dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort) dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
handler(gonet.NewTCPConn(&wq, ep), dest) handler(gonet.NewTCPConn(&wq, ep), src, dest)
ep.Close() ep.Close()
r.Complete(false) r.Complete(false)
}(r) }(r)
+162
View File
@@ -0,0 +1,162 @@
package amneziawgnet
import (
"net"
"net/netip"
"testing"
"time"
awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
func rawTCPSegment(src, dst netip.AddrPort, seq, ack uint32, flags header.TCPFlags) []byte {
srcIP, dstIP := tcpip.AddrFromSlice(src.Addr().AsSlice()), tcpip.AddrFromSlice(dst.Addr().AsSlice())
packet := make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize)
ip := header.IPv4(packet)
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(packet)),
TTL: 64,
Protocol: uint8(header.TCPProtocolNumber),
SrcAddr: srcIP,
DstAddr: dstIP,
})
ip.SetChecksum(^ip.CalculateChecksum())
tcp := header.TCP(packet[header.IPv4MinimumSize:])
tcp.Encode(&header.TCPFields{
SrcPort: src.Port(),
DstPort: dst.Port(),
SeqNum: seq,
AckNum: ack,
DataOffset: header.TCPMinimumSize,
Flags: flags,
WindowSize: 65535,
})
xsum := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcIP, dstIP, header.TCPMinimumSize)
tcp.SetChecksum(^tcp.CalculateChecksum(checksum.Checksum(nil, xsum)))
return packet
}
func injectFromPeer(t *testing.T, tun awgtun.Device, packet []byte) {
t.Helper()
if _, err := tun.Write([][]byte{packet}, 0); err != nil {
t.Fatalf("inject packet: %v", err)
}
}
// A peer's RST can beat the handler, and a reset endpoint has no RemoteAddr:
// deriving the source from it panicked the panel on a goroutine with no recover.
func TestTCPForwarderHandsOverSourceOfAnAlreadyResetConnection(t *testing.T) {
tun, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.9.0.1")}, 1420)
if err != nil {
t.Fatalf("createNetTUNWithStack: %v", err)
}
defer tun.Close()
peer := netip.MustParseAddrPort("10.9.0.2:40000")
target := netip.MustParseAddrPort("203.0.113.7:443")
type handedOver struct {
src, dest netip.AddrPort
remoteAddr net.Addr
}
accepted, resetSent := make(chan struct{}), make(chan struct{})
got := make(chan handedOver, 1)
AttachTCPForwarder(gstack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) {
defer conn.Close()
close(accepted)
<-resetSent
// Returns only once the stack has processed the RST.
_, _ = conn.Read(make([]byte, 1))
got <- handedOver{src: src, dest: dest, remoteAddr: conn.RemoteAddr()}
})
const peerISN = 1000
injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN, 0, header.TCPFlagSyn))
bufs, sizes := [][]byte{make([]byte, 2048)}, make([]int, 1)
if _, err := tun.Read(bufs, sizes, 0); err != nil {
t.Fatalf("read SYN-ACK: %v", err)
}
synAck := header.TCP(bufs[0][header.IPv4MinimumSize:sizes[0]])
if synAck.Flags() != header.TCPFlagSyn|header.TCPFlagAck {
t.Fatalf("first reply has flags %s, want SYN-ACK", synAck.Flags())
}
injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN+1, synAck.SequenceNumber()+1, header.TCPFlagAck))
select {
case <-accepted:
case <-time.After(5 * time.Second):
t.Fatal("handler never ran")
}
injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN+1, 0, header.TCPFlagRst))
close(resetSent)
select {
case h := <-got:
if h.remoteAddr != nil {
t.Fatalf("RemoteAddr() = %v after the RST, want nil: the test no longer reaches the reset state", h.remoteAddr)
}
if h.src != peer {
t.Errorf("src = %s, want %s", h.src, peer)
}
if h.dest != target {
t.Errorf("dest = %s, want %s", h.dest, target)
}
case <-time.After(5 * time.Second):
t.Fatal("handler never saw the reset")
}
}
// ownerBySource counts every tunnel source address as a peer of its own.
func ownerBySource(src netip.Addr) (string, bool) {
return src.String(), true
}
// The forwarder's in-flight limit is shared by the interface, so one peer's bare SYNs used
// to take every slot and leave the other clients' connections unanswered.
func TestTCPForwarderPeerCannotStarveOthers(t *testing.T) {
tun, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.8.1.1")}, 1420)
if err != nil {
t.Fatalf("createNetTUNWithStack: %v", err)
}
defer tun.Close()
flooderRange := netip.MustParsePrefix("10.8.2.0/24")
AttachTCPForwarder(gstack, func(src netip.Addr) (string, bool) {
if flooderRange.Contains(src) {
return "flooder", true
}
return "other", true
}, func(conn *gonet.TCPConn, _, _ netip.AddrPort) { conn.Close() })
other := netip.MustParseAddrPort("10.8.1.3:40000")
answered := make(chan struct{})
go func() {
bufs, sizes := [][]byte{make([]byte, 2048)}, []int{0}
for {
if _, err := tun.Read(bufs, sizes, 0); err != nil {
return
}
reply := header.IPv4(bufs[0][:sizes[0]])
seg := header.TCP(reply.Payload())
if reply.DestinationAddress() == tcpip.AddrFrom4(other.Addr().As4()) && seg.Flags() == header.TCPFlagSyn|header.TCPFlagAck {
close(answered)
return
}
}
}()
dst := netip.MustParseAddrPort("10.8.1.1:80")
for n := range 70000 {
src := netip.AddrPortFrom(netip.AddrFrom4([4]byte{10, 8, 2, byte(n)}), uint16(1024+n/256))
injectFromPeer(t, tun, rawTCPSegment(src, dst, 1000, 0, header.TCPFlagSyn))
}
injectFromPeer(t, tun, rawTCPSegment(other, dst, 1000, 0, header.TCPFlagSyn))
select {
case <-answered:
case <-time.After(5 * time.Second):
t.Fatalf("no SYN-ACK for %s; dropped at the in-flight limit: %d", other, gstack.Stats().TCP.ForwardMaxInFlightDrop.Value())
}
}
+3 -1
View File
@@ -49,7 +49,9 @@ func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) {
bestBits := -1 bestBits := -1
var bestPeer amneziawg.Peer var bestPeer amneziawg.Peer
for _, e := range idx.entries { 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 continue
} }
bestBits = e.prefix.Bits() bestBits = e.prefix.Bits()
+72
View File
@@ -0,0 +1,72 @@
package amneziawgnet
import (
"net/netip"
"slices"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestPeerIndexLookupMatchesDeviceAllowedIPs catches Lookup attributing a
// prefix two peers both carry to a peer the device accepts no such source from.
func TestPeerIndexLookupMatchesDeviceAllowedIPs(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
var peers []amneziawg.Peer
for _, p := range []amneziawg.Peer{
{Email: "first@example.com", AllowedIPs: []string{"10.202.0.2/32", "fd00:202::2/128", "0.0.0.0/0", "::/0"}},
{Email: "last@example.com", AllowedIPs: []string{"10.202.0.3/32", "fd00:202::3/128", "0.0.0.0/0", "::/0"}},
} {
if _, p.PublicKey, err = wireguard.GenerateWireguardKeypair(); err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
peers = append(peers, p)
}
dev, err := NewDevice(amneziawg.Instance{
Id: 1,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.202.0.1/24"},
MTU: 1420,
Peers: peers,
}, DeviceOptions{})
if err != nil {
t.Fatalf("NewDevice: %v", err)
}
defer dev.Close()
dump, err := dev.IpcGet()
if err != nil {
t.Fatalf("IpcGet: %v", err)
}
_, states := parseUAPIDump(dump)
idx := NewPeerIndex(peers)
for _, tc := range []struct{ addr, prefix string }{
{"10.99.0.1", "0.0.0.0/0"},
{"fd00:99::1", "::/0"},
{"10.202.0.2", "10.202.0.2/32"},
{"10.202.0.3", "10.202.0.3/32"},
{"fd00:202::2", "fd00:202::2/128"},
{"fd00:202::3", "fd00:202::3/128"},
} {
t.Run(tc.addr, func(t *testing.T) {
got, ok := idx.Lookup(netip.MustParseAddr(tc.addr))
if !ok {
t.Fatalf("Lookup(%s) found no peer", tc.addr)
}
gotHex, err := wireguard.KeyToHex(got.PublicKey)
if err != nil {
t.Fatalf("KeyToHex: %v", err)
}
if !slices.Contains(states[gotHex].allowedIPs, tc.prefix) {
t.Errorf("Lookup(%s) = %s, but the device holds %s for another peer", tc.addr, got.Email, tc.prefix)
}
})
}
}
+39 -14
View File
@@ -62,6 +62,11 @@ func (m *managed) lookupPeer(addr netip.Addr) (amneziawg.Peer, bool) {
return peers.Lookup(addr) 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) { func (m *managed) handleUDP(src, dst netip.AddrPort, payload []byte) {
peer, ok := m.lookupPeer(src.Addr()) peer, ok := m.lookupPeer(src.Addr())
if !ok { if !ok {
@@ -70,6 +75,27 @@ func (m *managed) handleUDP(src, dst netip.AddrPort, payload []byte) {
m.udpRelay.Handle(src, dst, peer.Email, payload) 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() { func (m *managed) close() {
m.portForwards.Close() m.portForwards.Close()
// Stop packet delivery before closing the relay so an in-flight handler // Stop packet delivery before closing the relay so an in-flight handler
@@ -90,6 +116,9 @@ func (m *managed) close() {
type Manager struct { type Manager struct {
mu sync.Mutex mu sync.Mutex
ifaces map[int]*managed 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 ( var (
@@ -163,7 +192,7 @@ func (m *Manager) ensureLocked(d Desired) error {
if conf == cur.uapiConfig { if conf == cur.uapiConfig {
cur.peers.Store(NewPeerIndex(inst.Peers)) cur.peers.Store(NewPeerIndex(inst.Peers))
cur.inst = inst cur.inst = inst
applyV6Aliases(diffV6Aliases(oldInst, inst)) m.applyV6Aliases(diffV6Aliases(oldInst, inst))
// buildUAPIConfig never reads ForwardedPorts (it's a panel-level // buildUAPIConfig never reads ForwardedPorts (it's a panel-level
// concept, not a WireGuard UAPI field), so a ForwardedPorts-only // concept, not a WireGuard UAPI field), so a ForwardedPorts-only
// edit renders byte-identical here and takes this exact no-op // 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 { if err := cur.dev.IpcSet(conf); err != nil {
return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err) 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.inst = inst
cur.uapiConfig = conf cur.uapiConfig = conf
applyV6Aliases(diffV6Aliases(oldInst, inst)) m.applyV6Aliases(diffV6Aliases(oldInst, inst))
cur.portForwards.Reconcile(inst) cur.portForwards.Reconcile(inst)
cur.dropReassignedFlows(oldPeers)
return nil return nil
} }
@@ -205,15 +235,10 @@ func (m *Manager) ensureLocked(d Desired) error {
structFP: structFP, structFP: structFP,
} }
next.peers.Store(NewPeerIndex(inst.Peers)) next.peers.Store(NewPeerIndex(inst.Peers))
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { AttachTCPForwarder(dev.Stack, next.peerEmail, func(conn *gonet.TCPConn, src, dest netip.AddrPort) {
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
if err != nil {
conn.Close()
return
}
// Reload for every connection: in-place reconfiguration swaps the peer // Reload for every connection: in-place reconfiguration swaps the peer
// index without reattaching handlers and may hold the lifecycle lock. // 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 { if !ok {
conn.Close() conn.Close()
return return
@@ -240,7 +265,7 @@ func (m *Manager) ensureLocked(d Desired) error {
conf, _ := buildUAPIConfig(inst, opts) conf, _ := buildUAPIConfig(inst, opts)
next.uapiConfig = conf next.uapiConfig = conf
m.ifaces[inst.Id] = next m.ifaces[inst.Id] = next
applyV6Aliases(diffV6Aliases(oldInst, inst)) m.applyV6Aliases(diffV6Aliases(oldInst, inst))
portForwards.Reconcile(inst) portForwards.Reconcile(inst)
logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id) logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
return nil return nil
@@ -280,7 +305,7 @@ func (m *Manager) Reconcile(desired []Desired) {
if _, ok := want[id]; ok { if _, ok := want[id]; ok {
continue continue
} }
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.close() cur.close()
delete(m.ifaces, id) delete(m.ifaces, id)
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
@@ -303,7 +328,7 @@ func (m *Manager) Remove(id int) {
if !exists { if !exists {
return return
} }
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.close() cur.close()
delete(m.ifaces, id) delete(m.ifaces, id)
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
@@ -314,7 +339,7 @@ func (m *Manager) StopAll() {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
for id, cur := range m.ifaces { for id, cur := range m.ifaces {
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.close() cur.close()
delete(m.ifaces, id) delete(m.ifaces, id)
} }
+117 -5
View File
@@ -1,6 +1,7 @@
package amneziawgnet package amneziawgnet
import ( import (
"context"
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
@@ -8,6 +9,7 @@ import (
"time" "time"
"github.com/amnezia-vpn/amneziawg-go/v3/device" "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/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -27,7 +29,7 @@ func TestManagerLifecycle(t *testing.T) {
inst := amneziawg.Instance{ inst := amneziawg.Instance{
Id: 3, Id: 3,
InterfaceName: "awgtest3", InterfaceName: "awgtest3",
ListenPort: 58714, ListenPort: 0,
PrivateKey: priv, PrivateKey: priv,
PublicKey: pub, PublicKey: pub,
Address: []string{"10.203.0.1/24"}, Address: []string{"10.203.0.1/24"},
@@ -115,7 +117,7 @@ func TestEnsureRebuildsWhenS4ChangesTheDerivedMTU(t *testing.T) {
inst := amneziawg.Instance{ inst := amneziawg.Instance{
Id: 9 + i, Id: 9 + i,
InterfaceName: fmt.Sprintf("awgtest%d", 9+i), InterfaceName: fmt.Sprintf("awgtest%d", 9+i),
ListenPort: 58719 + i, ListenPort: 0,
PrivateKey: priv, PrivateKey: priv,
PublicKey: pub, PublicKey: pub,
Address: []string{"10.209.0.1/24"}, Address: []string{"10.209.0.1/24"},
@@ -212,7 +214,7 @@ func TestEnsureUnchangedInstanceDoesNotResetLivePeers(t *testing.T) {
inst := amneziawg.Instance{ inst := amneziawg.Instance{
Id: 4, Id: 4,
InterfaceName: "awgtest4", InterfaceName: "awgtest4",
ListenPort: 58715, ListenPort: 0,
PrivateKey: priv, PrivateKey: priv,
PublicKey: pub, PublicKey: pub,
Address: []string{"10.204.0.1/24"}, Address: []string{"10.204.0.1/24"},
@@ -330,6 +332,116 @@ func TestForwardedPortsOnlyChangeStillReconcilesPortForwards(t *testing.T) {
conn.Close() 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 // TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace is a regression
// test for the Phase 3.7 AWG 3.0 wiring: proves that populating // test for the Phase 3.7 AWG 3.0 wiring: proves that populating
// Desired.Options with a real HeaderProtectionKey/ContentPaddingAddition // Desired.Options with a real HeaderProtectionKey/ContentPaddingAddition
@@ -353,7 +465,7 @@ func TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace(t *testing.T) {
inst := amneziawg.Instance{ inst := amneziawg.Instance{
Id: 7, Id: 7,
InterfaceName: "awgtest7", InterfaceName: "awgtest7",
ListenPort: 58717, ListenPort: 0,
PrivateKey: priv, PrivateKey: priv,
PublicKey: pub, PublicKey: pub,
Address: []string{"10.207.0.1/24"}, Address: []string{"10.207.0.1/24"},
@@ -413,7 +525,7 @@ func TestEnsureRejectsHeaderProtectionKeyWithLowS1S4(t *testing.T) {
inst := amneziawg.Instance{ inst := amneziawg.Instance{
Id: 8, Id: 8,
InterfaceName: "awgtest8", InterfaceName: "awgtest8",
ListenPort: 58718, ListenPort: 0,
PrivateKey: priv, PrivateKey: priv,
PublicKey: pub, PublicKey: pub,
Address: []string{"10.208.0.1/24"}, Address: []string{"10.208.0.1/24"},
+30
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"sync" "sync"
"syscall" "syscall"
"time"
awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun" 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. // 1024 starved simultaneous TCP slow-starts; channel.Endpoint drops silently when full.
const tunQueueDepth = 8192 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 // 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. // cap held a single client upload near 310 Mbit/s at 50 ms RTT.
const maxReceiveBuffer = 8 << 20 const maxReceiveBuffer = 8 << 20
@@ -191,10 +196,21 @@ func (t *stackTun) WriteNotify() {
view := pkt.ToView() view := pkt.ToView()
pkt.DecRef() 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 // Select against done so racing dispatch abandons packet on close
// without blocking Close or panicking on closed channel. // without blocking Close or panicking on closed channel.
select { select {
case t.incomingPacket <- view: case t.incomingPacket <- view:
case <-timer.C:
view.Release()
case <-t.done: case <-t.done:
view.Release() view.Release()
} }
@@ -226,6 +242,20 @@ func enablePromiscuousRouting(gstack *stack.Stack) {
gstack.SetSpoofing(1, true) 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. // addrFromTcpip converts a gVisor tcpip.Address to netip.Addr.
func addrFromTcpip(a tcpip.Address) netip.Addr { func addrFromTcpip(a tcpip.Address) netip.Addr {
if a.Len() == 4 { if a.Len() == 4 {
+46
View File
@@ -1,9 +1,14 @@
package amneziawgnet package amneziawgnet
import ( import (
"net/netip"
"testing" "testing"
"time"
"gvisor.dev/gvisor/pkg/buffer" "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" "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) t.Fatalf("Read allocates %v times per packet, want <=1: drained views are not being released", allocs)
} }
} }
// TestStackTunWriteSurvivesFullOutboundQueue: Write runs on amneziawg-go's per-peer
// receiver, so a reply the stack cannot queue must be dropped rather than wait for Read.
func TestStackTunWriteSurvivesFullOutboundQueue(t *testing.T) {
tun, _, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.9.0.1")}, 1420)
if err != nil {
t.Fatalf("createNetTUNWithStack: %v", err)
}
defer tun.Close()
ping := make([]byte, header.IPv4MinimumSize+header.ICMPv4MinimumSize)
ip := header.IPv4(ping)
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(ping)),
TTL: 64,
Protocol: uint8(header.ICMPv4ProtocolNumber),
SrcAddr: tcpip.AddrFrom4([4]byte{10, 9, 0, 2}),
DstAddr: tcpip.AddrFrom4([4]byte{10, 9, 0, 1}),
})
ip.SetChecksum(^ip.CalculateChecksum())
icmp := header.ICMPv4(ping[header.IPv4MinimumSize:])
icmp.SetType(header.ICMPv4Echo)
icmp.SetChecksum(^checksum.Checksum(icmp, 0))
// Nobody calls Read, so the echo replies fill incomingPacket and overflow it.
done := make(chan struct{})
go func() {
defer close(done)
for range tunQueueDepth + 8 {
_, _ = tun.Write([][]byte{ping}, 0)
}
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Write is still waiting for room in the outbound queue: the receiver would deadlock a rebind")
}
if got := len(tun.(*stackTun).incomingPacket); got != tunQueueDepth {
t.Fatalf("incomingPacket holds %d replies, want %d: the queue never filled", got, tunQueueDepth)
}
}
+68 -14
View File
@@ -270,6 +270,43 @@ func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForward
const portForwardDialTimeout = 10 * time.Second 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 // tunnelNetwork returns the gVisor network protocol number matching addr's
// address family, for dialing toward it inside the embedded stack. // address family, for dialing toward it inside the embedded stack.
func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber { 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 // 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 { type tcpForwardListener struct {
ln net.Listener ln net.Listener
closing chan struct{} ctx context.Context
cancel context.CancelFunc
} }
// listenPortForwardTCP opens a host-facing TCP listener on key.port and // 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) logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err)
return nil 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) logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening tcp :%d", inboundID, key.email, key.port)
go l.acceptLoop(gstack, inboundID, key, target) go l.acceptLoop(gstack, inboundID, key, target)
return l return l
@@ -320,24 +359,36 @@ func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key
conn, err := l.ln.Accept() conn, err := l.ln.Accept()
if err != nil { if err != nil {
select { select {
case <-l.closing: case <-l.ctx.Done():
return // intentional shutdown, not a real accept error return // intentional shutdown, not a real accept error
default: default:
} }
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err) 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() defer conn.Close()
addr, ok := target(key.email) addr, ok := target(key.email)
if !ok { if !ok {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), portForwardDialTimeout) ctx, cancel := context.WithTimeout(l.ctx, portForwardDialTimeout)
defer cancel() defer cancel()
tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr)) tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr))
if err != nil { if err != nil {
@@ -346,14 +397,17 @@ func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key port
} }
defer tunnelConn.Close() defer tunnelConn.Close()
stop := context.AfterFunc(l.ctx, func() {
conn.Close()
tunnelConn.Close()
})
defer stop()
pipeBothWays(conn, tunnelConn) pipeBothWays(conn, tunnelConn)
} }
// Close stops accepting new connections. Already-relaying connections are // Close stops accepting and cuts the connections already relaying, as
// left to finish on their own -- there's no shared state to tear down early // udpForwardListener.Close does: a withdrawn forward must not stay usable.
// for, and an abrupt cut would just look like a network error to whichever
// external client was mid-transfer.
func (l *tcpForwardListener) Close() { func (l *tcpForwardListener) Close() {
close(l.closing) l.cancel()
l.ln.Close() l.ln.Close()
} }
+170 -6
View File
@@ -2,11 +2,14 @@ package amneziawgnet
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/netip" "net/netip"
"sync" "sync"
"sync/atomic"
"syscall"
"testing" "testing"
"time" "time"
@@ -142,7 +145,7 @@ func TestPortForwardSetReconcileOpensAndClosesListeners(t *testing.T) {
gs := newTestStack(t, "10.211.0.1") gs := newTestStack(t, "10.211.0.1")
set := NewPortForwardSet(gs, 501) set := NewPortForwardSet(gs, 501)
const port = 58910 port := freeTCPUDPPort(t)
inst := amneziawg.Instance{Peers: []amneziawg.Peer{ inst := amneziawg.Instance{Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", fmt.Sprintf("%d", port), "10.211.0.2/32"), 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") gs := newTestStack(t, "10.211.1.1")
set := NewPortForwardSet(gs, 502) set := NewPortForwardSet(gs, 502)
const collidingPort = 58911 collidingPort, okPort := freeTCPUDPPort(t), freeTCPUDPPort(t)
const okPort = 58912
blocker, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", collidingPort)) blocker, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", collidingPort))
if err != nil { if err != nil {
t.Fatalf("pre-bind test port: %v", err) t.Fatalf("pre-bind test port: %v", err)
@@ -222,6 +224,44 @@ func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) {
set.Close() 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 round trip: a genuine amneziawg-go client handshakes against a
// real server Device, PortForwardSet opens a real host listener, and a real // real server Device, PortForwardSet opens a real host listener, and a real
// external-side dial (this test's own process) round-trips bytes through // 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) t.Fatalf("generate client keypair: %v", err)
} }
const listenPort = 58920 // fixed loopback test port, matches this package's existing test convention listenPort := freeUDPPort(t)
const tcpPort = 58921 tcpPort, udpPort := freeTCPUDPPort(t), freeTCPUDPPort(t)
const udpPort = 58922
const clientAddr = "10.202.0.2" const clientAddr = "10.202.0.2"
inst := amneziawg.Instance{ inst := amneziawg.Instance{
@@ -423,4 +462,129 @@ primed:
if string(gotUDP) != wantUDP { if string(gotUDP) != wantUDP {
t.Errorf("UDP round trip = %q, want %q", gotUDP, wantUDP) t.Errorf("UDP round trip = %q, want %q", gotUDP, wantUDP)
} }
// Withdrawing the forward must end the connection already relaying over
// it, not just refuse new ones.
withdrawn := inst
withdrawn.Peers = []amneziawg.Peer{
{Email: "client@test", PublicKey: clientPub, AllowedIPs: []string{clientAddr + "/32"}},
}
set.Reconcile(withdrawn)
tcpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := tcpConn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Errorf("read on the relayed connection after its forward was withdrawn = %v, want io.EOF", err)
}
}
// leavePortForwardSlots fills slots until only free remain, so a test hits the
// ceiling with a handful of flows; returns how many it holds (cleanup releases).
func leavePortForwardSlots(t *testing.T, slots chan struct{}, free int) int {
t.Helper()
held := 0
for acquirePortForwardSlot(slots) {
held++
}
for range free {
<-slots
held--
}
t.Cleanup(func() {
for range held {
<-slots
}
})
return held
}
func waitPortForwardSlots(t *testing.T, slots chan struct{}, want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for len(slots) > want {
if time.Now().After(deadline) {
t.Fatalf("slots still taken = %d, want at most %d", len(slots), want)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestPortForwardUDPSessionsAreCapped(t *testing.T) {
port := freeUDPPort(t)
const free, sources = 3, 10
held := leavePortForwardSlots(t, portForwardUDPSlots, free)
gs := newTestStack(t, "10.211.2.1")
l := listenPortForwardUDP(gs, 503, portForwardKey{email: "a@x", port: port, proto: udpForward}, func(string) (netip.Addr, bool) {
return netip.MustParseAddr("10.211.2.2"), true
})
if l == nil {
t.Fatal("listenPortForwardUDP returned nil")
}
defer l.Close()
for range sources {
conn, err := net.Dial("udp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatalf("dial udp: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte("x")); err != nil {
t.Fatalf("write udp: %v", err)
}
}
// Long enough for readLoop to have drained every datagram sent above.
most := 0
for settle := time.Now().Add(500 * time.Millisecond); time.Now().Before(settle); time.Sleep(10 * time.Millisecond) {
l.mu.Lock()
most = max(most, len(l.sessions))
l.mu.Unlock()
}
if most < free || most > portForwardMaxFlows-held {
t.Fatalf("sessions opened by %d sources = %d, want the %d free slots", sources, most, free)
}
// A slot that outlived its session would shrink the ceiling for good.
l.Close()
waitPortForwardSlots(t, portForwardUDPSlots, held)
}
func TestPortForwardTCPConnectionsAreCapped(t *testing.T) {
port := freePort(t)
const free, clients = 3, 10
held := leavePortForwardSlots(t, portForwardTCPSlots, free)
// Parks every relayed connection at its first step, holding its slot.
release := make(chan struct{})
gs := newTestStack(t, "10.211.3.1")
l := listenPortForwardTCP(gs, 504, portForwardKey{email: "a@x", port: port, proto: tcpForward}, func(string) (netip.Addr, bool) {
<-release
return netip.Addr{}, false
})
if l == nil {
t.Fatal("listenPortForwardTCP returned nil")
}
defer l.Close()
relayed := 0
for range clients {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
if err != nil {
t.Fatalf("dial tcp: %v", err)
}
defer conn.Close()
// A refused connection is closed at once; a relayed one stays silent.
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
if _, err := conn.Read(make([]byte, 1)); err != nil {
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
relayed++
}
}
}
if relayed < free || relayed > portForwardMaxFlows-held {
t.Fatalf("connections relayed out of %d = %d, want the %d free slots", clients, relayed, free)
}
close(release)
waitPortForwardSlots(t, portForwardTCPSlots, held)
} }
+6
View File
@@ -78,9 +78,14 @@ func (l *udpForwardListener) readLoop(gstack *stack.Stack, inboundID int, key po
if !ok { if !ok {
continue 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) raddr := tunnelFullAddress(addr, key.port)
conn, err := gonet.DialUDP(gstack, nil, &raddr, tunnelNetwork(addr)) conn, err := gonet.DialUDP(gstack, nil, &raddr, tunnelNetwork(addr))
if err != nil { 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) logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
continue continue
} }
@@ -109,6 +114,7 @@ func (l *udpForwardListener) pump(src netip.AddrPort, sess *udpForwardSession) {
delete(l.sessions, src) delete(l.sessions, src)
l.mu.Unlock() l.mu.Unlock()
sess.conn.Close() sess.conn.Close()
<-portForwardUDPSlots
}() }()
buf := make([]byte, 65536) buf := make([]byte, 65536)
for { for {
+24 -2
View File
@@ -386,11 +386,12 @@ type UDPRelay struct {
// session map: src.String() would allocate on every relayed datagram. // session map: src.String() would allocate on every relayed datagram.
mu sync.Mutex mu sync.Mutex
sessions map[netip.AddrPort]*socks5UDPSession sessions map[netip.AddrPort]*socks5UDPSession
slots *peerSlots
} }
// NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack. // NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack.
func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay { 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 // 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() u.mu.Unlock()
if !ok { if !ok {
if !u.slots.acquire(email) {
return
}
fresh, err := newSocks5UDPSession(u.relay.Addr, email, u.relay.Password) fresh, err := newSocks5UDPSession(u.relay.Addr, email, u.relay.Password)
if err != nil { if err != nil {
u.slots.release(email)
logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err) logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err)
return return
} }
@@ -415,12 +420,16 @@ func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte)
if existing, dup := u.sessions[src]; dup { if existing, dup := u.sessions[src]; dup {
u.mu.Unlock() u.mu.Unlock()
fresh.Close() fresh.Close()
u.slots.release(email)
sess = existing sess = existing
} else { } else {
u.sessions[src] = fresh u.sessions[src] = fresh
u.mu.Unlock() u.mu.Unlock()
sess = fresh 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 { 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 // Close tears down every open session. Call when the owning Device is
// closed. // closed.
func (u *UDPRelay) Close() { func (u *UDPRelay) Close() {
+37 -9
View File
@@ -77,7 +77,7 @@ func TestSocksRelayAgainstRealXray(t *testing.T) {
t.Fatalf("generate client keypair: %v", err) t.Fatalf("generate client keypair: %v", err)
} }
const listenPort = 58715 listenPort := freeUDPPort(t)
inst := amneziawg.Instance{ inst := amneziawg.Instance{
Id: 4, Id: 4,
InterfaceName: "awgtest4", InterfaceName: "awgtest4",
@@ -161,13 +161,8 @@ func TestSocksRelayAgainstRealXray(t *testing.T) {
udpRelay := NewUDPRelay(relay, dev.Stack) udpRelay := NewUDPRelay(relay, dev.Stack)
defer udpRelay.Close() defer udpRelay.Close()
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) {
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String()) peer, ok := idx.Lookup(src.Addr().Unmap())
if err != nil {
conn.Close()
return
}
peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap())
if !ok { if !ok {
conn.Close() conn.Close()
return return
@@ -310,7 +305,7 @@ func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
} }
const wantEmail = "manager-e2e-peer@example.com" const wantEmail = "manager-e2e-peer@example.com"
const listenPort = 58716 listenPort := freeUDPPort(t)
const inboundID = 5 const inboundID = 5
tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP) tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
@@ -600,6 +595,39 @@ func freePort(t *testing.T) int {
return l.Addr().(*net.TCPAddr).Port 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) { func waitForPort(t *testing.T, port int) {
t.Helper() t.Helper()
deadline := time.Now().Add(15 * time.Second) deadline := time.Now().Add(15 * time.Second)
+73
View File
@@ -297,3 +297,76 @@ func TestNewSocks5UDPSessionGivesUpOnSilentServer(t *testing.T) {
t.Fatal("newSocks5UDPSession still blocked 2s past the associate deadline: a silent SOCKS5 server parks the tunnel's delivery path") t.Fatal("newSocks5UDPSession still blocked 2s past the associate deadline: a silent SOCKS5 server parks the tunnel's delivery path")
} }
} }
// startFakeSocks5UDP serves just enough no-auth SOCKS5 for UDP ASSOCIATE to
// succeed, pointing every association at one UDP socket nobody reads.
func startFakeSocks5UDP(t *testing.T) string {
t.Helper()
sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("listen udp: %v", err)
}
t.Cleanup(func() { _ = sink.Close() })
port := sink.LocalAddr().(*net.UDPAddr).Port
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen tcp: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
var greeting [4]byte
var associate [10]byte
if _, err := io.ReadFull(c, greeting[:]); err != nil {
return
}
_, _ = c.Write([]byte{0x05, 0x00})
if _, err := io.ReadFull(c, associate[:]); err != nil {
return
}
_, _ = c.Write([]byte{0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, byte(port >> 8), byte(port)})
_, _ = io.Copy(io.Discard, c)
}()
}
}()
return ln.Addr().String()
}
// Each fresh tunnel source port bought a SOCKS5 association and a UDP socket with no
// per-peer bound, so one client could take the host's UDP ports from everyone.
func TestUDPRelayCapsSessionsPerPeer(t *testing.T) {
relay := NewUDPRelay(SocksRelay{Addr: startFakeSocks5UDP(t), Password: "x"}, nil)
defer relay.Close()
dst := netip.MustParseAddrPort("192.0.2.1:53")
for n := 1; n < maxUDPSessionsPerPeer; n++ {
relay.slots.acquire("greedy")
}
relay.Handle(netip.MustParseAddrPort("10.8.1.5:1000"), dst, "greedy", []byte("x"))
relay.Handle(netip.MustParseAddrPort("10.8.1.5:1001"), dst, "greedy", []byte("x"))
relay.Handle(netip.MustParseAddrPort("10.8.1.6:1000"), dst, "modest", []byte("x"))
relay.mu.Lock()
_, last := relay.sessions[netip.MustParseAddrPort("10.8.1.5:1000")]
_, excess := relay.sessions[netip.MustParseAddrPort("10.8.1.5:1001")]
_, other := relay.sessions[netip.MustParseAddrPort("10.8.1.6:1000")]
relay.mu.Unlock()
if !last || excess || !other {
t.Fatalf("sessions: greedy's last allowed = %v, greedy's excess = %v, modest's = %v; want true, false, true", last, excess, other)
}
relay.Close()
deadline := time.Now().Add(2 * time.Second)
for !relay.slots.acquire("greedy") {
if time.Now().After(deadline) {
t.Fatal("closing greedy's session never released its slot")
}
time.Sleep(10 * time.Millisecond)
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ func TestNewDeviceUDPHandlerAndReply(t *testing.T) {
t.Fatalf("generate client keypair: %v", err) 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 wantEmail = "udp-test-peer@example.com"
const echoPayload = "hello-from-client" const echoPayload = "hello-from-client"
+23 -10
View File
@@ -117,16 +117,30 @@ const ipCommandTimeout = 3 * time.Second
// aliased at once is harmless). Never surfaces an error — an alias failing // aliased at once is harmless). Never surfaces an error — an alias failing
// only narrows that one peer's own outbound-source-identity feature, never // only narrows that one peer's own outbound-source-identity feature, never
// a reason to fail the tunnel or its SOCKS5 relay. // a reason to fail the tunnel or its SOCKS5 relay.
func applyV6Aliases(add, remove []v6Alias) { func (m *Manager) applyV6Aliases(add, remove []v6Alias) {
for _, a := range add { if m.v6Owned == nil {
addV6Alias(a) 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 { 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) 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) ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
defer cancel() defer cancel()
// nodad: this address is a specific peer's own admin-assigned identity, // 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") stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad")
if err == nil { if err == nil {
logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface) logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface)
return return true
} }
if strings.Contains(stderr, "File exists") { if strings.Contains(stderr, "File exists") {
// Already the desired end state -- most commonly hit once, harmlessly, // Already there: the host's own address, or an alias an ungraceful
// right after an ungraceful panel restart (the OS-level alias from // restart left behind -- indistinguishable, so neither is ours.
// before the crash outlives the process; the in-memory managed map return false
// doesn't).
return
} }
logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr)) 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) { func removeV6Alias(a v6Alias) {
+63 -5
View File
@@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "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 { func peerWithIPs(email string, ips ...string) amneziawg.Peer {
@@ -264,14 +265,71 @@ func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) {
} }
return "", nil 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::99", Iface: "eth0"}},
[]v6Alias{{Addr: "fd86::2", Iface: "eth0"}}, []v6Alias{{Addr: "fd86::2", Iface: "eth0"}},
) )
if len(*calls) != 2 { if len(*calls) != 3 {
t.Fatalf("expected exactly 2 calls, got %d", len(*calls)) t.Fatalf("expected exactly 3 calls, got %d", len(*calls))
} }
if order[0] != "add" || order[1] != "del" { if order[1] != "add" || order[2] != "del" {
t.Fatalf("expected add before del, got order=%v", order) t.Fatalf("expected add before del, got order=%v", order[1:])
}
}
// An address the host already held (::1 on lo, its own /128) answers the add
// with EEXIST; tearing the inbound down must leave it in place.
func TestManagerRemoveLeavesHostOwnedAddressInPlace(t *testing.T) {
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
if args[2] == "add" && args[3] == "::1/128" {
return "RTNETLINK answers: File exists", errors.New("exit status 2")
}
return "", nil
})
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
_, foreignPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
_, hostOwnPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
m := &Manager{ifaces: map[int]*managed{}}
defer m.StopAll()
inst := amneziawg.Instance{
Id: 7,
InterfaceName: "awgtest7",
ListenPort: 0,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.207.0.1/24", "fd86:ea04:1115::1/64"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20},
IPv6Enabled: true,
IPv6ExternalInterface: "lo",
Peers: []amneziawg.Peer{
{Email: "foreign", PublicKey: foreignPub, AllowedIPs: []string{"10.207.0.2/32", "2001:db8:beef::1/128"}},
{Email: "host-own", PublicKey: hostOwnPub, AllowedIPs: []string{"10.207.0.3/32", "::1/128"}},
},
}
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure: %v", err)
}
m.Remove(inst.Id)
var deleted []string
for _, c := range *calls {
if c[2] == "del" {
deleted = append(deleted, c[3])
}
}
if len(deleted) != 1 || deleted[0] != "2001:db8:beef::1/128" {
t.Fatalf("deleted %v, want only the alias this process added (2001:db8:beef::1/128)", deleted)
} }
} }
+119 -2
View File
@@ -405,7 +405,10 @@ func migrateSyncOrphanColumns() error {
if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") { if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") {
return nil 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 // The client identity checks match emails case-insensitively; without an
@@ -1527,7 +1530,7 @@ func runSeeders(isUsersEmpty bool) error {
} }
if empty && isUsersEmpty { 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 { for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err 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 !slices.Contains(seedersHistory, "OutboundRemovedKeysFix") {
if err := migrateOutboundRemovedKeys(); err != nil { if err := migrateOutboundRemovedKeys(); err != nil {
return err 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 { if err := seedHostsFromExternalProxy(); err != nil {
return err 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 { func migrateOutboundRemovedKeys() error {
var setting model.Setting var setting model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
@@ -3133,6 +3237,11 @@ func seedClientsFromInboundJSON() error {
continue continue
} }
model.NormalizeLegacyClientFields(obj) 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) blob, err := json.Marshal(obj)
if err != nil { if err != nil {
continue continue
@@ -3154,6 +3263,14 @@ func seedClientsFromInboundJSON() error {
if err := tx.Create(incoming).Error; err != nil { if err := tx.Create(incoming).Error; err != nil {
return err 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 byEmail[email] = incoming
row = incoming row = incoming
} else { } else {
+197
View File
@@ -2,6 +2,7 @@ package database
import ( import (
"encoding/json" "encoding/json"
"fmt"
"path/filepath" "path/filepath"
"regexp" "regexp"
"testing" "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) { func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
dbDir := t.TempDir() dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir) 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) { func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) {
dbDir := t.TempDir() dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir) t.Setenv("XUI_DB_FOLDER", dbDir)
@@ -295,3 +387,108 @@ func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
} }
} }
} }
// A padded group name was listed as its own group while every action on it hit the trimmed
// one, so the upgrade folds stored names onto their trimmed form.
func TestClientGroupNamesTrimmedOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
for email, group := range map[string]string{"a@x": "vip ", "b@x": "\tvip", "c@x": "vip"} {
if err := db.Create(&model.ClientRecord{Email: email, Group: group, Enable: true}).Error; err != nil {
t.Fatalf("seed %s: %v", email, err)
}
}
if err := db.Where("seeder_name = ?", "ClientGroupNameTrim").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var groups []string
if err := db.Model(&model.ClientRecord{}).Distinct().Pluck("group_name", &groups).Error; err != nil {
t.Fatalf("read groups: %v", err)
}
if len(groups) != 1 || groups[0] != "vip" {
t.Fatalf("groups after upgrade = %q, want [vip]", groups)
}
}
// Requests naming a subId no client owns stored HWID rows that nothing ever reads; the
// upgrade drops them and keeps the rows of real subscriptions.
func TestOwnerlessClientHwidsPurgedOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.ClientRecord{Email: "a@x", SubID: "owned", Enable: true}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
for i, sub := range []string{"owned", "junk-1", "junk-2"} {
if err := db.Create(&model.ClientHwid{SubID: sub, HwidHash: fmt.Sprintf("h%d", i), FirstSeen: 1, LastSeen: 1}).Error; err != nil {
t.Fatalf("seed hwid %s: %v", sub, err)
}
}
if err := db.Where("seeder_name = ?", "OwnerlessClientHwidPurge").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
var subs []string
if err := db.Model(&model.ClientHwid{}).Pluck("sub_id", &subs).Error; err != nil {
t.Fatalf("read hwids: %v", err)
}
if len(subs) != 1 || subs[0] != "owned" {
t.Fatalf("hwid rows after upgrade = %q, want [owned]", subs)
}
}
// The editor fills forwardedPorts from the record, and an emptied field now withdraws the
// forwards: a record left at its creation value would drop live forwards on the next save.
func TestClientForwardedPortsBackfilledOnUpgrade(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.ClientRecord{Email: "fwd@x", Enable: true}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.ClientRecord{Email: "kept@x", Enable: true, ForwardedPorts: "9000"}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.Inbound{
Tag: "awg", Port: 51820, Protocol: model.AmneziaWG, Enable: true,
Settings: `{"clients":[{"email":"fwd@x","forwardedPorts":"8022"},{"email":"kept@x","forwardedPorts":"9001"}]}`,
}).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := db.Where("seeder_name = ?", "ClientForwardedPortsBackfill").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("reopen InitDB: %v", err)
}
for email, want := range map[string]string{"fwd@x": "8022", "kept@x": "9000"} {
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("read %s: %v", email, err)
}
if rec.ForwardedPorts != want {
t.Errorf("%s forwardedPorts = %q after upgrade, want %q", email, rec.ForwardedPorts, want)
}
}
}
+41 -5
View File
@@ -4,6 +4,7 @@ package model
import ( import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -356,7 +357,9 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
if listen == "" { if listen == "" {
listen = "0.0.0.0" 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) protocol := string(i.Protocol)
settings := i.Settings settings := i.Settings
switch i.Protocol { switch i.Protocol {
@@ -705,6 +708,16 @@ func ValidMtprotoAdTag(tag string) bool {
return err == nil 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 // StripMtprotoInboundSecret removes the vestigial inbound-level `secret` from an
// mtproto inbound's settings JSON. MTProto is multi-client: every secret lives on // 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 // 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"` AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
PreSharedKey string `json:"preSharedKey,omitempty"` PreSharedKey string `json:"preSharedKey,omitempty"`
KeepAlive *KeepAliveValue `json:"keepAlive,omitempty"` // Numeric values or AmneziaWG ranges; 0 disables, omission preserves. 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"` Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"` AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
Email string `json:"email"` // Client email identifier 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 // 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. // deleting; orphans from any other cause stay at zero and are never reaped.
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"` 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" } func (ClientRecord) TableName() string { return "clients" }
@@ -1269,6 +1284,27 @@ func keepAliveValue(value *KeepAliveValue) KeepAliveValue {
return *value 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 { func (c *Client) ToRecord() *ClientRecord {
rec := &ClientRecord{ rec := &ClientRecord{
Email: c.Email, Email: c.Email,
@@ -1284,7 +1320,7 @@ func (c *Client) ToRecord() *ClientRecord {
ExpiryTime: c.ExpiryTime, ExpiryTime: c.ExpiryTime,
Enable: c.Enable, Enable: c.Enable,
TgID: c.TgID, TgID: c.TgID,
Group: c.Group, Group: strings.TrimSpace(c.Group),
Comment: c.Comment, Comment: c.Comment,
Reset: c.Reset, Reset: c.Reset,
ResetDay: c.ResetDay, ResetDay: c.ResetDay,
@@ -1300,7 +1336,7 @@ func (c *Client) ToRecord() *ClientRecord {
AllowedIPs: strings.Join(c.AllowedIPs, ","), AllowedIPs: strings.Join(c.AllowedIPs, ","),
PreSharedKey: c.PreSharedKey, PreSharedKey: c.PreSharedKey,
KeepAlive: keepAliveValue(c.KeepAlive), KeepAlive: keepAliveValue(c.KeepAlive),
ForwardedPorts: c.ForwardedPorts, ForwardedPorts: c.ForwardedPortsSpec(),
Secret: c.Secret, Secret: c.Secret,
AdTag: c.AdTag, AdTag: c.AdTag,
} }
@@ -1360,7 +1396,7 @@ func (r *ClientRecord) ToClient() *Client {
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs), AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
PreSharedKey: r.PreSharedKey, PreSharedKey: r.PreSharedKey,
KeepAlive: keepAlivePtr(r.KeepAlive), KeepAlive: keepAlivePtr(r.KeepAlive),
ForwardedPorts: r.ForwardedPorts, ForwardedPorts: nonEmptyForwardedPorts(r.ForwardedPorts),
Secret: r.Secret, Secret: r.Secret,
AdTag: r.AdTag, 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) { func TestStripMtprotoInboundAdTag(t *testing.T) {
in := `{"adTag":"0123456789abcdef0123456789abcdef","clients":[{"email":"a","adTag":"fedcba9876543210fedcba9876543210"}]}` in := `{"adTag":"0123456789abcdef0123456789abcdef","clients":[{"email":"a","adTag":"fedcba9876543210fedcba9876543210"}]}`
out, changed := StripMtprotoInboundAdTag(in) out, changed := StripMtprotoInboundAdTag(in)
@@ -16,6 +16,8 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) {
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=", PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=",
KeepAlive: KeepAlivePtr(25), KeepAlive: KeepAlivePtr(25),
ForwardedPorts: ForwardedPortsPtr("80,8000-8100"),
} }
rec := c.ToRecord() rec := c.ToRecord()
@@ -32,6 +34,7 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) {
{"PublicKey", c.PublicKey, got.PublicKey}, {"PublicKey", c.PublicKey, got.PublicKey},
{"PreSharedKey", c.PreSharedKey, got.PreSharedKey}, {"PreSharedKey", c.PreSharedKey, got.PreSharedKey},
{"KeepAlive", c.KeepAliveSeconds(), got.KeepAliveSeconds()}, {"KeepAlive", c.KeepAliveSeconds(), got.KeepAliveSeconds()},
{"ForwardedPorts", c.ForwardedPortsSpec(), got.ForwardedPortsSpec()},
} { } {
if f.a != f.b { if f.a != f.b {
t.Errorf("%s round-trip = %v, want %v", f.name, f.b, f.a) 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) { func TestClientRecordEmptyAllowedIPs(t *testing.T) {
rec := &ClientRecord{Email: "bob@example.test", AllowedIPs: ""} rec := &ClientRecord{Email: "bob@example.test", AllowedIPs: ""}
if got := rec.ToClient().AllowedIPs; got != nil { if got := rec.ToClient().AllowedIPs; got != nil {
+13 -5
View File
@@ -169,8 +169,9 @@ func GetManager() *Manager {
// InstanceFromInbound derives a desired Instance from an mtproto inbound, // InstanceFromInbound derives a desired Instance from an mtproto inbound,
// building one named secret per active client. Secrets are healed on save (see // 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 // normalizeMtprotoSecret) and by the migration, so they are read as-is here to
// keep the fingerprint stable across reconciles. Returns false when the inbound // keep the fingerprint stable across reconciles; a client whose stored values
// is not a usable mtproto inbound or has no active client secret to serve. // 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) { func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.MTProto { if ib == nil || ib.Protocol != model.MTProto {
return Instance{}, false return Instance{}, false
@@ -205,15 +206,15 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
} }
secrets := make([]SecretEntry, 0, len(parsed.Clients)) secrets := make([]SecretEntry, 0, len(parsed.Clients))
for _, c := range parsed.Clients { for _, c := range parsed.Clients {
if !c.Enable || c.Secret == "" || c.Email == "" { if !c.Enable || c.Email == "" || !model.ValidMtprotoSecret(c.Secret) {
continue continue
} }
entry := SecretEntry{Name: c.Email, Secret: c.Secret, AdTag: usableAdTag(c.AdTag)} entry := SecretEntry{Name: c.Email, Secret: c.Secret, AdTag: usableAdTag(c.AdTag)}
if c.TotalGB > 0 { if c.TotalGB > 0 {
entry.QuotaBytes = c.TotalGB entry.QuotaBytes = min(c.TotalGB, maxQuotaBytes)
} }
if c.ExpiryTime > 0 { if c.ExpiryTime > 0 {
entry.ExpiresUnix = c.ExpiryTime / 1000 entry.ExpiresUnix = min(c.ExpiryTime/1000, maxExpiresUnix)
} }
secrets = append(secrets, entry) secrets = append(secrets, entry)
} }
@@ -253,6 +254,13 @@ func usableAdTag(tag string) string {
return tag 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 // 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. // configuration changed. A no-op when the desired process is already running.
func (m *Manager) Ensure(inst Instance) error { func (m *Manager) Ensure(inst Instance) error {
+37 -1
View File
@@ -76,7 +76,7 @@ func TestInstanceFromInbound(t *testing.T) {
t.Fatal("an inbound with no active secret should not produce an instance") 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) badInst, ok := InstanceFromInbound(badTags)
if !ok { if !ok {
t.Fatal("expected a usable instance despite a malformed ad tag") 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) { func TestRenderConfig(t *testing.T) {
// A bare instance emits only the required keys, api-bind-to, and the // A bare instance emits only the required keys, api-bind-to, and the
// [secrets] section, with no optional keys and no [domain-fronting]. // [secrets] section, with no optional keys and no [domain-fronting].
+19 -29
View File
@@ -15,7 +15,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" 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") variants, closed := subReq.clientHostVariants(inbound, client, "clash")
hasBoundClosed = hasBoundClosed || closed hasBoundClosed = hasBoundClosed || closed
for _, variant := range variants { 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}) ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
} }
continue continue
@@ -106,7 +107,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
injectExternalProxy(&clone, effectiveEps) injectExternalProxy(&clone, effectiveEps)
effective = &clone effective = &clone
} }
proxies = append(proxies, s.getProxies(subReq, effective, client, host)...) proxies = append(proxies, s.budgetedProxies(subReq, effective, client, host)...)
} }
} }
if subReq.hostsFirst { if subReq.hostsFirst {
@@ -668,8 +669,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
return nil return nil
} }
var inboundSettings map[string]any inboundSettings := subReq.linkSettings(inbound)
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
secretKey, _ := inboundSettings["secretKey"].(string) secretKey, _ := inboundSettings["secretKey"].(string)
proxy := map[string]any{ 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 { func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
inst, ok := tuic.InstanceFromInbound(inbound) inst := subReq.tuicInstanceForLink(inbound)
if !ok { if inst == nil {
return nil return nil
} }
uuid := client.ID uuid := client.ID
password := client.Password password := client.Password
for _, c := range inst.Clients { if c := inst.byEmail[client.Email]; c != nil {
if c.Email == client.Email { if uuid == "" {
if uuid == "" { uuid = c.UUID
uuid = c.UUID }
} if password == "" {
if password == "" { password = c.Password
password = c.Password
}
break
} }
} }
if uuid == "" || 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 // amneziaWGClientAddresses prefers this inbound's own settings entry over the
// shared clients.wg_allowed_ips column, which for an identity attached to both // 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. // a wireguard and an amneziawg inbound holds the other one's address.
func amneziaWGClientAddresses(settingsClients []model.Client, client model.Client) []string { func amneziaWGClientAddresses(subReq *SubService, inbound *model.Inbound, client model.Client) []string {
for i := range settingsClients { if own, ok := subReq.clientForLink(inbound, client.Email); ok && len(own.AllowedIPs) > 0 {
if !strings.EqualFold(settingsClients[i].Email, client.Email) { return own.AllowedIPs
continue
}
if len(settingsClients[i].AllowedIPs) > 0 {
return settingsClients[i].AllowedIPs
}
break
} }
return client.AllowedIPs return client.AllowedIPs
} }
@@ -809,11 +800,10 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model
return nil return nil
} }
var parsed amneziawg.InboundSettings server := subReq.amneziaWGServerForLink(inbound)
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil { if server == nil {
return nil return nil
} }
server := parsed.Server
proxy := map[string]any{ proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, ""), "name": subReq.endpointRemark(inbound, client.Email, ep, ""),
@@ -834,7 +824,7 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model
proxy["persistent-keepalive"] = ka proxy["persistent-keepalive"] = ka
} }
for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) { for _, addr := range amneziaWGClientAddresses(subReq, inbound, client) {
ip := stripCIDR(addr) ip := stripCIDR(addr)
if ip == "" { if ip == "" {
continue continue
+44 -5
View File
@@ -3,7 +3,10 @@ package sub
import ( import (
"reflect" "reflect"
"regexp" "regexp"
"strconv"
"strings" "strings"
"unicode"
"unicode/utf8"
"github.com/goccy/go-yaml" "github.com/goccy/go-yaml"
) )
@@ -41,30 +44,66 @@ func yamlScalarIsAmbiguous(s string) bool {
return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s) 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 { func quoteAmbiguousYAMLScalars(v any) any {
if v == nil { if v == nil {
return nil return nil
} }
if s, ok := v.(string); ok { if s, ok := v.(string); ok {
if yamlScalarIsAmbiguous(s) { return quoteYAMLString(s)
return yamlQuotedString(s)
}
return s
} }
rv := reflect.ValueOf(v) rv := reflect.ValueOf(v)
switch rv.Kind() { switch rv.Kind() {
case reflect.Map: case reflect.Map:
out := make(map[string]any, rv.Len()) out := make(map[string]any, rv.Len())
escapedKeys := false
iter := rv.MapRange() iter := rv.MapRange()
for iter.Next() { for iter.Next() {
key, ok := iter.Key().Interface().(string) key, ok := iter.Key().Interface().(string)
if !ok { if !ok {
return v return v
} }
escapedKeys = escapedKeys || yamlNeedsEscaping(key)
out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface()) 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: case reflect.Slice, reflect.Array:
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 { if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
return v return v
+29
View File
@@ -127,3 +127,32 @@ func TestQuotedScalarEscapesQuotes(t *testing.T) {
t.Errorf("password round-tripped to %#v\n%s", got, out) t.Errorf("password round-tripped to %#v\n%s", got, out)
} }
} }
// Node-reported paths, remarks and header names reach the profile; a line break the
// encoder writes bare would start a top-level key of the subscriber's Clash config.
func TestLineBreaksStayInsideTheirScalar(t *testing.T) {
for _, value := range []string{
"/ws\r\n\r\nmode:\r direct", "/ws\n\n\n\n\n\n\rmode:\r direct", "a\nb\nc\u0085mode: direct",
"a\nb\nc
mode: direct", "a\nb\nc
mode: direct", "2026-01-01\n\n\rmode: direct",
} {
profile := map[string]any{"proxies": []any{map[string]any{
"name": "n",
"ws-opts": map[string]any{"path": value, "headers": map[string]any{value: "v"}},
}}}
out, err := marshalClashYAML(profile)
if err != nil {
t.Fatalf("marshal %q: %v", value, err)
}
if strings.ContainsAny(string(out), "\r\u0085

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

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