mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-15 22:57:13 +00:00
Compare commits
36 Commits
v3.8.0
..
dev-latest
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ce2bcecb | |||
| c9e62451e6 | |||
| 5008906c4c | |||
| 5fe4f241c1 | |||
| e8bab17c2f | |||
| 14b92fbcff | |||
| 1d85ef138e | |||
| 3fa44915c1 | |||
| 7fc86f87de | |||
| 3c1498d806 | |||
| d1c4e0261b | |||
| bc49c1a68f | |||
| 3c8cf35734 | |||
| dea7cd9cc1 | |||
| 56bb876d8d | |||
| eb11e8c85a | |||
| a84bbeab2e | |||
| ea66aa4971 | |||
| cfa8350d10 | |||
| af466b6a24 | |||
| 789a03065a | |||
| bc424f0968 | |||
| ac3fc12077 | |||
| d9c7c76fb0 | |||
| d440c2b932 | |||
| e790f46757 | |||
| d089adeeea | |||
| 4a8fdceed6 | |||
| 574caa63e9 | |||
| baef3cdd07 | |||
| 43e64993fc | |||
| d52b598abf | |||
| 2d8d304850 | |||
| a036ddd66f | |||
| 78ab7a9246 | |||
| a810f497e6 |
@@ -3,6 +3,6 @@ import { llms } from 'fumadocs-core/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET() {
|
||||
return new Response(llms(source).index());
|
||||
export async function GET() {
|
||||
return new Response(await llms(source).index());
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ inbounds** at once, with per-client traffic accounting.
|
||||
| **Comment** | all | Free-text note. |
|
||||
|
||||
<Callout type="info">
|
||||
Reaching the **traffic** or **expiry** limit disables the client; the panel can
|
||||
restart Xray automatically when clients are auto-disabled
|
||||
Reaching the **traffic** or **expiry** limit disables the client, and a client
|
||||
disabled or deleted by hand counts too; the panel restarts Xray then
|
||||
(`restartXrayOnClientDisable`, on by default).
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read:
|
||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||
**`Announce`** — optional branding shown by some clients.
|
||||
|
||||
### Profile page links and upgrades
|
||||
|
||||
In **Subscription → Profile → Profile page**, choose `subProfileMode` for all
|
||||
subscription clients:
|
||||
|
||||
- **No link** (`none`, default): omit `Profile-Web-Page-Url`.
|
||||
- **Built-in subscription page** (`builtin`): link to the client's built-in page.
|
||||
- **Custom website** (`custom`): use `subProfileUrl`; a blank URL omits the header.
|
||||
|
||||
**Upgrade note:** previously, an empty `subProfileUrl` automatically linked to
|
||||
the built-in page. After upgrading, an unset mode with an empty or whitespace-only
|
||||
URL becomes **No link**; an existing nonempty URL remains a **Custom website**.
|
||||
To restore the built-in link, select **Built-in subscription page** above and
|
||||
save the settings.
|
||||
|
||||
The built-in page exposes subscription URLs and node configurations, including
|
||||
for Happ encrypted subscriptions. Enable it only if you intend to provide that
|
||||
access.
|
||||
|
||||
### Optional month-end expiry display
|
||||
|
||||
Under **Subscription → Information**, **Month-end subscription expiry display**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Reference",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ icon: Users
|
||||
| **Comment** | همه | یادداشت متنی آزاد. |
|
||||
|
||||
<Callout type="info">
|
||||
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ پنل میتواند
|
||||
هنگام غیرفعالشدن خودکار کلاینتها، Xray را بهصورت خودکار راهاندازی مجدد کند
|
||||
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ غیرفعالسازی یا
|
||||
حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راهاندازی مجدد میکند
|
||||
(`restartXrayOnClientDisable`، بهصورت پیشفرض فعال).
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) بهعلاوه
|
||||
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
|
||||
**`Announce`** — برندینگ اختیاری که برخی کلاینتها نمایش میدهند.
|
||||
|
||||
### لینک صفحه پروفایل
|
||||
|
||||
در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`)
|
||||
لینک را برای همه کلاینتهای اشتراک کنترل میکند:
|
||||
|
||||
- **بدون لینک** (`none`، پیشفرض) — هدر `Profile-Web-Page-Url` ارسال نمیشود.
|
||||
- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه میشود.
|
||||
- **وبسایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده میشود؛ اگر خالی باشد، هدر ارسال نمیشود.
|
||||
|
||||
**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl`
|
||||
خالی یا فقط شامل فاصله باشد، بهجای لینک خودکار صفحه داخلی، حالت **بدون لینک**
|
||||
انتخاب میشود. آدرس سفارشی غیرخالی قبلی در حالت **وبسایت سفارشی** حفظ میشود.
|
||||
|
||||
برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات
|
||||
را ذخیره کنید. این صفحه آدرسهای اشتراک و پیکربندی گرهها را آشکار میکند، حتی
|
||||
برای اشتراکهای رمزگذاریشده Happ.
|
||||
|
||||
## قالبهای سفارشی صفحه
|
||||
|
||||
برای برندینگ صفحهی HTML اشتراک، `subThemeDir` را به یک پوشهی حاوی قالب سفارشیِ
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "مرجع",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ icon: Users
|
||||
| **Comment** | все | Произвольная текстовая заметка. |
|
||||
|
||||
<Callout type="info">
|
||||
Достижение лимита **трафика** или **срока действия** отключает клиента; при
|
||||
автоматическом отключении клиентов панель может автоматически перезапускать
|
||||
Xray (`restartXrayOnClientDisable`, включено по умолчанию).
|
||||
Достижение лимита **трафика** или **срока действия** отключает клиента, как и
|
||||
ручное отключение или удаление; тогда панель перезапускает Xray
|
||||
(`restartXrayOnClientDisable`, включено по умолчанию).
|
||||
</Callout>
|
||||
|
||||
## Лимиты и контроль IP
|
||||
|
||||
@@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва
|
||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
|
||||
|
||||
### Ссылка на страницу профиля
|
||||
|
||||
В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`)
|
||||
управляет ссылкой для всех клиентов подписки:
|
||||
|
||||
- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется.
|
||||
- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки.
|
||||
- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется.
|
||||
|
||||
**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl`
|
||||
пуст или содержит только пробелы, вместо автоматической ссылки на встроенную
|
||||
страницу теперь используется **Без ссылки**. Существующий непустой пользовательский
|
||||
адрес сохраняется в режиме **Свой сайт**.
|
||||
|
||||
Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле
|
||||
и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации
|
||||
узлов, в том числе для зашифрованных подписок Happ.
|
||||
|
||||
## Пользовательские шаблоны страниц
|
||||
|
||||
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Справочник",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ icon: Users
|
||||
| **Comment** | 全部 | 自由文本备注。 |
|
||||
|
||||
<Callout type="info">
|
||||
达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以
|
||||
自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
||||
达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
|
||||
此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
||||
</Callout>
|
||||
|
||||
## 限制与 IP 控制
|
||||
|
||||
@@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,
|
||||
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
|
||||
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
|
||||
|
||||
### 资料页链接与升级说明
|
||||
|
||||
在 **订阅 → 资料 → 资料页方式** 中选择 `subProfileMode`,对所有订阅客户端生效:
|
||||
|
||||
- **不提供**(`none`,默认):不发送 `Profile-Web-Page-Url`。
|
||||
- **内置订阅页**(`builtin`):提供该客户端的内置订阅页链接。
|
||||
- **自定义网站**(`custom`):使用 `subProfileUrl`;地址留空时不发送该响应头。
|
||||
|
||||
**升级提示:** 旧版在 `subProfileUrl` 留空时会自动提供内置订阅页链接。升级后,尚未设置模式且地址为空或仅含空白字符的配置会使用 **不提供**;已有非空地址继续使用 **自定义网站**。需要恢复内置入口时,在上述位置选择 **内置订阅页** 并保存设置。
|
||||
|
||||
内置订阅页会公开订阅地址和节点配置,Happ 加密订阅也不例外;请在确定需要提供这些内容时开启。
|
||||
|
||||
## 自定义页面模板
|
||||
|
||||
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "参考",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
+20
-20
@@ -18,34 +18,34 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"fumadocs-core": "^16.15.5",
|
||||
"fumadocs-docgen": "^3.1.0",
|
||||
"fumadocs-mdx": "^15.4.0",
|
||||
"fumadocs-openapi": "^11.4.0",
|
||||
"fumadocs-ui": "^16.15.5",
|
||||
"lucide-react": "^1.39.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"next": "16.3.4",
|
||||
"fumadocs-core": "^16.15.11",
|
||||
"fumadocs-docgen": "^3.1.1",
|
||||
"fumadocs-mdx": "^15.4.1",
|
||||
"fumadocs-openapi": "^11.4.3",
|
||||
"fumadocs-ui": "^16.15.11",
|
||||
"lucide-react": "^1.46.0",
|
||||
"mermaid": "^12.0.0",
|
||||
"next": "16.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"zbsearch": "4.0.0",
|
||||
"zod": "^4.5.4"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"oxfmt": "0.66.0",
|
||||
"oxlint": "1.81.0",
|
||||
"postcss": "^8.5.26",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"oxfmt": "0.68.0",
|
||||
"oxlint": "1.83.0",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"packageManager": "pnpm@11.25.0"
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
}
|
||||
|
||||
Generated
+1284
-1099
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,16 @@ minimumReleaseAgeExclude:
|
||||
- mermaid@11.17.0
|
||||
- lucide-react@1.33.0
|
||||
- postcss@8.5.26
|
||||
- fumadocs-mdx@15.3.0
|
||||
- '@fumadocs/api-docs@0.2.7'
|
||||
- fumadocs-mdx@15.3.0 || 15.4.1
|
||||
- '@fumadocs/api-docs@0.2.7 || 0.2.9'
|
||||
- '@types/node@26.4.1'
|
||||
- fumadocs-core@16.15.5
|
||||
- fumadocs-openapi@11.4.0
|
||||
- fumadocs-ui@16.15.5
|
||||
- fumadocs-core@16.15.5 || 16.15.11
|
||||
- fumadocs-openapi@11.4.0 || 11.4.3
|
||||
- fumadocs-ui@16.15.5 || 16.15.11
|
||||
- '@fumadocs/tailwind@0.1.2'
|
||||
- '@fumari/image-size@0.1.1'
|
||||
- '@fumari/stf@1.1.1'
|
||||
- '@vitest/mocker@5.0.1'
|
||||
- '@vitest/spy@5.0.1'
|
||||
- fumadocs-docgen@3.1.1
|
||||
- vitest@5.0.1
|
||||
|
||||
@@ -395,6 +395,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -618,6 +621,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1046,6 +1050,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1277,6 +1284,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const withTheme: Decorator = (Story, context) => {
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.remove('dark', 'light');
|
||||
document.body.classList.add(dark ? 'dark' : 'light');
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}, [dark]);
|
||||
return (
|
||||
|
||||
@@ -395,6 +395,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -618,6 +621,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1046,6 +1050,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1277,6 +1284,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
|
||||
@@ -115,6 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subProfileMode": "",
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
@@ -271,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subProfileMode": "",
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
|
||||
@@ -369,6 +369,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -592,6 +595,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1020,6 +1024,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1251,6 +1258,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface AllSetting {
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileMode: string;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
@@ -279,6 +280,7 @@ export interface AllSettingView {
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileMode: string;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
|
||||
@@ -136,6 +136,7 @@ export const AllSettingSchema = z.object({
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileMode: z.string(),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
@@ -294,6 +295,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileMode: z.string(),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
|
||||
@@ -15,6 +15,8 @@ function readBool(key: string, fallback: boolean): boolean {
|
||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||
document.body.classList.remove('dark', 'light');
|
||||
document.body.classList.add(isDark ? 'dark' : 'light');
|
||||
// Native scrollbars read color-scheme, not the body class.
|
||||
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,9 @@ export function formatPanelVersion(version: string | undefined | null): string {
|
||||
|
||||
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
||||
if (!latest || !current) return false;
|
||||
// A dev+<sha> label and a release tag sit on different channels and carry no
|
||||
// order, so a node moved to the other channel is not "behind" the master's latest.
|
||||
if (latest.trim().startsWith('dev+') !== current.trim().startsWith('dev+')) return false;
|
||||
const a = parseVersionParts(latest);
|
||||
const b = parseVersionParts(current);
|
||||
if (!a || !b) {
|
||||
|
||||
@@ -786,7 +786,8 @@ function dnsRuleToWire(r: DnsRuleForm) {
|
||||
const result: Raw = { action };
|
||||
const qType = r.qType.trim();
|
||||
if (qType) {
|
||||
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
|
||||
// The core reads a numeric 0 as no qType at all, which matches every query.
|
||||
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
|
||||
}
|
||||
const domains = r.domain
|
||||
.split(',')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ObjectUtil } from '@/utils';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
|
||||
export class AllSetting {
|
||||
webListen = '';
|
||||
@@ -46,6 +47,7 @@ export class AllSetting {
|
||||
subClashUserAgentRegex = '';
|
||||
subTitle = '';
|
||||
subSupportUrl = '';
|
||||
subProfileMode: SubProfileMode = 'none';
|
||||
subProfileUrl = '';
|
||||
subAnnounce = '';
|
||||
subEnableRouting = false;
|
||||
@@ -167,6 +169,15 @@ export class AllSetting {
|
||||
if (data != null) {
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
// Legacy settings with a custom URL retain it until an explicit mode is saved.
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
(!('subProfileMode' in data) || data.subProfileMode === undefined) &&
|
||||
this.subProfileUrl.trim() !== ''
|
||||
) {
|
||||
this.subProfileMode = 'custom';
|
||||
}
|
||||
const cpu = Math.round(Number(this.tgCpu));
|
||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||
|
||||
@@ -45,15 +45,14 @@
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events h2 {
|
||||
margin-top: 0;
|
||||
.api-docs-page .swagger-ui .section-tabs {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events pre {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
|
||||
import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
|
||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
||||
|
||||
interface TaggedOperations {
|
||||
keySeq: () => { first: () => string | undefined };
|
||||
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
|
||||
}
|
||||
|
||||
interface LayoutSelectors {
|
||||
currentFilter: () => string | false;
|
||||
}
|
||||
|
||||
interface SectionTabsProps {
|
||||
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
|
||||
layoutSelectors: LayoutSelectors;
|
||||
layoutActions: { updateFilter: (tag: string) => void };
|
||||
}
|
||||
|
||||
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
|
||||
const tags = specSelectors
|
||||
.tags()
|
||||
.toJS()
|
||||
.map((tag) => tag.name);
|
||||
return (
|
||||
<div className="wrapper section-tabs">
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={layoutSelectors.currentFilter() || tags[0]}
|
||||
onChange={layoutActions.updateFilter}
|
||||
items={tags.map((tag) => ({ key: tag, label: tag }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
|
||||
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
|
||||
const sectionTabsPlugin = {
|
||||
statePlugins: {
|
||||
spec: {
|
||||
wrapSelectors: {
|
||||
taggedOperations:
|
||||
(
|
||||
select: (...args: unknown[]) => TaggedOperations,
|
||||
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
|
||||
) =>
|
||||
(...args: unknown[]) => {
|
||||
const operations = select(...args);
|
||||
const active =
|
||||
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
|
||||
return operations.filter((_, tag) => tag === active);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: { FilterContainer: SectionTabs },
|
||||
};
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<section className="websocket-events" aria-labelledby="websocket-events-title">
|
||||
<Typography.Title id="websocket-events-title" level={2}>
|
||||
WebSocket events
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
|
||||
upgrade, every server message uses{' '}
|
||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
|
||||
is Unix milliseconds.
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={[12, 12]}>
|
||||
{websocketEvents.map((event) => (
|
||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
|
||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</section>
|
||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
docExpansion="list"
|
||||
deepLinking={false}
|
||||
tryItOutEnabled
|
||||
persistAuthorization
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'panel-api',
|
||||
label: '3X-UI Panel API',
|
||||
children: (
|
||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
docExpansion="list"
|
||||
deepLinking={false}
|
||||
plugins={[sectionTabsPlugin]}
|
||||
tryItOutEnabled
|
||||
persistAuthorization
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'websocket-events',
|
||||
label: 'WebSocket events',
|
||||
children: (
|
||||
<section className="websocket-events">
|
||||
<Typography.Paragraph>
|
||||
After the cookie-authenticated{' '}
|
||||
<Typography.Text code>GET /ws</Typography.Text> upgrade, every server
|
||||
message uses{' '}
|
||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
|
||||
time value is Unix milliseconds.
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={[12, 12]}>
|
||||
{websocketEvents.map((event) => (
|
||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Typography.Text code>{event.type}</Typography.Text>}
|
||||
>
|
||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* The body is capped at the viewport and scrolls; a trailing item margin
|
||||
alone must not push it past the cap and summon a scrollbar. */
|
||||
.client-form-modal .ant-tabs-content > .ant-form-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
} from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||
import './ClientFormModal.css';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
|
||||
@@ -803,6 +804,7 @@ export default function ClientFormModal({
|
||||
open={open}
|
||||
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
||||
destroyOnHidden
|
||||
className="client-form-modal"
|
||||
width={720}
|
||||
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
||||
style={{ top: 20 }}
|
||||
|
||||
@@ -61,6 +61,23 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
margin: -4px -8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.summary-stat:hover,
|
||||
.summary-stat:focus-visible {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.summary-stat.selected {
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryStatProps {
|
||||
title: string;
|
||||
value: number;
|
||||
prefix: ReactNode;
|
||||
emails?: string[];
|
||||
selected?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
|
||||
const stat = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
className={selected ? 'summary-stat selected' : 'summary-stat'}
|
||||
onClick={onSelect}
|
||||
onKeyDown={activateOnKey(onSelect)}
|
||||
>
|
||||
<Statistic title={title} value={String(value)} prefix={prefix} />
|
||||
</div>
|
||||
);
|
||||
if (!emails) return stat;
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
open={value ? undefined : false}
|
||||
content={<ClientEmailList emails={emails} total={value} />}
|
||||
>
|
||||
{stat}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||
|
||||
interface PersistedFilterState {
|
||||
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
|
||||
const someSelected =
|
||||
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
||||
|
||||
const isOnlyBucket = (bucket: string) =>
|
||||
filters.buckets.length === 1 && filters.buckets[0] === bucket;
|
||||
|
||||
// Clicking the card that is already the sole status filter clears it again.
|
||||
function selectBucket(bucket: string | null) {
|
||||
const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
|
||||
setFilters({ ...filters, buckets });
|
||||
}
|
||||
|
||||
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
||||
if (key === 'expiryFrom' || key === 'expiryTo') {
|
||||
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
||||
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Statistic
|
||||
<SummaryStat
|
||||
title={t('clients')}
|
||||
value={String(summary.total)}
|
||||
value={summary.total}
|
||||
prefix={<TeamOutlined />}
|
||||
onSelect={() => selectBucket(null)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('online')}
|
||||
open={summary.onlineCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.online}
|
||||
total={summary.onlineCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('online')}
|
||||
value={String(summary.onlineCount)}
|
||||
prefix={<span className="dot dot-blue" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.onlineCount}
|
||||
emails={summary.online}
|
||||
prefix={<span className="dot dot-blue" />}
|
||||
selected={isOnlyBucket('online')}
|
||||
onSelect={() => selectBucket('online')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('depleted')}
|
||||
open={summary.depletedCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.depleted}
|
||||
total={summary.depletedCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('depleted')}
|
||||
value={String(summary.depletedCount)}
|
||||
prefix={<span className="dot dot-red" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.depletedCount}
|
||||
emails={summary.depleted}
|
||||
prefix={<span className="dot dot-red" />}
|
||||
selected={isOnlyBucket('depleted')}
|
||||
onSelect={() => selectBucket('depleted')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('depletingSoon')}
|
||||
open={summary.expiringCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.expiring}
|
||||
total={summary.expiringCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('depletingSoon')}
|
||||
value={String(summary.expiringCount)}
|
||||
prefix={<span className="dot dot-orange" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.expiringCount}
|
||||
emails={summary.expiring}
|
||||
prefix={<span className="dot dot-orange" />}
|
||||
selected={isOnlyBucket('expiring')}
|
||||
onSelect={() => selectBucket('expiring')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('disabled')}
|
||||
open={summary.deactiveCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.deactive}
|
||||
total={summary.deactiveCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('disabled')}
|
||||
value={String(summary.deactiveCount)}
|
||||
prefix={<span className="dot dot-gray" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.deactiveCount}
|
||||
emails={summary.deactive}
|
||||
prefix={<span className="dot dot-gray" />}
|
||||
selected={isOnlyBucket('deactive')}
|
||||
onSelect={() => selectBucket('deactive')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Statistic
|
||||
<SummaryStat
|
||||
title={t('subscription.active')}
|
||||
value={String(summary.active)}
|
||||
value={summary.active}
|
||||
prefix={<span className="dot dot-green" />}
|
||||
selected={isOnlyBucket('active')}
|
||||
onSelect={() => selectBucket('active')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
|
||||
return map;
|
||||
}
|
||||
|
||||
// Most pushes repeat the previous online sets; handing back a new Map anyway
|
||||
// re-ran the client rollup over every inbound on each traffic event.
|
||||
function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [key, set] of b) {
|
||||
const prev = a.get(key);
|
||||
if (!prev || prev.size !== set.size) return false;
|
||||
for (const value of set) if (!prev.has(value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
|
||||
@@ -440,10 +452,12 @@ export function useInbounds() {
|
||||
setOnlineClients(p.onlineClients);
|
||||
}
|
||||
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
||||
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
|
||||
const next = toGuidOnlineMap(p.onlineByGuid);
|
||||
setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||
}
|
||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||
setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
|
||||
const next = toGuidOnlineMap(p.activeInbounds);
|
||||
setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||
}
|
||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||
@@ -537,8 +551,7 @@ export function useInbounds() {
|
||||
? stats.map((stat) => {
|
||||
const su = byEmail.get(stat.email);
|
||||
if (!su) return stat;
|
||||
statsTouched = true;
|
||||
return {
|
||||
const merged = {
|
||||
...stat,
|
||||
up: typeof su.up === 'number' ? su.up : stat.up,
|
||||
down: typeof su.down === 'number' ? su.down : stat.down,
|
||||
@@ -546,9 +559,27 @@ export function useInbounds() {
|
||||
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
|
||||
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
|
||||
} as ClientStats;
|
||||
if (
|
||||
merged.up === stat.up &&
|
||||
merged.down === stat.down &&
|
||||
merged.total === stat.total &&
|
||||
merged.expiryTime === stat.expiryTime &&
|
||||
merged.enable === stat.enable
|
||||
) {
|
||||
return stat;
|
||||
}
|
||||
statsTouched = true;
|
||||
return merged;
|
||||
})
|
||||
: null;
|
||||
if (!upd && !statsTouched) return ib;
|
||||
// Every push lists all inbounds' totals, so only a row whose numbers moved counts.
|
||||
const inboundMoved =
|
||||
!!upd &&
|
||||
((typeof upd.up === 'number' && upd.up !== ib.up) ||
|
||||
(typeof upd.down === 'number' && upd.down !== ib.down) ||
|
||||
(typeof upd.total === 'number' && upd.total !== ib.total) ||
|
||||
(typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
|
||||
if (!inboundMoved && !statsTouched) return ib;
|
||||
touched = true;
|
||||
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
|
||||
if (upd) {
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
style={{ width: 100 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
style={{ width: 100 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function OverviewActionBar({
|
||||
|
||||
return (
|
||||
<div className="ov-bar">
|
||||
{status.xray.state === 'error' && status.xray.errorMsg ? (
|
||||
{status.xray.errorMsg ? (
|
||||
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||
{statePill}
|
||||
</Tooltip>
|
||||
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
|
||||
statePill
|
||||
)}
|
||||
|
||||
{status.xray.state === 'running' && status.xray.errorMsg ? (
|
||||
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||
<Tag color="error">{t('pages.index.xrayStatusError')}</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{updateAvailable ? (
|
||||
<Tag
|
||||
className="ov-update-tag"
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
style={{ width: 100 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
|
||||
@@ -145,17 +145,22 @@ function formatUptime(secs?: number): string {
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
// Stable per language: the columns memo depends on it, and a fresh function each
|
||||
// render rebuilt every column, re-rendering all rows on each heartbeat push.
|
||||
function useRelativeTime() {
|
||||
const { t } = useTranslation();
|
||||
return (unixSeconds?: number) => {
|
||||
if (!unixSeconds) return t('pages.nodes.never');
|
||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||
return `${Math.floor(diffSec / 86400)}d`;
|
||||
};
|
||||
return useMemo(
|
||||
() => (unixSeconds?: number) => {
|
||||
if (!unixSeconds) return t('pages.nodes.never');
|
||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||
return `${Math.floor(diffSec / 86400)}d`;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
}
|
||||
|
||||
export default function NodeList({
|
||||
@@ -530,6 +535,47 @@ export default function NodeList({
|
||||
],
|
||||
);
|
||||
|
||||
// rc-table re-runs every cell renderer whenever the Table re-renders, so keep the
|
||||
// same element until its inputs change rather than re-rendering all rows each time.
|
||||
const nodeTable = useMemo(
|
||||
() => (
|
||||
<Table<NodeRow>
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="key"
|
||||
rowSelection={
|
||||
dataSource.length > 1
|
||||
? {
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) =>
|
||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="card-empty">
|
||||
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
rowExpandable: (record) => !record.transitive,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[dataSource, columns, loading, selectedIds, onSelectionChange, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="small" hoverable>
|
||||
<div className="toolbar">
|
||||
@@ -806,39 +852,7 @@ export default function NodeList({
|
||||
</Modal>
|
||||
</>
|
||||
) : (
|
||||
<Table<NodeRow>
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="key"
|
||||
rowSelection={
|
||||
dataSource.length > 1
|
||||
? {
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) =>
|
||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="card-empty">
|
||||
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
rowExpandable: (record) => !record.transitive,
|
||||
}}
|
||||
/>
|
||||
nodeTable
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
|
||||
import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
||||
import {
|
||||
BranchesOutlined,
|
||||
CompassOutlined,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
@@ -279,16 +280,44 @@ export default function SubscriptionGeneralTab({
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subProfileUrl')}
|
||||
description={t('pages.settings.subProfileUrlDesc')}
|
||||
title={t('pages.settings.subProfileMode')}
|
||||
description={t('pages.settings.subProfileModeDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
<Select<SubProfileMode>
|
||||
id="sub-profile-mode"
|
||||
aria-label={t('pages.settings.subProfileMode')}
|
||||
value={allSetting.subProfileMode}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => updateSetting({ subProfileMode: value })}
|
||||
options={[
|
||||
{ value: 'none', label: t('pages.settings.subProfileModeNone') },
|
||||
{ value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
|
||||
{ value: 'custom', label: t('pages.settings.subProfileModeCustom') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
{allSetting.subProfileMode === 'builtin' ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ margin: '12px 20px' }}
|
||||
title={t('pages.settings.subProfileBuiltinWarning')}
|
||||
/>
|
||||
) : null}
|
||||
{allSetting.subProfileMode === 'custom' ? (
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subProfileUrl')}
|
||||
description={t('pages.settings.subProfileUrlDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
) : null}
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subAnnounce')}
|
||||
|
||||
@@ -4,6 +4,9 @@ const port = z.number().int().min(1).max(65535);
|
||||
const nonNegativeInt = z.number().int().min(0);
|
||||
const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash');
|
||||
|
||||
export const SubProfileModeSchema = z.enum(['none', 'builtin', 'custom']);
|
||||
export type SubProfileMode = z.infer<typeof SubProfileModeSchema>;
|
||||
|
||||
export const AllSettingSchema = z
|
||||
.object({
|
||||
webListen: z.string().optional(),
|
||||
@@ -50,6 +53,7 @@ export const AllSettingSchema = z
|
||||
subClashUserAgentRegex: z.string().max(2048).optional(),
|
||||
subTitle: z.string().optional(),
|
||||
subSupportUrl: z.string().optional(),
|
||||
subProfileMode: SubProfileModeSchema.optional(),
|
||||
subProfileUrl: z.string().optional(),
|
||||
subAnnounce: z.string().optional(),
|
||||
subEnableRouting: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { useInbounds } from '@/pages/inbounds/useInbounds';
|
||||
|
||||
import { makeTestQueryClient } from './test-utils';
|
||||
|
||||
function seedInbounds() {
|
||||
const rows = [1, 2].map((id) => ({
|
||||
id,
|
||||
protocol: 'vless',
|
||||
tag: `in-${id}`,
|
||||
enable: true,
|
||||
up: 10,
|
||||
down: 20,
|
||||
total: 0,
|
||||
expiryTime: 0,
|
||||
settings: JSON.stringify({ clients: [{ email: `c${id}@x`, enable: true }] }),
|
||||
clientStats: [
|
||||
{ email: `c${id}@x`, up: 1, down: 2, total: 0, expiryTime: 0, enable: true, inboundId: id },
|
||||
],
|
||||
}));
|
||||
const queryClient = makeTestQueryClient();
|
||||
queryClient.setQueryData(keys.inbounds.slim(), rows);
|
||||
queryClient.setQueryData(keys.clients.onlines(), []);
|
||||
queryClient.setQueryData(keys.clients.onlinesByGuid(), {});
|
||||
queryClient.setQueryData(keys.clients.activeInbounds(), {});
|
||||
queryClient.setQueryData(keys.clients.lastOnline(), {});
|
||||
queryClient.setQueryData(keys.settings.defaults(), {});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { rows, wrapper };
|
||||
}
|
||||
|
||||
async function renderInbounds() {
|
||||
const { rows, wrapper } = seedInbounds();
|
||||
const hook = renderHook(() => useInbounds(), { wrapper });
|
||||
await waitFor(() => expect(hook.result.current.dbInbounds).toHaveLength(2));
|
||||
return { rows, result: hook.result };
|
||||
}
|
||||
|
||||
// Every client_stats push carries all inbounds' totals, so rebuilding a row whether or
|
||||
// not its numbers moved re-ran the client rollup and the whole table on each push.
|
||||
describe('inbound websocket merges keep unchanged state', () => {
|
||||
it('keeps rows and the client rollup when a client_stats push changes nothing', async () => {
|
||||
const { rows, result } = await renderInbounds();
|
||||
const before = result.current.dbInbounds;
|
||||
const rollup = result.current.clientCount;
|
||||
|
||||
act(() =>
|
||||
result.current.applyClientStatsEvent({
|
||||
inbounds: rows.map((r) => ({
|
||||
id: r.id,
|
||||
up: r.up,
|
||||
down: r.down,
|
||||
total: r.total,
|
||||
enable: r.enable,
|
||||
})),
|
||||
clients: [{ email: 'c1@x', up: 1, down: 2, total: 0, expiryTime: 0, enable: true }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.dbInbounds).toBe(before);
|
||||
expect(result.current.clientCount).toBe(rollup);
|
||||
});
|
||||
|
||||
it('still rebuilds exactly the rows whose numbers moved', async () => {
|
||||
const { result } = await renderInbounds();
|
||||
const before = result.current.dbInbounds;
|
||||
|
||||
act(() =>
|
||||
result.current.applyClientStatsEvent({
|
||||
inbounds: [
|
||||
{ id: 1, up: 99, down: 20, total: 0, enable: true },
|
||||
{ id: 2, up: 10, down: 20, total: 0, enable: true },
|
||||
],
|
||||
clients: [{ email: 'c2@x', up: 5, down: 2, total: 0, expiryTime: 0, enable: true }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [first, second] = result.current.dbInbounds;
|
||||
expect(first).not.toBe(before[0]);
|
||||
expect(first.up).toBe(99);
|
||||
expect(second).not.toBe(before[1]);
|
||||
expect(second.clientStats?.[0]?.up).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps the client rollup when a traffic push repeats the same online sets', async () => {
|
||||
const { result } = await renderInbounds();
|
||||
const push = () =>
|
||||
result.current.applyTrafficEvent({
|
||||
onlineClients: ['c1@x'],
|
||||
onlineByGuid: { 'node:1': ['c1@x'] },
|
||||
activeInbounds: { 'node:1': ['in-1'] },
|
||||
});
|
||||
act(push);
|
||||
const rollup = result.current.clientCount;
|
||||
|
||||
act(push);
|
||||
|
||||
expect(result.current.clientCount).toBe(rollup);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import NodeList from '@/pages/nodes/NodeList';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
|
||||
import { makeTestQueryClient } from './test-utils';
|
||||
|
||||
const updateChecks = vi.hoisted(() => ({ count: 0 }));
|
||||
|
||||
vi.mock('@/lib/panel-version', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/panel-version')>();
|
||||
return {
|
||||
...actual,
|
||||
isPanelUpdateAvailable: (...args: Parameters<typeof actual.isPanelUpdateAvailable>) => {
|
||||
updateChecks.count++;
|
||||
return actual.isPanelUpdateAvailable(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Every heartbeat push re-rendered all rows, unchanged ones too: the columns and
|
||||
// table props were rebuilt on each render, so every cell re-ran its renderer.
|
||||
describe('NodeList re-render', () => {
|
||||
it('leaves the rows alone when its parent re-renders with the same nodes', () => {
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const nodes: NodeRecord[] = [1, 2, 3].map((id) => ({
|
||||
id,
|
||||
name: `node-${id}`,
|
||||
guid: `g${id}`,
|
||||
transitive: false,
|
||||
enable: true,
|
||||
status: 'online',
|
||||
panelVersion: '3.0.0',
|
||||
}));
|
||||
const noop = () => {};
|
||||
const props = {
|
||||
nodes,
|
||||
isMobile: false,
|
||||
latestVersion: '3.0.1',
|
||||
selectedIds: [] as number[],
|
||||
onSelectionChange: noop,
|
||||
onAdd: noop,
|
||||
onMtls: noop,
|
||||
onEdit: noop,
|
||||
onDelete: noop,
|
||||
onProbe: noop,
|
||||
onToggleEnable: noop,
|
||||
onUpdateNode: noop,
|
||||
onUpdateSelected: noop,
|
||||
};
|
||||
const view = render(<NodeList {...props} />, { wrapper });
|
||||
expect(updateChecks.count).toBeGreaterThan(0);
|
||||
|
||||
updateChecks.count = 0;
|
||||
view.rerender(<NodeList {...props} />);
|
||||
|
||||
expect(updateChecks.count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -336,6 +336,19 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
|
||||
});
|
||||
|
||||
it('dns rules keep qType 0 a string, since the core reads a numeric 0 as every query', () => {
|
||||
const back = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'dns',
|
||||
settings: { rules: [{ action: 'drop', qType: 0 }] },
|
||||
}),
|
||||
);
|
||||
const rules = (back.settings as Record<string, unknown>).rules as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(rules[0]).toEqual({ action: 'drop', qType: '0' });
|
||||
});
|
||||
|
||||
it('dns rules read the legacy qtype wire key for back-compat', () => {
|
||||
const wire = {
|
||||
protocol: 'dns',
|
||||
|
||||
@@ -30,6 +30,13 @@ describe('isPanelUpdateAvailable', () => {
|
||||
expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true);
|
||||
expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('compares dev builds by commit and never across channels', () => {
|
||||
expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+0f0f0f0f')).toBe(true);
|
||||
expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+1a2b3c4d')).toBe(false);
|
||||
expect(isPanelUpdateAvailable('v3.5.0', 'dev+1a2b3c4d')).toBe(false);
|
||||
expect(isPanelUpdateAvailable('dev+1a2b3c4d', '3.5.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPanelVersion', () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { AllSettingSchema } from '@/schemas/setting';
|
||||
|
||||
describe('subscription profile mode', () => {
|
||||
it.each([undefined, {}, { subProfileUrl: '' }, { subProfileUrl: ' ' }])(
|
||||
'defaults to no link without a legacy URL: %j',
|
||||
(data) => {
|
||||
expect(new AllSetting(data).subProfileMode).toBe('none');
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves a legacy custom URL when its mode is missing', () => {
|
||||
const setting = new AllSetting({ subProfileUrl: 'https://example.com/profile' });
|
||||
expect(setting.subProfileMode).toBe('custom');
|
||||
expect(setting.subProfileUrl).toBe('https://example.com/profile');
|
||||
});
|
||||
|
||||
it.each(['none', 'builtin', 'custom'])(
|
||||
'honors the explicit %s mode with a stored URL',
|
||||
(mode) => {
|
||||
const result = AllSettingSchema.safeParse({
|
||||
subProfileMode: mode,
|
||||
subProfileUrl: 'https://example.com/profile',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(new AllSetting(result.data).subProfileMode).toBe(mode);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['auto', '', null, true])('rejects an invalid mode: %j', (mode) => {
|
||||
expect(AllSettingSchema.safeParse({ subProfileMode: mode }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
|
||||
import { withTheme } from '../../.storybook/preview';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { ThemeProvider, useTheme } from '@/hooks/useTheme';
|
||||
|
||||
function Story() {
|
||||
return <div>Story</div>;
|
||||
@@ -14,9 +14,37 @@ function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
|
||||
> as Parameters<typeof withTheme>[1]);
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
const { toggleTheme } = useTheme();
|
||||
return <button onClick={toggleTheme}>toggle</button>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.className = '';
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.style.colorScheme = '';
|
||||
});
|
||||
|
||||
// Without color-scheme the browser paints native scrollbars light inside dark
|
||||
// modals, e.g. the Edit Client body.
|
||||
test('native scrollbars follow the panel theme', () => {
|
||||
const { getByRole } = render(
|
||||
<ThemeProvider>
|
||||
<ThemeToggle />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
expect(document.documentElement.style.colorScheme).toBe('light');
|
||||
});
|
||||
|
||||
test('native scrollbars follow the Storybook theme', () => {
|
||||
const { rerender } = render(<StorybookTheme theme="light" />);
|
||||
expect(document.documentElement.style.colorScheme).toBe('light');
|
||||
|
||||
rerender(<StorybookTheme theme="dark" />);
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
test('preserves unrelated body classes when applying the Storybook theme', () => {
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
import { chooseSelectOption, renderWithProviders } from './test-utils';
|
||||
|
||||
function ProfileSettingsHarness({ initial }: { initial?: unknown }) {
|
||||
const [allSetting, setAllSetting] = useState(() => new AllSetting(initial));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubscriptionGeneralTab
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) =>
|
||||
setAllSetting((current) => new AllSetting({ ...current, ...patch }))
|
||||
}
|
||||
/>
|
||||
<output data-testid="profile-settings">
|
||||
{allSetting.subProfileMode}|{allSetting.subProfileUrl}
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
@@ -18,6 +37,60 @@ function LocationProbe() {
|
||||
}
|
||||
|
||||
describe('SubscriptionGeneralTab', () => {
|
||||
it('switches profile modes without losing the custom URL and warns only for the built-in page', () => {
|
||||
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
|
||||
const editedUrl = 'https://example.com/account/{{SUB_ID}}';
|
||||
const warning =
|
||||
'This page exposes subscription URLs and node configurations, including for Happ encrypted subscriptions.';
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<ProfileSettingsHarness initial={{ subProfileMode: 'none', subProfileUrl: storedUrl }} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
|
||||
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${storedUrl}`);
|
||||
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
|
||||
expect(screen.queryByText(warning)).toBeNull();
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'Built-in subscription page');
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`builtin|${storedUrl}`);
|
||||
expect(screen.getByRole('alert').textContent).toContain(warning);
|
||||
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'Custom website');
|
||||
expect(screen.queryByText(warning)).toBeNull();
|
||||
fireEvent.change(screen.getByDisplayValue(storedUrl), { target: { value: editedUrl } });
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'No link');
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${editedUrl}`);
|
||||
expect(screen.queryByDisplayValue(editedUrl)).toBeNull();
|
||||
expect(screen.queryByText(warning)).toBeNull();
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'Custom website');
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
|
||||
expect(screen.getByDisplayValue(editedUrl)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens a legacy custom profile URL with custom mode selected', () => {
|
||||
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<ProfileSettingsHarness initial={{ subProfileUrl: storedUrl }} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
|
||||
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
|
||||
expect(screen.getByText('Custom website')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue(storedUrl)).toBeTruthy();
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${storedUrl}`);
|
||||
});
|
||||
|
||||
it('keeps the stored subscription port when the field is cleared', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ func IsAmneziaWGOutbound(raw []byte) bool {
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return false
|
||||
}
|
||||
return probe.Protocol == "amneziawg"
|
||||
return strings.EqualFold(probe.Protocol, "amneziawg")
|
||||
}
|
||||
|
||||
// outboundSettingsOf extracts the nested "settings" block from a raw
|
||||
|
||||
@@ -134,14 +134,11 @@ func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
|
||||
return out
|
||||
}
|
||||
|
||||
// desiredPortForwardKeys returns the full set of listener keys inst wants
|
||||
// right now: one tcpForward and one udpForward key per port in every peer's
|
||||
// ForwardedPorts spec, for every peer that also has a resolvable target
|
||||
// (see desiredPeerTargets) -- a key never exists without a target, so
|
||||
// Reconcile can always resolve one for any key it opens.
|
||||
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
|
||||
out := map[portForwardKey]struct{}{}
|
||||
// forwardingPeers is the one gate a host listener comes from: no email, port
|
||||
// spec and resolvable target (see desiredPeerTargets), no socket.
|
||||
func forwardingPeers(inst amneziawg.Instance) []amneziawg.Peer {
|
||||
targets := desiredPeerTargets(inst)
|
||||
out := make([]amneziawg.Peer, 0, len(inst.Peers))
|
||||
for _, p := range inst.Peers {
|
||||
if p.Email == "" || p.ForwardedPorts == "" {
|
||||
continue
|
||||
@@ -149,6 +146,16 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
|
||||
if _, ok := targets[p.Email]; !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// desiredPortForwardKeys returns every listener key inst wants right now: one
|
||||
// tcpForward and one udpForward per forwarded port of the forwarding peers.
|
||||
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
|
||||
out := map[portForwardKey]struct{}{}
|
||||
for _, p := range forwardingPeers(inst) {
|
||||
for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
|
||||
out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
|
||||
out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
|
||||
@@ -157,6 +164,19 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
|
||||
return out
|
||||
}
|
||||
|
||||
// ForwardedPortOwner names the peer Reconcile opens a listener on port for --
|
||||
// the same peers and expansion as desiredPortForwardKeys, never a silent one.
|
||||
func ForwardedPortOwner(inst amneziawg.Instance, port int) (string, bool) {
|
||||
for _, p := range forwardingPeers(inst) {
|
||||
for _, candidate := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
|
||||
if candidate == port {
|
||||
return p.Email, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Reconcile brings the supervisor's open listeners in line with what inst
|
||||
// currently wants: closes anything no longer desired, opens anything newly
|
||||
// desired, leaves everything else untouched. Never returns an error --
|
||||
|
||||
@@ -11,10 +11,13 @@ import (
|
||||
// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
|
||||
const SOCKSBasePort = 65100
|
||||
|
||||
// relayPortSlots is how many ids fit above SOCKSBasePort before wrapping.
|
||||
const relayPortSlots = 65535 - SOCKSBasePort
|
||||
|
||||
// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
|
||||
// its id, so config generation and the dialing relay never need to negotiate.
|
||||
// its id, wrapping ids past relayPortSlots so no id ever lacks a port.
|
||||
func SOCKSPortForInbound(inboundID int) int {
|
||||
return SOCKSBasePort + inboundID
|
||||
return SOCKSBasePort + 1 + (inboundID-1)%relayPortSlots
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package amneziawgnet
|
||||
|
||||
import "testing"
|
||||
|
||||
// An inbound id past the slot count used to be refused outright, which capped a
|
||||
// database at 435 AmneziaWG inbounds for its whole life (#6537).
|
||||
func TestSOCKSPortForInboundKeepsEverySlotInsideTheWindow(t *testing.T) {
|
||||
t.Run("no id derives a port outside the window", func(t *testing.T) {
|
||||
for _, id := range []int{1, 2, 434, 435, 436, 437, 870, 871, 6537, 70350, 1_000_000} {
|
||||
port := SOCKSPortForInbound(id)
|
||||
if port < SOCKSBasePort+1 || port > 65535 {
|
||||
t.Errorf("id %d derives relay port %d, outside %d..65535", id, port, SOCKSBasePort+1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Every id the old formula reached must keep its exact port, or upgrading
|
||||
// moves a running relay. Ids 1..435 also leave SOCKSBasePort itself unused.
|
||||
t.Run("ids up to the slot count keep the port they always had", func(t *testing.T) {
|
||||
for id := 1; id <= 435; id++ {
|
||||
if got, want := SOCKSPortForInbound(id), SOCKSBasePort+id; got != want {
|
||||
t.Errorf("id %d moved from relay port %d to %d", id, want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
+76
-3
@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||
for _, name := range seeders {
|
||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||
return err
|
||||
@@ -1383,6 +1383,12 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "DNSOutboundQTypeZeroFix") {
|
||||
if err := migrateDNSOutboundQTypeZero(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
|
||||
if err := seedNodeInboundsAdopted(); err != nil {
|
||||
return err
|
||||
@@ -1920,9 +1926,10 @@ func legacyDNSOutboundRules(mode string, blockTypes []int) []any {
|
||||
return append(rules, fallback)
|
||||
}
|
||||
|
||||
// dnsQTypeValue keeps a lone qType a number, the way the core marshals one.
|
||||
// dnsQTypeValue keeps a lone qType a number the way the core marshals one, except
|
||||
// 0: the core drops a numeric 0, and a rule with no qTypes matches every query.
|
||||
func dnsQTypeValue(blockTypes []int) any {
|
||||
if len(blockTypes) == 1 {
|
||||
if len(blockTypes) == 1 && blockTypes[0] != 0 {
|
||||
return blockTypes[0]
|
||||
}
|
||||
parts := make([]string, 0, len(blockTypes))
|
||||
@@ -1932,6 +1939,72 @@ func dnsQTypeValue(blockTypes []int) any {
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// migrateDNSOutboundQTypeZero repairs the numeric qType 0 that 3.8.0's legacy-keys
|
||||
// seeder stored, which that seeder's own history row keeps it from revisiting.
|
||||
func migrateDNSOutboundQTypeZero() error {
|
||||
var setting model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, changed, rErr := RewriteDNSOutboundQTypeZero(setting.Value)
|
||||
if rErr != nil {
|
||||
log.Printf("DNSOutboundQTypeZeroFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if changed {
|
||||
if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
|
||||
Update("value", updated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RewriteDNSOutboundQTypeZero spells a dns rule's numeric qType 0 as "0", the one
|
||||
// form the core reads as query type 0 rather than as every query.
|
||||
func RewriteDNSOutboundQTypeZero(raw string) (string, bool, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw, false, nil
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return raw, false, err
|
||||
}
|
||||
outbounds, _ := cfg["outbounds"].([]any)
|
||||
changed := false
|
||||
for _, ob := range outbounds {
|
||||
obj, _ := ob.(map[string]any)
|
||||
if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "dns") {
|
||||
continue
|
||||
}
|
||||
settings, _ := obj["settings"].(map[string]any)
|
||||
rules, _ := settings["rules"].([]any)
|
||||
for _, r := range rules {
|
||||
rule, _ := r.(map[string]any)
|
||||
if qType, ok := rule["qType"].(float64); ok && qType == 0 {
|
||||
rule["qType"] = "0"
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw, false, nil
|
||||
}
|
||||
out, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return raw, false, err
|
||||
}
|
||||
return string(out), true, nil
|
||||
}
|
||||
|
||||
func normalizeSettingPaths() error {
|
||||
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
|
||||
var rows []model.Setting
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
"github.com/xtls/xray-core/proxy/dns"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// The core drops a lone numeric qType 0 from its PortList, and a rule with no
|
||||
// qTypes matches every query, so blockTypes [0] must not be written that way.
|
||||
func TestRewriteDNSOutboundLegacyKeysKeepsQTypeZeroPolicy(t *testing.T) {
|
||||
for _, mode := range []string{"skip", "reject"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
legacy := `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"` + mode + `","blockTypes":[0]}}`
|
||||
updated, changed, err := rewriteDNSOutboundLegacyKeys(`{"outbounds":[` + legacy + `]}`)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("rewrite: changed=%v err=%v", changed, err)
|
||||
}
|
||||
got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, updated))
|
||||
if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
|
||||
t.Fatalf("rewritten policy = %v, want the legacy policy %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Installs that already ran the legacy-keys seeder store the match-all rule, and
|
||||
// that seeder never runs again, so the repair has to reach them on its own.
|
||||
func TestSeedersRepairStoredDNSQTypeZero(t *testing.T) {
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
if err := InitDB(config.GetDBPath()); err != nil {
|
||||
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
|
||||
t.Skipf("sqlite needs cgo: %v", err)
|
||||
}
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
// The legacy-keys seeder matched the protocol id without case, so it wrote both.
|
||||
for _, protocol := range []string{"dns", "DNS"} {
|
||||
t.Run(protocol, func(t *testing.T) {
|
||||
legacy := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[0]}}`
|
||||
stored := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"rules":[{"action":"drop","qType":0},{"action":"hijack","qType":"1,28"},{"action":"drop"}]}}`
|
||||
seedDNSOutboundTemplate(t, `{"outbounds":[`+stored+`]}`)
|
||||
if err := db.Where("seeder_name = ?", "DNSOutboundQTypeZeroFix").
|
||||
Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||
t.Fatalf("clear seeder history: %v", err)
|
||||
}
|
||||
|
||||
if err := runSeeders(false); err != nil {
|
||||
t.Fatalf("runSeeders: %v", err)
|
||||
}
|
||||
|
||||
got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, storedDNSOutboundTemplate(t)))
|
||||
if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
|
||||
t.Fatalf("repaired policy = %v, want the legacy policy %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func firstTemplateOutbound(t *testing.T, template string) []byte {
|
||||
t.Helper()
|
||||
var cfg struct {
|
||||
Outbounds []json.RawMessage `json:"outbounds"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(template), &cfg); err != nil || len(cfg.Outbounds) == 0 {
|
||||
t.Fatalf("template has no outbound (%v): %s", err, template)
|
||||
}
|
||||
return cfg.Outbounds[0]
|
||||
}
|
||||
|
||||
func coreDNSOutboundPolicy(t *testing.T, raw []byte) *dns.Config {
|
||||
t.Helper()
|
||||
var outbound conf.OutboundDetourConfig
|
||||
if err := json.Unmarshal(raw, &outbound); err != nil {
|
||||
t.Fatalf("unmarshal outbound: %v", err)
|
||||
}
|
||||
handler, err := outbound.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("core build: %v", err)
|
||||
}
|
||||
instance, err := handler.ProxySettings.GetInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("core settings: %v", err)
|
||||
}
|
||||
cfg, ok := instance.(*dns.Config)
|
||||
if !ok {
|
||||
t.Fatalf("core settings type = %T, want *dns.Config", instance)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -53,6 +53,7 @@ type cachedSubTemplate struct {
|
||||
type SUBController struct {
|
||||
subTitle string
|
||||
subSupportUrl string
|
||||
subProfileMode string
|
||||
subProfileUrl string
|
||||
subAnnounce string
|
||||
subEnableRouting bool
|
||||
@@ -115,6 +116,7 @@ type subControllerConfig struct {
|
||||
|
||||
subTitle string
|
||||
subSupportURL string
|
||||
subProfileMode string
|
||||
subProfileURL string
|
||||
subAnnounce string
|
||||
subEnableRouting bool
|
||||
@@ -224,6 +226,10 @@ func WithSUBProfileURL(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subProfileURL = value }
|
||||
}
|
||||
|
||||
func WithSUBProfileMode(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subProfileMode = value }
|
||||
}
|
||||
|
||||
func WithSUBAnnounce(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subAnnounce = value }
|
||||
}
|
||||
@@ -260,6 +266,7 @@ func defaultSUBControllerConfig() subControllerConfig {
|
||||
subEncrypt: true,
|
||||
remarkTemplate: service.DefaultRemarkTemplate,
|
||||
updateInterval: "12",
|
||||
subProfileMode: service.SubProfileModeNone,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +284,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
a := &SUBController{
|
||||
subTitle: config.subTitle,
|
||||
subSupportUrl: config.subSupportURL,
|
||||
subProfileMode: config.subProfileMode,
|
||||
subProfileUrl: config.subProfileURL,
|
||||
subAnnounce: config.subAnnounce,
|
||||
subEnableRouting: config.subEnableRouting,
|
||||
@@ -485,8 +493,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
|
||||
// Add headers
|
||||
header := subReq.subscriptionUserinfo(traffic)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
|
||||
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
||||
@@ -818,14 +825,13 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
||||
if len(jsonSub) == 0 && header == "" {
|
||||
return false
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
var subReq *SubService
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
if subReq == nil {
|
||||
subReq = a.subService.ForRequest(host)
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
}, subId, builtinProfileURL(c, scheme, hostWithPort))
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
|
||||
@@ -887,14 +893,13 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
|
||||
if len(clashSub) == 0 && header == "" {
|
||||
return false
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
var subReq *SubService
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
if subReq == nil {
|
||||
subReq = a.subService.ForRequest(host)
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
}, subId, builtinProfileURL(c, scheme, hostWithPort))
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
||||
@@ -906,6 +911,11 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
|
||||
return true
|
||||
}
|
||||
|
||||
func builtinProfileURL(c *gin.Context, scheme, hostWithPort string) string {
|
||||
// Drop download/format selectors so the opt-in link always opens the HTML page.
|
||||
return fmt.Sprintf("%s://%s%s?html=1", scheme, hostWithPort, c.Request.URL.EscapedPath())
|
||||
}
|
||||
|
||||
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||
func (a *SUBController) ApplyCommonHeaders(
|
||||
c *gin.Context,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
type subPlaceholderData struct {
|
||||
@@ -76,10 +77,17 @@ func subMetadataUsesPlaceholders(values ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID string, fallbackProfileURL string) renderedSubMetadata {
|
||||
func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID, builtinURL string) renderedSubMetadata {
|
||||
profileURL := ""
|
||||
switch a.subProfileMode {
|
||||
case service.SubProfileModeBuiltin:
|
||||
profileURL = builtinURL
|
||||
case service.SubProfileModeCustom:
|
||||
profileURL = strings.TrimSpace(a.subProfileUrl)
|
||||
}
|
||||
var context remarkContext
|
||||
var hasContext bool
|
||||
if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) {
|
||||
if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, profileURL, a.subAnnounce) {
|
||||
var err error
|
||||
subReq := getSubReq()
|
||||
context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
|
||||
@@ -87,12 +95,8 @@ func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subI
|
||||
logger.Warning("sub: load template contexts for subscription metadata:", err)
|
||||
}
|
||||
}
|
||||
profileURL := a.subProfileUrl
|
||||
if profileURL == "" {
|
||||
profileURL = fallbackProfileURL
|
||||
} else {
|
||||
profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
|
||||
}
|
||||
// Disabled modes ignore the retained custom URL and never fall back to the request URL.
|
||||
profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
|
||||
data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
|
||||
return renderedSubMetadata{
|
||||
Title: renderSubPlaceholders(a.subTitle, data),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestRenderSubPlaceholders(t *testing.T) {
|
||||
@@ -68,20 +73,75 @@ func TestRenderSubPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataForSubRequestDoesNotExpandFallbackProfileURL(t *testing.T) {
|
||||
func TestMetadataForSubRequestOmitsUnconfiguredProfileURL(t *testing.T) {
|
||||
a := &SUBController{
|
||||
subTitle: "isVPN",
|
||||
subSupportUrl: "https://support.example/",
|
||||
}
|
||||
fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}"
|
||||
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
|
||||
return nil
|
||||
}, "sub-123", fallback)
|
||||
}, "sub-123", "https://sub.example/sub-123")
|
||||
|
||||
if metadata.ProfileURL != fallback {
|
||||
t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback)
|
||||
if metadata.ProfileURL != "" {
|
||||
t.Fatalf("ProfileURL = %q, want no link when unconfigured", metadata.ProfileURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionProfileURLRequiresExplicitConfiguration(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
initSubDB(t)
|
||||
seedInfoEndpointSub(t, "profile-sub", "profile@example.com")
|
||||
|
||||
for _, config := range []struct {
|
||||
name, profileURL, want string
|
||||
}{
|
||||
{name: "empty"},
|
||||
{name: "whitespace", profileURL: " "},
|
||||
{name: "explicit", profileURL: "https://portal.example.com/account", want: "https://portal.example.com/account"},
|
||||
{name: "template", profileURL: "https://portal.example.com/account?sub={{SUB_ID}}", want: "https://portal.example.com/account?sub=profile-sub"},
|
||||
} {
|
||||
t.Run(config.name, func(t *testing.T) {
|
||||
for _, client := range []struct {
|
||||
name, userAgent string
|
||||
happAutoDetect bool
|
||||
}{
|
||||
{name: "Happ", userAgent: "Happ/3.22.0 (Android)", happAutoDetect: true},
|
||||
{name: "Happ without auto-detection", userAgent: "Happ/3.22.0 (Android)"},
|
||||
{name: "standard", userAgent: "v2rayNG/1.8.5", happAutoDetect: true},
|
||||
} {
|
||||
t.Run(client.name, func(t *testing.T) {
|
||||
// All formats must honor the opt-in, independently of Happ customization.
|
||||
router := gin.New()
|
||||
NewSUBController(router.Group("/"),
|
||||
WithSUBJsonEnabled(true), WithSUBClashEnabled(true),
|
||||
WithSUBProfileURL(config.profileURL),
|
||||
WithSUBProfileMode(service.SubProfileModeCustom),
|
||||
WithSUBHappConfig(HappConfig{AutoDetect: client.happAutoDetect}),
|
||||
)
|
||||
for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "sub.example.com"
|
||||
req.Header.Set("User-Agent", client.userAgent)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Profile-Web-Page-Url"); got != config.want {
|
||||
t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, config.want)
|
||||
}
|
||||
if config.want == "" {
|
||||
if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
|
||||
t.Fatal("unconfigured profile header must be omitted")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +170,13 @@ func TestMetadataForSubRequestUsesStableClientIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
a := &SUBController{
|
||||
subTitle: "isVPN — {{EMAIL}}",
|
||||
subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
|
||||
subProfileUrl: "https://profile.example/account/{{ID}}",
|
||||
subAnnounce: "Subscription {{SUB_ID}}",
|
||||
subTitle: "isVPN — {{EMAIL}}",
|
||||
subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
|
||||
subProfileUrl: "https://profile.example/account/{{ID}}",
|
||||
subProfileMode: service.SubProfileModeCustom,
|
||||
subAnnounce: "Subscription {{SUB_ID}}",
|
||||
}
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://fallback.example/{{EMAIL}}")
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://sub.example/sub-123")
|
||||
|
||||
if metadata.Title != "isVPN — john doe@example.com" {
|
||||
t.Fatalf("Title = %q", metadata.Title)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestSubscriptionProfileModesFromSavedSettings(t *testing.T) {
|
||||
oldFS, oldMode := distFS, gin.Mode()
|
||||
oldWriter, oldErrorWriter := gin.DefaultWriter, gin.DefaultErrorWriter
|
||||
SetDistFS(testDistFS)
|
||||
t.Cleanup(func() {
|
||||
SetDistFS(oldFS)
|
||||
gin.SetMode(oldMode)
|
||||
gin.DefaultWriter, gin.DefaultErrorWriter = oldWriter, oldErrorWriter
|
||||
})
|
||||
for _, config := range []struct {
|
||||
name, mode, profileURL, want string
|
||||
}{
|
||||
{name: "new installation"},
|
||||
{name: "legacy whitespace", profileURL: " "},
|
||||
{name: "legacy custom", profileURL: "https://portal.example/account", want: "https://portal.example/account"},
|
||||
{name: "none retains custom", mode: "none", profileURL: "https://portal.example/account"},
|
||||
{name: "builtin", mode: "builtin", profileURL: "https://portal.example/account"},
|
||||
{name: "custom", mode: "custom", profileURL: "https://portal.example/?sub={{SUB_ID}}", want: "https://portal.example/?sub=profile-sub"},
|
||||
{name: "empty custom", mode: "custom"},
|
||||
{name: "invalid mode", mode: "invalid", profileURL: "https://portal.example/account"},
|
||||
} {
|
||||
t.Run(config.name, func(t *testing.T) {
|
||||
initSubDB(t)
|
||||
seedInfoEndpointSub(t, "profile-sub", "profile@example.com")
|
||||
settings := []model.Setting{
|
||||
{Key: "subPath", Value: "/sub/"},
|
||||
{Key: "subJsonPath", Value: "/json/"},
|
||||
{Key: "subClashPath", Value: "/clash/"},
|
||||
{Key: "subJsonEnable", Value: "true"},
|
||||
{Key: "subClashEnable", Value: "true"},
|
||||
{Key: "subProfileUrl", Value: config.profileURL},
|
||||
}
|
||||
if config.mode != "" {
|
||||
settings = append(settings, model.Setting{Key: "subProfileMode", Value: config.mode})
|
||||
}
|
||||
for _, setting := range settings {
|
||||
if err := database.GetDB().Where("key = ?", setting.Key).Delete(&model.Setting{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.GetDB().Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
router, err := (&Server{}).initRouter()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub", "/json/profile-sub?view=raw", "/clash/profile-sub?view=raw", "/mihomo/profile-sub"} {
|
||||
for _, userAgent := range []string{"Happ/3.22.0 (Android)", "v2rayNG/1.8.5"} {
|
||||
t.Run(path+"/"+userAgent, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "https://sub.example.com:8443"+path, nil)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
want := config.want
|
||||
if config.mode == "builtin" {
|
||||
want = "https://sub.example.com:8443" + req.URL.EscapedPath() + "?html=1"
|
||||
}
|
||||
if got := resp.Header().Get("Profile-Web-Page-Url"); got != want {
|
||||
t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, want)
|
||||
}
|
||||
if want == "" {
|
||||
if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
|
||||
t.Fatal("disabled profile header must be absent")
|
||||
}
|
||||
}
|
||||
if config.mode == "builtin" {
|
||||
// The restored link must open the page, even when copied from a raw download.
|
||||
page := httptest.NewRecorder()
|
||||
router.ServeHTTP(page, httptest.NewRequest(http.MethodGet, want, nil))
|
||||
if page.Code != http.StatusOK || !strings.Contains(page.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("builtin link did not serve HTML: status=%d, type=%q", page.Code, page.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
if err != nil {
|
||||
SubProfileUrl = ""
|
||||
}
|
||||
SubProfileMode, err := s.settingService.GetSubProfileMode()
|
||||
if err != nil {
|
||||
SubProfileMode = service.SubProfileModeNone
|
||||
}
|
||||
|
||||
SubAnnounce, err := s.settingService.GetSubAnnounce()
|
||||
if err != nil {
|
||||
@@ -329,6 +333,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
WithSUBTitle(SubTitle),
|
||||
WithSUBSupportURL(SubSupportUrl),
|
||||
WithSUBProfileURL(SubProfileUrl),
|
||||
WithSUBProfileMode(SubProfileMode),
|
||||
WithSUBAnnounce(SubAnnounce),
|
||||
WithSUBEnableRouting(SubEnableRouting),
|
||||
WithSUBRoutingRules(SubRoutingRules),
|
||||
|
||||
@@ -48,6 +48,7 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
|
||||
lines := splitLines(text)
|
||||
var outbounds []Outbound
|
||||
var identities []string
|
||||
seen := map[string]int{}
|
||||
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
@@ -59,8 +60,14 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
|
||||
// Ignore unparseable lines (comments, unsupported protocols, etc.)
|
||||
continue
|
||||
}
|
||||
identity := res.Identity
|
||||
// A repeated identity would share one stored tag, shifting both tags on every refresh.
|
||||
if n := seen[res.Identity]; n > 0 {
|
||||
identity = fmt.Sprintf("%s#%d", res.Identity, n)
|
||||
}
|
||||
seen[res.Identity]++
|
||||
outbounds = append(outbounds, res.Outbound)
|
||||
identities = append(identities, res.Identity)
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
return outbounds, identities, nil
|
||||
}
|
||||
@@ -1047,10 +1054,18 @@ func firstParam(p url.Values, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// realityPerRequestParams are picked per request by subscription servers (3x-ui randomizes
|
||||
// sid/sni, older releases spx too), so they must not split one server into new identities.
|
||||
var realityPerRequestParams = map[string]bool{"sid": true, "sni": true, "spx": true}
|
||||
|
||||
func canonicalQuery(p url.Values) string {
|
||||
// Sort keys for stable identity
|
||||
reality := p.Get("security") == "reality"
|
||||
keys := make([]string, 0, len(p))
|
||||
for k := range p {
|
||||
if reality && realityPerRequestParams[k] {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// simple sort
|
||||
|
||||
@@ -24,6 +24,17 @@ func TestParseVmessLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkIdentityKeepsTLSServerName(t *testing.T) {
|
||||
a, errA := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=a.example.com#node")
|
||||
b, errB := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=b.example.com#node")
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("parse vless: %v, %v", errA, errB)
|
||||
}
|
||||
if a.Identity == b.Identity {
|
||||
t.Fatalf("TLS links for different SNIs share identity %q", a.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVlessLink(t *testing.T) {
|
||||
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
|
||||
res, err := ParseLink(link)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package totp
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/xlzd/gotp"
|
||||
)
|
||||
|
||||
// SkewWindows is how many 30s steps around now VerifyWithSkew accepts.
|
||||
// Standard TOTP clock-drift tolerance, see MHSanaei/3x-ui#6535.
|
||||
const SkewWindows = 1
|
||||
|
||||
// VerifyWithSkew accepts the code for the current step plus/minus SkewWindows.
|
||||
func VerifyWithSkew(secret, code string, now time.Time) bool {
|
||||
totp := gotp.NewDefaultTOTP(secret)
|
||||
for i := -SkewWindows; i <= SkewWindows; i++ {
|
||||
if totp.AtTime(now.Add(time.Duration(i*30)*time.Second)) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package totp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xlzd/gotp"
|
||||
)
|
||||
|
||||
func TestVerifyWithSkew(t *testing.T) {
|
||||
secret := "JBSWY3DPEHPK3PXP"
|
||||
totp := gotp.NewDefaultTOTP(secret)
|
||||
// Anchor mid-window so a step boundary can't fall between sampling and verify.
|
||||
now := time.Unix((time.Now().Unix()/30)*30+15, 0).UTC()
|
||||
|
||||
if !VerifyWithSkew(secret, totp.AtTime(now), now) {
|
||||
t.Fatal("current window code should verify")
|
||||
}
|
||||
if !VerifyWithSkew(secret, totp.AtTime(now.Add(-30*time.Second)), now) {
|
||||
t.Fatal("previous window code should verify (clock skew)")
|
||||
}
|
||||
if !VerifyWithSkew(secret, totp.AtTime(now.Add(30*time.Second)), now) {
|
||||
t.Fatal("next window code should verify (clock skew)")
|
||||
}
|
||||
if VerifyWithSkew(secret, totp.AtTime(now.Add(-60*time.Second)), now) {
|
||||
t.Fatal("code two windows old should not verify")
|
||||
}
|
||||
if VerifyWithSkew(secret, totp.AtTime(now.Add(60*time.Second)), now) {
|
||||
t.Fatal("code two windows ahead should not verify")
|
||||
}
|
||||
if VerifyWithSkew(secret, "000000", now) {
|
||||
t.Fatal("wrong code should not verify")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ type AllSetting struct {
|
||||
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
|
||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||
SubProfileMode string `json:"subProfileMode" form:"subProfileMode"`
|
||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
||||
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
|
||||
|
||||
@@ -612,7 +612,8 @@ func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPW
|
||||
return actionable
|
||||
}
|
||||
|
||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
|
||||
// disconnectClientTemporarily drops a client's credential for a moment, so new
|
||||
// handshakes are refused; the fail2ban ban is what ends live traffic.
|
||||
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
||||
var xrayAPI xray.XrayAPI
|
||||
apiPort := j.resolveXrayAPIPort()
|
||||
@@ -626,11 +627,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
||||
|
||||
// Find the client config
|
||||
var clientConfig map[string]any
|
||||
var reverseClient bool
|
||||
for _, client := range clients {
|
||||
if client.Email == clientEmail {
|
||||
// Convert client to map for API
|
||||
clientBytes, _ := json.Marshal(client)
|
||||
_ = json.Unmarshal(clientBytes, &clientConfig)
|
||||
reverseClient = client.Reverse != nil
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -650,6 +653,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
||||
return
|
||||
}
|
||||
|
||||
// RemoveUser drops a reverse client's outbound handler and the re-add below
|
||||
// cannot restore it, so its tunnel would stay down until Xray restarts.
|
||||
if reverseClient {
|
||||
logger.Warningf("[LIMIT_IP] Not disconnecting %s: its reverse proxy config does not survive a temporary removal", clientEmail)
|
||||
return
|
||||
}
|
||||
|
||||
// For Shadowsocks, ensure the required "cipher" field is present by
|
||||
// reading it from the inbound settings (e.g., settings["method"]).
|
||||
if string(inbound.Protocol) == "shadowsocks" {
|
||||
@@ -663,14 +673,16 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user to disconnect all connections
|
||||
// The core's RemoveUser clears its validator: a session already up keeps
|
||||
// running, except a reverse vless client, which is skipped above.
|
||||
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
||||
if err != nil {
|
||||
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait a moment for disconnection to take effect
|
||||
// Nothing is pending here: AlterInbound applies the removal inline, so this
|
||||
// only widens the window in which new handshakes fail.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Re-add user to allow new connections
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// ldapHungNodeInbound seeds an online node whose client writes hang until the gate
|
||||
// opens, plus one inbound on it holding the given enabled clients.
|
||||
func ldapHungNodeInbound(t *testing.T, emails []string) (*resetGate, *model.Inbound) {
|
||||
t.Helper()
|
||||
initLdapJobDB(t)
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
gate := &resetGate{release: make(chan struct{})}
|
||||
const tag = "ldap-node-in"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "inbounds/list") {
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":[{"id":1,"tag":"` + tag + `"}]}`))
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
gate.entered.Add(1)
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-gate.release:
|
||||
}
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Cleanup(gate.open)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
db := database.GetDB()
|
||||
node := &model.Node{
|
||||
Name: "ldap-node", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
|
||||
Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||
}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
clients := make([]model.Client, 0, len(emails))
|
||||
for i, email := range emails {
|
||||
clients = append(clients, model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), Enable: true})
|
||||
}
|
||||
settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Enable: true, Port: 47200, Protocol: model.VLESS, NodeID: &node.Id,
|
||||
Tag: tag, Settings: string(settings), StreamSettings: `{"network":"tcp"}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
for _, c := range clients {
|
||||
rec := model.ClientRecord{Email: c.Email, UUID: c.ID, Enable: true}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("link client: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create client traffic: %v", err)
|
||||
}
|
||||
}
|
||||
return gate, ib
|
||||
}
|
||||
|
||||
func inboundClientEnables(t *testing.T, inboundID int) map[string]bool {
|
||||
t.Helper()
|
||||
var ib model.Inbound
|
||||
if err := database.GetDB().First(&ib, inboundID).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
var settings struct {
|
||||
Clients []model.Client `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||
t.Fatalf("parse settings: %v", err)
|
||||
}
|
||||
out := make(map[string]bool, len(settings.Clients))
|
||||
for _, c := range settings.Clients {
|
||||
out[c.Email] = c.Enable
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Each LDAP user was disabled on its own, and every per-user push held the inbound
|
||||
// lock for the push timeout, so users sharing a hung node inbound queued behind it.
|
||||
func TestLdapBatchSetEnableDoesNotQueueUsersOnHungNode(t *testing.T) {
|
||||
emails := []string{"u1@ldap", "u2@ldap", "u3@ldap", "u4@ldap", "u5@ldap"}
|
||||
gate, ib := ldapHungNodeInbound(t, emails)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
NewLdapSyncJob().batchSetEnable(emails, false)
|
||||
}()
|
||||
t.Cleanup(func() { gate.open(); <-done })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(9 * time.Second):
|
||||
t.Fatalf("disabling %d LDAP users on one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
|
||||
}
|
||||
for email, enabled := range inboundClientEnables(t, ib.Id) {
|
||||
if enabled {
|
||||
t.Errorf("client %s still enabled after the LDAP disable", email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clients missing from LDAP were detached one at a time, each waiting out the push
|
||||
// timeout on a hung node inbound, so a directory cleanup could run for hours.
|
||||
func TestLdapDeleteDoesNotQueueClientsOnHungNode(t *testing.T) {
|
||||
emails := []string{"gone1@ldap", "gone2@ldap", "gone3@ldap", "gone4@ldap", "gone5@ldap"}
|
||||
gate, ib := ldapHungNodeInbound(t, emails)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
NewLdapSyncJob().deleteClientsNotInLDAP(ib.Tag, map[string]struct{}{})
|
||||
}()
|
||||
t.Cleanup(func() { gate.open(); <-done })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(9 * time.Second):
|
||||
t.Fatalf("detaching %d clients from one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
|
||||
}
|
||||
if left := inboundClientEnables(t, ib.Id); len(left) != 0 {
|
||||
t.Errorf("clients still on the inbound after the LDAP cleanup: %v", left)
|
||||
}
|
||||
}
|
||||
@@ -131,8 +131,7 @@ func (j *LdapSyncJob) Run() {
|
||||
}
|
||||
|
||||
clientsToCreate := []model.Client{}
|
||||
clientsToEnable := map[string][]string{} // tag -> []email
|
||||
clientsToDisable := map[string][]string{} // tag -> []email
|
||||
var clientsToEnable, clientsToDisable []string
|
||||
|
||||
for email, allowed := range flags {
|
||||
existing := allClients[email]
|
||||
@@ -142,24 +141,21 @@ func (j *LdapSyncJob) Run() {
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, tag := range resolvedTags {
|
||||
if allowed && !existing.Enable {
|
||||
clientsToEnable[tag] = append(clientsToEnable[tag], email)
|
||||
} else if !allowed && existing.Enable {
|
||||
clientsToDisable[tag] = append(clientsToDisable[tag], email)
|
||||
}
|
||||
if len(resolvedTags) == 0 {
|
||||
continue
|
||||
}
|
||||
if allowed && !existing.Enable {
|
||||
clientsToEnable = append(clientsToEnable, email)
|
||||
} else if !allowed && existing.Enable {
|
||||
clientsToDisable = append(clientsToDisable, email)
|
||||
}
|
||||
}
|
||||
|
||||
j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
|
||||
|
||||
// --- Execute enable/disable batch ---
|
||||
for tag, emails := range clientsToEnable {
|
||||
j.batchSetEnable(inboundMap[tag], emails, true)
|
||||
}
|
||||
for tag, emails := range clientsToDisable {
|
||||
j.batchSetEnable(inboundMap[tag], emails, false)
|
||||
}
|
||||
j.batchSetEnable(clientsToEnable, true)
|
||||
j.batchSetEnable(clientsToDisable, false)
|
||||
|
||||
// --- Auto delete clients not in LDAP ---
|
||||
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
|
||||
@@ -257,34 +253,28 @@ func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int,
|
||||
logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
|
||||
}
|
||||
|
||||
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
|
||||
// batchSetEnable takes the bulk path: per-user calls held each inbound's lock through
|
||||
// its node push, so users sharing a hung node inbound queued one push timeout apiece.
|
||||
func (j *LdapSyncJob) batchSetEnable(emails []string, enable bool) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
restartNeeded := false
|
||||
changed := 0
|
||||
for _, email := range emails {
|
||||
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
|
||||
if err != nil {
|
||||
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
changed++
|
||||
}
|
||||
if needRestart {
|
||||
restartNeeded = true
|
||||
}
|
||||
result, needRestart, err := j.clientService.BulkSetEnable(&j.inboundService, emails, enable)
|
||||
if err != nil {
|
||||
logger.Warningf("Batch set enable=%v failed: %v", enable, err)
|
||||
}
|
||||
if changed > 0 {
|
||||
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
|
||||
for _, skipped := range result.Skipped {
|
||||
logger.Warningf("Batch set enable failed for %s: %s", skipped.Email, skipped.Reason)
|
||||
}
|
||||
if restartNeeded {
|
||||
if result.Changed > 0 {
|
||||
logger.Infof("Batch set enable=%v for %d clients", enable, result.Changed)
|
||||
}
|
||||
if needRestart {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
|
||||
// deleteClientsNotInLDAP detaches clients not in LDAP, one bulk detach per inbound
|
||||
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
|
||||
inbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
@@ -292,7 +282,6 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
|
||||
return
|
||||
}
|
||||
|
||||
batchSize := 50 // clients in 1 batch
|
||||
restartNeeded := false
|
||||
|
||||
for _, ib := range inbounds {
|
||||
@@ -317,23 +306,23 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
|
||||
continue
|
||||
}
|
||||
|
||||
for i := 0; i < len(toDelete); i += batchSize {
|
||||
end := min(i+batchSize, len(toDelete))
|
||||
batch := toDelete[i:end]
|
||||
|
||||
for _, c := range batch {
|
||||
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
|
||||
c.Email, ib.Id, ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
|
||||
c.Email, ib.Id, ib.Tag)
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
emails := make([]string, len(toDelete))
|
||||
for i, c := range toDelete {
|
||||
emails[i] = c.Email
|
||||
}
|
||||
result, nr, err := j.clientService.BulkDetach(&j.inboundService, emails, []int{ib.Id})
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to delete clients from inbound id=%d(tag=%s): %v", ib.Id, ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
for _, msg := range result.Errors {
|
||||
logger.Warningf("Failed to delete client from inbound id=%d(tag=%s): %s", ib.Id, ib.Tag, msg)
|
||||
}
|
||||
for _, email := range result.Detached {
|
||||
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", email, ib.Id, ib.Tag)
|
||||
}
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,39 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// A reverse client has to be left alone: the cycle drops its reverse outbound
|
||||
// handler and re-adds an account without a reverse, which no retry restores.
|
||||
func TestDisconnectClientTemporarilySkipsReverseClient(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
|
||||
const email = "rev-limit-probe"
|
||||
inbound := &model.Inbound{
|
||||
Id: 1,
|
||||
Protocol: model.VLESS,
|
||||
Tag: "rev-limit-probe-tag",
|
||||
Settings: `{"clients":[]}`,
|
||||
}
|
||||
clients := []model.Client{{Email: email, ID: "11111111-1111-1111-1111-111111111111", Reverse: &model.ClientReverse{Tag: "rev-out"}}}
|
||||
|
||||
(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
|
||||
|
||||
var skipped, attempted bool
|
||||
for _, line := range logger.GetLogs(500, "warning") {
|
||||
if strings.Contains(line, "Not disconnecting "+email) {
|
||||
skipped = true
|
||||
}
|
||||
if strings.Contains(line, "Failed to remove user "+email) {
|
||||
attempted = true
|
||||
}
|
||||
}
|
||||
if attempted {
|
||||
t.Fatal("a reverse client must not be removed and re-added")
|
||||
}
|
||||
if !skipped {
|
||||
t.Fatal("the skip must be reported, not silent")
|
||||
}
|
||||
}
|
||||
|
||||
// The protocol gate must let hysteria through: XrayAPI supports it, and the
|
||||
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
|
||||
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func transitiveGuids(t *testing.T) []string {
|
||||
t.Helper()
|
||||
tree, err := (&service.NodeService{}).GetNodeTree()
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeTree: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range tree {
|
||||
if n.Transitive {
|
||||
out = append(out, n.Guid)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The heartbeat skips a disabled node and never sees a deleted one, so the
|
||||
// sub-nodes it had learned from them stayed on the Nodes page for good.
|
||||
func TestHeartbeatDropsSubNodesOfNodesItNoLongerProbes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
retire func(t *testing.T, nodeID int)
|
||||
}{
|
||||
{"disabled", func(t *testing.T, nodeID int) {
|
||||
if err := (&service.NodeService{}).SetEnable(nodeID, false); err != nil {
|
||||
t.Fatalf("SetEnable: %v", err)
|
||||
}
|
||||
}},
|
||||
{"deleted", func(t *testing.T, nodeID int) {
|
||||
if err := (&service.NodeService{}).Delete(nodeID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "server/status"):
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"panelGuid":"direct-guid","xray":{"state":"running"}}}`))
|
||||
case strings.HasSuffix(r.URL.Path, "server/descendants"):
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":[{"guid":"sub-guid","parentGuid":"direct-guid","name":"sub","status":"online"}]}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
node := &model.Node{
|
||||
Name: "direct", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
|
||||
Enable: true, Status: "unknown", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
hb := NewNodeHeartbeatJob()
|
||||
hb.Run()
|
||||
if got := transitiveGuids(t); len(got) != 1 || got[0] != "sub-guid" {
|
||||
t.Fatalf("sub-nodes after first heartbeat = %v, want [sub-guid]", got)
|
||||
}
|
||||
|
||||
tc.retire(t, node.Id)
|
||||
hb.Run()
|
||||
if got := transitiveGuids(t); len(got) != 0 {
|
||||
t.Fatalf("sub-nodes after the node was %s = %v, want none", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +20,10 @@ import (
|
||||
const (
|
||||
nodeHeartbeatConcurrency = 32
|
||||
nodeHeartbeatRequestTimeout = 4 * time.Second
|
||||
// Past this many same-direction transitions in one tick, one summary event goes out:
|
||||
// per-node events overflow the notifier queues and every chat's rate limit.
|
||||
nodeTransitionBurst = 5
|
||||
nodeTransitionBurstNames = 10
|
||||
)
|
||||
|
||||
type NodeHeartbeatJob struct {
|
||||
@@ -39,12 +46,15 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
logger.Warning("node heartbeat: load nodes failed:", err)
|
||||
return
|
||||
}
|
||||
j.nodeService.RetainEnabledNodeDescendants(nodes)
|
||||
if len(nodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, nodeHeartbeatConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var transitionsMu sync.Mutex
|
||||
var transitions []eventbus.Event
|
||||
for _, n := range nodes {
|
||||
if !n.Enable {
|
||||
continue
|
||||
@@ -55,10 +65,15 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
common.GoRecover("node-heartbeat:"+n.Name, func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
j.probeOne(n)
|
||||
if event := j.probeOne(n); event != nil {
|
||||
transitionsMu.Lock()
|
||||
transitions = append(transitions, *event)
|
||||
transitionsMu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
publishNodeTransitions(transitions)
|
||||
|
||||
if !websocket.HasClients() {
|
||||
return
|
||||
@@ -71,7 +86,7 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
websocket.BroadcastNodes(updated)
|
||||
}
|
||||
|
||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) *eventbus.Event {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||
defer cancel()
|
||||
prevStatus := n.Status
|
||||
@@ -84,7 +99,6 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
|
||||
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
|
||||
}
|
||||
publishNodeTransition(n, prevStatus, patch)
|
||||
// Learn the nodes this node manages so the panel can surface them as
|
||||
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
|
||||
// be spent. Drop them when the node is unreachable.
|
||||
@@ -95,15 +109,12 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
} else {
|
||||
j.nodeService.ClearDescendants(n.Id)
|
||||
}
|
||||
return nodeTransitionEvent(n, prevStatus, patch)
|
||||
}
|
||||
|
||||
// publishNodeTransition emits node.down / node.up only on a genuine state change.
|
||||
// An "unknown"/empty previous status (fresh start) is treated as not-online, so a
|
||||
// node coming up for the first time fires node.up but never a spurious node.down.
|
||||
func publishNodeTransition(n *model.Node, prevStatus string, patch service.HeartbeatPatch) {
|
||||
if EventBus == nil {
|
||||
return
|
||||
}
|
||||
// nodeTransitionEvent is node.down / node.up on a genuine state change only; an unknown
|
||||
// previous status (fresh start) counts as not-online, so it never yields node.down.
|
||||
func nodeTransitionEvent(n *model.Node, prevStatus string, patch service.HeartbeatPatch) *eventbus.Event {
|
||||
var eventType eventbus.EventType
|
||||
switch {
|
||||
case prevStatus == "online" && patch.Status == "offline":
|
||||
@@ -111,13 +122,13 @@ func publishNodeTransition(n *model.Node, prevStatus string, patch service.Heart
|
||||
case prevStatus != "online" && patch.Status == "online":
|
||||
eventType = eventbus.EventNodeUp
|
||||
default:
|
||||
return
|
||||
return nil
|
||||
}
|
||||
source := n.Name
|
||||
if source == "" {
|
||||
source = "node-" + strconv.Itoa(n.Id)
|
||||
}
|
||||
EventBus.Publish(eventbus.Event{
|
||||
return &eventbus.Event{
|
||||
Type: eventType,
|
||||
Source: source,
|
||||
Data: &eventbus.NodeHealthData{
|
||||
@@ -128,5 +139,34 @@ func publishNodeTransition(n *model.Node, prevStatus string, patch service.Heart
|
||||
XrayState: patch.XrayState,
|
||||
XrayError: patch.XrayError,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// publishNodeTransitions sends one tick's transitions, folding a same-direction burst
|
||||
// (a master-side blip flips every node at once) into one event naming the nodes.
|
||||
func publishNodeTransitions(events []eventbus.Event) {
|
||||
if EventBus == nil {
|
||||
return
|
||||
}
|
||||
namesByType := make(map[eventbus.EventType][]string)
|
||||
for _, e := range events {
|
||||
namesByType[e.Type] = append(namesByType[e.Type], e.Source)
|
||||
}
|
||||
for _, e := range events {
|
||||
if len(namesByType[e.Type]) <= nodeTransitionBurst {
|
||||
EventBus.Publish(e)
|
||||
}
|
||||
}
|
||||
for _, eventType := range []eventbus.EventType{eventbus.EventNodeDown, eventbus.EventNodeUp} {
|
||||
names := namesByType[eventType]
|
||||
if len(names) <= nodeTransitionBurst {
|
||||
continue
|
||||
}
|
||||
sort.Strings(names)
|
||||
source := strings.Join(names[:min(len(names), nodeTransitionBurstNames)], ", ")
|
||||
if extra := len(names) - nodeTransitionBurstNames; extra > 0 {
|
||||
source += fmt.Sprintf(" (+%d)", extra)
|
||||
}
|
||||
EventBus.Publish(eventbus.Event{Type: eventType, Source: source})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// A node's IP-limit job only reads rows for its own clients, so pushing the
|
||||
// whole table made every node store and echo back the entire fleet's IPs.
|
||||
func TestNodeTrafficSyncPushesOnlyHostedClientIps(t *testing.T) {
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
service.StartTrafficWriter()
|
||||
t.Cleanup(service.StopTrafficWriter)
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
|
||||
var mu sync.Mutex
|
||||
pushed := map[string][]string{}
|
||||
now := time.Now().Unix()
|
||||
for i, email := range []string{"a@node", "b@node"} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "inbounds/list"):
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"id":"0000000%d-0000-4000-8000-000000000000","enable":true}],"decryption":"none"}`, email, i)
|
||||
ib, _ := json.Marshal([]map[string]any{{
|
||||
"id": 1, "tag": fmt.Sprintf("in-%d", 20000+i), "port": 20000 + i, "protocol": "vless", "enable": true,
|
||||
"settings": settings, "streamSettings": `{"network":"tcp"}`, "sniffing": `{}`,
|
||||
"clientStats": []map[string]any{{"email": email, "enable": true}},
|
||||
}})
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":` + string(ib) + `}`))
|
||||
return
|
||||
case strings.HasSuffix(r.URL.Path, "server/clientIps") && r.Method == http.MethodPost:
|
||||
var rows []model.InboundClientIps
|
||||
_ = json.NewDecoder(r.Body).Decode(&rows)
|
||||
mu.Lock()
|
||||
for _, row := range rows {
|
||||
pushed[email] = append(pushed[email], row.ClientEmail)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
if err := database.GetDB().Create(&model.Node{
|
||||
Name: email, Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
|
||||
Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Create(&model.InboundClientIps{
|
||||
ClientEmail: email, Ips: fmt.Sprintf(`[{"ip":"10.0.0.%d","timestamp":%d}]`, i+1, now),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client ips: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
NewNodeTrafficSyncJob().Run()
|
||||
|
||||
for _, email := range []string{"a@node", "b@node"} {
|
||||
if got := pushed[email]; !slices.Equal(got, []string{email}) {
|
||||
t.Errorf("node hosting %s received IP rows for %v, want only [%s]", email, got, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
nodeTrafficSyncConcurrency = 8
|
||||
// The heartbeat's bound: at 8, 300 nodes 80ms away took 25-30s per 5s tick on SQLite
|
||||
// and 6-9s at 32; neither SQLite nor Postgres raised lock or pool errors.
|
||||
nodeTrafficSyncConcurrency = 32
|
||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||
nodeReconcileTimeout = 30 * time.Second
|
||||
nodeClientIpSyncInterval = 10 * time.Second
|
||||
@@ -94,6 +96,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
logger.Warning("node traffic sync: load nodes failed:", err)
|
||||
return
|
||||
}
|
||||
j.inboundService.RetainSyncedNodeOnlineClients(nodes)
|
||||
if len(nodes) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -447,7 +450,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
logger.Warningf("node traffic sync: fetch client ips from %s failed: %v", n.Name, err)
|
||||
}
|
||||
|
||||
masterIps, err := j.inboundService.GetAllInboundClientIps()
|
||||
masterIps, err := j.inboundService.GetNodeInboundClientIps(n.Id)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
|
||||
return active
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// The sync tick is the only place that sees which nodes it no longer fetches, so it
|
||||
// must drop their online sets itself: a disabled node here, a deleted one below.
|
||||
func TestNodeTrafficSyncDropsOnlineClientsOfUnsyncedNodes(t *testing.T) {
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
process := xray.NewTestProcess(nil, "")
|
||||
t.Cleanup(service.SetXrayProcessForTest(process))
|
||||
|
||||
disabled := &model.Node{Name: "disabled", Address: "127.0.0.1", Port: 1, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := database.GetDB().Create(disabled).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Model(disabled).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable node: %v", err)
|
||||
}
|
||||
process.SetNodeOnlineTree(disabled.Id, map[string][]string{"g-disabled": {"a@x"}})
|
||||
process.SetNodeOnlineTree(disabled.Id+100, map[string][]string{"g-deleted": {"b@x"}})
|
||||
|
||||
NewNodeTrafficSyncJob().Run()
|
||||
|
||||
if got := process.GetMergedNodeTrees(); len(got) != 0 {
|
||||
t.Fatalf("online sets after a sync tick = %v, want none for a disabled or deleted node", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
// goingDownNodes seeds n online nodes whose address refuses connections, so the
|
||||
// next heartbeat flips every one of them to offline in the same tick.
|
||||
func goingDownNodes(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
srv := httptest.NewServer(nil)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
srv.Close()
|
||||
for i := range n {
|
||||
node := &model.Node{
|
||||
Name: fmt.Sprintf("node-%02d", i), Scheme: "http", Address: host, Port: portNum, BasePath: "/",
|
||||
ApiToken: "tok", Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectNodeEvents(t *testing.T) func() []eventbus.Event {
|
||||
t.Helper()
|
||||
bus := eventbus.New(eventbus.DefaultBufferSize)
|
||||
var mu sync.Mutex
|
||||
var got []eventbus.Event
|
||||
bus.Subscribe("test", func(e eventbus.Event) {
|
||||
mu.Lock()
|
||||
got = append(got, e)
|
||||
mu.Unlock()
|
||||
})
|
||||
prev := EventBus
|
||||
EventBus = bus
|
||||
t.Cleanup(func() {
|
||||
EventBus = prev
|
||||
bus.Stop()
|
||||
})
|
||||
return func() []eventbus.Event {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]eventbus.Event(nil), got...)
|
||||
}
|
||||
}
|
||||
|
||||
// A master-side blip flipped every node in one tick and published one event per
|
||||
// node, overflowing the notifier queues and every chat's rate limit.
|
||||
func TestHeartbeatSummarizesNodeDownBurst(t *testing.T) {
|
||||
goingDownNodes(t, 12)
|
||||
events := collectNodeEvents(t)
|
||||
|
||||
NewNodeHeartbeatJob().Run()
|
||||
|
||||
got := events()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("heartbeat published %d events for 12 nodes going down, want 1 summary", len(got))
|
||||
}
|
||||
want := "node-00, node-01, node-02, node-03, node-04, node-05, node-06, node-07, node-08, node-09 (+2)"
|
||||
if got[0].Type != eventbus.EventNodeDown || got[0].Source != want {
|
||||
t.Fatalf("summary event = %s %q, want %s %q", got[0].Type, got[0].Source, eventbus.EventNodeDown, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatKeepsSingleNodeDownEvent(t *testing.T) {
|
||||
goingDownNodes(t, 1)
|
||||
events := collectNodeEvents(t)
|
||||
|
||||
NewNodeHeartbeatJob().Run()
|
||||
|
||||
got := events()
|
||||
if len(got) != 1 || got[0].Source != "node-00" {
|
||||
t.Fatalf("events = %+v, want the node's own node.down", got)
|
||||
}
|
||||
if _, ok := got[0].Data.(*eventbus.NodeHealthData); !ok {
|
||||
t.Fatalf("single node.down lost its health data: %#v", got[0].Data)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// periodicResetConcurrency bounds how many inbounds or clients one run resets at once:
|
||||
// each waits on its node, so one at a time a few hanging nodes stretched a run for hours.
|
||||
const periodicResetConcurrency = 8
|
||||
|
||||
// Period represents the time period for traffic resets.
|
||||
type Period string
|
||||
|
||||
@@ -35,6 +42,21 @@ func monthlyResetDue(resetDay int, now time.Time) bool {
|
||||
return now.Day() == min(resetDay, lastDay)
|
||||
}
|
||||
|
||||
func forEachResetBounded(n int, reset func(i int)) {
|
||||
sem := make(chan struct{}, periodicResetConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
common.GoRecover("periodic-traffic-reset", func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
reset(i)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Run resets traffic statistics for all inbounds that match the configured reset
|
||||
// period, then for the clients carrying that period on their own (#5497).
|
||||
func (j *PeriodicTrafficResetJob) Run() {
|
||||
@@ -64,8 +86,9 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
|
||||
}
|
||||
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
|
||||
|
||||
resetCount := 0
|
||||
for _, inbound := range inbounds {
|
||||
var resetCount atomic.Int32
|
||||
forEachResetBounded(len(inbounds), func(i int) {
|
||||
inbound := inbounds[i]
|
||||
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||
if resetInboundErr != nil {
|
||||
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
||||
@@ -77,12 +100,12 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
|
||||
}
|
||||
|
||||
if resetInboundErr == nil && resetClientErr == nil {
|
||||
resetCount++
|
||||
resetCount.Add(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if resetCount > 0 {
|
||||
logger.Infof("Periodic traffic reset completed: %d inbounds reset", resetCount)
|
||||
if count := resetCount.Load(); count > 0 {
|
||||
logger.Infof("Periodic traffic reset completed: %d inbounds reset", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,19 +138,23 @@ func (j *PeriodicTrafficResetJob) resetClientsOnTheirOwnCycle() {
|
||||
}
|
||||
logger.Infof("Running periodic traffic reset job for period: %s (%d matching clients)", j.period, len(due))
|
||||
|
||||
var mu sync.Mutex
|
||||
resetCount := 0
|
||||
needRestart := false
|
||||
for _, c := range due {
|
||||
forEachResetBounded(len(due), func(i int) {
|
||||
c := due[i]
|
||||
// ResetTrafficByEmail rather than a bulk UPDATE: it is the path that also
|
||||
// propagates to the client's node and clears the MTProto sidecar quota.
|
||||
nr, resetErr := j.clientService.ResetTrafficByEmail(&j.inboundService, c.Email)
|
||||
if resetErr != nil {
|
||||
logger.Warning("Failed to reset traffic for client", c.Email, ":", resetErr)
|
||||
continue
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
needRestart = needRestart || nr
|
||||
resetCount++
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
// Dropping this leaves a re-enabled client absent from the running core until
|
||||
// something unrelated restarts it.
|
||||
if needRestart {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// resetGate holds every node reset open until released, counting how many nodes
|
||||
// the job reaches at once.
|
||||
type resetGate struct {
|
||||
entered atomic.Int32
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (g *resetGate) open() { g.once.Do(func() { close(g.release) }) }
|
||||
|
||||
func (g *resetGate) waitAll(t *testing.T, want int32) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for g.entered.Load() < want {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("periodic reset reached %d of %d hanging nodes, want all of them at once", g.entered.Load(), want)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// resetNode is a node whose every traffic reset hangs until the gate opens.
|
||||
func resetNode(t *testing.T, gate *resetGate, name string) int {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
if strings.Contains(r.URL.Path, "resetTraffic") {
|
||||
gate.entered.Add(1)
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-gate.release:
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
node := &model.Node{
|
||||
Name: name, Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
|
||||
Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
return node.Id
|
||||
}
|
||||
|
||||
func runResetJobAgainstGate(t *testing.T, gate *resetGate, want int32) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
NewPeriodicTrafficResetJob("daily", time.UTC).Run()
|
||||
}()
|
||||
t.Cleanup(func() { gate.open(); <-done })
|
||||
gate.waitAll(t, want)
|
||||
}
|
||||
|
||||
func newResetFleet(t *testing.T) *resetGate {
|
||||
t.Helper()
|
||||
initResetJobDB(t)
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
return &resetGate{release: make(chan struct{})}
|
||||
}
|
||||
|
||||
// The job reset due clients and inbounds one by one, each waiting on its node,
|
||||
// so a few hanging nodes stretched one run across hours.
|
||||
func TestPeriodicResetReachesClientNodesConcurrently(t *testing.T) {
|
||||
gate := newResetFleet(t)
|
||||
db := database.GetDB()
|
||||
for i := range 3 {
|
||||
nodeID := resetNode(t, gate, fmt.Sprintf("client-node-%d", i))
|
||||
email := fmt.Sprintf("cycle-%d@node", i)
|
||||
client := model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-00000000000%d", i), Enable: true, TrafficReset: "daily"}
|
||||
settings, _ := json.Marshal(map[string]any{"clients": []model.Client{client}})
|
||||
ib := model.Inbound{
|
||||
UserId: 1, Enable: true, Port: 47000 + i, Protocol: model.VLESS, NodeID: &nodeID,
|
||||
Tag: "reset-client-" + strconv.Itoa(i), TrafficReset: "never", Settings: string(settings),
|
||||
}
|
||||
if err := db.Create(&ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
rec := model.ClientRecord{Email: email, UUID: client.ID, Enable: true, TrafficReset: "daily"}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("link client: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Enable: true, Up: 500, Down: 700}).Error; err != nil {
|
||||
t.Fatalf("create traffic: %v", err)
|
||||
}
|
||||
}
|
||||
runResetJobAgainstGate(t, gate, 3)
|
||||
}
|
||||
|
||||
func TestPeriodicResetReachesInboundNodesConcurrently(t *testing.T) {
|
||||
gate := newResetFleet(t)
|
||||
for i := range 3 {
|
||||
nodeID := resetNode(t, gate, fmt.Sprintf("inbound-node-%d", i))
|
||||
ib := model.Inbound{
|
||||
UserId: 1, Enable: true, Port: 47100 + i, Protocol: model.VLESS, NodeID: &nodeID,
|
||||
Tag: "reset-inbound-" + strconv.Itoa(i), TrafficReset: "daily", Settings: `{"clients":[]}`,
|
||||
}
|
||||
if err := database.GetDB().Create(&ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
}
|
||||
runResetJobAgainstGate(t, gate, 3)
|
||||
}
|
||||
@@ -168,10 +168,15 @@ func sameRemoteIdentity(a, b *model.Node) bool {
|
||||
a.OutboundTag == b.OutboundTag
|
||||
}
|
||||
|
||||
// InvalidateNode forgets everything cached for a node, its pooled HTTP client too:
|
||||
// only a later call for that node would prune it, and a deleted node never makes one.
|
||||
func (m *Manager) InvalidateNode(nodeID int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.remotes, nodeID)
|
||||
m.mu.Unlock()
|
||||
nodeClientsMu.Lock()
|
||||
dropNodeClients(nodeID, "")
|
||||
nodeClientsMu.Unlock()
|
||||
}
|
||||
|
||||
func loadNode(id int) (*model.Node, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -132,22 +133,104 @@ func (t *credentialRotatingTransport) CloseIdleConnections() {
|
||||
current.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
|
||||
// mode or plain http); shared so connections pool across nodes.
|
||||
var defaultNodeHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
// The global cap must exceed the fleet size: below it Go closes a node's
|
||||
// connection before its next heartbeat, costing a handshake every tick.
|
||||
const (
|
||||
maxIdleNodeConns = 512
|
||||
maxIdleNodeConnsPerHost = 8
|
||||
)
|
||||
|
||||
func newNodeTransport(tlsCfg *tls.Config) *http.Transport {
|
||||
return &http.Transport{
|
||||
MaxIdleConns: maxIdleNodeConns,
|
||||
MaxIdleConnsPerHost: maxIdleNodeConnsPerHost,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
},
|
||||
TLSClientConfig: tlsCfg,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
|
||||
// mode or plain http); shared so connections pool across nodes.
|
||||
var defaultNodeHTTPClient = &http.Client{Transport: newNodeTransport(nil)}
|
||||
|
||||
// nodeClients caches one client per node: heartbeat and traffic sync reach it
|
||||
// every few seconds, and a rebuilt client would open its own empty pool.
|
||||
type nodeClientEntry struct {
|
||||
nodeID int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
nodeClientsMu sync.Mutex
|
||||
nodeClientsCache = map[string]nodeClientEntry{}
|
||||
)
|
||||
|
||||
// nodeClientIdentity covers everything that decides how the node is trusted; the
|
||||
// proxy URL is a variant of it, so it stays out of the identity itself.
|
||||
func nodeClientIdentity(n *model.Node, mode string) string {
|
||||
return fmt.Sprintf("%d|%s|%s|%s|%d|%s", n.Id, mode, n.Scheme, n.Address, n.Port, n.PinnedCertSha256)
|
||||
}
|
||||
|
||||
// dropNodeClients discards every cached client of one node except keep, so a
|
||||
// node never holds more than the variant it is using now. Callers hold the lock.
|
||||
func dropNodeClients(nodeID int, keep string) {
|
||||
for key, entry := range nodeClientsCache {
|
||||
if entry.nodeID != nodeID || key == keep {
|
||||
continue
|
||||
}
|
||||
entry.client.CloseIdleConnections()
|
||||
delete(nodeClientsCache, key)
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPClientForNode returns the pooled client for n, building it on first use
|
||||
// and whenever the node's identity or TLS material changes.
|
||||
func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
|
||||
mode := n.TlsVerifyMode
|
||||
if mode == "" {
|
||||
mode = "verify"
|
||||
}
|
||||
if mode == "verify" || n.Scheme == "http" {
|
||||
// Shared across nodes and not node-specific: nothing to key on.
|
||||
if proxyURL == "" {
|
||||
nodeClientsMu.Lock()
|
||||
dropNodeClients(n.Id, "")
|
||||
nodeClientsMu.Unlock()
|
||||
return defaultNodeHTTPClient, nil
|
||||
}
|
||||
}
|
||||
|
||||
identity := nodeClientIdentity(n, mode)
|
||||
key := identity + "|" + proxyURL
|
||||
nodeClientsMu.Lock()
|
||||
if entry, ok := nodeClientsCache[key]; ok {
|
||||
nodeClientsMu.Unlock()
|
||||
return entry.client, nil
|
||||
}
|
||||
nodeClientsMu.Unlock()
|
||||
|
||||
client, err := buildNodeHTTPClient(n, mode, proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeClientsMu.Lock()
|
||||
if entry, ok := nodeClientsCache[key]; ok {
|
||||
// A concurrent caller won the race; keep its client and drop ours.
|
||||
nodeClientsMu.Unlock()
|
||||
client.CloseIdleConnections()
|
||||
return entry.client, nil
|
||||
}
|
||||
// Any other variant is dead weight: a stale identity's pool fits no trust
|
||||
// decision now, and an ephemeral proxy URL is never asked for twice.
|
||||
dropNodeClients(n.Id, key)
|
||||
nodeClientsCache[key] = nodeClientEntry{nodeID: n.Id, client: client}
|
||||
nodeClientsMu.Unlock()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func buildNodeHTTPClient(n *model.Node, mode, proxyURL string) (*http.Client, error) {
|
||||
if proxyURL != "" {
|
||||
if mode == "mtls" && n.Scheme != "http" {
|
||||
timeout := remoteHTTPTimeout
|
||||
@@ -191,22 +274,13 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
return client, nil
|
||||
}
|
||||
if mode == "verify" || n.Scheme == "http" {
|
||||
return defaultNodeHTTPClient, nil
|
||||
}
|
||||
if mode == "mtls" {
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: tlsCfg,
|
||||
}, nil
|
||||
return newNodeTransport(tlsCfg), nil
|
||||
}
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
if err != nil {
|
||||
@@ -218,15 +292,7 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: tlsCfg,
|
||||
},
|
||||
}, nil
|
||||
return &http.Client{Transport: newNodeTransport(tlsCfg)}, nil
|
||||
}
|
||||
|
||||
func tlsConfigForNode(n *model.Node) (*tls.Config, error) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Only a later call for the same node pruned its pooled client, so a deleted
|
||||
// node kept its client and transport cached for the life of the process.
|
||||
func TestInvalidateNodeDropsItsCachedHTTPClient(t *testing.T) {
|
||||
node := &model.Node{Id: 8801, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
|
||||
if _, err := HTTPClientForNode(node, ""); err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
if got := nodeClientEntries(node.Id); got != 1 {
|
||||
t.Fatalf("cached clients before invalidation = %d, want 1", got)
|
||||
}
|
||||
|
||||
NewManager(LocalDeps{}).InvalidateNode(node.Id)
|
||||
|
||||
if got := nodeClientEntries(node.Id); got != 0 {
|
||||
t.Fatalf("cached clients after InvalidateNode = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
)
|
||||
|
||||
type generationProbeTransport struct {
|
||||
@@ -77,6 +80,130 @@ func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat and traffic sync ask for a client every few seconds; a rebuilt one
|
||||
// owns an empty pool, so each tick paid a fresh TCP+TLS handshake per node.
|
||||
func TestHTTPClientForNodeReusesOneConnectionAcrossCalls(t *testing.T) {
|
||||
var handshakes atomic.Int32
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
handshakes.Add(1)
|
||||
}
|
||||
}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
u, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server url: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("test server port: %v", err)
|
||||
}
|
||||
node := &model.Node{
|
||||
Id: 31, Address: u.Hostname(), Port: port, Scheme: "https",
|
||||
TlsVerifyMode: "skip", AllowPrivateAddress: true,
|
||||
}
|
||||
|
||||
for tick := range 2 {
|
||||
client, err := HTTPClientForNode(node, "")
|
||||
if err != nil {
|
||||
t.Fatalf("tick %d: HTTPClientForNode: %v", tick, err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(
|
||||
netsafe.ContextWithAllowPrivate(context.Background(), true), http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tick %d: new request: %v", tick, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("tick %d: request: %v", tick, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := handshakes.Load(); got != 1 {
|
||||
t.Fatalf("TLS handshakes = %d, want 1: a rebuilt client re-handshakes on every tick", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that switches to pinning (or gains a proxy) must not be served by the
|
||||
// client built for its previous trust decision.
|
||||
func TestHTTPClientForNodeRebuildsWhenNodeIdentityChanges(t *testing.T) {
|
||||
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||
node := &model.Node{Id: 32, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
|
||||
skipped, err := HTTPClientForNode(node, "")
|
||||
if err != nil {
|
||||
t.Fatalf("skip client: %v", err)
|
||||
}
|
||||
|
||||
pinned := *node
|
||||
pinned.TlsVerifyMode = "pin"
|
||||
pinned.PinnedCertSha256 = pin
|
||||
pinnedClient, err := HTTPClientForNode(&pinned, "")
|
||||
if err != nil {
|
||||
t.Fatalf("pin client: %v", err)
|
||||
}
|
||||
if skipped == pinnedClient {
|
||||
t.Fatal("a pinned node must not reuse the client built to skip verification")
|
||||
}
|
||||
|
||||
proxied, err := HTTPClientForNode(node, "socks5://127.0.0.1:1080")
|
||||
if err != nil {
|
||||
t.Fatalf("proxied client: %v", err)
|
||||
}
|
||||
if skipped == proxied {
|
||||
t.Fatal("a proxied node must not reuse the direct client")
|
||||
}
|
||||
|
||||
if again, err := HTTPClientForNode(node, ""); err != nil || again == pinnedClient {
|
||||
t.Fatalf("a skip request must never be served the pinned client; again=%p err=%v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func nodeClientEntries(id int) int {
|
||||
nodeClientsMu.Lock()
|
||||
defer nodeClientsMu.Unlock()
|
||||
count := 0
|
||||
for _, entry := range nodeClientsCache {
|
||||
if entry.nodeID == id {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// withOutboundBridge mints a fresh loopback port per call, so the variant it
|
||||
// asks for can never be hit again; only the variant in use may stay cached.
|
||||
func TestHTTPClientForNodeKeepsOneClientPerNode(t *testing.T) {
|
||||
node := &model.Node{Id: 77, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
|
||||
variants := []string{"socks5://127.0.0.1:41001", "socks5://127.0.0.1:41002", ""}
|
||||
for _, variant := range variants {
|
||||
if _, err := HTTPClientForNode(node, variant); err != nil {
|
||||
t.Fatalf("HTTPClientForNode(%q): %v", variant, err)
|
||||
}
|
||||
if got := nodeClientEntries(node.Id); got != 1 {
|
||||
t.Fatalf("cached clients for the node after %q = %d, want 1", variant, got)
|
||||
}
|
||||
if client, err := HTTPClientForNode(node, variant); err != nil || client == nil {
|
||||
t.Fatalf("repeat HTTPClientForNode(%q): client=%p err=%v", variant, client, err)
|
||||
}
|
||||
}
|
||||
|
||||
verify := *node
|
||||
verify.TlsVerifyMode = "verify"
|
||||
if client, err := HTTPClientForNode(&verify, ""); err != nil || client != defaultNodeHTTPClient {
|
||||
t.Fatalf("verify client = %p, want the shared one (%p); err=%v", client, defaultNodeHTTPClient, err)
|
||||
}
|
||||
if got := nodeClientEntries(node.Id); got != 0 {
|
||||
t.Fatalf("cached clients for a node now on verify = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
|
||||
before := masterCertEpoch.Load()
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
|
||||
@@ -1802,6 +1802,7 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
|
||||
"auth": ch.client.Auth,
|
||||
"password": ch.client.Password,
|
||||
"cipher": cipher,
|
||||
"reverse": ch.client.Reverse,
|
||||
})
|
||||
if err1 != nil {
|
||||
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
||||
@@ -1812,6 +1813,9 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
|
||||
if err1 != nil && !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", ch.email)) {
|
||||
logger.Debug("Error in removing client on", rt.Name(), ":", err1)
|
||||
res.needRestart = true
|
||||
} else if err1 == nil && droppedClientNeedsRestart() {
|
||||
// A removed credential does not end the session it was serving.
|
||||
res.needRestart = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// The manual switch applies through the runtime, and the core's API removal
|
||||
// drops the credential only: whether the live session ends is what the setting
|
||||
// asks for, exactly as on the auto-disable path #6533 reports from.
|
||||
func TestManualClientDisableHonoursRestartSetting(t *testing.T) {
|
||||
const email = "manual-disable@example.com"
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setting bool
|
||||
want bool
|
||||
}{
|
||||
{"setting on", true, true},
|
||||
{"setting off", false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
setRestartOnClientDisable(t, tc.setting)
|
||||
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
||||
mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
|
||||
seedInboundConflict(t, "manual-disable", "0.0.0.0", 50055, model.VLESS, `{"network":"tcp"}`,
|
||||
`{"clients":[{"email":"`+email+`","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a333","enable":true}]}`)
|
||||
inbound := loadInboundByTag(t, "manual-disable")
|
||||
|
||||
inboundSvc := InboundService{}
|
||||
clientSvc := ClientService{}
|
||||
clients, err := inboundSvc.GetClients(inbound)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients: %v", err)
|
||||
}
|
||||
if err := clientSvc.SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
changed, needRestart, err := clientSvc.SetClientEnableByEmail(&inboundSvc, email, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetClientEnableByEmail: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("the disable must be recorded")
|
||||
}
|
||||
if needRestart != tc.want {
|
||||
t.Fatalf("needRestart = %v, want %v", needRestart, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// droppedClientNeedsRestart is what restartXrayOnClientDisable asks for: the core
|
||||
// API drops the credential only, so a live session needs the process replaced.
|
||||
func droppedClientNeedsRestart() bool {
|
||||
on, err := (&SettingService{}).GetRestartXrayOnClientDisable()
|
||||
if err != nil {
|
||||
logger.Warning("get RestartXrayOnClientDisable failed:", err)
|
||||
}
|
||||
return on
|
||||
}
|
||||
|
||||
func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool {
|
||||
aa := maps.Clone(a)
|
||||
bb := maps.Clone(b)
|
||||
@@ -600,6 +610,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||
"allowedIPs": client.AllowedIPs,
|
||||
"preSharedKey": client.PreSharedKey,
|
||||
"keepAlive": keepAliveStr(client.KeepAliveSeconds()),
|
||||
"reverse": client.Reverse,
|
||||
})
|
||||
if err1 == nil {
|
||||
logger.Debug("Client added on", rt.Name(), ":", client.Email)
|
||||
@@ -1011,6 +1022,11 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
|
||||
if err1 == nil {
|
||||
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
|
||||
// The API removal is enough only while the client is re-added; a
|
||||
// dropped one ends its session only through a restart.
|
||||
if !clients[0].Enable && droppedClientNeedsRestart() {
|
||||
needRestart = true
|
||||
}
|
||||
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
|
||||
logger.Debug("User is already deleted. Nothing to do more...")
|
||||
} else {
|
||||
@@ -1035,6 +1051,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
"allowedIPs": clients[0].AllowedIPs,
|
||||
"preSharedKey": clients[0].PreSharedKey,
|
||||
"keepAlive": keepAliveStr(clients[0].KeepAliveSeconds()),
|
||||
"reverse": clients[0].Reverse,
|
||||
})
|
||||
if err1 == nil {
|
||||
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
|
||||
@@ -1199,7 +1216,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||
needRestart = true
|
||||
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
|
||||
logger.Debug("Client deleted on", rt.Name(), ":", email)
|
||||
needRestart = false
|
||||
needRestart = droppedClientNeedsRestart()
|
||||
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
||||
logger.Debug("User is already deleted. Nothing to do more...")
|
||||
} else {
|
||||
|
||||
@@ -195,10 +195,9 @@ func (q clientQuery) activeExpr() string {
|
||||
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
|
||||
}
|
||||
|
||||
// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
|
||||
// client that also ran out counts once, under depleted, so the stat cards add
|
||||
// up to the client total.
|
||||
func (q clientQuery) summaryDeactiveExpr() string {
|
||||
// deactiveExpr leaves a disabled client that also ran out to depleted, so the
|
||||
// stat cards add up to the total and each card's filter lists what it counts.
|
||||
func (q clientQuery) deactiveExpr() string {
|
||||
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
|
||||
}
|
||||
|
||||
@@ -275,9 +274,9 @@ func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
|
||||
for _, b := range buckets {
|
||||
switch b {
|
||||
case "active":
|
||||
conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
|
||||
conds = append(conds, q.activeExpr())
|
||||
case "deactive":
|
||||
conds = append(conds, "(NOT "+sqlClientEnabled+")")
|
||||
conds = append(conds, q.deactiveExpr())
|
||||
case "depleted":
|
||||
conds = append(conds, q.depletedExpr())
|
||||
case "expiring":
|
||||
@@ -490,7 +489,7 @@ func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error
|
||||
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
||||
" COALESCE(SUM(CASE WHEN " + q.deactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
||||
).Scan(&counts).Error; err != nil {
|
||||
return s, err
|
||||
}
|
||||
@@ -506,7 +505,7 @@ func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error
|
||||
}{
|
||||
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
|
||||
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
|
||||
{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
|
||||
{q.deactiveExpr(), s.DeactiveCount, &s.Deactive},
|
||||
}
|
||||
for _, b := range buckets {
|
||||
// The counter already says the bucket is empty, so skip the scan that
|
||||
|
||||
@@ -157,9 +157,9 @@ func TestListPagedFilters(t *testing.T) {
|
||||
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
|
||||
},
|
||||
{
|
||||
name: "deactive bucket is every disabled client",
|
||||
name: "deactive bucket leaves a disabled client that ran out to depleted",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
|
||||
want: []string{"echo@x", "foxtrot@x"},
|
||||
want: []string{"echo@x"},
|
||||
},
|
||||
{
|
||||
name: "expiring bucket covers near expiry and near quota",
|
||||
@@ -167,9 +167,9 @@ func TestListPagedFilters(t *testing.T) {
|
||||
want: []string{"golf@x", "hotel@x"},
|
||||
},
|
||||
{
|
||||
name: "active bucket keeps enabled clients that still have room",
|
||||
name: "active bucket leaves clients near depletion to expiring",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "active"},
|
||||
want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
want: []string{"alpha@x", "bravo@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "buckets are ORed",
|
||||
@@ -477,6 +477,19 @@ func TestListPagedSummary(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clicking a stat card filters to exactly the clients it counts", func(t *testing.T) {
|
||||
cards := map[string]int{"active": s.Active, "depleted": s.DepletedCount, "expiring": s.ExpiringCount, "deactive": s.DeactiveCount}
|
||||
for bucket, count := range cards {
|
||||
page, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: bucket})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged(%s): %v", bucket, err)
|
||||
}
|
||||
if page.Filtered != count {
|
||||
t.Fatalf("filter %q matched %d clients, card counts %d", bucket, page.Filtered, count)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
|
||||
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
|
||||
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// reverseUserProbe records the account maps the panel pushes to the core: the
|
||||
// last place a stored reverse tag can be dropped before it reaches a listener.
|
||||
type reverseUserProbe struct {
|
||||
fakeNodeRuntime
|
||||
mu sync.Mutex
|
||||
users []map[string]any
|
||||
}
|
||||
|
||||
func (p *reverseUserProbe) AddUser(_ context.Context, _ *model.Inbound, user map[string]any) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.users = append(p.users, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *reverseUserProbe) recorded() []map[string]any {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]map[string]any(nil), p.users...)
|
||||
}
|
||||
|
||||
const reverseProbeID = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a333"
|
||||
|
||||
func reverseProbeClient(email string, enable bool) model.Client {
|
||||
return model.Client{Email: email, ID: reverseProbeID, Enable: enable, Reverse: &model.ClientReverse{Tag: "portal"}}
|
||||
}
|
||||
|
||||
// seedReverseProbeInbound seeds one local vless inbound holding a single reverse
|
||||
// client, and the recording runtime every local apply of it lands on.
|
||||
func seedReverseProbeInbound(t *testing.T, tag string, port int, enable bool) (*model.Inbound, string, *reverseUserProbe) {
|
||||
t.Helper()
|
||||
setupConflictDB(t)
|
||||
mgr := useTestRuntimeManager(t)
|
||||
probe := &reverseUserProbe{}
|
||||
mgr.SetLocalRuntimeOverride(probe)
|
||||
|
||||
email := tag + "@example.test"
|
||||
client := reverseProbeClient(email, enable)
|
||||
seedInboundConflict(t, tag, "0.0.0.0", port, model.VLESS, `{"network":"tcp"}`, clientsSettings(t, []model.Client{client}))
|
||||
inbound := loadInboundByTag(t, tag)
|
||||
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, []model.Client{client}); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
return inbound, email, probe
|
||||
}
|
||||
|
||||
// assertReverseReAdd fails unless the core was handed the client's own tag: the
|
||||
// handler is gone the moment RemoveUser runs, and only the tag rebuilds it.
|
||||
func assertReverseReAdd(t *testing.T, probe *reverseUserProbe, email string) {
|
||||
t.Helper()
|
||||
users := probe.recorded()
|
||||
if len(users) == 0 {
|
||||
t.Fatalf("%s was never re-added to the core, so its reverse tag was never checked", email)
|
||||
}
|
||||
found := false
|
||||
for _, user := range users {
|
||||
if got, _ := user["email"].(string); got != email {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
tag, _ := user["reverse"].(*model.ClientReverse)
|
||||
if tag == nil || tag.Tag != "portal" {
|
||||
t.Fatalf("the re-add of %s carries reverse %#v, want its stored tag portal", email, user["reverse"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no re-add of %s reached the core: %v", email, users)
|
||||
}
|
||||
}
|
||||
|
||||
// The panel's most ordinary action on a reverse client: editing it removes the
|
||||
// account and adds it back, and the core rebuilds nothing without the tag.
|
||||
func TestClientEditKeepsTheReverseTag(t *testing.T) {
|
||||
_, email, probe := seedReverseProbeInbound(t, "rev-edit", 50071, true)
|
||||
rec := lookupClientRecord(t, email)
|
||||
|
||||
edited := reverseProbeClient(email, true)
|
||||
edited.Comment = "edited after the tunnel was up"
|
||||
if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, edited, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
assertReverseReAdd(t, probe, email)
|
||||
}
|
||||
|
||||
func TestBulkReEnableKeepsTheReverseTag(t *testing.T) {
|
||||
_, email, probe := seedReverseProbeInbound(t, "rev-bulk", 50072, false)
|
||||
|
||||
if _, _, err := (&ClientService{}).BulkSetEnable(&InboundService{}, []string{email}, true); err != nil {
|
||||
t.Fatalf("BulkSetEnable: %v", err)
|
||||
}
|
||||
assertReverseReAdd(t, probe, email)
|
||||
}
|
||||
|
||||
// The route an operator hits most often: a client that exhausted its quota is
|
||||
// removed, then re-added by the reset that renews it.
|
||||
func TestTrafficResetKeepsTheReverseTag(t *testing.T) {
|
||||
inbound, email, probe := seedReverseProbeInbound(t, "rev-quota", 50073, true)
|
||||
depleteClientTraffic(t, inbound.Id, email)
|
||||
|
||||
if _, err := (&InboundService{}).ResetClientTraffic(inbound.Id, email); err != nil {
|
||||
t.Fatalf("ResetClientTraffic: %v", err)
|
||||
}
|
||||
assertReverseReAdd(t, probe, email)
|
||||
}
|
||||
|
||||
func TestAddingClientsKeepsTheReverseTag(t *testing.T) {
|
||||
inbound, _, probe := seedReverseProbeInbound(t, "rev-add", 50074, true)
|
||||
const added = "rev-add-second@example.test"
|
||||
second := reverseProbeClient(added, true)
|
||||
second.ID = "7c3fad07-4b1c-4d66-9f83-7db2f3c8b444"
|
||||
|
||||
if _, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{
|
||||
Id: inbound.Id,
|
||||
Protocol: model.VLESS,
|
||||
Settings: clientsSettings(t, []model.Client{second}),
|
||||
}); err != nil {
|
||||
t.Fatalf("AddInboundClient: %v", err)
|
||||
}
|
||||
assertReverseReAdd(t, probe, added)
|
||||
}
|
||||
|
||||
// depleteClientTraffic leaves the client enabled in settings but out of quota,
|
||||
// the state a traffic reset re-adds it from.
|
||||
func depleteClientTraffic(t *testing.T, inboundId int, email string) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"enable": false, "up": 1, "down": 1})
|
||||
if res.Error != nil {
|
||||
t.Fatalf("deplete traffic: %v", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1}).Error; err != nil {
|
||||
t.Fatalf("create depleted traffic: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -1238,20 +1237,31 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// The relay port is derived from the id, only known after Save; checkPortConflictTx
|
||||
// ran the reverse-direction check above with ignoreId==0, so it couldn't yet.
|
||||
if inbound.Protocol == model.AmneziaWG {
|
||||
if amneziawgnet.SOCKSPortForInbound(inbound.Id) > 65535 {
|
||||
return common.NewErrorf("amneziawg: inbound id %d exceeds the relay port window (ids above %d are not supported)",
|
||||
inbound.Id, 65535-amneziawgnet.SOCKSBasePort)
|
||||
// The relay port is derived from the id, only known after Save, and only a
|
||||
// local row owns one: checkPortConflictTx ran no relay check with ignoreId==0.
|
||||
if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG {
|
||||
if self := amneziawgnetSocksSelfConflict(inbound, inbound.Id); self != "" {
|
||||
return common.NewError(self)
|
||||
}
|
||||
conflict, cErr := checkAmneziawgnetSocksReverseConflict(tx, inbound.Id)
|
||||
conflict, cErr := checkAmneziawgnetSocksRelayCollision(tx, inbound.Id)
|
||||
if cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
if conflict != nil {
|
||||
return common.NewError(conflict.String())
|
||||
}
|
||||
conflict, cErr = checkAmneziawgnetSocksReverseConflict(tx, inbound.Id)
|
||||
if cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
if conflict != nil {
|
||||
return common.NewError(conflict.String())
|
||||
}
|
||||
// The clients' forward specs were validated while this row had no id,
|
||||
// so the ports it now derives were never in the guard's context.
|
||||
if aErr := s.checkAmneziaWGForwardedPorts(tx, inbound.Settings); aErr != nil {
|
||||
return aErr
|
||||
}
|
||||
}
|
||||
// Emails seeded here (import's ClientStats, e.g. the controller's forced
|
||||
// Enable=true on every imported stat row) are authoritative for this call
|
||||
@@ -1353,10 +1363,20 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
}
|
||||
|
||||
func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
needRestart, nodePush, err := s.delInbound(id)
|
||||
if nodePush != nil {
|
||||
nodePush()
|
||||
}
|
||||
return needRestart, err
|
||||
}
|
||||
|
||||
// delInbound deletes the central row and returns the node push instead of running
|
||||
// it, so a bulk delete can fan the pushes out once every row is gone.
|
||||
func (s *InboundService) delInbound(id int) (bool, func(), error) {
|
||||
db := database.GetDB()
|
||||
|
||||
needRestart := false
|
||||
var postCommitApply func()
|
||||
var postCommitApply, nodePush func()
|
||||
var ib model.Inbound
|
||||
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
||||
if loadErr == nil {
|
||||
@@ -1367,7 +1387,7 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
if perr != nil {
|
||||
logger.Warning("DelInbound: node runtime lookup failed, deleting central row anyway:", perr)
|
||||
} else if push {
|
||||
postCommitApply = func() {
|
||||
nodePush = func() {
|
||||
if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
|
||||
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
|
||||
} else {
|
||||
@@ -1430,7 +1450,7 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return needRestart, err
|
||||
return needRestart, nil, err
|
||||
}
|
||||
if postCommitApply != nil {
|
||||
postCommitApply()
|
||||
@@ -1445,11 +1465,11 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
if !database.IsPostgres() {
|
||||
var count int64
|
||||
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
||||
return needRestart, err
|
||||
return needRestart, nodePush, err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := db.Exec("DELETE FROM sqlite_sequence WHERE name = ?", "inbounds").Error; err != nil {
|
||||
return needRestart, err
|
||||
return needRestart, nodePush, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1457,7 +1477,7 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
if mtprotoRoutesThroughXray(&ib) {
|
||||
needRestart = true
|
||||
}
|
||||
return needRestart, nil
|
||||
return needRestart, nodePush, nil
|
||||
}
|
||||
|
||||
type BulkDelInboundResult struct {
|
||||
@@ -1477,8 +1497,14 @@ type BulkDelInboundReport struct {
|
||||
func (s *InboundService) DelInbounds(ids []int) (BulkDelInboundResult, bool, error) {
|
||||
result := BulkDelInboundResult{}
|
||||
needRestart := false
|
||||
var pushIDs []int
|
||||
var nodePushes []func()
|
||||
for _, id := range ids {
|
||||
r, err := s.DelInbound(id)
|
||||
r, nodePush, err := s.delInbound(id)
|
||||
if nodePush != nil {
|
||||
pushIDs = append(pushIDs, id)
|
||||
nodePushes = append(nodePushes, nodePush)
|
||||
}
|
||||
if err != nil {
|
||||
result.Skipped = append(result.Skipped, BulkDelInboundReport{Id: id, Reason: err.Error()})
|
||||
continue
|
||||
@@ -1488,6 +1514,11 @@ func (s *InboundService) DelInbounds(ids []int) (BulkDelInboundResult, bool, err
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
// Rows go one at a time for the shared routing rewrite; only node pushes fan out.
|
||||
fanoutInboundResults(pushIDs, nodeFanoutConcurrency, func(i int) struct{} {
|
||||
nodePushes[i]()
|
||||
return struct{}{}
|
||||
})
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
@@ -1571,6 +1602,17 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
// Enabling puts this row's ports into the running config, and the guards ran
|
||||
// only if it was saved: a restored or hand-edited row reaches it unchecked.
|
||||
if enable && inbound.NodeID == nil {
|
||||
conflict, err := checkPortConflictTx(db, inbound, inbound.Id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if conflict != nil {
|
||||
return false, common.NewError(conflict.String())
|
||||
}
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(model.Inbound{}).Where("id = ?", id).
|
||||
Update("enable", enable).Error; err != nil {
|
||||
|
||||
@@ -278,8 +278,8 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
|
||||
}
|
||||
for i := range parsed.Clients {
|
||||
c := &parsed.Clients[i]
|
||||
if hit := s.checkForwardedPortsConflict(portCtx, c.ForwardedPorts); hit != "" {
|
||||
return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
|
||||
if err := s.amneziaWGForwardedPortsConflict(portCtx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := amneziawg.ValidateConfigValue("email", c.Email); err != nil {
|
||||
return fmt.Errorf("amneziawg: %w", err)
|
||||
@@ -313,21 +313,15 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
|
||||
return nil
|
||||
}
|
||||
|
||||
// portConflictContext caches the state checkForwardedPortsConflict needs —
|
||||
// the panel's own port and this host's enabled inbound ports — so validating
|
||||
// N clients in one save (normalizeAmneziaWGSettings, or a bulk client add)
|
||||
// costs one query total instead of N. Load it once with
|
||||
// loadPortConflictContext and pass it to every checkForwardedPortsConflict
|
||||
// call in that batch.
|
||||
// portConflictContext caches what checkForwardedPortsConflict needs — the panel's
|
||||
// own port and this host's enabled rows — so one save costs one query, not N.
|
||||
type portConflictContext struct {
|
||||
webPort int
|
||||
inbounds []*model.Inbound
|
||||
}
|
||||
|
||||
// loadPortConflictContext loads the panel's own port and every enabled
|
||||
// inbound hosted on THIS panel (node_id IS NULL) — an inbound hosted on a
|
||||
// different node listens on that node's own host, never this one, so it can
|
||||
// never collide with a DNAT rule this process installs.
|
||||
// loadPortConflictContext loads the panel's own port and every enabled inbound
|
||||
// hosted on THIS panel: a node-hosted one listens on that node's host, not here.
|
||||
func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictContext, error) {
|
||||
var ctx portConflictContext
|
||||
if webPort, err := (&SettingService{}).GetPort(); err == nil {
|
||||
@@ -339,15 +333,37 @@ func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictConte
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// checkForwardedPortsConflict reports whether a client's ForwardedPorts spec
|
||||
// exceeds the cap, covers the panel's own web port, one of this host's own
|
||||
// enabled inbound listen ports, or an AmneziaWG inbound's own phantom SOCKS5
|
||||
// relay port (SOCKSPortForInbound -- never a real inbounds row, so the loop
|
||||
// below can't see it any other way). A collision on the SOCKS5 port would
|
||||
// let a port-forward listener race Xray's own relay for the bind and, if it
|
||||
// wins, take down that inbound's entire relay rather than just one forward.
|
||||
// Returns a human-readable description of the first collision found, or ""
|
||||
// when there is none.
|
||||
// amneziaWGForwardedPortsConflict renders one client's ForwardedPorts collision,
|
||||
// or nil: the single copy both the pre-Save pass and the post-Save re-run use.
|
||||
func (s *InboundService) amneziaWGForwardedPortsConflict(ctx portConflictContext, c *model.Client) error {
|
||||
hit := s.checkForwardedPortsConflict(ctx, c.ForwardedPorts)
|
||||
if hit == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
|
||||
}
|
||||
|
||||
// checkAmneziaWGForwardedPorts re-runs the guard over one row's stored clients:
|
||||
// on create it ran before Save, when the row's own ports were not in the context.
|
||||
func (s *InboundService) checkAmneziaWGForwardedPorts(db *gorm.DB, settings string) error {
|
||||
var parsed amneziawg.InboundSettings
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return nil
|
||||
}
|
||||
ctx, err := s.loadPortConflictContext(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range parsed.Clients {
|
||||
if err := s.amneziaWGForwardedPortsConflict(ctx, &parsed.Clients[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkForwardedPortsConflict names the panel, inbound or AmneziaWG relay port a
|
||||
// client's ForwardedPorts spec would collide with: a lost bind race kills the relay.
|
||||
func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, forwardedPorts string) string {
|
||||
if forwardedPorts == "" {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// awgRelayWindowSettings builds an AmneziaWG settings blob AddInbound accepts:
|
||||
// real X25519 keys, one enabled peer, and an email unique to tag.
|
||||
func awgRelayWindowSettings(t *testing.T, tag string) string {
|
||||
t.Helper()
|
||||
_, clientPub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate client keypair: %v", err)
|
||||
}
|
||||
return `{"server":{"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey +
|
||||
`","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"` + tag + `@relay-window","enable":true,"publicKey":"` +
|
||||
clientPub + `","allowedIPs":["10.8.1.2/32"]}]}`
|
||||
}
|
||||
|
||||
// awgRelayWindowSettingsWithForward is awgRelayWindowSettings with one client's
|
||||
// forwardedPorts set, the field the create-time guard validates.
|
||||
func awgRelayWindowSettingsWithForward(t *testing.T, tag, forwardedPorts string) string {
|
||||
t.Helper()
|
||||
settings := awgRelayWindowSettings(t, tag)
|
||||
return strings.Replace(settings, `"enable":true`, `"enable":true,"forwardedPorts":"`+forwardedPorts+`"`, 1)
|
||||
}
|
||||
|
||||
// pushInboundIDSequence makes the next inbounds insert land on nextID, standing
|
||||
// in for a long-lived database whose AUTOINCREMENT counter has climbed there.
|
||||
func pushInboundIDSequence(t *testing.T, nextID int) {
|
||||
t.Helper()
|
||||
// The counter is a sqlite_sequence row, so this has no PostgreSQL equivalent.
|
||||
if database.IsPostgres() {
|
||||
t.Skip("the inbounds AUTOINCREMENT counter is a SQLite row")
|
||||
}
|
||||
res := database.GetDB().Exec("UPDATE sqlite_sequence SET seq = ? WHERE name = ?", nextID-1, "inbounds")
|
||||
if res.Error != nil {
|
||||
t.Fatalf("push the inbounds sequence to %d: %v", nextID, res.Error)
|
||||
}
|
||||
if res.RowsAffected != 1 {
|
||||
t.Fatalf("inbounds has no AUTOINCREMENT counter row to push (%d rows updated)", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
func addAmneziaWGInbound(t *testing.T, tag string, port int, enable bool) *model.Inbound {
|
||||
t.Helper()
|
||||
created, _, err := (&InboundService{}).AddInbound(&model.Inbound{
|
||||
Tag: tag,
|
||||
Enable: enable,
|
||||
Listen: "0.0.0.0",
|
||||
Port: port,
|
||||
Protocol: model.AmneziaWG,
|
||||
Settings: awgRelayWindowSettings(t, tag),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddInbound(%s): %v", tag, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
// An id past the slot count used to be refused outright, which capped a
|
||||
// database at 435 AmneziaWG inbounds for its entire life (#6537).
|
||||
func TestAddInbound_AmneziawgPastTheRelayPortWindowStillCreates(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
// Self-check: the low-id path must work, or the assertion below could pass
|
||||
// because the fixture never created an AmneziaWG inbound at all.
|
||||
addAmneziaWGInbound(t, "awg-low-id", 51820, true)
|
||||
|
||||
pushInboundIDSequence(t, 70001)
|
||||
created := addAmneziaWGInbound(t, "awg-past-window", 51821, true)
|
||||
if created.Id < 436 {
|
||||
t.Fatalf("fixture: inbound id %d is still inside the old window", created.Id)
|
||||
}
|
||||
if port := amneziawgnet.SOCKSPortForInbound(created.Id); port < amneziawgnet.SOCKSBasePort+1 || port > 65535 {
|
||||
t.Fatalf("inbound %d derived relay port %d, outside %d..65535", created.Id, port, amneziawgnet.SOCKSBasePort+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping ids makes the id -> relay-port map non-injective, so a create can
|
||||
// land on a port an existing inbound's relay already owns.
|
||||
func TestAddInbound_AmneziawgRefusesAClaimedRelayPort(t *testing.T) {
|
||||
for _, blockerEnabled := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("blocker enabled=%t", blockerEnabled), func(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
blocker := addAmneziaWGInbound(t, "awg-blocker", 51820, blockerEnabled)
|
||||
|
||||
// One slot-window further on is the id that derives the blocker's port.
|
||||
collidingID := blocker.Id + 435
|
||||
pushInboundIDSequence(t, collidingID)
|
||||
|
||||
_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
|
||||
Tag: "awg-collides",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 51821,
|
||||
Protocol: model.AmneziaWG,
|
||||
Settings: awgRelayWindowSettings(t, "awg-collides"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("inbound %d derives relay port %d, already owned by %q; the create must be refused",
|
||||
collidingID, amneziawgnet.SOCKSPortForInbound(blocker.Id), blocker.Tag)
|
||||
}
|
||||
if !strings.Contains(err.Error(), blocker.Tag) {
|
||||
t.Fatalf("the conflict must name the inbound owning the port, got %v", err)
|
||||
}
|
||||
// The blocker's own port is its WireGuard one, so without this the
|
||||
// message reads as if that inbound listened on an unrelated port.
|
||||
if !strings.Contains(err.Error(), "relay port") {
|
||||
t.Fatalf("the refusal must say the port is an automatic relay one, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping makes id -> relay port non-injective, so an edit landing on a slot a
|
||||
// local inbound already owns has to be refused: the create guard never sees it.
|
||||
func TestCheckPortConflict_LocalAmneziawgRelayCollisionBlocksTheEdit(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
blocker := addAmneziaWGInbound(t, "awg-blocker", 51820, true)
|
||||
|
||||
local := &model.Inbound{
|
||||
Tag: "awg-edited",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 51821,
|
||||
Protocol: model.AmneziaWG,
|
||||
Settings: awgRelayWindowSettings(t, "awg-edited"),
|
||||
}
|
||||
collidingID := blocker.Id + 435
|
||||
|
||||
got, err := (&InboundService{}).checkPortConflict(local, collidingID)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("id %d derives relay port %d, already owned by %q; the save must be refused",
|
||||
collidingID, amneziawgnet.SOCKSPortForInbound(blocker.Id), blocker.Tag)
|
||||
}
|
||||
if !strings.Contains(got.String(), blocker.Tag) {
|
||||
t.Fatalf("the conflict must name the inbound owning the port, got %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled row still owns the relay slot its id derives: SetInboundEnable
|
||||
// flips the column with no port check, so enabling it later would break Xray.
|
||||
func TestCheckPortConflict_DisabledAmneziawgStillOwnsItsRelaySlot(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
owner := addAmneziaWGInbound(t, "awg-disabled", 51820, false)
|
||||
relayPort := amneziawgnet.SOCKSPortForInbound(owner.Id)
|
||||
|
||||
got, err := (&InboundService{}).checkPortConflict(&model.Inbound{
|
||||
Tag: "takes-the-slot",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: relayPort,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("inbound #%d is disabled but still owns relay port %d; the save must be refused",
|
||||
owner.Id, relayPort)
|
||||
}
|
||||
if !strings.Contains(got.String(), owner.Tag) {
|
||||
t.Fatalf("the conflict must name the inbound owning the port, got %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The forwarded-ports guard runs before Save, when the row has no id yet, so a
|
||||
// client's spec never saw the relay port the row itself derives.
|
||||
func TestAddInbound_AmneziawgRefusesAClientForwardingItsOwnRelayPort(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
placeholder := addAmneziaWGInbound(t, "awg-placeholder", 51820, true)
|
||||
ownPort := amneziawgnet.SOCKSPortForInbound(placeholder.Id + 1)
|
||||
|
||||
_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
|
||||
Tag: "awg-forward",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 51821,
|
||||
Protocol: model.AmneziaWG,
|
||||
Settings: awgRelayWindowSettingsWithForward(t, "awg-forward", fmt.Sprintf("%d", ownPort)),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("inbound #%d derives relay port %d and its own client forwards that port; the create must be refused",
|
||||
placeholder.Id+1, ownPort)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "forwardedPorts") {
|
||||
t.Fatalf("the refusal must come from the forwarded-ports guard, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The row's own WireGuard port can be the relay port its own id derives, and
|
||||
// every relay check excludes that id, so nothing else compares the two.
|
||||
func TestAddInbound_AmneziawgRefusesItsOwnRelayPort(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
// Read the sequence instead of assuming id 1: the victim's own derived port
|
||||
// has to be known before it is created.
|
||||
placeholder := addAmneziaWGInbound(t, "awg-placeholder", 51820, true)
|
||||
selfPort := amneziawgnet.SOCKSPortForInbound(placeholder.Id + 1)
|
||||
|
||||
_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
|
||||
Tag: "awg-self",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: selfPort,
|
||||
Protocol: model.AmneziaWG,
|
||||
Settings: awgRelayWindowSettings(t, "awg-self"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("WireGuard port %d is inbound #%d's own relay port; the create must be refused",
|
||||
selfPort, placeholder.Id+1)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "relay port") {
|
||||
t.Fatalf("the refusal must say the port is an automatic relay one, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The edit path knows the id the relay port comes from, so it has to refuse the
|
||||
// same self-collision -- the reverse check skips the row it computes for.
|
||||
func TestUpdateInbound_AmneziawgRefusesItsOwnRelayPort(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
created := addAmneziaWGInbound(t, "awg-self-edit", 51820, true)
|
||||
|
||||
edit := *created
|
||||
edit.Port = amneziawgnet.SOCKSPortForInbound(created.Id)
|
||||
if edit.Port == created.Port {
|
||||
t.Fatalf("fixture: inbound #%d already listens on its derived relay port", created.Id)
|
||||
}
|
||||
|
||||
_, _, err := (&InboundService{}).UpdateInbound(&edit)
|
||||
if err == nil {
|
||||
t.Fatalf("WireGuard port %d is inbound #%d's own relay port; the save must be refused",
|
||||
edit.Port, created.Id)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "relay port") {
|
||||
t.Fatalf("the refusal must say the port is an automatic relay one, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A row adopted from a node keeps the protocol it arrived with and its central
|
||||
// id (inbound_node.go:737), but gets no relay -- so its slot can never be taken.
|
||||
func TestCheckPortConflict_NodeAssignedAmneziawgOwnsNoRelaySlot(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
blocker := addAmneziaWGInbound(t, "awg-blocker", 51820, true)
|
||||
|
||||
nodeID := 7
|
||||
adopted := &model.Inbound{
|
||||
Tag: "awg-adopted",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 51821,
|
||||
Protocol: model.AmneziaWG,
|
||||
Settings: awgRelayWindowSettings(t, "awg-adopted"),
|
||||
NodeID: &nodeID,
|
||||
}
|
||||
collidingID := blocker.Id + 435
|
||||
if amneziawgnet.SOCKSPortForInbound(collidingID) != amneziawgnet.SOCKSPortForInbound(blocker.Id) {
|
||||
t.Fatalf("fixture: id %d does not derive the blocker's relay port", collidingID)
|
||||
}
|
||||
|
||||
got, err := (&InboundService{}).checkPortConflict(adopted, collidingID)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("id %d is node-assigned and binds no relay, so it cannot collide; got %q",
|
||||
collidingID, got.String())
|
||||
}
|
||||
|
||||
// The same rule covers the row's own port: with no relay on this host, its
|
||||
// WireGuard port may legitimately BE the port its id would derive.
|
||||
adopted.Port = amneziawgnet.SOCKSPortForInbound(collidingID)
|
||||
got, err = (&InboundService{}).checkPortConflict(adopted, collidingID)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("id %d is node-assigned and binds no relay, so its own port is not a conflict; got %q",
|
||||
collidingID, got.String())
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,33 @@ func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, err
|
||||
return ips, err
|
||||
}
|
||||
|
||||
// nodeHostedEmails is every client one node serves, its descendants' included.
|
||||
// Per-node pushes are scoped to it so their cost tracks the node, not the fleet.
|
||||
func nodeHostedEmails(db *gorm.DB, nodeID int) ([]string, error) {
|
||||
var emails []string
|
||||
err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", nodeID).Pluck("email", &emails).Error
|
||||
return emails, err
|
||||
}
|
||||
|
||||
// GetNodeInboundClientIps returns the IP rows of the clients nodeID hosts: a node's
|
||||
// IP-limit job reads no other row, so pushing the rest only made it echo them back.
|
||||
func (s *InboundService) GetNodeInboundClientIps(nodeID int) ([]model.InboundClientIps, error) {
|
||||
db := database.GetDB()
|
||||
emails, err := nodeHostedEmails(db, nodeID)
|
||||
if err != nil || len(emails) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var ips []model.InboundClientIps
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var page []model.InboundClientIps
|
||||
if err := db.Where("client_email IN ?", batch).Find(&page).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips = append(ips, page...)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
|
||||
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
|
||||
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
func setupEnablePortTest(t *testing.T) {
|
||||
t.Helper()
|
||||
setupConflictDB(t)
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
||||
mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
}
|
||||
|
||||
func disableInboundRow(t *testing.T, id int) {
|
||||
t.Helper()
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", id).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable row %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Saving a row while it is disabled skips every port guard, so enabling it later
|
||||
// was the one path that could still put two inbounds on one socket.
|
||||
func TestSetInboundEnableRefusesAPortAnotherEnabledInboundServes(t *testing.T) {
|
||||
setupEnablePortTest(t)
|
||||
seedInboundConflict(t, "holder", "0.0.0.0", 44431, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
seedInboundConflict(t, "sleeper", "0.0.0.0", 44431, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
sleeper := loadInboundByTag(t, "sleeper")
|
||||
disableInboundRow(t, sleeper.Id)
|
||||
|
||||
_, err := (&InboundService{}).SetInboundEnable(sleeper.Id, true)
|
||||
if err == nil {
|
||||
t.Fatal("enabling a row onto a port another enabled inbound serves must be refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "holder") {
|
||||
t.Fatalf("the refusal must name the row that owns the port; got %q", err)
|
||||
}
|
||||
if after := loadInboundByTag(t, "sleeper"); after.Enable {
|
||||
t.Fatal("a refused enable must not write the flag")
|
||||
}
|
||||
}
|
||||
|
||||
// The enable path must keep the tcp/udp coexistence the rest of the guards
|
||||
// allow, or it would refuse half of the working setups out there.
|
||||
func TestSetInboundEnableAllowsTCPUDPCoexistence(t *testing.T) {
|
||||
setupEnablePortTest(t)
|
||||
seedInboundConflict(t, "udp-holder", "0.0.0.0", 44432, model.Hysteria, ``, `{}`)
|
||||
seedInboundConflict(t, "tcp-sleeper", "0.0.0.0", 44432, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
sleeper := loadInboundByTag(t, "tcp-sleeper")
|
||||
disableInboundRow(t, sleeper.Id)
|
||||
|
||||
if _, err := (&InboundService{}).SetInboundEnable(sleeper.Id, true); err != nil {
|
||||
t.Fatalf("a tcp inbound must be able to enable onto a udp-only row's port: %v", err)
|
||||
}
|
||||
if after := loadInboundByTag(t, "tcp-sleeper"); !after.Enable {
|
||||
t.Fatal("the row must be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes run their own Xray, so a node row sharing a local port is legal and must
|
||||
// stay enableable.
|
||||
func TestSetInboundEnableAllowsANodeRowOnALocalPort(t *testing.T) {
|
||||
setupEnablePortTest(t)
|
||||
seedInboundConflict(t, "local-holder", "0.0.0.0", 44433, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, Scheme: "https", Enable: true, Status: "online"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
seedInboundConflictNode(t, "node-row", "0.0.0.0", 44433, model.VLESS, `{"network":"tcp"}`, `{}`, &node.Id)
|
||||
row := loadInboundByTag(t, "node-row")
|
||||
disableInboundRow(t, row.Id)
|
||||
|
||||
if _, err := (&InboundService{}).SetInboundEnable(row.Id, true); err != nil {
|
||||
t.Fatalf("a node row must be enableable regardless of a local row's port: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
@@ -33,6 +34,10 @@ const nodeBulkPushThreshold = 32
|
||||
// committed and the node flagged dirty, so a slow node defers to the reconcile.
|
||||
const nodeClientPushTimeout = 4 * time.Second
|
||||
|
||||
// nodeFanoutConcurrency bounds an operation that calls every node, as the heartbeat
|
||||
// does: one at a time, a few hanging nodes outlast the request's write timeout.
|
||||
const nodeFanoutConcurrency = 32
|
||||
|
||||
func nodePushContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), nodeClientPushTimeout)
|
||||
}
|
||||
@@ -354,6 +359,10 @@ func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnaps
|
||||
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed)
|
||||
return inner
|
||||
})
|
||||
if err != nil {
|
||||
// As on a failed fetch: a node whose snapshot did not merge keeps no online set.
|
||||
s.ClearNodeOnlineClients(nodeID)
|
||||
}
|
||||
return structuralChange, err
|
||||
}
|
||||
|
||||
@@ -1376,17 +1385,20 @@ func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
|
||||
if !restartOnDisable {
|
||||
return
|
||||
}
|
||||
for _, nodeID := range nodeIDs {
|
||||
nodeIDCopy := nodeID
|
||||
rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
|
||||
if rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
|
||||
continue
|
||||
// Best-effort and never replayed: a hanging node must not hold the traffic poll.
|
||||
common.GoRecover("restart-nodes-on-client-disable", func() {
|
||||
for _, nodeID := range nodeIDs {
|
||||
nodeIDCopy := nodeID
|
||||
rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
|
||||
if rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
|
||||
continue
|
||||
}
|
||||
if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
|
||||
}
|
||||
}
|
||||
if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *InboundService) GetOnlineClients() []string {
|
||||
@@ -1450,6 +1462,20 @@ func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
|
||||
}
|
||||
}
|
||||
|
||||
// RetainSyncedNodeOnlineClients keeps online clients only for nodes the traffic
|
||||
// sync still fetches; a node missing from nodes was deleted.
|
||||
func (s *InboundService) RetainSyncedNodeOnlineClients(nodes []*model.Node) {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return
|
||||
}
|
||||
synced := make(map[int]bool, len(nodes))
|
||||
for _, n := range nodes {
|
||||
synced[n.Id] = n.Enable && n.Status == "online"
|
||||
}
|
||||
process.RetainNodeOnlineClients(func(nodeID int) bool { return synced[nodeID] })
|
||||
}
|
||||
|
||||
// panelGuid returns this panel's stable self-identifier, used to key the local
|
||||
// panel's own clients in the per-node online maps (#4983).
|
||||
func (s *InboundService) panelGuid() string {
|
||||
|
||||
@@ -27,18 +27,24 @@ const depletedClientsClause = "reset = 0 and reset_day = 0 and ((total > 0 and u
|
||||
|
||||
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
|
||||
var disabledNodeIDs []int
|
||||
var remotePlans []trafficInboundUpdatePlan
|
||||
err = submitTrafficWrite(func() error {
|
||||
var inner error
|
||||
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
needRestart, clientsDisabled, disabledNodeIDs, remotePlans, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
return inner
|
||||
})
|
||||
if err == nil && len(disabledNodeIDs) > 0 {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Off the serial writer: a hanging node must not stall traffic accounting.
|
||||
needRestart = s.applyTrafficRemotePlans(remotePlans) || needRestart
|
||||
if len(disabledNodeIDs) > 0 {
|
||||
s.restartRemoteNodesOnDisable(disabledNodeIDs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
|
||||
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, []trafficInboundUpdatePlan, error) {
|
||||
db := database.GetDB()
|
||||
// Commit durable traffic before best-effort lifecycle maintenance so helper
|
||||
// failures cannot discard usage already reported by Xray.
|
||||
@@ -48,7 +54,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
}
|
||||
return s.addClientTraffic(tx, clientTraffics)
|
||||
}); err != nil {
|
||||
return false, false, nil, err
|
||||
return false, false, nil, nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -93,10 +99,10 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("traffic lifecycle maintenance failed after traffic commit:", err)
|
||||
return false, false, nil, nil
|
||||
return false, false, nil, nil, nil
|
||||
}
|
||||
needRestart = needRestart || s.applyTrafficMutationBatch(batch)
|
||||
return needRestart, clientsDisabled, disabledNodeIDs, nil
|
||||
return needRestart, clientsDisabled, disabledNodeIDs, batch.remotePlans, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
||||
@@ -728,6 +734,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
"flow": client.Flow,
|
||||
"password": client.Password,
|
||||
"cipher": cipher,
|
||||
"reverse": client.Reverse,
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
reenableNodeID = inbound.NodeID
|
||||
@@ -826,13 +833,18 @@ func (s *InboundService) propagateResetAllTrafficsToNodes() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
|
||||
ids := make([]int, len(nodes))
|
||||
for i, node := range nodes {
|
||||
ids[i] = node.Id
|
||||
}
|
||||
fanoutInboundResults(ids, nodeFanoutConcurrency, func(i int) struct{} {
|
||||
if rt, err := runtime.GetManager().RuntimeFor(&ids[i]); err == nil {
|
||||
if e := rt.ResetAllTraffics(context.Background()); e != nil {
|
||||
logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return struct{}{}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *InboundService) ResetInboundTraffic(id int) error {
|
||||
|
||||
@@ -57,22 +57,37 @@ func (b *trafficMutationBatch) markNodesTx(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyTrafficRemotePlans is bounded like every per-client node push: the nodes are
|
||||
// already dirty, so an offline or slow one defers to the reconcile.
|
||||
func (s *InboundService) applyTrafficRemotePlans(plans []trafficInboundUpdatePlan) bool {
|
||||
ids := make([]int, len(plans))
|
||||
for i := range plans {
|
||||
ids[i] = plans[i].newInbound.Id
|
||||
}
|
||||
failed, panics := fanoutInboundResults(ids, inboundFanoutConcurrency, func(i int) bool {
|
||||
rt, push, _, err := s.nodePushPlan(&plans[i].newInbound)
|
||||
if err == nil && push {
|
||||
ctx, cancel := nodePushContext()
|
||||
err = rt.UpdateInbound(ctx, &plans[i].oldInbound, &plans[i].newInbound)
|
||||
cancel()
|
||||
}
|
||||
if err != nil {
|
||||
logger.Debug("traffic post-commit remote apply failed:", err)
|
||||
}
|
||||
return err != nil
|
||||
})
|
||||
needRestart := false
|
||||
for i := range failed {
|
||||
needRestart = needRestart || failed[i] || panics[i] != nil
|
||||
}
|
||||
return needRestart
|
||||
}
|
||||
|
||||
func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
needRestart := false
|
||||
for i := range b.remotePlans {
|
||||
plan := &b.remotePlans[i]
|
||||
rt, err := s.runtimeFor(&plan.newInbound)
|
||||
if err == nil {
|
||||
err = rt.UpdateInbound(context.Background(), &plan.oldInbound, &plan.newInbound)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Debug("traffic post-commit remote apply failed:", err)
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
for i := range b.localPlans {
|
||||
plan := &b.localPlans[i]
|
||||
if plan.inbound.Protocol == model.MTProto {
|
||||
|
||||
@@ -168,10 +168,8 @@ func overlayGlobalTrafficValues(db *gorm.DB, rows []xray.ClientTraffic) {
|
||||
// its own aggregate.
|
||||
func (s *InboundService) GetNodeClientTraffics(nodeID int) ([]*xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
var emails []string
|
||||
if err := db.Model(&model.NodeClientTraffic{}).
|
||||
Where("node_id = ?", nodeID).
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
emails, err := nodeHostedEmails(db, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
|
||||
@@ -878,8 +878,9 @@ func (s *NodeService) Delete(id int) error {
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
mgr.InvalidateNode(id)
|
||||
}
|
||||
nodeMetrics.drop(nodeMetricKey(id, "cpu"))
|
||||
nodeMetrics.drop(nodeMetricKey(id, "mem"))
|
||||
for _, metric := range NodeMetricKeys {
|
||||
nodeMetrics.drop(nodeMetricKey(id, metric))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -935,12 +936,11 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("runtime manager unavailable")
|
||||
}
|
||||
results := make([]NodeUpdateResult, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
results, panics := fanoutInboundResults(ids, nodeFanoutConcurrency, func(i int) NodeUpdateResult {
|
||||
id := ids[i]
|
||||
n, err := s.GetById(id)
|
||||
if err != nil || n == nil {
|
||||
results = append(results, NodeUpdateResult{Id: id, OK: false, Error: "node not found"})
|
||||
continue
|
||||
return NodeUpdateResult{Id: id, OK: false, Error: "node not found"}
|
||||
}
|
||||
res := NodeUpdateResult{Id: id, Name: n.Name}
|
||||
switch {
|
||||
@@ -963,7 +963,12 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err
|
||||
res.OK = true
|
||||
}
|
||||
}
|
||||
results = append(results, res)
|
||||
return res
|
||||
})
|
||||
for i, panicErr := range panics {
|
||||
if panicErr != nil {
|
||||
results[i] = NodeUpdateResult{Id: ids[i], Error: panicErr.Error()}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// fanoutGate holds every node call open until released, so a test sees how many
|
||||
// nodes an operation reaches at once.
|
||||
type fanoutGate struct {
|
||||
entered atomic.Int32
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newFanoutGate() *fanoutGate { return &fanoutGate{release: make(chan struct{})} }
|
||||
|
||||
func (g *fanoutGate) hold(ctx context.Context) error {
|
||||
g.entered.Add(1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-g.release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g *fanoutGate) open() { g.once.Do(func() { close(g.release) }) }
|
||||
|
||||
func (g *fanoutGate) waitAll(t *testing.T, want int32, op string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for g.entered.Load() < want {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s reached %d of %d hanging nodes, want all of them at once", op, g.entered.Load(), want)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
type gatedNodeRuntime struct {
|
||||
fakeNodeRuntime
|
||||
gate *fanoutGate
|
||||
}
|
||||
|
||||
func (r *gatedNodeRuntime) ResetAllTraffics(ctx context.Context) error { return r.gate.hold(ctx) }
|
||||
|
||||
func (r *gatedNodeRuntime) DelInbound(ctx context.Context, _ *model.Inbound) error {
|
||||
return r.gate.hold(ctx)
|
||||
}
|
||||
|
||||
func gatedNodes(t *testing.T, gate *fanoutGate, n int) []int {
|
||||
t.Helper()
|
||||
mgr := useTestRuntimeManager(t)
|
||||
ids := make([]int, 0, n)
|
||||
for i := range n {
|
||||
node := &model.Node{Name: fmt.Sprintf("fanout-%d", i), Address: "127.0.0.1", Port: 2100 + i, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
mgr.SetRuntimeOverride(node.Id, &gatedNodeRuntime{gate: gate})
|
||||
ids = append(ids, node.Id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Operations that touch every node walked them one at a time, so a few hanging
|
||||
// nodes kept the request running for minutes past the panel's write timeout.
|
||||
func TestResetAllTrafficsReachesNodesConcurrently(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
gate := newFanoutGate()
|
||||
gatedNodes(t, gate, 3)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_ = (&InboundService{}).ResetAllTraffics()
|
||||
}()
|
||||
t.Cleanup(func() { gate.open(); <-done })
|
||||
gate.waitAll(t, 3, "ResetAllTraffics")
|
||||
}
|
||||
|
||||
func TestDelInboundsPushesNodeDeletesConcurrently(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
gate := newFanoutGate()
|
||||
var inboundIDs []int
|
||||
for i, nodeID := range gatedNodes(t, gate, 3) {
|
||||
inboundIDs = append(inboundIDs, nodeInbound(t, nodeID, 46400+i, nil).Id)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
var result BulkDelInboundResult
|
||||
var err error
|
||||
go func() {
|
||||
defer close(done)
|
||||
result, _, err = (&InboundService{}).DelInbounds(inboundIDs)
|
||||
}()
|
||||
t.Cleanup(func() { gate.open(); <-done })
|
||||
gate.waitAll(t, 3, "DelInbounds")
|
||||
gate.open()
|
||||
<-done
|
||||
if err != nil || result.Deleted != 3 || len(result.Skipped) != 0 {
|
||||
t.Fatalf("DelInbounds = %+v, %v; want 3 deleted", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePanelsReachesNodesConcurrently(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
useTestRuntimeManager(t)
|
||||
gate := newFanoutGate()
|
||||
var ids []int
|
||||
for i := range 3 {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
if strings.HasSuffix(r.URL.Path, "server/updatePanel") {
|
||||
_ = gate.hold(r.Context())
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
node := &model.Node{
|
||||
Name: fmt.Sprintf("panel-%d", i), Scheme: "http", Address: host, Port: portNum, BasePath: "/",
|
||||
ApiToken: "tok", Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
ids = append(ids, node.Id)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
var results []NodeUpdateResult
|
||||
go func() {
|
||||
defer close(done)
|
||||
results, _ = (&NodeService{}).UpdatePanels(ids, false)
|
||||
}()
|
||||
t.Cleanup(func() { gate.open(); <-done })
|
||||
gate.waitAll(t, 3, "UpdatePanels")
|
||||
gate.open()
|
||||
<-done
|
||||
if len(results) != len(ids) {
|
||||
t.Fatalf("UpdatePanels returned %d results for %d nodes", len(results), len(ids))
|
||||
}
|
||||
for i, res := range results {
|
||||
if res.Id != ids[i] || !res.OK {
|
||||
t.Fatalf("UpdatePanels result %d = %+v, want node %d updated, in request order", i, res, ids[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Deleting a node dropped only its cpu and mem series, while the heartbeat also
|
||||
// records netUp and netDown, so each deleted node leaked two histories for good.
|
||||
func TestDeleteNodeDropsEveryMetricSeries(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
node := &model.Node{Id: 9101, Name: "gone", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
ns := NodeService{}
|
||||
if err := ns.UpdateHeartbeat(node.Id, HeartbeatPatch{
|
||||
Status: "online", LastHeartbeat: time.Now().Unix(), CpuPct: 1, MemPct: 2, NetUp: 3, NetDown: 4,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateHeartbeat: %v", err)
|
||||
}
|
||||
|
||||
if err := ns.Delete(node.Id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
prefix := nodeMetricKey(node.Id, "")
|
||||
var left []string
|
||||
nodeMetrics.mu.Lock()
|
||||
for key := range nodeMetrics.series {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
left = append(left, key)
|
||||
}
|
||||
}
|
||||
nodeMetrics.mu.Unlock()
|
||||
if len(left) != 0 {
|
||||
t.Fatalf("metric series left after deleting the node: %v", left)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,22 @@ func (s *NodeService) ClearDescendants(nodeID int) {
|
||||
nodeDescendantsMu.Unlock()
|
||||
}
|
||||
|
||||
// RetainEnabledNodeDescendants drops sub-nodes learned from nodes the heartbeat no
|
||||
// longer probes: disabled ones it skips, deleted ones missing from nodes.
|
||||
func (s *NodeService) RetainEnabledNodeDescendants(nodes []*model.Node) {
|
||||
enabled := make(map[int]bool, len(nodes))
|
||||
for _, n := range nodes {
|
||||
enabled[n.Id] = n.Enable
|
||||
}
|
||||
nodeDescendantsMu.Lock()
|
||||
for nodeID := range nodeDescendantsCache {
|
||||
if !enabled[nodeID] {
|
||||
delete(nodeDescendantsCache, nodeID)
|
||||
}
|
||||
}
|
||||
nodeDescendantsMu.Unlock()
|
||||
}
|
||||
|
||||
func cachedDescendants() []model.NodeSummary {
|
||||
nodeDescendantsMu.RLock()
|
||||
defer nodeDescendantsMu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func useOnlineTestProcess(t *testing.T) *xray.Process {
|
||||
t.Helper()
|
||||
previousProcess, previousResult := xrayState.snapshot()
|
||||
process := xray.NewTestProcess(nil, "")
|
||||
xrayState.replace(process)
|
||||
t.Cleanup(func() {
|
||||
xrayState.mu.Lock()
|
||||
xrayState.process = previousProcess
|
||||
xrayState.result = previousResult
|
||||
xrayState.mu.Unlock()
|
||||
})
|
||||
return process
|
||||
}
|
||||
|
||||
// Only a failed snapshot fetch used to clear a node's online set, so a node the
|
||||
// sync stopped reaching (disabled, marked offline, deleted) kept its clients online.
|
||||
func TestRetainSyncedNodeOnlineClientsDropsUnsyncedNodes(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
process := useOnlineTestProcess(t)
|
||||
svc := InboundService{}
|
||||
for id := 1; id <= 4; id++ {
|
||||
guid := fmt.Sprintf("g%d", id)
|
||||
svc.SetNodeOnlineTree(id, map[string][]string{guid: {guid + "@x"}})
|
||||
process.SetNodeActiveInboundTree(id, map[string][]string{guid: {"in-" + guid}})
|
||||
}
|
||||
|
||||
svc.RetainSyncedNodeOnlineClients([]*model.Node{
|
||||
{Id: 1, Enable: true, Status: "online"},
|
||||
{Id: 2, Enable: false, Status: "online"},
|
||||
{Id: 3, Enable: true, Status: "offline"},
|
||||
})
|
||||
|
||||
if got, want := svc.GetOnlineClientsByGuid(), map[string][]string{"g1": {"g1@x"}}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("online by guid = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := svc.GetActiveInboundsByGuid(), map[string][]string{"g1": {"in-g1"}}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("active inbounds by guid = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRemoteTrafficFailureClearsNodeOnlineClients(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
useOnlineTestProcess(t)
|
||||
svc := InboundService{}
|
||||
svc.SetNodeOnlineTree(7, map[string][]string{"g7": {"a@x"}})
|
||||
if err := database.GetDB().Exec("DROP TABLE inbounds").Error; err != nil {
|
||||
t.Fatalf("drop inbounds: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.SetRemoteTraffic(7, &runtime.TrafficSnapshot{}, false, false); err == nil {
|
||||
t.Fatal("SetRemoteTraffic succeeded without an inbounds table")
|
||||
}
|
||||
if got := svc.GetOnlineClientsByGuid(); len(got) != 0 {
|
||||
t.Errorf("online by guid after a failed merge = %v, want none", got)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user