mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-07 21:55:01 +03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17e6b5a460 | |||
| 34d2591e50 | |||
| ca6955d88b | |||
| 411271b454 | |||
| e862d81c60 | |||
| 6af2995930 | |||
| 041476a317 | |||
| ff954ec48c | |||
| 8f49327efb | |||
| 8bbca76bdd | |||
| a2774bf212 | |||
| 48675ff197 | |||
| 604986598f | |||
| b6473004ac | |||
| 579acbc669 |
@@ -1,24 +1,5 @@
|
||||
name: Claude Bot
|
||||
|
||||
# Every prompt: / claude_args: block below interpolates ${{ }}, so GitHub parses
|
||||
# the whole block scalar as ONE expression and caps it at 21000 characters.
|
||||
# Going over does not fail a job - the entire workflow stops parsing and
|
||||
# vanishes from Actions, with the run reported only as a workflow file issue.
|
||||
# The two triage prompts are the ones to watch: roughly 15100 characters each.
|
||||
# Put shared context in CLAUDE.md and docs/architecture.md, which are in the
|
||||
# checkout, instead of pasting it here.
|
||||
#
|
||||
# CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0 on the two jobs that set
|
||||
# allowed_non_write_users: the action otherwise turns subprocess isolation on
|
||||
# for them, installs bubblewrap, and every Bash call then dies in the sandbox
|
||||
# with "bwrap: Can't create file at /home/.mcp.json: Permission denied" before
|
||||
# the command runs. The job still reports success, so the bot silently answers
|
||||
# nothing - which is what the "Fail if ..." steps catch.
|
||||
#
|
||||
# Only resolve-conflicts may change code, and only the merge it is handed: the
|
||||
# model there has no shell at all, and the commit and push are done by a
|
||||
# workflow step from the event payload, never by the model.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
@@ -815,11 +796,11 @@ jobs:
|
||||
fi
|
||||
base=$(gh pr view "$PR" --json baseRefName --jq '.baseRefName')
|
||||
head=$(gh pr view "$PR" --json headRefName --jq '.headRefName')
|
||||
gh pr checkout "$PR"
|
||||
git config core.hooksPath /dev/null
|
||||
git config core.quotePath false
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
gh pr checkout "$PR"
|
||||
git fetch origin "$base"
|
||||
if git merge --no-commit --no-ff "origin/${base}"; then
|
||||
git merge --abort 2>/dev/null || true
|
||||
@@ -838,6 +819,14 @@ jobs:
|
||||
git merge --abort 2>/dev/null || true
|
||||
hand_back "The merge of \`${base}\` failed without leaving a conflicted file, so it needs a human. Nothing was changed."
|
||||
fi
|
||||
odd=$(printf '%s\n' "$files" | grep -vE '^[A-Za-z0-9._][A-Za-z0-9._/-]*$' || true)
|
||||
if [ -n "$odd" ]; then
|
||||
git merge --abort 2>/dev/null || true
|
||||
hand_back "The merge of \`${base}\` conflicts over paths this job refuses to hand to its tooling:
|
||||
$(printf '%s\n' "$odd" | sed 's/^/- /')
|
||||
|
||||
Nothing was changed. Resolve those by hand."
|
||||
fi
|
||||
rules=""
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
|
||||
@@ -369,8 +369,8 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
|
||||
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
||||
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
||||
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
||||
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | IP-limit and Xray access/error log cleanup; traffic resets |
|
||||
| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
|
||||
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
|
||||
| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
|
||||
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
||||
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
|
||||
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
|
||||
|
||||
@@ -40,6 +40,9 @@ See [Clients](/docs/config/clients).
|
||||
Optionally cap total traffic and set an expiry date for the inbound, and choose a
|
||||
periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
|
||||
`weekly`, or `monthly`.
|
||||
|
||||
For `monthly` resets, select a day from 1 to 31. If the selected day does not
|
||||
exist in a shorter month, the reset runs on that month's last day.
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
||||
- **Leaked private key.** Only ever distribute the **public** key to clients.
|
||||
- **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
|
||||
the inbound client entry and the share link.
|
||||
- **Old client cores rejected by default.** An empty **Min Client Ver** is not
|
||||
"no limit": Xray-core falls back to the built-in minimum of the core build you
|
||||
run (26.3.27 in current releases) that keeps client TLS fingerprints fresh, so
|
||||
third-party cores such as Mihomo and sing-box fail REALITY verification even
|
||||
with a correct config — clients see timeouts while only Xray-core based apps
|
||||
connect. Set it to `1.0.0` only if you must support them; that also re-admits
|
||||
outdated fingerprints.
|
||||
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقالها](/docs/c
|
||||
بهصورت اختیاری میتوانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
|
||||
تعیین کنید، و یک زمانبندی **بازنشانی ترافیک** دورهای انتخاب کنید: `never`
|
||||
(پیشفرض)، `hourly`، `daily`، `weekly` یا `monthly`.
|
||||
|
||||
برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاهتر
|
||||
وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام میشود.
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
||||
- **نشت کلید خصوصی.** فقط و فقط **کلید عمومی** را میان کلاینتها توزیع کنید.
|
||||
- **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
|
||||
مدخل کلاینت و هم در لینک اشتراکگذاری نیاز دارد.
|
||||
- **هستههای قدیمی کلاینت بهطور پیشفرض رد میشوند.** خالی گذاشتن
|
||||
**حداقل نسخه کلاینت** به معنای «بدون محدودیت» نیست: Xray-core به حداقل داخلیِ
|
||||
نسخهٔ هستهای که اجرا میکنید (در نسخههای فعلی 26.3.27) بازمیگردد تا اثر انگشتهای TLS کلاینتها تازه
|
||||
بمانند؛ در نتیجه هستههای شخص ثالث مانند Mihomo و sing-box حتی با پیکربندی
|
||||
کاملاً درست در تأیید REALITY شکست میخورند — کلاینتها تایماوت میبینند و فقط
|
||||
اپلیکیشنهای مبتنی بر Xray-core وصل میشوند. تنها در صورت نیاز به پشتیبانی از
|
||||
آنها مقدار `1.0.0` را تنظیم کنید؛ این کار اثر انگشتهای قدیمی را هم میپذیرد.
|
||||
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ icon: ArrowDownToLine
|
||||
При необходимости ограничьте общий объём трафика и установите дату истечения для
|
||||
входящего подключения, а также выберите расписание периодического **сброса трафика**:
|
||||
`never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
|
||||
|
||||
Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
|
||||
коротком месяце, сброс выполняется в последний день этого месяца.
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
@@ -123,6 +123,14 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
||||
ключ.
|
||||
- **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
|
||||
как в записи клиента входящего подключения, так и в ссылке для подключения.
|
||||
- **Старые ядра клиентов отклоняются по умолчанию.** Пустое поле
|
||||
**Мин. версия клиента** не означает «без ограничений»: Xray-core использует
|
||||
встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах),
|
||||
который поддерживает свежесть
|
||||
TLS-отпечатков клиентов, поэтому сторонние ядра, такие как Mihomo и sing-box,
|
||||
не проходят проверку REALITY даже при корректной конфигурации — клиенты видят
|
||||
таймауты, а подключаются только приложения на базе Xray-core. Ставьте `1.0.0`,
|
||||
только если они вам необходимы; это также допустит устаревшие отпечатки.
|
||||
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ icon: ArrowDownToLine
|
||||
|
||||
可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
|
||||
`never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
|
||||
|
||||
选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
|
||||
则在该月最后一天重置。
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
@@ -105,6 +105,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
||||
- **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
|
||||
- **私钥泄露。** 永远只把**公钥**分发给客户端。
|
||||
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
|
||||
- **旧客户端内核默认被拒。** **最小客户端版本**留空并不是“不限制”:Xray-core 会退回到所运行内核版本的内置最低值(当前版本为 26.3.27)以保证客户端 TLS 指纹的新鲜度,因此 Mihomo、sing-box 等第三方内核即使配置完全正确也会导致 REALITY 验证失败——表现为客户端超时,只有基于 Xray-core 的应用能连上。只有在必须支持它们时才填 `1.0.0`;这同时也会放行过时的指纹。
|
||||
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -411,6 +411,9 @@
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -479,6 +482,7 @@
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -916,6 +920,9 @@
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -991,6 +998,7 @@
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
|
||||
@@ -270,6 +270,9 @@
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -441,6 +444,7 @@
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -737,6 +741,9 @@
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -915,6 +922,7 @@
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -1860,6 +1868,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"trafficResetDay": {
|
||||
"description": "Day of month for monthly traffic resets",
|
||||
"example": 1,
|
||||
"maximum": 31,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"description": "Upload traffic in bytes",
|
||||
"format": "int64",
|
||||
@@ -1886,6 +1901,7 @@
|
||||
"tag",
|
||||
"total",
|
||||
"trafficReset",
|
||||
"trafficResetDay",
|
||||
"up"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -3151,6 +3167,7 @@
|
||||
"tag": "in-443-tcp",
|
||||
"total": 0,
|
||||
"trafficReset": "never",
|
||||
"trafficResetDay": 1,
|
||||
"up": 0
|
||||
}
|
||||
]
|
||||
@@ -5816,6 +5833,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/get/tgId/{tgId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.",
|
||||
"operationId": "get_panel_api_clients_get_tgId_tgId",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Telegram user ID (numeric).",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/add": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -121,7 +121,9 @@ export default function DateTimePicker({
|
||||
<DatePicker
|
||||
value={value}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||
needConfirm={false}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subPort": 1,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
@@ -186,6 +187,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subPort": 1,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
@@ -448,6 +450,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"tag": "in-443-tcp",
|
||||
"total": 0,
|
||||
"trafficReset": "never",
|
||||
"trafficResetDay": 1,
|
||||
"up": 0
|
||||
},
|
||||
"InboundClientIps": {
|
||||
|
||||
@@ -244,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -415,6 +418,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -711,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -889,6 +896,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -1834,6 +1842,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"trafficResetDay": {
|
||||
"description": "Day of month for monthly traffic resets",
|
||||
"example": 1,
|
||||
"maximum": 31,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"description": "Upload traffic in bytes",
|
||||
"format": "int64",
|
||||
@@ -1860,6 +1875,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"tag",
|
||||
"total",
|
||||
"trafficReset",
|
||||
"trafficResetDay",
|
||||
"up"
|
||||
],
|
||||
"type": "object"
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface AllSetting {
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
@@ -193,6 +194,7 @@ export interface AllSettingView {
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
@@ -426,6 +428,7 @@ export interface Inbound {
|
||||
tag: string;
|
||||
total: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export const AllSettingSchema = z.object({
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
@@ -206,6 +207,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
@@ -451,6 +453,7 @@ export const InboundSchema = z.object({
|
||||
tag: z.string(),
|
||||
total: z.number().int(),
|
||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||
trafficResetDay: z.number().int().min(1).max(31),
|
||||
up: z.number().int(),
|
||||
});
|
||||
export type Inbound = z.infer<typeof InboundSchema>;
|
||||
|
||||
@@ -117,6 +117,19 @@ export function computeClientsSummary(
|
||||
return { total: stats.length, active, online, depleted, expiring, deactive };
|
||||
}
|
||||
|
||||
export function pickClientsSummary(
|
||||
serverSummary: ClientsSummary,
|
||||
allClientStats: ClientStatRow[],
|
||||
onlineSet: Set<string>,
|
||||
expireDiffMs: number,
|
||||
trafficDiffBytes: number,
|
||||
): ClientsSummary {
|
||||
if (allClientStats.length === 0) return serverSummary;
|
||||
if (serverSummary.total > allClientStats.length) return serverSummary;
|
||||
const live = computeClientsSummary(allClientStats, onlineSet, expireDiffMs, trafficDiffBytes);
|
||||
return { ...live, total: serverSummary.total || live.total };
|
||||
}
|
||||
|
||||
function buildQS(p: ClientQueryParams): string {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(p.page || 1));
|
||||
@@ -265,18 +278,12 @@ export function useClients() {
|
||||
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
||||
const pageSize = (defaults.pageSize as number) ?? 0;
|
||||
|
||||
// Live summary: the client_stats WS event refreshes allClientStats every few
|
||||
// seconds, so the top counters track reality without a page refresh. Falls
|
||||
// back to the server-computed summary until the first event lands, and keeps
|
||||
// the server's authoritative total for the headline count.
|
||||
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
||||
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||
const summary = useMemo<ClientsSummary>(() => {
|
||||
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
||||
if (allClientStats.length === 0) return serverSummary;
|
||||
const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff);
|
||||
return { ...live, total: serverSummary.total || live.total };
|
||||
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
|
||||
const summary = useMemo<ClientsSummary>(
|
||||
() => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
|
||||
[allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
|
||||
);
|
||||
|
||||
const invalidateAll = useCallback(
|
||||
() => {
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface RawInboundRow {
|
||||
enable?: boolean;
|
||||
expiryTime?: number;
|
||||
trafficReset?: string;
|
||||
trafficResetDay?: number;
|
||||
lastTrafficResetTime?: number;
|
||||
nodeId?: number | null;
|
||||
shareAddrStrategy?: string;
|
||||
@@ -60,6 +61,7 @@ export interface WireInboundPayload {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: TrafficReset;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
@@ -202,6 +204,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
down: row.down ?? 0,
|
||||
total: row.total ?? 0,
|
||||
trafficReset: coerceTrafficReset(row.trafficReset),
|
||||
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
|
||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||
nodeId: row.nodeId ?? null,
|
||||
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||
@@ -344,6 +347,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||
enable: values.enable,
|
||||
expiryTime: values.expiryTime,
|
||||
trafficReset: values.trafficReset,
|
||||
trafficResetDay: values.trafficResetDay,
|
||||
lastTrafficResetTime: values.lastTrafficResetTime,
|
||||
listen: values.listen,
|
||||
port: values.port,
|
||||
|
||||
@@ -42,8 +42,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
||||
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
|
||||
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
|
||||
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
|
||||
// reading the share link wouldn't honor them. Mirrors the legacy
|
||||
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
|
||||
// reading the share link wouldn't honor them.
|
||||
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
|
||||
if (!xhttp) return null;
|
||||
const extra: Record<string, unknown> = {};
|
||||
@@ -85,6 +84,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
|
||||
}
|
||||
// xray-core #6258 renamed these fields, but older clients still read the
|
||||
// legacy names from share-link extra. Emit both names so one link works
|
||||
// across old and new clients while the stored panel config stays canonical.
|
||||
if (typeof extra.sessionIDPlacement === 'string') {
|
||||
extra.sessionPlacement = extra.sessionIDPlacement;
|
||||
}
|
||||
if (typeof extra.sessionIDKey === 'string') {
|
||||
extra.sessionKey = extra.sessionIDKey;
|
||||
}
|
||||
|
||||
// Headers on the wire are a record; emit them as a map upstream's
|
||||
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
|
||||
|
||||
@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a REALITY client-version string the way xray-core's config loader
|
||||
* does: one to three dot-separated numeric parts, each 0-255. Returns the
|
||||
* parts padded to three entries, or undefined when the string is not a valid
|
||||
* version.
|
||||
*/
|
||||
export function parseRealityClientVer(value: string): [number, number, number] | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parts = trimmed.split('.');
|
||||
if (parts.length > 3) return undefined;
|
||||
const nums: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^\d+$/.test(part)) return undefined;
|
||||
const n = Number(part);
|
||||
if (n > 255) return undefined;
|
||||
nums.push(n);
|
||||
}
|
||||
while (nums.length < 3) nums.push(0);
|
||||
return nums as [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a REALITY client-version field; empty means "not set" and is
|
||||
* valid. The value is saved exactly as typed and xray-core's part parser
|
||||
* accepts no surrounding whitespace, so a value that differs from its
|
||||
* trimmed form is rejected rather than silently passed to the wire.
|
||||
*/
|
||||
export function validateRealityClientVer(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value !== value.trim() || !parseRealityClientVer(value)) {
|
||||
return 'pages.inbounds.form.clientVerInvalid';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the max client-version field: format first, then that a non-empty
|
||||
* max is not below a non-empty min (an inverted range rejects every client).
|
||||
* An empty or malformed min is left to the min field's own validation.
|
||||
*/
|
||||
export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
|
||||
const formatError = validateRealityClientVer(max);
|
||||
if (formatError) return formatError;
|
||||
const maxParts = parseRealityClientVer(max);
|
||||
const minParts = parseRealityClientVer(min);
|
||||
if (!maxParts || !minParts) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (maxParts[i] !== minParts[i]) {
|
||||
return maxParts[i] < minParts[i]
|
||||
? 'pages.inbounds.form.maxClientVerBelowMin'
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
|
||||
const lift = (legacy: string, renamed: string) => {
|
||||
const v = obj[legacy];
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DBInboundInit = Partial<{
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
@@ -76,6 +77,7 @@ export class DBInbound {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
trafficResetDay: number;
|
||||
lastTrafficResetTime: number;
|
||||
|
||||
listen: string;
|
||||
@@ -105,6 +107,7 @@ export class DBInbound {
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = "never";
|
||||
this.trafficResetDay = 1;
|
||||
this.lastTrafficResetTime = 0;
|
||||
|
||||
this.listen = "";
|
||||
|
||||
@@ -14,6 +14,7 @@ export class AllSetting {
|
||||
expireDiff = 0;
|
||||
trafficDiff = 0;
|
||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||
subShowIdentityOnAllLinks = false;
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
|
||||
@@ -582,6 +582,16 @@ export const sections: readonly Section[] = [
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get/tgId/:tgId',
|
||||
summary: 'Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.',
|
||||
params: [
|
||||
{ name: 'tgId', in: 'path', type: 'integer', desc: 'Telegram user ID (numeric).' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [],\n "usedTraffic": 1048576\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/add',
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function ClientsPage() {
|
||||
|
||||
const {
|
||||
clients, total, filtered,
|
||||
summary: serverSummary,
|
||||
summary,
|
||||
allGroups,
|
||||
setQuery,
|
||||
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
|
||||
@@ -385,9 +385,6 @@ export default function ClientsPage() {
|
||||
// a rename.
|
||||
const filteredClients = clients;
|
||||
|
||||
// Server-computed counts that stay stable as the user paginates/filters.
|
||||
const summary = serverSummary;
|
||||
|
||||
// Sort is server-side now; the page already arrives in the requested
|
||||
// order, so we just hand it through.
|
||||
const sortedClients = filteredClients;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
isSS2022,
|
||||
} from '@/lib/xray/protocol-capabilities';
|
||||
import {
|
||||
InboundDbFieldsSchema,
|
||||
InboundFormBaseSchema,
|
||||
InboundFormSchema,
|
||||
type InboundFormValues,
|
||||
@@ -255,6 +256,7 @@ export default function InboundFormModal({
|
||||
const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
|
||||
const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
|
||||
const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
|
||||
const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never';
|
||||
const autoTagRef = useRef(true);
|
||||
const lastWrittenTagRef = useRef('');
|
||||
const currentTagInput = (): InboundTagInput => ({
|
||||
@@ -619,6 +621,16 @@ export default function InboundFormModal({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{trafficReset === 'monthly' && (
|
||||
<FormField
|
||||
name="trafficResetDay"
|
||||
label={t('pages.inbounds.periodicTrafficResetDay')}
|
||||
rules={{ validate: rhfZodValidate(InboundDbFieldsSchema.shape.trafficResetDay) }}
|
||||
>
|
||||
<InputNumber min={1} max={31} />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||
import {
|
||||
validateRealityClientVer,
|
||||
validateRealityMaxClientVer,
|
||||
validateRealityTarget,
|
||||
} from '@/lib/xray/stream-wire-normalize';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
||||
|
||||
@@ -39,7 +44,14 @@ export default function RealityForm({
|
||||
clearMldsa65,
|
||||
}: RealityFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getFieldState, trigger } = useFormContext();
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
|
||||
const revalidateMaxClientVer = () => {
|
||||
if (getFieldState(maxClientVerPath).error) {
|
||||
void trigger(maxClientVerPath);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
@@ -127,14 +139,31 @@ export default function RealityForm({
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'minClientVer']}
|
||||
label={t('pages.inbounds.form.minClientVer')}
|
||||
tooltip={t('pages.inbounds.form.minClientVerHint')}
|
||||
onAfterChange={revalidateMaxClientVer}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
const errKey = validateRealityClientVer(typeof value === 'string' ? value : '');
|
||||
return errKey ? errKey : true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="26.3.27" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxClientVer']}
|
||||
label={t('pages.inbounds.form.maxClientVer')}
|
||||
tooltip={t('pages.inbounds.form.maxClientVerHint')}
|
||||
rules={{
|
||||
validate: (value, formValues) => {
|
||||
const max = typeof value === 'string' ? value : '';
|
||||
const min = formValues?.streamSettings?.realitySettings?.minClientVer;
|
||||
const errKey = validateRealityMaxClientVer(max, typeof min === 'string' ? min : '');
|
||||
return errKey ? errKey : true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="25.9.11" />
|
||||
<Input placeholder="x.y.z" />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.shortIds')}>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
||||
@@ -64,7 +65,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
|
||||
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
|
||||
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
@@ -170,7 +171,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
|
||||
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
|
||||
@@ -179,7 +180,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
|
||||
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
@@ -208,7 +209,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
|
||||
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
||||
@@ -234,11 +235,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
|
||||
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
|
||||
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
@@ -308,7 +309,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
|
||||
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
|
||||
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
|
||||
@@ -387,15 +388,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
|
||||
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
|
||||
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
|
||||
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { GoRegexInput } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
<div className="format-settings">
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
|
||||
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
|
||||
onChange={onNumber((v) => setMuxField('concurrency', v))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
|
||||
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
|
||||
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
|
||||
<Select
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined,
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -57,7 +58,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
|
||||
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subPort: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
|
||||
<Input
|
||||
@@ -93,10 +94,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
maxLength={256}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subShowIdentityOnAllLinks')}
|
||||
description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.subShowIdentityOnAllLinks}
|
||||
onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
||||
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
|
||||
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import {
|
||||
BurstObservatorySchema,
|
||||
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={burst.pingConfig.sampling}
|
||||
onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
|
||||
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
@@ -311,7 +312,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
min={0}
|
||||
step={60}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
|
||||
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
|
||||
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
|
||||
import type { DnsServerValue } from './DnsServerModal';
|
||||
|
||||
@@ -113,7 +115,7 @@ export function useFakednsColumns({
|
||||
aria-label={t('pages.xray.fakedns.poolSize')}
|
||||
min={1}
|
||||
size="small"
|
||||
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
|
||||
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import PromptModal from '@/components/feedback/PromptModal';
|
||||
import TextModal from '@/components/feedback/TextModal';
|
||||
|
||||
@@ -626,14 +627,14 @@ export default function OutboundsTab({
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={intervalHours}
|
||||
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
|
||||
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.hours')}
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={59}
|
||||
value={intervalMinutes}
|
||||
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
|
||||
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
|
||||
style={{ width: 80 }}
|
||||
/> {t('pages.xray.outboundSub.minutes')}
|
||||
</Space>
|
||||
|
||||
@@ -22,6 +22,7 @@ export const InboundDbFieldsSchema = z.object({
|
||||
down: z.number().int().min(0).default(0),
|
||||
total: z.number().int().min(0).default(0),
|
||||
trafficReset: TrafficResetSchema.default('never'),
|
||||
trafficResetDay: z.number().int().min(1).max(31).default(1),
|
||||
lastTrafficResetTime: z.number().int().default(0),
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
|
||||
|
||||
@@ -18,6 +18,7 @@ export const AllSettingSchema = z.object({
|
||||
expireDiff: nonNegativeInt.optional(),
|
||||
trafficDiff: nonNegativeInt.max(100).optional(),
|
||||
remarkTemplate: z.string().optional(),
|
||||
subShowIdentityOnAllLinks: z.boolean().optional(),
|
||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||
tgBotEnable: z.boolean().optional(),
|
||||
tgBotToken: z.string().optional(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { computeClientsSummary } from '@/hooks/useClients';
|
||||
import type { ClientTraffic } from '@/schemas/client';
|
||||
import { computeClientsSummary, pickClientsSummary } from '@/hooks/useClients';
|
||||
import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
|
||||
|
||||
// Parity with web/service/client.go buildClientsSummary: the same client must
|
||||
// land in the same bucket whether the count comes from the server (list fetch)
|
||||
@@ -60,3 +60,27 @@ describe('computeClientsSummary', () => {
|
||||
expect(s.depleted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickClientsSummary', () => {
|
||||
const serverSummary: ClientsSummary = {
|
||||
total: 67, active: 58, online: [], depleted: [], expiring: [], deactive: [],
|
||||
};
|
||||
|
||||
it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
|
||||
const shortSnapshot: Row[] = Array.from({ length: 58 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
|
||||
const s = pickClientsSummary(serverSummary, shortSnapshot, new Set(), 3 * DAY, 1 * GB);
|
||||
expect(s).toEqual(serverSummary);
|
||||
});
|
||||
|
||||
it('uses the live recompute when the snapshot covers every client', () => {
|
||||
const fullSnapshot: Row[] = Array.from({ length: 67 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
|
||||
const s = pickClientsSummary(serverSummary, fullSnapshot, new Set(), 3 * DAY, 1 * GB);
|
||||
expect(s.total).toBe(67);
|
||||
expect(s.active).toBe(67);
|
||||
});
|
||||
|
||||
it('falls back to the server summary before the first WS snapshot arrives', () => {
|
||||
const s = pickClientsSummary(serverSummary, [], new Set(), 3 * DAY, 1 * GB);
|
||||
expect(s).toEqual(serverSummary);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DateTimePicker from '@/components/form/DateTimePicker';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function openPicker(): void {
|
||||
const input = document.querySelector('.ant-picker input');
|
||||
if (!input) throw new Error('picker input not rendered');
|
||||
fireEvent.mouseDown(input);
|
||||
fireEvent.click(input);
|
||||
}
|
||||
|
||||
function clickDayCell(title: string): void {
|
||||
const cell = document.querySelector(`.ant-picker-cell[title="${title}"] .ant-picker-cell-inner`);
|
||||
if (!cell) throw new Error(`day cell ${title} not rendered`);
|
||||
fireEvent.click(cell);
|
||||
}
|
||||
|
||||
describe('DateTimePicker', () => {
|
||||
it('commits a clicked calendar date without an OK press', () => {
|
||||
const onChange = vi.fn<(next: Dayjs | null) => void>();
|
||||
renderWithProviders(<DateTimePicker value={null} onChange={onChange} />);
|
||||
|
||||
openPicker();
|
||||
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD');
|
||||
clickDayCell(tomorrow);
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
const committed = onChange.mock.calls.at(-1)?.[0];
|
||||
expect(committed?.format('YYYY-MM-DD HH:mm:ss')).toBe(`${tomorrow} 00:00:00`);
|
||||
});
|
||||
|
||||
it('renders no OK confirm button in the picker footer', () => {
|
||||
renderWithProviders(<DateTimePicker value={null} onChange={vi.fn()} />);
|
||||
|
||||
openPicker();
|
||||
|
||||
expect(document.querySelector('.ant-picker-ok')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import GeneralTab from '@/pages/settings/GeneralTab';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
describe('GeneralTab', () => {
|
||||
it('keeps the stored page size when the field is cleared', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const pageSizeInput = screen.getByDisplayValue('25');
|
||||
fireEvent.change(pageSizeInput, { target: { value: '' } });
|
||||
fireEvent.blur(pageSizeInput);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect((pageSizeInput as HTMLInputElement).value).toBe('25');
|
||||
});
|
||||
|
||||
it('forwards typed page sizes unchanged, zero included', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith({ pageSize: 0 });
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ const vlessRow: RawInboundRow = {
|
||||
total: 1_000_000_000,
|
||||
expiryTime: 0,
|
||||
trafficReset: 'monthly',
|
||||
trafficResetDay: 15,
|
||||
lastTrafficResetTime: 0,
|
||||
tag: 'inbound-1',
|
||||
nodeId: null,
|
||||
@@ -267,6 +268,7 @@ describe('formValuesToWirePayload', () => {
|
||||
enable: payload.enable,
|
||||
expiryTime: payload.expiryTime,
|
||||
trafficReset: payload.trafficReset,
|
||||
trafficResetDay: payload.trafficResetDay,
|
||||
lastTrafficResetTime: payload.lastTrafficResetTime,
|
||||
nodeId: payload.nodeId ?? null,
|
||||
});
|
||||
@@ -276,8 +278,13 @@ describe('formValuesToWirePayload', () => {
|
||||
expect(replay.listen).toBe(original.listen);
|
||||
expect(replay.up).toBe(original.up);
|
||||
expect(replay.down).toBe(original.down);
|
||||
expect(replay.trafficResetDay).toBe(original.trafficResetDay);
|
||||
expect(replay.streamSettings).toEqual(original.streamSettings);
|
||||
});
|
||||
|
||||
it('defaults a missing monthly reset day to the first', () => {
|
||||
expect(rawInboundToFormValues({ ...vlessRow, trafficResetDay: undefined }).trafficResetDay).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subSortIndex', () => {
|
||||
|
||||
@@ -745,3 +745,73 @@ describe('genVlessLink flow gating (#5322)', () => {
|
||||
expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('genVlessLink XHTTP extra compatibility', () => {
|
||||
it('emits both sessionID and legacy session keys in XHTTP extra', () => {
|
||||
const typed = InboundSchema.parse({
|
||||
id: 1,
|
||||
up: 0,
|
||||
down: 0,
|
||||
total: 0,
|
||||
remark: 'xhttp-session',
|
||||
enable: true,
|
||||
expiryTime: 0,
|
||||
listen: '',
|
||||
port: 443,
|
||||
tag: 'inbound-vless-xhttp',
|
||||
sniffing: {
|
||||
enabled: false,
|
||||
destOverride: [],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
ipsExcluded: [],
|
||||
domainsExcluded: [],
|
||||
},
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
clients: [
|
||||
{
|
||||
id: '11111111-2222-3333-4444-555555555555',
|
||||
email: 'a@example.test',
|
||||
flow: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
enable: true,
|
||||
tgId: 0,
|
||||
subId: 's1',
|
||||
comment: '',
|
||||
reset: 0,
|
||||
},
|
||||
],
|
||||
decryption: 'none',
|
||||
encryption: 'none',
|
||||
fallbacks: [],
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'none',
|
||||
xhttpSettings: {
|
||||
path: '/sp',
|
||||
host: 'edge.example.test',
|
||||
mode: 'auto',
|
||||
sessionIDPlacement: 'header',
|
||||
sessionIDKey: 'X-Session',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const link = genVlessLink({
|
||||
inbound: typed,
|
||||
address: 'example.test',
|
||||
port: 443,
|
||||
clientId: '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<string, unknown>;
|
||||
|
||||
expect(extra.sessionIDPlacement).toBe('header');
|
||||
expect(extra.sessionIDKey).toBe('X-Session');
|
||||
expect(extra.sessionPlacement).toBe('header');
|
||||
expect(extra.sessionKey).toBe('X-Session');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
describe('onNumber', () => {
|
||||
it('forwards numeric values, including zero and negatives', () => {
|
||||
const apply = vi.fn();
|
||||
const handler = onNumber(apply);
|
||||
handler(8443);
|
||||
handler(0);
|
||||
handler(-1);
|
||||
expect(apply.mock.calls).toEqual([[8443], [0], [-1]]);
|
||||
});
|
||||
|
||||
it('ignores cleared events instead of writing a synthetic value', () => {
|
||||
const apply = vi.fn();
|
||||
const handler = onNumber(apply);
|
||||
handler(null);
|
||||
handler(undefined);
|
||||
handler(NaN);
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AllSettingSchema } from '@/schemas/setting';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
describe('subShowIdentityOnAllLinks', () => {
|
||||
it('defaults to false on AllSetting', () => {
|
||||
expect(new AllSetting().subShowIdentityOnAllLinks).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts boolean values in the settings schema', () => {
|
||||
for (const v of [true, false]) {
|
||||
const r = AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v });
|
||||
expect(r.success, `subShowIdentityOnAllLinks=${v}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-boolean values', () => {
|
||||
for (const v of ['true', 1, null]) {
|
||||
expect(AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
normalizeSockoptForWire,
|
||||
normalizeStreamSettingsForWire,
|
||||
normalizeXhttpForWire,
|
||||
validateRealityClientVer,
|
||||
validateRealityMaxClientVer,
|
||||
validateRealityTarget,
|
||||
} from '@/lib/xray/stream-wire-normalize';
|
||||
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
@@ -26,6 +28,64 @@ describe('validateRealityTarget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRealityClientVer', () => {
|
||||
it('accepts empty (not set) and core-style versions', () => {
|
||||
expect(validateRealityClientVer('')).toBeUndefined();
|
||||
expect(validateRealityClientVer('26.3.27')).toBeUndefined();
|
||||
expect(validateRealityClientVer('1.0.0')).toBeUndefined();
|
||||
expect(validateRealityClientVer('26')).toBeUndefined();
|
||||
expect(validateRealityClientVer('26.3')).toBeUndefined();
|
||||
expect(validateRealityClientVer('0.0.255')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects untrimmed values because the save path ships them verbatim', () => {
|
||||
expect(validateRealityClientVer('26.3.27 ')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer(' 26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer(' ')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
});
|
||||
|
||||
it('rejects what the core parser rejects', () => {
|
||||
expect(validateRealityClientVer('26.3.27.1')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('26.3.256')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('v26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('26..27')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('26.3.')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
expect(validateRealityClientVer('-1.0.0')).toBe('pages.inbounds.form.clientVerInvalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRealityMaxClientVer', () => {
|
||||
it('accepts an empty max, an empty min, and a valid range', () => {
|
||||
expect(validateRealityMaxClientVer('', '26.3.27')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('27.0.0', '')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('26.3.27', '26.3.27')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('27.1.2', '26.3.27')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a max below the min, the stale-placeholder trap included', () => {
|
||||
expect(validateRealityMaxClientVer('25.9.11', '26.3.27')).toBe(
|
||||
'pages.inbounds.form.maxClientVerBelowMin',
|
||||
);
|
||||
expect(validateRealityMaxClientVer('26.3.26', '26.3.27')).toBe(
|
||||
'pages.inbounds.form.maxClientVerBelowMin',
|
||||
);
|
||||
});
|
||||
|
||||
it('pads short versions like the core does before comparing', () => {
|
||||
expect(validateRealityMaxClientVer('26', '26.0.0')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('26', '26.3')).toBe(
|
||||
'pages.inbounds.form.maxClientVerBelowMin',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports format errors before range errors and skips a malformed min', () => {
|
||||
expect(validateRealityMaxClientVer('25.9', 'not-a-version')).toBeUndefined();
|
||||
expect(validateRealityMaxClientVer('nope', '26.3.27')).toBe(
|
||||
'pages.inbounds.form.clientVerInvalid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeXhttpForWire stream-one', () => {
|
||||
it('drops packet-up and stream-up-only fields on inbound', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
|
||||
@@ -12,6 +12,37 @@ function LocationProbe() {
|
||||
}
|
||||
|
||||
describe('SubscriptionGeneralTab', () => {
|
||||
it('keeps the stored subscription port when the field is cleared', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const portInput = screen.getByDisplayValue('2096');
|
||||
fireEvent.change(portInput, { target: { value: '' } });
|
||||
fireEvent.blur(portInput);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect((portInput as HTMLInputElement).value).toBe('2096');
|
||||
});
|
||||
|
||||
it('forwards typed subscription ports unchanged', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('2096'), { target: { value: '8443' } });
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith({ subPort: 8443 });
|
||||
});
|
||||
|
||||
it('uses router navigation to open subscription format settings', () => {
|
||||
const allSetting = new AllSetting({ subClashEnable: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Wraps an Ant Design InputNumber change handler with the shared
|
||||
* cleared-field semantic: null and undefined change events (a cleared or
|
||||
* unparsable field) are ignored, leaving the stored value in place — the
|
||||
* input snaps back on blur — while real numbers pass through unchanged.
|
||||
* Number-only by design: not for `stringMode` inputs, whose whole point is
|
||||
* to avoid the IEEE-754 round trip this signature would force.
|
||||
*/
|
||||
export function onNumber(
|
||||
apply: (value: number) => void,
|
||||
): (value: number | null | undefined) => void {
|
||||
return (value) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return;
|
||||
apply(value);
|
||||
};
|
||||
}
|
||||
+1
-3
@@ -6,8 +6,6 @@ blue='\033[0;34m'
|
||||
yellow='\033[0;33m'
|
||||
plain='\033[0m'
|
||||
|
||||
cur_dir=$(pwd)
|
||||
|
||||
xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
|
||||
xui_service="${XUI_SERVICE:=/etc/systemd/system}"
|
||||
|
||||
@@ -36,7 +34,7 @@ arch() {
|
||||
armv6* | armv6) echo 'armv6' ;;
|
||||
armv5* | armv5) echo 'armv5' ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;;
|
||||
*) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f "$(realpath "$0")" && exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,9 @@ func initModels() error {
|
||||
if err := migrateVmessRemovedSecurities(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateTgIDIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
if IsPostgres() {
|
||||
if err := resyncPostgresSequences(db, models); err != nil {
|
||||
log.Printf("Error resyncing postgres sequences: %v", err)
|
||||
@@ -884,6 +887,17 @@ func migrateVmessRemovedSecurities() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateTgIDIndex creates an index on the clients.tg_id column so that
|
||||
// lookups by Telegram ID do not require a full table scan. The index tag
|
||||
// on the struct field already causes AutoMigrate to create it on new
|
||||
// installations; the explicit migration ensures existing databases get it.
|
||||
func migrateTgIDIndex() error {
|
||||
if db.Migrator().HasIndex(&model.ClientRecord{}, "idx_clients_tg_id") {
|
||||
return nil
|
||||
}
|
||||
return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID")
|
||||
}
|
||||
|
||||
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
|
||||
// minimum (rows written by builds that defaulted the column to 0, or by nodes
|
||||
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
|
||||
|
||||
@@ -54,6 +54,7 @@ type Inbound struct {
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||
TrafficResetDay int `json:"trafficResetDay" form:"trafficResetDay" gorm:"default:1" validate:"omitempty,gte=1,lte=31" example:"1"` // Day of month for monthly traffic resets
|
||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||
|
||||
@@ -904,7 +905,7 @@ type ClientRecord struct {
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
|
||||
+79
-22
@@ -40,6 +40,26 @@ func (ctx remarkContext) configName() string {
|
||||
// underscores only, so ordinary braces in a remark are left untouched.
|
||||
var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
|
||||
|
||||
// remarkToken is one {{TOKEN}} occurrence: its name and the byte range it spans
|
||||
// in the segment it was found in.
|
||||
type remarkToken struct {
|
||||
name string
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// remarkTokens locates every {{TOKEN}} in seg. Both the template-level filter and
|
||||
// the value-level expansion walk a segment through this, so they share one notion
|
||||
// of where a token begins and ends and what the literal text between two of them is.
|
||||
func remarkTokens(seg string) []remarkToken {
|
||||
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
|
||||
tokens := make([]remarkToken, len(locs))
|
||||
for i, loc := range locs {
|
||||
tokens[i] = remarkToken{name: seg[loc[2]:loc[3]], start: loc[0], end: loc[1]}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// unlimitedMark is the value the human-readable quota/expiry tokens render when
|
||||
// the client has no limit. A segment built only around such a token carries no
|
||||
// information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
|
||||
@@ -107,7 +127,9 @@ func translateUISingleBrackets(template string) string {
|
||||
// value. Unknown tokens resolve to "" (never the literal text). The template is
|
||||
// split on "|" into segments: a segment whose only value is an unlimited quota
|
||||
// or expiry (∞) drops out whole — decoration and separator included — so an
|
||||
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
|
||||
// unlimited client gets "host" instead of "host|📊∞|⏳∞D". Inside a surviving
|
||||
// segment expandSegment also elides a hyphen separator an empty token would
|
||||
// leave dangling.
|
||||
func expandRemarkVars(template string, ctx remarkContext) string {
|
||||
template = translateUISingleBrackets(template)
|
||||
if !strings.Contains(template, "{{") {
|
||||
@@ -129,18 +151,43 @@ func expandRemarkVars(template string, ctx remarkContext) string {
|
||||
// — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
|
||||
// say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
|
||||
// tokens) is always kept.
|
||||
//
|
||||
// A hyphen standing alone between two adjacent tokens is treated as their
|
||||
// separator and elided when no token before it has produced a value yet or when
|
||||
// the token after it resolves to nothing. "{{INBOUND}}-{{EMAIL}}" gives "john"
|
||||
// for an inbound with no remark, "🌐{{INBOUND}}-{{EMAIL}}" gives "🌐john" so
|
||||
// leading decoration does not keep the separator alive, and
|
||||
// "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}" keeps a single separator when the middle
|
||||
// token is empty. A hyphen anywhere else in the segment is literal text and is
|
||||
// kept as written.
|
||||
func expandSegment(seg string, ctx remarkContext) (string, bool) {
|
||||
hasToken, hasOtherValue := false, false
|
||||
out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
|
||||
hasToken = true
|
||||
token := m[2 : len(m)-2]
|
||||
val := remarkVarValue(token, ctx)
|
||||
if val != "" && (!unlimitedDropTokens[token] || val != unlimitedMark) {
|
||||
tokens := remarkTokens(seg)
|
||||
hasToken, hasOtherValue := len(tokens) > 0, false
|
||||
values := make([]string, len(tokens))
|
||||
for i, tok := range tokens {
|
||||
val := remarkVarValue(tok.name, ctx)
|
||||
values[i] = val
|
||||
if val != "" && (!unlimitedDropTokens[tok.name] || val != unlimitedMark) {
|
||||
hasOtherValue = true
|
||||
}
|
||||
return val
|
||||
})
|
||||
return out, hasToken && !hasOtherValue
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
start, wroteValue := 0, false
|
||||
for i, tok := range tokens {
|
||||
result.WriteString(seg[start:tok.start])
|
||||
result.WriteString(values[i])
|
||||
wroteValue = wroteValue || values[i] != ""
|
||||
start = tok.end
|
||||
if i+1 < len(tokens) {
|
||||
between := seg[start:tokens[i+1].start]
|
||||
if strings.TrimSpace(between) == "-" && (!wroteValue || values[i+1] == "") {
|
||||
start = tokens[i+1].start
|
||||
}
|
||||
}
|
||||
}
|
||||
result.WriteString(seg[start:])
|
||||
return result.String(), hasToken && !hasOtherValue
|
||||
}
|
||||
|
||||
func remarkVarValue(token string, ctx remarkContext) string {
|
||||
@@ -511,11 +558,17 @@ func filterRemarkTemplate(template string, remove map[string]bool) string {
|
||||
return strings.Join(kept, "|")
|
||||
}
|
||||
|
||||
// filterRemarkSegment drops whole token categories from one segment while it is
|
||||
// still a template, before any value is known. Literal text touching a removed
|
||||
// token goes with it and the surviving runs rejoin with a space, so filtering the
|
||||
// usage tokens out of "{{EMAIL}} 📊{{TRAFFIC_LEFT}}" leaves "{{EMAIL}}". This is
|
||||
// the template-level counterpart to expandSegment, which works one layer later on
|
||||
// tokens that survive here but resolve to an empty value.
|
||||
func filterRemarkSegment(seg string, remove map[string]bool) string {
|
||||
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
|
||||
tokens := remarkTokens(seg)
|
||||
hasRemove := false
|
||||
for _, loc := range locs {
|
||||
if remove[seg[loc[2]:loc[3]]] {
|
||||
for _, tok := range tokens {
|
||||
if remove[tok.name] {
|
||||
hasRemove = true
|
||||
break
|
||||
}
|
||||
@@ -525,28 +578,28 @@ func filterRemarkSegment(seg string, remove map[string]bool) string {
|
||||
}
|
||||
runs := make([]string, 0, 2)
|
||||
runStart, leftRemoved := 0, false
|
||||
for _, loc := range locs {
|
||||
if !remove[seg[loc[2]:loc[3]]] {
|
||||
for _, tok := range tokens {
|
||||
if !remove[tok.name] {
|
||||
continue
|
||||
}
|
||||
runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
|
||||
runStart, leftRemoved = loc[1], true
|
||||
runs = appendKeptRun(runs, seg[runStart:tok.start], leftRemoved, true)
|
||||
runStart, leftRemoved = tok.end, true
|
||||
}
|
||||
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
|
||||
return strings.Join(runs, " ")
|
||||
}
|
||||
|
||||
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
|
||||
locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
|
||||
if len(locs) == 0 {
|
||||
tokens := remarkTokens(run)
|
||||
if len(tokens) == 0 {
|
||||
return runs
|
||||
}
|
||||
start, end := 0, len(run)
|
||||
if leftRemoved {
|
||||
start = locs[0][0]
|
||||
start = tokens[0].start
|
||||
}
|
||||
if rightRemoved {
|
||||
end = locs[len(locs)-1][1]
|
||||
end = tokens[len(tokens)-1].end
|
||||
}
|
||||
if frag := strings.TrimSpace(run[start:end]); frag != "" {
|
||||
runs = append(runs, frag)
|
||||
@@ -560,7 +613,11 @@ func (s *SubService) effectiveTemplate(email string) string {
|
||||
s.usageShown = map[string]bool{}
|
||||
}
|
||||
if s.usageShown[email] {
|
||||
return filterRemarkTemplate(translated, firstLinkOnlyBodyTokens)
|
||||
remove := firstLinkOnlyBodyTokens
|
||||
if s.showIdentityOnAllLinks {
|
||||
remove = usageInfoTokens
|
||||
}
|
||||
return filterRemarkTemplate(translated, remove)
|
||||
}
|
||||
s.usageShown[email] = true
|
||||
return translated
|
||||
|
||||
@@ -102,6 +102,41 @@ func TestExpandRemarkVars_EdgeCases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRemarkTemplate mirrors the panel's shipped remark template, the one an
|
||||
// inbound with no remark used to render with a leading hyphen.
|
||||
const defaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
|
||||
|
||||
func TestExpandRemarkVars_DropsHyphenBetweenEmptyTokens(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tmpl string
|
||||
inbound string
|
||||
email string
|
||||
want string
|
||||
}{
|
||||
{name: "both values", tmpl: "{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "Germany-john"},
|
||||
{name: "empty inbound", tmpl: "{{INBOUND}}-{{EMAIL}}", email: "john", want: "john"},
|
||||
{name: "empty email", tmpl: "{{INBOUND}} - {{EMAIL}}", inbound: "Germany", want: "Germany"},
|
||||
{name: "literal leading hyphen", tmpl: "-{{EMAIL}}", email: "john", want: "-john"},
|
||||
{name: "literal leading hyphen before an empty token", tmpl: "-{{INBOUND}}-{{EMAIL}}", email: "john", want: "-john"},
|
||||
{name: "empty var between two values keeps one separator", tmpl: "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}", email: "john", want: "john-john"},
|
||||
{name: "emoji decoration before an empty token", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", email: "john", want: "🌐john"},
|
||||
{name: "literal word before an empty token", tmpl: "Sub {{INBOUND}}-{{EMAIL}}", email: "john", want: "Sub john"},
|
||||
{name: "decoration kept when both tokens resolve", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "🌐Germany-john"},
|
||||
{name: "default template, empty inbound", tmpl: defaultRemarkTemplate, email: "john", want: "john"},
|
||||
{name: "default template, empty email", tmpl: defaultRemarkTemplate, inbound: "Germany", want: "Germany"},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := expandCtx(model.Client{Email: tt.email}, xray.ClientTraffic{Enable: true}, &model.Inbound{Remark: tt.inbound})
|
||||
if got := expandRemarkVars(tt.tmpl, ctx); got != tt.want {
|
||||
t.Errorf("expandRemarkVars(%q) = %q, want %q", tt.tmpl, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unlimited client drops the quota/expiry segments whole — decoration and the
|
||||
// "|" separator included — instead of printing "📊∞|⏳∞D".
|
||||
func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
|
||||
@@ -652,3 +687,40 @@ func TestEmailOnFirstLinkOnly(t *testing.T) {
|
||||
t.Fatalf("second link should still carry the inbound name: %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityOnAllLinks(t *testing.T) {
|
||||
const template = "{{INBOUND}}-{{EMAIL}}|{{USERNAME}}|📊{{TRAFFIC_LEFT}}|{{STATUS_EMOJI}}"
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
ClientStats: []xray.ClientTraffic{{
|
||||
Email: "alice@x",
|
||||
Enable: true,
|
||||
Total: 100 * gb,
|
||||
Up: 20 * gb,
|
||||
}},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
wantSecond string
|
||||
}{
|
||||
{name: "disabled", wantSecond: "DE"},
|
||||
{name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: template,
|
||||
subscriptionBody: true,
|
||||
showIdentityOnAllLinks: tt.enabled,
|
||||
}
|
||||
client := model.Client{Email: "alice@x"}
|
||||
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
|
||||
t.Fatalf("first link = %q", got)
|
||||
}
|
||||
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
|
||||
t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+17
-9
@@ -40,9 +40,10 @@ type SubService struct {
|
||||
// usageShown tracks, per client email, whether the info part of the template
|
||||
// has already been emitted this request, so it appears on the first body
|
||||
// link only. Per-request state; reset in PrepareForRequest.
|
||||
usageShown map[string]bool
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
usageShown map[string]bool
|
||||
showIdentityOnAllLinks bool
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
// nodesByID is populated per request from the Node table so
|
||||
// resolveInboundAddress can return the node's address for any
|
||||
// inbound whose NodeID is set. Keeps the per-link host derivation
|
||||
@@ -197,6 +198,10 @@ func (s *SubService) loadRemarkSettings() {
|
||||
if err != nil {
|
||||
s.datepicker = "gregorian"
|
||||
}
|
||||
s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
|
||||
if err != nil {
|
||||
s.showIdentityOnAllLinks = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubService) configuredPublicHost() string {
|
||||
@@ -1625,12 +1630,6 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
|
||||
}
|
||||
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
|
||||
tlsSettings["fingerprint"] = fp
|
||||
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
tlsSettings["settings"] = settings
|
||||
}
|
||||
settings["fingerprint"] = fp
|
||||
}
|
||||
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
|
||||
tlsSettings["alpn"] = alpn
|
||||
@@ -2018,6 +2017,15 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Older clients still read the pre-#6258 names from the subscription
|
||||
// extra JSON. Emit aliases after lifting legacy inputs so both old and
|
||||
// new clients can consume the same link.
|
||||
if v, ok := extra["sessionIDPlacement"].(string); ok && len(v) > 0 {
|
||||
extra["sessionPlacement"] = v
|
||||
}
|
||||
if v, ok := extra["sessionIDKey"].(string); ok && len(v) > 0 {
|
||||
extra["sessionKey"] = v
|
||||
}
|
||||
|
||||
for _, field := range []string{"uplinkChunkSize"} {
|
||||
if v, ok := nonZeroShareValue(xhttp[field]); ok {
|
||||
|
||||
@@ -335,6 +335,8 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
|
||||
"mode": "packet-up",
|
||||
"xPaddingBytes": "100-1000",
|
||||
"uplinkHTTPMethod": "GET",
|
||||
"sessionIDPlacement": "header",
|
||||
"sessionIDKey": "X-Session",
|
||||
"uplinkChunkSize": float64(4096),
|
||||
"noGRPCHeader": true,
|
||||
"scMinPostsIntervalMs": "20-40",
|
||||
@@ -375,6 +377,16 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
|
||||
if extra["mode"] != "packet-up" {
|
||||
t.Fatalf("extra[mode] = %#v, want packet-up", extra["mode"])
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"sessionIDPlacement": "header",
|
||||
"sessionIDKey": "X-Session",
|
||||
"sessionPlacement": "header",
|
||||
"sessionKey": "X-Session",
|
||||
} {
|
||||
if extra[key] != want {
|
||||
t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
|
||||
}
|
||||
}
|
||||
|
||||
headers, ok := extra["headers"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -388,6 +400,24 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_LegacySessionFieldsEmitBothNames(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"sessionPlacement": "query",
|
||||
"sessionKey": "sess",
|
||||
})
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"sessionIDPlacement": "query",
|
||||
"sessionIDKey": "sess",
|
||||
"sessionPlacement": "query",
|
||||
"sessionKey": "sess",
|
||||
} {
|
||||
if extra[key] != want {
|
||||
t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"uplinkHTTPMethod": "",
|
||||
@@ -775,6 +805,26 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSToStream_FingerprintNotDuplicated(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]any{},
|
||||
}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "fingerprint": "chrome"}
|
||||
|
||||
applyExternalProxyTLSToStream(ep, stream, "tls")
|
||||
|
||||
ts, _ := stream["tlsSettings"].(map[string]any)
|
||||
if ts["fingerprint"] != "chrome" {
|
||||
t.Fatalf("tlsSettings.fingerprint = %v, want %q", ts["fingerprint"], "chrome")
|
||||
}
|
||||
if settings, ok := ts["settings"].(map[string]any); ok {
|
||||
if got, dup := settings["fingerprint"]; dup {
|
||||
t.Fatalf("fingerprint must not be duplicated into tlsSettings.settings, got %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
|
||||
params := map[string]string{"security": "tls"}
|
||||
ep := map[string]any{
|
||||
|
||||
@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/list/paged", a.listPaged)
|
||||
g.GET("/get/:email", a.get)
|
||||
g.GET("/get/tgId/:tgId", a.getByTgId)
|
||||
g.GET("/traffic/:email", a.getTrafficByEmail)
|
||||
g.GET("/subLinks/:subId", a.getSubLinks)
|
||||
g.GET("/links/:email", a.getClientLinks)
|
||||
@@ -105,6 +106,32 @@ func (a *ClientController) listPaged(c *gin.Context) {
|
||||
jsonObj(c, resp, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, error) {
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Flow = flow
|
||||
var usedTraffic int64
|
||||
if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
|
||||
usedTraffic = t.Up + t.Down
|
||||
}
|
||||
return gin.H{
|
||||
"client": rec,
|
||||
"inboundIds": inboundIds,
|
||||
"externalLinks": externalLinks,
|
||||
"usedTraffic": usedTraffic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
rec, err := a.clientService.GetRecordByEmail(nil, email)
|
||||
@@ -112,30 +139,36 @@ func (a *ClientController) get(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
|
||||
payload, err := a.buildClientPayload(rec)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
|
||||
jsonObj(c, payload, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getByTgId(c *gin.Context) {
|
||||
tgIdStr := c.Param("tgId")
|
||||
tgId, err := strconv.ParseInt(tgIdStr, 10, 64)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
|
||||
records, err := a.clientService.GetRecordsByTgID(tgId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
rec.Flow = flow
|
||||
// Consumed bytes (up+down, including cross-node global overlay) so API
|
||||
// consumers can pair usage with the client's totalGB quota (#4973).
|
||||
// Best-effort: a traffic lookup failure must not break the client fetch.
|
||||
var usedTraffic int64
|
||||
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
|
||||
usedTraffic = t.Up + t.Down
|
||||
results := make([]gin.H, 0, len(records))
|
||||
for _, rec := range records {
|
||||
payload, err := a.buildClientPayload(rec)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
results = append(results, payload)
|
||||
}
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
|
||||
jsonObj(c, results, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) create(c *gin.Context) {
|
||||
|
||||
@@ -28,11 +28,12 @@ type AllSetting struct {
|
||||
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
|
||||
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"`
|
||||
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
|
||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
|
||||
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
|
||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
||||
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
|
||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
@@ -13,15 +15,25 @@ type PeriodicTrafficResetJob struct {
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
period Period
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
// NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
|
||||
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
|
||||
func NewPeriodicTrafficResetJob(period Period, location *time.Location) *PeriodicTrafficResetJob {
|
||||
return &PeriodicTrafficResetJob{
|
||||
period: period,
|
||||
period: period,
|
||||
location: location,
|
||||
}
|
||||
}
|
||||
|
||||
func monthlyResetDue(resetDay int, now time.Time) bool {
|
||||
if resetDay < 1 {
|
||||
resetDay = 1
|
||||
}
|
||||
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
return now.Day() == min(resetDay, lastDay)
|
||||
}
|
||||
|
||||
// Run resets traffic statistics for all inbounds that match the configured reset period.
|
||||
func (j *PeriodicTrafficResetJob) Run() {
|
||||
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
|
||||
@@ -30,13 +42,22 @@ func (j *PeriodicTrafficResetJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
if j.period == "monthly" {
|
||||
now := time.Now().In(j.location)
|
||||
due := inbounds[:0]
|
||||
for _, inbound := range inbounds {
|
||||
if monthlyResetDue(inbound.TrafficResetDay, now) {
|
||||
due = append(due, inbound)
|
||||
}
|
||||
}
|
||||
inbounds = due
|
||||
}
|
||||
if len(inbounds) == 0 {
|
||||
return
|
||||
}
|
||||
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
|
||||
|
||||
resetCount := 0
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||
if resetInboundErr != nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMonthlyResetDue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resetDay int
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{"legacy default on first", 0, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), true},
|
||||
{"configured day", 15, time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), true},
|
||||
{"before configured day", 15, time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC), false},
|
||||
{"month end", 31, time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), true},
|
||||
{"short month fallback", 31, time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), true},
|
||||
{"leap year fallback", 31, time.Date(2028, time.February, 29, 0, 0, 0, 0, time.UTC), true},
|
||||
{"not before short month end", 31, time.Date(2028, time.February, 28, 0, 0, 0, 0, time.UTC), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := monthlyResetDue(tc.resetDay, tc.now); got != tc.want {
|
||||
t.Fatalf("monthlyResetDue(%d, %s) = %v, want %v", tc.resetDay, tc.now.Format(time.DateOnly), got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -770,6 +770,9 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
|
||||
if ib.TrafficReset != "" {
|
||||
v.Set("trafficReset", ib.TrafficReset)
|
||||
}
|
||||
if ib.TrafficResetDay > 0 {
|
||||
v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -252,9 +252,12 @@ func TestIsNonEmptySlice(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWireInboundTrafficReset(t *testing.T) {
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
|
||||
if got := with.Get("trafficReset"); got != "daily" {
|
||||
t.Fatalf("trafficReset = %q, want daily", got)
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
|
||||
if got := with.Get("trafficReset"); got != "monthly" {
|
||||
t.Fatalf("trafficReset = %q, want monthly", got)
|
||||
}
|
||||
if got := with.Get("trafficResetDay"); got != "15" {
|
||||
t.Fatalf("trafficResetDay = %q, want 15", got)
|
||||
}
|
||||
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
|
||||
without := wireInbound(&model.Inbound{}, 0)
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -103,6 +104,15 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
|
||||
if tgId <= 0 {
|
||||
return nil, errors.New("tg_id must be a positive integer")
|
||||
}
|
||||
var rows []*model.ClientRecord
|
||||
err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
||||
row := &model.ClientRecord{}
|
||||
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestGetRecordsByTgID(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
db := database.GetDB()
|
||||
|
||||
records := []model.ClientRecord{
|
||||
{Email: "alice@x", TgID: 100, SubID: "sa"},
|
||||
{Email: "bob@x", TgID: 100, SubID: "sb"},
|
||||
{Email: "carol@x", TgID: 200, SubID: "sc"},
|
||||
{Email: "dave@x", TgID: 0, SubID: "sd"},
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := db.Create(&r).Error; err != nil {
|
||||
t.Fatalf("create record %q: %v", r.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("multiple clients share tgId", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(100): %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 records, got %d", len(got))
|
||||
}
|
||||
emails := make(map[string]bool)
|
||||
for _, r := range got {
|
||||
emails[r.Email] = true
|
||||
}
|
||||
if !emails["alice@x"] || !emails["bob@x"] {
|
||||
t.Fatalf("expected alice@x and bob@x, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single client by tgId", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(200)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(200): %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(got))
|
||||
}
|
||||
if got[0].Email != "carol@x" {
|
||||
t.Fatalf("expected carol@x, got %s", got[0].Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tgId zero rejected as sentinel", func(t *testing.T) {
|
||||
_, err := svc.GetRecordsByTgID(0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tgId=0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative tgId rejected", func(t *testing.T) {
|
||||
_, err := svc.GetRecordsByTgID(-5)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tgId=-5")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent tgId returns empty", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(999)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(999): %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected 0 records, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -34,6 +34,13 @@ type InboundService struct {
|
||||
fallbackService FallbackService
|
||||
}
|
||||
|
||||
func normalizeTrafficResetDay(day int) int {
|
||||
if day < 1 {
|
||||
return 1
|
||||
}
|
||||
return min(day, 31)
|
||||
}
|
||||
|
||||
func normalizeInboundShareAddrStrategy(strategy string) string {
|
||||
strategy = strings.TrimSpace(strategy)
|
||||
switch strategy {
|
||||
@@ -905,6 +912,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.Id = 0
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
@@ -1331,6 +1339,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
@@ -1460,6 +1469,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
oldInbound.Enable = inbound.Enable
|
||||
oldInbound.ExpiryTime = inbound.ExpiryTime
|
||||
oldInbound.TrafficReset = inbound.TrafficReset
|
||||
oldInbound.TrafficResetDay = inbound.TrafficResetDay
|
||||
oldInbound.Listen = inbound.Listen
|
||||
oldInbound.Port = inbound.Port
|
||||
oldInbound.Protocol = inbound.Protocol
|
||||
|
||||
@@ -19,11 +19,18 @@ import (
|
||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||
db := database.GetDB()
|
||||
query := fmt.Sprintf(
|
||||
"DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)",
|
||||
"DELETE FROM client_traffics WHERE email NOT IN (SELECT email FROM clients) AND email NOT IN (SELECT %s %s)",
|
||||
database.JSONFieldText("client.value", "email"),
|
||||
database.JSONClientsFromInbound(),
|
||||
)
|
||||
db.Exec(query)
|
||||
result := db.Exec(query)
|
||||
if result.Error != nil {
|
||||
logger.Warning("MigrationRemoveOrphanedTraffics failed:", result.Error)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("MigrationRemoveOrphanedTraffics: removed %d orphaned client_traffics row(s)", result.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) MigrationRequirements() {
|
||||
|
||||
@@ -129,6 +129,65 @@ func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRemoveOrphanedTraffics(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
clientSvc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const attachedEmail = "attached@example.com"
|
||||
attachedClient := model.Client{Email: attachedEmail, ID: "11111111-1111-1111-1111-111111111111", SubID: attachedEmail, Enable: true}
|
||||
attachedIb := mkInbound(t, 30003, model.VLESS, clientsSettings(t, []model.Client{attachedClient}))
|
||||
if err := clientSvc.SyncInbound(nil, attachedIb.Id, []model.Client{attachedClient}); err != nil {
|
||||
t.Fatalf("seed attached client: %v", err)
|
||||
}
|
||||
mkTraffic(t, attachedIb.Id, attachedEmail, 0, 0, 0, 0, true)
|
||||
|
||||
const detachedEmail = "detached@example.com"
|
||||
detachedClient := model.Client{Email: detachedEmail, ID: "22222222-2222-2222-2222-222222222222", SubID: detachedEmail, Enable: true}
|
||||
detachedIb := mkInbound(t, 30004, model.VLESS, clientsSettings(t, []model.Client{detachedClient}))
|
||||
if err := clientSvc.SyncInbound(nil, detachedIb.Id, []model.Client{detachedClient}); err != nil {
|
||||
t.Fatalf("seed detached client: %v", err)
|
||||
}
|
||||
mkTraffic(t, detachedIb.Id, detachedEmail, 123, 456, 0, 0, true)
|
||||
detachedRec := lookupClientRecord(t, detachedEmail)
|
||||
if _, err := clientSvc.Detach(inboundSvc, detachedRec.Id, []int{detachedIb.Id}); err != nil {
|
||||
t.Fatalf("Detach: %v", err)
|
||||
}
|
||||
|
||||
const jsonOnlyEmail = "jsononly@example.com"
|
||||
jsonOnlyClient := model.Client{Email: jsonOnlyEmail, ID: "33333333-3333-3333-3333-333333333333", SubID: jsonOnlyEmail, Enable: true}
|
||||
jsonOnlyIb := mkInbound(t, 30005, model.VLESS, clientsSettings(t, []model.Client{jsonOnlyClient}))
|
||||
mkTraffic(t, jsonOnlyIb.Id, jsonOnlyEmail, 0, 0, 0, 0, true)
|
||||
|
||||
const trulyOrphanedEmail = "deleted@example.com"
|
||||
mkTraffic(t, attachedIb.Id, trulyOrphanedEmail, 0, 0, 0, 0, true)
|
||||
|
||||
inboundSvc.MigrationRemoveOrphanedTraffics()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
want int64
|
||||
}{
|
||||
{"attached, in clients table and JSON", attachedEmail, 1},
|
||||
{"detached-but-alive, in clients table only", detachedEmail, 1},
|
||||
{"seeder-skipped-but-live, in JSON only", jsonOnlyEmail, 1},
|
||||
{"truly orphaned, in neither", trulyOrphanedEmail, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got int64
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", c.email).Count(&got).Error; err != nil {
|
||||
t.Fatalf("count client_traffics for %s: %v", c.email, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("client_traffics count for %s: got %d, want %d", c.email, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
@@ -311,7 +311,8 @@ func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool {
|
||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||
c.StreamSettings != snapIb.StreamSettings ||
|
||||
c.Sniffing != snapIb.Sniffing ||
|
||||
c.TrafficReset != snapIb.TrafficReset
|
||||
c.TrafficReset != snapIb.TrafficReset ||
|
||||
c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
}
|
||||
|
||||
// adoptedWireInbound is the central inbound as it reads after adopting the
|
||||
@@ -330,6 +331,7 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
|
||||
a.StreamSettings = snapIb.StreamSettings
|
||||
a.Sniffing = snapIb.Sniffing
|
||||
a.TrafficReset = snapIb.TrafficReset
|
||||
a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
return &a
|
||||
}
|
||||
|
||||
@@ -561,6 +563,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
StreamSettings: snapIb.StreamSettings,
|
||||
Sniffing: snapIb.Sniffing,
|
||||
TrafficReset: snapIb.TrafficReset,
|
||||
TrafficResetDay: normalizeTrafficResetDay(snapIb.TrafficResetDay),
|
||||
LastTrafficResetTime: snapIb.LastTrafficResetTime,
|
||||
Enable: snapIb.Enable,
|
||||
Remark: snapIb.Remark,
|
||||
@@ -616,6 +619,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
updates["stream_settings"] = snapIb.StreamSettings
|
||||
updates["sniffing"] = snapIb.Sniffing
|
||||
updates["traffic_reset"] = snapIb.TrafficReset
|
||||
updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
|
||||
if adoptedWireChanged(c, snapIb, adoptedSettings) {
|
||||
adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(c, snapIb, adoptedSettings))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTrafficResetDay(t *testing.T) {
|
||||
tests := map[int]int{
|
||||
0: 1,
|
||||
1: 1,
|
||||
15: 15,
|
||||
31: 31,
|
||||
32: 31,
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := normalizeTrafficResetDay(input); got != want {
|
||||
t.Errorf("normalizeTrafficResetDay(%d) = %d, want %d", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ var defaultValueMap = map[string]string{
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
@@ -649,6 +650,10 @@ func (s *SettingService) GetRemarkTemplate() (string, error) {
|
||||
return s.getString("remarkTemplate")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
|
||||
return s.getBool("subShowIdentityOnAllLinks")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSecret() ([]byte, error) {
|
||||
secret, err := s.getString("secret")
|
||||
if secret == defaultValueMap["secret"] {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||
t.Fatalf("missing setting = %t, %v; want false, nil", got, err)
|
||||
}
|
||||
settings, err := s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.SubShowIdentityOnAllLinks {
|
||||
t.Fatal("GetAllSetting returned true for a missing setting")
|
||||
}
|
||||
|
||||
settings.SubShowIdentityOnAllLinks = true
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
|
||||
t.Fatalf("persisted setting = %t, %v; want true, nil", got, err)
|
||||
}
|
||||
|
||||
settings, err = s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.SubShowIdentityOnAllLinks = false
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||
t.Fatalf("persisted setting = %t, %v; want false, nil", got, err)
|
||||
}
|
||||
}
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "استيراد إدخال",
|
||||
"periodicTrafficResetTitle": "إعادة تعيين حركة المرور",
|
||||
"periodicTrafficResetDesc": "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة",
|
||||
"periodicTrafficResetDay": "يوم إعادة التعيين الشهري",
|
||||
"lastReset": "آخر إعادة تعيين",
|
||||
"periodicTrafficReset": {
|
||||
"never": "أبداً",
|
||||
@@ -636,6 +637,10 @@
|
||||
"maxTimeDiff": "أقصى فرق زمن (ms)",
|
||||
"minClientVer": "أدنى إصدار للعميل",
|
||||
"maxClientVer": "أقصى إصدار للعميل",
|
||||
"minClientVerHint": "تركه فارغًا لا يعني بلا قيود: سيفرض Xray-core الحد الأدنى المدمج في إصدار النواة الذي تشغّله (26.3.27 في الإصدارات الحالية) ويرفض العملاء الذين يبلغون عن إصدار أقدم — بما في ذلك النوى الخارجية مثل Mihomo و sing-box. القيمة 1.0.0 تقبلها، مقابل السماح ببصمات TLS قديمة.",
|
||||
"maxClientVerHint": "تركه فارغًا يعني بلا حد أقصى. إذا عُيّن، يجب ألا يقل عن الحد الأدنى الفعلي — أدنى إصدار للعميل، أو الحد الأدنى المدمج في Xray-core عندما يكون ذلك الحقل فارغًا — وإلا سيُرفض جميع العملاء.",
|
||||
"clientVerInvalid": "يجب أن يتكون إصدار العميل من ثلاثة أرقام كحد أقصى مفصولة بنقاط، كل منها 0-255 (مثل 26.3.27)",
|
||||
"maxClientVerBelowMin": "أقصى إصدار للعميل يجب ألا يقل عن أدنى إصدار للعميل",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "مطلوب. يجب أن يتضمّن منفذًا (مثل example.com:443). بدون منفذ يرفض Xray-core البدء.",
|
||||
"realityTargetRequired": "هدف REALITY مطلوب",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
|
||||
"remarkTemplate": "قالب الملاحظة",
|
||||
"remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
|
||||
"subShowIdentityOnAllLinks": "إظهار الهوية في كل رابط",
|
||||
"subShowIdentityOnAllLinksDesc": "عند التفعيل، يبقى {{EMAIL}} و{{USERNAME}} في ملاحظة كل رابط في محتوى الاشتراك. تظل رموز الاستخدام في الرابط الأول فقط.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Import an Inbound",
|
||||
"periodicTrafficResetTitle": "Traffic Reset",
|
||||
"periodicTrafficResetDesc": "Automatically reset traffic counter at specified intervals",
|
||||
"periodicTrafficResetDay": "Monthly reset day",
|
||||
"lastReset": "Last Reset",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Never",
|
||||
@@ -648,6 +649,10 @@
|
||||
"maxTimeDiff": "Max Time Diff (ms)",
|
||||
"minClientVer": "Min Client Ver",
|
||||
"maxClientVer": "Max Client Ver",
|
||||
"minClientVerHint": "Empty does not mean unrestricted: Xray-core then enforces the built-in minimum of the core build you run (26.3.27 in current releases) and rejects clients that report an older version — including third-party cores such as Mihomo and sing-box. Set 1.0.0 to accept them, at the cost of admitting outdated TLS fingerprints.",
|
||||
"maxClientVerHint": "Empty means no upper limit. If set, it must not be lower than the effective minimum — Min Client Ver, or Xray-core's built-in minimum when that field is empty — otherwise every client is rejected.",
|
||||
"clientVerInvalid": "Client version must be up to three dot-separated numbers, each 0-255 (e.g. 26.3.27)",
|
||||
"maxClientVerBelowMin": "Max Client Ver must not be lower than Min Client Ver",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Required. Must include a port (e.g. example.com:443). Without a port Xray-core refuses to start.",
|
||||
"realityTargetRequired": "REALITY target is required",
|
||||
@@ -1259,6 +1264,8 @@
|
||||
"panelOutboundPh": "Direct connection",
|
||||
"remarkTemplate": "Remark Template",
|
||||
"remarkTemplateDesc": "When set, this replaces the remark model for every subscription link — write your own format with the variable tokens (use the button to insert them). Leave empty to use the model above.",
|
||||
"subShowIdentityOnAllLinks": "Show identity on every link",
|
||||
"subShowIdentityOnAllLinksDesc": "When enabled, {{EMAIL}} and {{USERNAME}} stay on every subscription-body remark. Usage tokens still appear on the first link only.",
|
||||
"datepicker": "Calendar Type",
|
||||
"datepickerPlaceholder": "Select date",
|
||||
"datepickerDescription": "Scheduled tasks will run based on this calendar.",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Importar un entrante",
|
||||
"periodicTrafficResetTitle": "Reset de Tráfico",
|
||||
"periodicTrafficResetDesc": "Reiniciar automáticamente el contador de tráfico en intervalos especificados",
|
||||
"periodicTrafficResetDay": "Día de reinicio mensual",
|
||||
"lastReset": "Último reinicio",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Nunca",
|
||||
@@ -657,6 +658,10 @@
|
||||
"maxTimeDiff": "Máx. diferencia de tiempo (ms)",
|
||||
"minClientVer": "Mín. versión cliente",
|
||||
"maxClientVer": "Máx. versión cliente",
|
||||
"minClientVerHint": "Vacío no significa sin restricción: Xray-core aplica entonces el mínimo integrado de la build del núcleo en uso (26.3.27 en las versiones actuales) y rechaza a los clientes que reportan una versión anterior, incluidos núcleos de terceros como Mihomo y sing-box. Con 1.0.0 se aceptan, a costa de admitir huellas TLS obsoletas.",
|
||||
"maxClientVerHint": "Vacío significa sin límite superior. Si se establece, no debe ser inferior al mínimo efectivo — la versión mínima del cliente o, si ese campo está vacío, el mínimo integrado de Xray-core — o todos los clientes serán rechazados.",
|
||||
"clientVerInvalid": "La versión del cliente debe tener hasta tres números separados por puntos, cada uno 0-255 (p. ej. 26.3.27)",
|
||||
"maxClientVerBelowMin": "La versión máxima del cliente no debe ser inferior a la versión mínima",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Obligatorio. Debe incluir un puerto (p. ej. example.com:443). Sin puerto, Xray-core no arranca.",
|
||||
"realityTargetRequired": "El destino REALITY es obligatorio",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "Uso de memoria alto (%)",
|
||||
"remarkTemplate": "Plantilla de notas",
|
||||
"remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
|
||||
"subShowIdentityOnAllLinks": "Mostrar identidad en cada enlace",
|
||||
"subShowIdentityOnAllLinksDesc": "Si está activado, {{EMAIL}} y {{USERNAME}} permanecen en la nota de cada enlace del cuerpo de la suscripción. Los tokens de uso siguen solo en el primer enlace.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "La ruta debe comenzar con /"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "افزودن یک ورودی",
|
||||
"periodicTrafficResetTitle": "بازنشانی ترافیک",
|
||||
"periodicTrafficResetDesc": "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص",
|
||||
"periodicTrafficResetDay": "روز بازنشانی ماهانه",
|
||||
"lastReset": "آخرین بازنشانی",
|
||||
"periodicTrafficReset": {
|
||||
"never": "هرگز",
|
||||
@@ -648,6 +649,10 @@
|
||||
"maxTimeDiff": "حداکثر اختلاف زمان (ms)",
|
||||
"minClientVer": "حداقل نسخه کلاینت",
|
||||
"maxClientVer": "حداکثر نسخه کلاینت",
|
||||
"minClientVerHint": "خالی بودن به معنای بدون محدودیت نیست: در این حالت Xray-core حداقل داخلیِ نسخهٔ هستهای را که اجرا میکنید (در نسخههای فعلی 26.3.27) اعمال میکند و کلاینتهایی را که نسخهٔ قدیمیتری اعلام میکنند رد میکند — از جمله هستههای شخص ثالث مانند Mihomo و sing-box. مقدار 1.0.0 آنها را میپذیرد، به بهای پذیرش اثر انگشتهای TLS قدیمی.",
|
||||
"maxClientVerHint": "خالی یعنی بدون سقف. در صورت تنظیم، نباید از حداقلِ مؤثر — حداقل نسخه کلاینت، و در صورت خالی بودن آن فیلد، حداقل داخلی Xray-core — کمتر باشد، وگرنه همهٔ کلاینتها رد میشوند.",
|
||||
"clientVerInvalid": "نسخهٔ کلاینت باید حداکثر سه عدد جداشده با نقطه باشد، هر یک 0-255 (مثلاً 26.3.27)",
|
||||
"maxClientVerBelowMin": "حداکثر نسخهٔ کلاینت نباید از حداقل نسخهٔ کلاینت کمتر باشد",
|
||||
"shortIds": "Short IDها",
|
||||
"realityTargetHint": "الزامی است. باید شامل پورت باشد (مثلاً example.com:443). بدون پورت، Xray-core اجرا نمیشود.",
|
||||
"realityTargetRequired": "هدف REALITY الزامی است",
|
||||
@@ -1142,6 +1147,8 @@
|
||||
"panelOutboundPh": "اتصال مستقیم",
|
||||
"remarkTemplate": "قالب ریمارک",
|
||||
"remarkTemplateDesc": "اگر پر شود، جای مدلِ ریمارک را برای همهی لینکهای اشتراک میگیرد — فرمت دلخواهت را با توکنهای متغیر بنویس (از دکمه برای درج استفاده کن). خالی = استفاده از مدلِ بالا.",
|
||||
"subShowIdentityOnAllLinks": "نمایش هویت در همه لینکها",
|
||||
"subShowIdentityOnAllLinksDesc": "در صورت فعال بودن، {{EMAIL}} و {{USERNAME}} در یادداشت هر لینک بدنه اشتراک باقی میمانند. توکنهای مصرف همچنان فقط در لینک اول نمایش داده میشوند.",
|
||||
"datepicker": "نوع تقویم",
|
||||
"datepickerPlaceholder": "انتخاب تاریخ",
|
||||
"datepickerDescription": "وظایف برنامه ریزی شده بر اساس این تقویم اجرا میشود",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Impor Masuk",
|
||||
"periodicTrafficResetTitle": "Reset Trafik Berkala",
|
||||
"periodicTrafficResetDesc": "Reset otomatis penghitung trafik pada interval tertentu",
|
||||
"periodicTrafficResetDay": "Hari reset bulanan",
|
||||
"lastReset": "Reset Terakhir",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Tidak Pernah",
|
||||
@@ -636,6 +637,10 @@
|
||||
"maxTimeDiff": "Maks. selisih waktu (ms)",
|
||||
"minClientVer": "Min. versi klien",
|
||||
"maxClientVer": "Maks. versi klien",
|
||||
"minClientVerHint": "Kosong bukan berarti tanpa batas: Xray-core akan memakai minimum bawaan dari build core yang dijalankan (26.3.27 pada rilis saat ini) dan menolak klien yang melaporkan versi lebih lama — termasuk core pihak ketiga seperti Mihomo dan sing-box. Isi 1.0.0 untuk menerimanya, dengan risiko mengizinkan sidik jari TLS yang usang.",
|
||||
"maxClientVerHint": "Kosong berarti tanpa batas atas. Jika diisi, tidak boleh lebih rendah dari minimum efektif — versi klien minimum, atau minimum bawaan Xray-core saat kolom itu kosong — atau semua klien akan ditolak.",
|
||||
"clientVerInvalid": "Versi klien harus berupa maksimal tiga angka dipisah titik, masing-masing 0-255 (mis. 26.3.27)",
|
||||
"maxClientVerBelowMin": "Versi klien maksimum tidak boleh lebih rendah dari versi klien minimum",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Wajib. Harus menyertakan port (mis. example.com:443). Tanpa port, Xray-core menolak untuk mulai.",
|
||||
"realityTargetRequired": "Target REALITY wajib diisi",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "Penggunaan memori tinggi (%)",
|
||||
"remarkTemplate": "Templat Catatan",
|
||||
"remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
|
||||
"subShowIdentityOnAllLinks": "Tampilkan identitas di setiap tautan",
|
||||
"subShowIdentityOnAllLinksDesc": "Jika diaktifkan, {{EMAIL}} dan {{USERNAME}} tetap ada di catatan setiap tautan isi langganan. Token penggunaan tetap hanya di tautan pertama.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Path harus diawali dengan /"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "インバウンドルールをインポート",
|
||||
"periodicTrafficResetTitle": "トラフィックリセット",
|
||||
"periodicTrafficResetDesc": "指定された間隔でトラフィックカウンタを自動的にリセット",
|
||||
"periodicTrafficResetDay": "毎月のリセット日",
|
||||
"lastReset": "最後のリセット",
|
||||
"periodicTrafficReset": {
|
||||
"never": "なし",
|
||||
@@ -657,6 +658,10 @@
|
||||
"maxTimeDiff": "最大時間差 (ms)",
|
||||
"minClientVer": "最小クライアントバージョン",
|
||||
"maxClientVer": "最大クライアントバージョン",
|
||||
"minClientVerHint": "空欄は無制限ではありません。Xray-core は実行中のコアに組み込まれた最低バージョン(現行リリースでは 26.3.27)を適用し、それより古いバージョンを名乗るクライアント(Mihomo や sing-box などのサードパーティコアを含む)を拒否します。1.0.0 を設定すると許可されますが、古い TLS フィンガープリントも受け入れることになります。",
|
||||
"maxClientVerHint": "空欄は上限なしを意味します。設定する場合は実効的な下限(最小クライアントバージョン。その欄が空欄の場合は Xray-core 組み込みの最低バージョン)を下回らないでください。下回るとすべてのクライアントが拒否されます。",
|
||||
"clientVerInvalid": "クライアントバージョンはドット区切りの数値(最大 3 つ、各 0-255)で指定してください(例:26.3.27)",
|
||||
"maxClientVerBelowMin": "最大クライアントバージョンは最小クライアントバージョンを下回れません",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "必須です。ポートを含める必要があります(例: example.com:443)。ポートがないと Xray-core は起動しません。",
|
||||
"realityTargetRequired": "REALITY ターゲットは必須です",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "メモリ使用率が高い (%)",
|
||||
"remarkTemplate": "備考テンプレート",
|
||||
"remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
|
||||
"subShowIdentityOnAllLinks": "すべてのリンクに識別情報を表示",
|
||||
"subShowIdentityOnAllLinksDesc": "有効にすると、{{EMAIL}} と {{USERNAME}} がサブスクリプション本文のすべてのリンク備考に残ります。使用量トークンは引き続き最初のリンクのみです。",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "パスは / で始まる必要があります"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Importar um Inbound",
|
||||
"periodicTrafficResetTitle": "Reset de Tráfego",
|
||||
"periodicTrafficResetDesc": "Reinicia automaticamente o contador de tráfego em intervalos especificados",
|
||||
"periodicTrafficResetDay": "Dia da redefinição mensal",
|
||||
"lastReset": "Último Reset",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Nunca",
|
||||
@@ -657,6 +658,10 @@
|
||||
"maxTimeDiff": "Máx. diferença de tempo (ms)",
|
||||
"minClientVer": "Mín. versão cliente",
|
||||
"maxClientVer": "Máx. versão cliente",
|
||||
"minClientVerHint": "Vazio não significa sem restrição: o Xray-core aplica o mínimo embutido da build do núcleo em uso (26.3.27 nas versões atuais) e rejeita clientes que reportam uma versão mais antiga — incluindo núcleos de terceiros como Mihomo e sing-box. Definir 1.0.0 os aceita, ao custo de admitir impressões digitais TLS desatualizadas.",
|
||||
"maxClientVerHint": "Vazio significa sem limite superior. Se definido, não deve ser menor que o mínimo efetivo — a versão mínima do cliente ou, se aquele campo estiver vazio, o mínimo embutido do Xray-core — ou todos os clientes serão rejeitados.",
|
||||
"clientVerInvalid": "A versão do cliente deve ter até três números separados por pontos, cada um 0-255 (ex.: 26.3.27)",
|
||||
"maxClientVerBelowMin": "A versão máxima do cliente não deve ser menor que a versão mínima",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Obrigatório. Deve incluir uma porta (ex.: example.com:443). Sem porta, o Xray-core não inicia.",
|
||||
"realityTargetRequired": "O alvo REALITY é obrigatório",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "Uso de memória alto (%)",
|
||||
"remarkTemplate": "Modelo de Observação",
|
||||
"remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
|
||||
"subShowIdentityOnAllLinks": "Mostrar identidade em todos os links",
|
||||
"subShowIdentityOnAllLinksDesc": "Quando ativado, {{EMAIL}} e {{USERNAME}} permanecem na observação de cada link do corpo da assinatura. Tokens de uso continuam só no primeiro link.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "O caminho deve começar com /"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Импорт подключений",
|
||||
"periodicTrafficResetTitle": "Сброс трафика",
|
||||
"periodicTrafficResetDesc": "Автоматический сброс счетчика трафика через указанные интервалы",
|
||||
"periodicTrafficResetDay": "День ежемесячного сброса",
|
||||
"lastReset": "Последний сброс",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Никогда",
|
||||
@@ -657,6 +658,10 @@
|
||||
"maxTimeDiff": "Макс. разница во времени (мс)",
|
||||
"minClientVer": "Мин. версия клиента",
|
||||
"maxClientVer": "Макс. версия клиента",
|
||||
"minClientVerHint": "Пустое поле не означает «без ограничений»: Xray-core применит встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах) и отклонит клиентов, сообщающих более старую версию, — включая сторонние ядра, такие как Mihomo и sing-box. Значение 1.0.0 разрешит их, но допустит устаревшие TLS-отпечатки.",
|
||||
"maxClientVerHint": "Пустое поле — без верхнего предела. Если задано, значение не должно быть ниже действующего минимума — «Мин. версия клиента», а при пустом том поле — встроенного минимума Xray-core, иначе все клиенты будут отклонены.",
|
||||
"clientVerInvalid": "Версия клиента — до трёх чисел через точку, каждое 0-255 (например 26.3.27)",
|
||||
"maxClientVerBelowMin": "Макс. версия клиента не должна быть ниже минимальной версии клиента",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Обязательно. Должно содержать порт (например, example.com:443). Без порта Xray-core не запускается.",
|
||||
"realityTargetRequired": "Цель REALITY обязательна",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "Превышение порога памяти (%)",
|
||||
"remarkTemplate": "Шаблон примечания",
|
||||
"remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
|
||||
"subShowIdentityOnAllLinks": "Показывать идентификатор на каждой ссылке",
|
||||
"subShowIdentityOnAllLinksDesc": "Если включено, {{EMAIL}} и {{USERNAME}} остаются в примечании каждой ссылки тела подписки. Токены использования по-прежнему только на первой ссылке.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Путь должен начинаться с /"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Gelen Bağlantı İçe Aktar",
|
||||
"periodicTrafficResetTitle": "Trafik Sıfırlama",
|
||||
"periodicTrafficResetDesc": "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla",
|
||||
"periodicTrafficResetDay": "Aylık sıfırlama günü",
|
||||
"lastReset": "Son Sıfırlama",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Asla",
|
||||
@@ -636,6 +637,10 @@
|
||||
"maxTimeDiff": "Maks. Zaman Farkı (ms)",
|
||||
"minClientVer": "Min. Kullanıcı Sürümü",
|
||||
"maxClientVer": "Maks. Kullanıcı Sürümü",
|
||||
"minClientVerHint": "Boş bırakmak sınırsız demek değildir: Xray-core, çalıştırdığınız çekirdek sürümünün yerleşik alt sınırını (güncel sürümlerde 26.3.27) uygular ve daha eski sürüm bildiren istemcileri reddeder — Mihomo ve sing-box gibi üçüncü taraf çekirdekler dahil. 1.0.0 girmek onları kabul eder; bedeli eski TLS parmak izlerine izin vermektir.",
|
||||
"maxClientVerHint": "Boş, üst sınır yok demektir. Ayarlanırsa geçerli alt sınırın — Min. Kullanıcı Sürümü, o alan boşsa Xray-core'un yerleşik alt sınırı — altında olmamalıdır, aksi halde tüm istemciler reddedilir.",
|
||||
"clientVerInvalid": "İstemci sürümü noktayla ayrılmış en fazla üç sayıdan oluşmalıdır, her biri 0-255 (örn. 26.3.27)",
|
||||
"maxClientVerBelowMin": "Maks. istemci sürümü, en düşük istemci sürümünün altında olamaz",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Zorunlu. Bir port içermelidir (ör. example.com:443). Port belirtilmezse Xray-core başlamaz.",
|
||||
"realityTargetRequired": "REALITY hedefi zorunludur",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "Bellek kullanımı yüksek (%)",
|
||||
"remarkTemplate": "Açıklama Şablonu",
|
||||
"remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
|
||||
"subShowIdentityOnAllLinks": "Kimliği her bağlantıda göster",
|
||||
"subShowIdentityOnAllLinksDesc": "Etkinleştirildiğinde {{EMAIL}} ve {{USERNAME}} abonelik gövdesindeki her bağlantı notunda kalır. Kullanım jetonları yine yalnızca ilk bağlantıda görünür.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Yol / ile başlamalıdır"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Імпортувати вхідний",
|
||||
"periodicTrafficResetTitle": "Скидання трафіку",
|
||||
"periodicTrafficResetDesc": "Автоматично скидати лічильник трафіку через певні проміжки часу",
|
||||
"periodicTrafficResetDay": "День щомісячного скидання",
|
||||
"lastReset": "Останнє скидання",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Ніколи",
|
||||
@@ -636,6 +637,10 @@
|
||||
"maxTimeDiff": "Макс. різниця в часі (мс)",
|
||||
"minClientVer": "Мін. версія клієнта",
|
||||
"maxClientVer": "Макс. версія клієнта",
|
||||
"minClientVerHint": "Порожнє поле не означає «без обмежень»: Xray-core застосує вбудований мінімум використовуваної збірки ядра (26.3.27 у поточних релізах) і відхилятиме клієнтів зі старішою версією — зокрема сторонні ядра, як-от Mihomo та sing-box. Значення 1.0.0 дозволить їх, але допустить застарілі TLS-відбитки.",
|
||||
"maxClientVerHint": "Порожнє поле — без верхньої межі. Якщо задано, значення не має бути нижчим за чинний мінімум — «Мін. версія клієнта», а коли те поле порожнє — вбудований мінімум Xray-core, інакше всіх клієнтів буде відхилено.",
|
||||
"clientVerInvalid": "Версія клієнта — до трьох чисел через крапку, кожне 0-255 (наприклад 26.3.27)",
|
||||
"maxClientVerBelowMin": "Макс. версія клієнта не має бути нижчою за мінімальну версію клієнта",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Обов'язково. Має містити порт (напр., example.com:443). Без порту Xray-core не запускається.",
|
||||
"realityTargetRequired": "Ціль REALITY обов'язкова",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "Високе використання пам'яті (%)",
|
||||
"remarkTemplate": "Шаблон примітки",
|
||||
"remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
|
||||
"subShowIdentityOnAllLinks": "Показувати ідентичність на кожному посиланні",
|
||||
"subShowIdentityOnAllLinksDesc": "Якщо увімкнено, {{EMAIL}} і {{USERNAME}} залишаються в примітці кожного посилання тіла підписки. Токени використання й надалі лише на першому посиланні.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Шлях має починатися з /"
|
||||
},
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Nhập inbound",
|
||||
"periodicTrafficResetTitle": "Đặt lại lưu lượng",
|
||||
"periodicTrafficResetDesc": "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định",
|
||||
"periodicTrafficResetDay": "Ngày đặt lại hàng tháng",
|
||||
"lastReset": "Đặt lại lần cuối",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Không bao giờ",
|
||||
@@ -657,6 +658,10 @@
|
||||
"maxTimeDiff": "Chênh lệch thời gian tối đa (ms)",
|
||||
"minClientVer": "Phiên bản client tối thiểu",
|
||||
"maxClientVer": "Phiên bản client tối đa",
|
||||
"minClientVerHint": "Để trống không có nghĩa là không giới hạn: Xray-core sẽ áp dụng mức tối thiểu tích hợp của bản core đang chạy (26.3.27 ở các bản phát hành hiện tại) và từ chối các client khai báo phiên bản cũ hơn — bao gồm các core bên thứ ba như Mihomo và sing-box. Đặt 1.0.0 để chấp nhận chúng, đổi lại là cho phép các dấu vân tay TLS lỗi thời.",
|
||||
"maxClientVerHint": "Để trống nghĩa là không có giới hạn trên. Nếu đặt, không được thấp hơn mức tối thiểu đang có hiệu lực — phiên bản client tối thiểu, hoặc mức tối thiểu tích hợp của Xray-core khi ô đó để trống — nếu không mọi client đều bị từ chối.",
|
||||
"clientVerInvalid": "Phiên bản client phải gồm tối đa ba số cách nhau bằng dấu chấm, mỗi số 0-255 (ví dụ 26.3.27)",
|
||||
"maxClientVerBelowMin": "Phiên bản client tối đa không được thấp hơn phiên bản client tối thiểu",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Bắt buộc. Phải bao gồm cổng (ví dụ example.com:443). Không có cổng, Xray-core sẽ không khởi động.",
|
||||
"realityTargetRequired": "Mục tiêu REALITY là bắt buộc",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
|
||||
"remarkTemplate": "Mẫu ghi chú",
|
||||
"remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
|
||||
"subShowIdentityOnAllLinks": "Hiện danh tính trên mọi liên kết",
|
||||
"subShowIdentityOnAllLinksDesc": "Khi bật, {{EMAIL}} và {{USERNAME}} vẫn có trong ghi chú mọi liên kết phần thân đăng ký. Token dung lượng vẫn chỉ ở liên kết đầu tiên.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
|
||||
},
|
||||
|
||||
@@ -82,8 +82,8 @@
|
||||
"secAlertPanelURI": "面板默认 URI 路径不安全。请配置复杂的 URI 路径。",
|
||||
"secAlertSubURI": "订阅默认 URI 路径不安全。请配置复杂的 URI 路径。",
|
||||
"secAlertSubJsonURI": "订阅 JSON 默认 URI 路径不安全。请配置复杂的 URI 路径。",
|
||||
"emptyDnsDesc": "未添加DNS服务器。",
|
||||
"emptyFakeDnsDesc": "未添加Fake DNS服务器。",
|
||||
"emptyDnsDesc": "未添加 DNS 服务器。",
|
||||
"emptyFakeDnsDesc": "未添加 Fake DNS 服务器。",
|
||||
"emptyBalancersDesc": "未添加负载均衡器。",
|
||||
"emptyReverseDesc": "未添加反向代理。",
|
||||
"somethingWentWrong": "出了点问题",
|
||||
@@ -169,7 +169,7 @@
|
||||
"xrayStatusRunning": "运行中",
|
||||
"xrayStatusStop": "停止",
|
||||
"xrayStatusError": "错误",
|
||||
"xrayErrorPopoverTitle": "运行Xray时发生错误",
|
||||
"xrayErrorPopoverTitle": "运行 Xray 时发生错误",
|
||||
"operationHours": "系统正常运行时间",
|
||||
"systemHistoryTitle": "系统历史",
|
||||
"historyTitleCpu": "CPU 使用率",
|
||||
@@ -215,8 +215,8 @@
|
||||
"systemLoad": "系统负载",
|
||||
"systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",
|
||||
"connectionCount": "连接数",
|
||||
"ipAddresses": "IP地址",
|
||||
"toggleIpVisibility": "切换IP可见性",
|
||||
"ipAddresses": "IP 地址",
|
||||
"toggleIpVisibility": "切换 IP 可见性",
|
||||
"overallSpeed": "整体速度",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
@@ -224,8 +224,8 @@
|
||||
"sent": "已发送",
|
||||
"received": "已接收",
|
||||
"documentation": "文档",
|
||||
"xraySwitchVersionDialog": "您确定要更改Xray版本吗?",
|
||||
"xraySwitchVersionDialogDesc": "这将把Xray版本更改为#version#。",
|
||||
"xraySwitchVersionDialog": "您确定要更改 Xray 版本吗?",
|
||||
"xraySwitchVersionDialogDesc": "这将把 Xray 版本更改为 #version#。",
|
||||
"xraySwitchVersionPopover": "Xray 更新成功",
|
||||
"panelUpdateDialog": "您确定要更新面板吗?",
|
||||
"panelUpdateDialogDesc": "这将把 3X-UI 更新到 #version# 并重启面板服务。",
|
||||
@@ -441,7 +441,7 @@
|
||||
"streamHelp": "Xray stream 块包装:",
|
||||
"jsonErrorPrefix": "高级 JSON"
|
||||
},
|
||||
"telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"telegramDesc": "请提供 Telegram 聊天 ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。",
|
||||
"subSortIndex": "订阅排序",
|
||||
"same": "相同",
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "导入入站规则",
|
||||
"periodicTrafficResetTitle": "流量重置",
|
||||
"periodicTrafficResetDesc": "按指定间隔自动重置流量计数器",
|
||||
"periodicTrafficResetDay": "每月重置日",
|
||||
"lastReset": "上次重置",
|
||||
"periodicTrafficReset": {
|
||||
"never": "从不",
|
||||
@@ -479,9 +480,9 @@
|
||||
"resetInboundClientTrafficSuccess": "流量已重置",
|
||||
"resetInboundTrafficSuccess": "入站流量已重置",
|
||||
"trafficGetError": "获取流量数据时出错",
|
||||
"getNewX25519CertError": "获取X25519证书时出错。",
|
||||
"getNewmldsa65Error": "获取mldsa65证书时出错。",
|
||||
"getNewVlessEncError": "获取VlessEnc证书时出错。",
|
||||
"getNewX25519CertError": "获取 X25519 证书时出错。",
|
||||
"getNewmldsa65Error": "获取 mldsa65 证书时出错。",
|
||||
"getNewVlessEncError": "获取 VlessEnc 证书时出错。",
|
||||
"scanRealityTargetError": "扫描 REALITY 目标失败。",
|
||||
"scanRealityTargetFeasible": "目标可用 — 已填入目标和 SNI。",
|
||||
"scanRealityTargetNotFeasible": "目标可达,但不适用于 REALITY。",
|
||||
@@ -656,6 +657,10 @@
|
||||
"maxTimeDiff": "最大时间差 (ms)",
|
||||
"minClientVer": "最小客户端版本",
|
||||
"maxClientVer": "最大客户端版本",
|
||||
"minClientVerHint": "留空不等于不限制:Xray-core 会改用所运行内核版本的内置最低值(当前版本为 26.3.27),拒绝自报版本更低的客户端——包括 Mihomo、sing-box 等第三方内核。填 1.0.0 可放行它们,代价是允许过时的 TLS 指纹。",
|
||||
"maxClientVerHint": "留空表示无上限。若填写,不得低于实际生效的下限——最小客户端版本,该字段留空时则为 Xray-core 的内置最低值——否则所有客户端都会被拒绝。",
|
||||
"clientVerInvalid": "客户端版本须为最多三段以点分隔的数字,每段 0-255(例如 26.3.27)",
|
||||
"maxClientVerBelowMin": "最大客户端版本不得低于最小客户端版本",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "必填。必须包含端口(例如 example.com:443)。没有端口时 Xray-core 将无法启动。",
|
||||
"realityTargetRequired": "REALITY 目标为必填项",
|
||||
@@ -810,7 +815,7 @@
|
||||
"online": "在线",
|
||||
"email": "邮箱",
|
||||
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
||||
"subIdInvalidChars": "订阅ID不能包含空格、'/'、'\\' 或控制字符",
|
||||
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
||||
"group": "分组",
|
||||
"groupDesc": "用于对相关客户端进行分桶的逻辑标签(如团队、客户、地区)。可从工具栏筛选。",
|
||||
"groupPlaceholder": "如 customer-a",
|
||||
@@ -1011,7 +1016,7 @@
|
||||
"regenerate": "重新生成令牌",
|
||||
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
|
||||
"allowPrivateAddress": "允许私有地址",
|
||||
"allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
|
||||
"allowPrivateAddressHint": "仅对私有网络或 VPN 上的节点启用。",
|
||||
"outboundTag": "连接出站",
|
||||
"outboundTagHint": "通过选定的 Xray 出站路由此节点的面板 API 流量。系统会自动将回环桥接入站添加到运行配置并实时应用。留空表示直接连接。",
|
||||
"outboundTagPlaceholder": "直接连接",
|
||||
@@ -1206,7 +1211,7 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
|
||||
"subClashUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上识别 Clash/Mihomo 客户端的 Go RE2 正则表达式。留空则使用默认规则。更改后请重启面板。",
|
||||
"subTitle": "订阅标题",
|
||||
"subTitleDesc": "在VPN客户端中显示的标题",
|
||||
"subTitleDesc": "在 VPN 客户端中显示的标题",
|
||||
"subSupportUrl": "支持链接",
|
||||
"subSupportUrlDesc": "VPN 客户端中显示的技术支持链接",
|
||||
"subProfileUrl": "个人资料链接",
|
||||
@@ -1315,7 +1320,7 @@
|
||||
"muxDesc": "在已建立的数据流内传输多个独立的数据流",
|
||||
"muxSett": "复用器设置",
|
||||
"direct": "直接连接",
|
||||
"directDesc": "直接与特定国家的域或IP范围建立连接",
|
||||
"directDesc": "直接与特定国家的域或 IP 范围建立连接",
|
||||
"notifications": "通知",
|
||||
"certs": "证书",
|
||||
"externalTraffic": "外部流量",
|
||||
@@ -1329,12 +1334,12 @@
|
||||
"security": {
|
||||
"admin": "管理员凭据",
|
||||
"twoFactor": "双重验证",
|
||||
"twoFactorEnable": "启用2FA",
|
||||
"twoFactorEnable": "启用 2FA",
|
||||
"twoFactorEnableDesc": "增加额外的验证层以提高安全性。",
|
||||
"twoFactorModalSetTitle": "启用双重认证",
|
||||
"twoFactorModalDeleteTitle": "停用双重认证",
|
||||
"twoFactorModalSteps": "要设定双重认证,请执行以下步骤:",
|
||||
"twoFactorModalFirstStep": "1. 在认证应用程序中扫描此QR码,或复制QR码附近的令牌并粘贴到应用程序中",
|
||||
"twoFactorModalFirstStep": "1. 在认证应用程序中扫描此 QR 码,或复制 QR 码附近的令牌并粘贴到应用程序中",
|
||||
"twoFactorModalSecondStep": "2. 输入应用程序中的验证码",
|
||||
"twoFactorModalRemoveStep": "输入应用程序中的验证码以移除双重认证。",
|
||||
"twoFactorModalChangeCredentialsTitle": "更改凭据",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "内存使用率高 (%)",
|
||||
"remarkTemplate": "备注模板",
|
||||
"remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
|
||||
"subShowIdentityOnAllLinks": "在每个链接上显示身份",
|
||||
"subShowIdentityOnAllLinksDesc": "启用后,{{EMAIL}} 和 {{USERNAME}} 会保留在每条订阅正文备注中。用量相关变量仍仅出现在第一条链接。",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "路径必须以 / 开头"
|
||||
},
|
||||
@@ -1456,8 +1463,8 @@
|
||||
"restartConfirmTitle": "重启 xray?",
|
||||
"restartConfirmContent": "使用已保存的配置重新加载 xray 服务。",
|
||||
"stopSuccess": "Xray 已成功停止",
|
||||
"restartError": "重启Xray时发生错误。",
|
||||
"stopError": "停止Xray时发生错误。",
|
||||
"restartError": "重启 Xray 时发生错误。",
|
||||
"stopError": "停止 Xray 时发生错误。",
|
||||
"basicTemplate": "基础配置",
|
||||
"advancedTemplate": "高级配置",
|
||||
"generalConfigs": "常规配置",
|
||||
@@ -1468,9 +1475,9 @@
|
||||
"basicRouting": "基本路由",
|
||||
"blockConnectionsConfigsDesc": "这些选项将根据特定的请求国家阻止流量。",
|
||||
"directConnectionsConfigsDesc": "直接连接确保特定的流量不会通过其他服务器路由。",
|
||||
"blockips": "阻止IP",
|
||||
"blockips": "阻止 IP",
|
||||
"blockdomains": "阻止域名",
|
||||
"directips": "直接IP",
|
||||
"directips": "直接 IP",
|
||||
"directdomains": "直接域名",
|
||||
"ipv4Routing": "IPv4 路由",
|
||||
"ipv4RoutingDesc": "此选项将仅通过 IPv4 路由到目标域",
|
||||
@@ -1848,28 +1855,28 @@
|
||||
"enableDesc": "启用内置 DNS 服务器",
|
||||
"tag": "DNS 入站标签",
|
||||
"tagDesc": "此标签将在路由规则中可用作入站标签",
|
||||
"clientIp": "客户端IP",
|
||||
"clientIpDesc": "用于在DNS查询期间通知服务器指定的IP位置",
|
||||
"clientIp": "客户端 IP",
|
||||
"clientIpDesc": "用于在 DNS 查询期间通知服务器指定的 IP 位置",
|
||||
"disableCache": "禁用缓存",
|
||||
"disableCacheDesc": "禁用DNS缓存",
|
||||
"disableCacheDesc": "禁用 DNS 缓存",
|
||||
"disableFallback": "禁用回退",
|
||||
"disableFallbackDesc": "禁用回退DNS查询",
|
||||
"disableFallbackDesc": "禁用回退 DNS 查询",
|
||||
"disableFallbackIfMatch": "匹配时禁用回退",
|
||||
"disableFallbackIfMatchDesc": "当DNS服务器的匹配域名列表命中时,禁用回退DNS查询",
|
||||
"disableFallbackIfMatchDesc": "当 DNS 服务器的匹配域名列表命中时,禁用回退 DNS 查询",
|
||||
"enableParallelQuery": "启用并行查询",
|
||||
"enableParallelQueryDesc": "启用并行DNS查询到多个服务器以实现更快的解析",
|
||||
"enableParallelQueryDesc": "启用并行 DNS 查询到多个服务器以实现更快的解析",
|
||||
"strategy": "查询策略",
|
||||
"strategyDesc": "解析域名的总体策略",
|
||||
"add": "添加服务器",
|
||||
"edit": "编辑服务器",
|
||||
"domains": "域名",
|
||||
"expectIPs": "预期 IP",
|
||||
"unexpectIPs": "意外IP",
|
||||
"useSystemHosts": "使用系统Hosts",
|
||||
"useSystemHostsDesc": "使用已安装系统的hosts文件",
|
||||
"unexpectIPs": "意外 IP",
|
||||
"useSystemHosts": "使用系统 Hosts",
|
||||
"useSystemHostsDesc": "使用已安装系统的 hosts 文件",
|
||||
"serveStale": "提供过期结果",
|
||||
"serveStaleDesc": "在后台刷新时返回过期的缓存结果",
|
||||
"serveExpiredTTL": "过期TTL",
|
||||
"serveExpiredTTL": "过期 TTL",
|
||||
"serveExpiredTTLDesc": "过期缓存条目的有效期(秒);0 = 永不过期",
|
||||
"timeoutMs": "超时 (毫秒)",
|
||||
"skipFallback": "跳过回退",
|
||||
@@ -1880,7 +1887,7 @@
|
||||
"hostsDomain": "域名 (例如 domain:example.com)",
|
||||
"hostsValues": "IP 或域名 — 输入后按 Enter",
|
||||
"usePreset": "使用模板",
|
||||
"dnsPresetTitle": "DNS模板",
|
||||
"dnsPresetTitle": "DNS 模板",
|
||||
"dnsPresetFamily": "家庭",
|
||||
"clearAll": "删除全部",
|
||||
"clearAllTitle": "删除所有 DNS 服务器?",
|
||||
@@ -2019,7 +2026,7 @@
|
||||
"noResult": "❗ 没有结果!",
|
||||
"noQuery": "❌ 未找到查询!请再次使用该命令!",
|
||||
"wentWrong": "❌ 出了点问题!",
|
||||
"noIpRecord": "❗ 没有IP记录!",
|
||||
"noIpRecord": "❗ 没有 IP 记录!",
|
||||
"noInbounds": "❗ 未找到入站!",
|
||||
"unlimited": "♾ 无限(重置)",
|
||||
"add": "添加",
|
||||
@@ -2043,8 +2050,8 @@
|
||||
"status": "✅ 机器人正常运行!",
|
||||
"usage": "❗ 请输入要搜索的文本!",
|
||||
"getID": "🆔 您的 ID 为:<code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "要重新启动 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜索客户电子邮件:\r\n<code>/usage [电子邮件]</code>\r\n\r\n要搜索入站(带有客户统计数据):\r\n<code>/inbound [备注]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n<code>/usage [电子邮件]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpAdminCommands": "要重新启动 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜索客户电子邮件:\r\n<code>/usage [电子邮件]</code>\r\n\r\n要搜索入站(带有客户统计数据):\r\n<code>/inbound [备注]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n<code>/usage [电子邮件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"restartUsage": "\r\n\r\n<code>/restart</code>",
|
||||
"restartSuccess": "✅ 操作成功!",
|
||||
"restartFailed": "❗ 操作错误。\r\n\r\n<code>错误: {{ .Error }}</code>.",
|
||||
|
||||
@@ -82,8 +82,8 @@
|
||||
"secAlertPanelURI": "面板預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
|
||||
"secAlertSubURI": "訂閱預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
|
||||
"secAlertSubJsonURI": "訂閱 JSON 預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
|
||||
"emptyDnsDesc": "未添加DNS伺服器。",
|
||||
"emptyFakeDnsDesc": "未添加Fake DNS伺服器。",
|
||||
"emptyDnsDesc": "未添加 DNS 伺服器。",
|
||||
"emptyFakeDnsDesc": "未添加 Fake DNS 伺服器。",
|
||||
"emptyBalancersDesc": "未添加負載平衡器。",
|
||||
"emptyReverseDesc": "未添加反向代理。",
|
||||
"somethingWentWrong": "發生錯誤",
|
||||
@@ -169,7 +169,7 @@
|
||||
"xrayStatusRunning": "運行中",
|
||||
"xrayStatusStop": "停止",
|
||||
"xrayStatusError": "錯誤",
|
||||
"xrayErrorPopoverTitle": "執行Xray時發生錯誤",
|
||||
"xrayErrorPopoverTitle": "執行 Xray 時發生錯誤",
|
||||
"operationHours": "系統正常執行時間",
|
||||
"systemHistoryTitle": "系統歷史",
|
||||
"historyTitleCpu": "CPU 使用率",
|
||||
@@ -215,8 +215,8 @@
|
||||
"systemLoad": "系統負載",
|
||||
"systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",
|
||||
"connectionCount": "連線數",
|
||||
"ipAddresses": "IP地址",
|
||||
"toggleIpVisibility": "切換IP可見性",
|
||||
"ipAddresses": "IP 地址",
|
||||
"toggleIpVisibility": "切換 IP 可見性",
|
||||
"overallSpeed": "整體速度",
|
||||
"upload": "上傳",
|
||||
"download": "下載",
|
||||
@@ -224,8 +224,8 @@
|
||||
"sent": "已發送",
|
||||
"received": "已接收",
|
||||
"documentation": "文件",
|
||||
"xraySwitchVersionDialog": "您確定要變更Xray版本嗎?",
|
||||
"xraySwitchVersionDialogDesc": "這將會把Xray版本變更為#version#。",
|
||||
"xraySwitchVersionDialog": "您確定要變更 Xray 版本嗎?",
|
||||
"xraySwitchVersionDialogDesc": "這將會把 Xray 版本變更為 #version#。",
|
||||
"xraySwitchVersionPopover": "Xray 更新成功",
|
||||
"panelUpdateDialog": "您確定要更新面板嗎?",
|
||||
"panelUpdateDialogDesc": "這將把 3X-UI 更新到 #version# 並重新啟動面板服務。",
|
||||
@@ -441,7 +441,7 @@
|
||||
"streamHelp": "Xray stream 區塊包裝:",
|
||||
"jsonErrorPrefix": "進階 JSON"
|
||||
},
|
||||
"telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"telegramDesc": "請提供 Telegram 聊天 ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。",
|
||||
"subSortIndex": "訂閱排序",
|
||||
"same": "相同",
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "匯入入站規則",
|
||||
"periodicTrafficResetTitle": "流量重置",
|
||||
"periodicTrafficResetDesc": "按指定間隔自動重置流量計數器",
|
||||
"periodicTrafficResetDay": "每月重置日",
|
||||
"lastReset": "上次重置",
|
||||
"periodicTrafficReset": {
|
||||
"never": "從不",
|
||||
@@ -479,9 +480,9 @@
|
||||
"resetInboundClientTrafficSuccess": "流量已重置",
|
||||
"resetInboundTrafficSuccess": "入站流量已重置",
|
||||
"trafficGetError": "取得流量資料時發生錯誤",
|
||||
"getNewX25519CertError": "取得X25519憑證時發生錯誤。",
|
||||
"getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
|
||||
"getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。",
|
||||
"getNewX25519CertError": "取得 X25519 憑證時發生錯誤。",
|
||||
"getNewmldsa65Error": "取得 mldsa65 憑證時發生錯誤。",
|
||||
"getNewVlessEncError": "取得 VlessEnc 憑證時發生錯誤。",
|
||||
"scanRealityTargetError": "掃描 REALITY 目標失敗。",
|
||||
"scanRealityTargetFeasible": "目標可用 — 已填入目標與 SNI。",
|
||||
"scanRealityTargetNotFeasible": "目標可達,但不適用於 REALITY。",
|
||||
@@ -636,6 +637,10 @@
|
||||
"maxTimeDiff": "最大時間差 (ms)",
|
||||
"minClientVer": "最小客戶端版本",
|
||||
"maxClientVer": "最大客戶端版本",
|
||||
"minClientVerHint": "留空不等於不限制:Xray-core 會改用所執行核心版本的內建最低值(目前版本為 26.3.27),拒絕自報版本較低的客戶端——包括 Mihomo、sing-box 等第三方核心。填 1.0.0 可放行它們,代價是允許過時的 TLS 指紋。",
|
||||
"maxClientVerHint": "留空表示無上限。若填寫,不得低於實際生效的下限——最小客戶端版本,該欄位留空時則為 Xray-core 的內建最低值——否則所有客戶端都會被拒絕。",
|
||||
"clientVerInvalid": "客戶端版本須為最多三段以點分隔的數字,每段 0-255(例如 26.3.27)",
|
||||
"maxClientVerBelowMin": "最大客戶端版本不得低於最小客戶端版本",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "必填。必須包含連接埠(例如 example.com:443)。沒有連接埠時 Xray-core 將無法啟動。",
|
||||
"realityTargetRequired": "REALITY 目標為必填項",
|
||||
@@ -810,7 +815,7 @@
|
||||
"online": "上線",
|
||||
"email": "電子郵件",
|
||||
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
|
||||
"subIdInvalidChars": "訂閱ID不能包含空格、'/'、'\\' 或控制字元",
|
||||
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
|
||||
"group": "群組",
|
||||
"groupDesc": "用於將相關客戶端歸類的邏輯標籤(如團隊、客戶、地區)。可從工具列篩選。",
|
||||
"groupPlaceholder": "如 customer-a",
|
||||
@@ -1011,7 +1016,7 @@
|
||||
"regenerate": "重新產生權杖",
|
||||
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
|
||||
"allowPrivateAddress": "允許私有地址",
|
||||
"allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
|
||||
"allowPrivateAddressHint": "僅對私有網路或 VPN 上的節點啟用。",
|
||||
"outboundTag": "連線出站",
|
||||
"outboundTagHint": "透過選定的 Xray 出站路由此節點的面板 API 流量。系統會自動將迴環橋接入站加入執行中的設定並即時套用。留空表示直接連線。",
|
||||
"outboundTagPlaceholder": "直接連線",
|
||||
@@ -1206,7 +1211,7 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
|
||||
"subClashUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上識別 Clash/Mihomo 用戶端的 Go RE2 正規表示式。留空則使用預設規則。變更後請重新啟動面板。",
|
||||
"subTitle": "訂閱標題",
|
||||
"subTitleDesc": "在VPN客戶端中顯示的標題",
|
||||
"subTitleDesc": "在 VPN 客戶端中顯示的標題",
|
||||
"subSupportUrl": "支援連結",
|
||||
"subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結",
|
||||
"subProfileUrl": "個人資料連結",
|
||||
@@ -1315,7 +1320,7 @@
|
||||
"muxDesc": "在已建立的資料流內傳輸多個獨立的資料流",
|
||||
"muxSett": "複用器設定",
|
||||
"direct": "直接連線",
|
||||
"directDesc": "直接與特定國家的域或IP範圍建立連線",
|
||||
"directDesc": "直接與特定國家的域或 IP 範圍建立連線",
|
||||
"notifications": "通知",
|
||||
"certs": "證書",
|
||||
"externalTraffic": "外部流量",
|
||||
@@ -1329,12 +1334,12 @@
|
||||
"security": {
|
||||
"admin": "管理員憑證",
|
||||
"twoFactor": "雙重驗證",
|
||||
"twoFactorEnable": "啟用2FA",
|
||||
"twoFactorEnable": "啟用 2FA",
|
||||
"twoFactorEnableDesc": "增加額外的驗證層以提高安全性。",
|
||||
"twoFactorModalSetTitle": "啟用雙重認證",
|
||||
"twoFactorModalDeleteTitle": "停用雙重認證",
|
||||
"twoFactorModalSteps": "要設定雙重認證,請執行以下步驟:",
|
||||
"twoFactorModalFirstStep": "1. 在認證應用程式中掃描此QR碼,或複製QR碼附近的令牌並貼到應用程式中",
|
||||
"twoFactorModalFirstStep": "1. 在認證應用程式中掃描此 QR 碼,或複製 QR 碼附近的令牌並貼到應用程式中",
|
||||
"twoFactorModalSecondStep": "2. 輸入應用程式中的驗證碼",
|
||||
"twoFactorModalRemoveStep": "輸入應用程式中的驗證碼以移除雙重認證。",
|
||||
"twoFactorModalChangeCredentialsTitle": "更改憑證",
|
||||
@@ -1433,6 +1438,8 @@
|
||||
"eventMemoryHigh": "記憶體使用率高 (%)",
|
||||
"remarkTemplate": "備註範本",
|
||||
"remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
|
||||
"subShowIdentityOnAllLinks": "在每個連結上顯示身分",
|
||||
"subShowIdentityOnAllLinksDesc": "啟用後,{{EMAIL}} 與 {{USERNAME}} 會保留在每條訂閱正文備註中。用量相關變數仍僅出現在第一條連結。",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "路徑必須以 / 開頭"
|
||||
},
|
||||
@@ -1448,8 +1455,8 @@
|
||||
"restartConfirmTitle": "重新啟動 xray?",
|
||||
"restartConfirmContent": "使用已儲存的設定重新載入 xray 服務。",
|
||||
"stopSuccess": "Xray 已成功停止",
|
||||
"restartError": "重新啟動Xray時發生錯誤。",
|
||||
"stopError": "停止Xray時發生錯誤。",
|
||||
"restartError": "重新啟動 Xray 時發生錯誤。",
|
||||
"stopError": "停止 Xray 時發生錯誤。",
|
||||
"importRules": "匯入規則",
|
||||
"exportRules": "匯出規則",
|
||||
"importOutbounds": "匯入出站",
|
||||
@@ -1468,9 +1475,9 @@
|
||||
"basicRouting": "基本路由",
|
||||
"blockConnectionsConfigsDesc": "這些選項將根據特定的請求國家阻止流量。",
|
||||
"directConnectionsConfigsDesc": "直接連線確保特定的流量不會通過其他伺服器路由。",
|
||||
"blockips": "阻止IP",
|
||||
"blockips": "阻止 IP",
|
||||
"blockdomains": "阻止域名",
|
||||
"directips": "直接IP",
|
||||
"directips": "直接 IP",
|
||||
"directdomains": "直接域名",
|
||||
"ipv4Routing": "IPv4 路由",
|
||||
"ipv4RoutingDesc": "此選項將僅通過 IPv4 路由到目標域",
|
||||
@@ -1848,28 +1855,28 @@
|
||||
"enableDesc": "啟用內建 DNS 伺服器",
|
||||
"tag": "DNS 入站標籤",
|
||||
"tagDesc": "此標籤將在路由規則中可用作入站標籤",
|
||||
"clientIp": "客戶端IP",
|
||||
"clientIpDesc": "用於在DNS查詢期間通知伺服器指定的IP位置",
|
||||
"clientIp": "客戶端 IP",
|
||||
"clientIpDesc": "用於在 DNS 查詢期間通知伺服器指定的 IP 位置",
|
||||
"disableCache": "禁用快取",
|
||||
"disableCacheDesc": "禁用DNS快取",
|
||||
"disableCacheDesc": "禁用 DNS 快取",
|
||||
"disableFallback": "禁用回退",
|
||||
"disableFallbackDesc": "禁用回退DNS查詢",
|
||||
"disableFallbackDesc": "禁用回退 DNS 查詢",
|
||||
"disableFallbackIfMatch": "匹配時禁用回退",
|
||||
"disableFallbackIfMatchDesc": "當DNS伺服器的匹配域名列表命中時,禁用回退DNS查詢",
|
||||
"disableFallbackIfMatchDesc": "當 DNS 伺服器的匹配域名列表命中時,禁用回退 DNS 查詢",
|
||||
"enableParallelQuery": "啟用並行查詢",
|
||||
"enableParallelQueryDesc": "啟用並行DNS查詢到多個伺服器以實現更快的解析",
|
||||
"enableParallelQueryDesc": "啟用並行 DNS 查詢到多個伺服器以實現更快的解析",
|
||||
"strategy": "查詢策略",
|
||||
"strategyDesc": "解析域名的總體策略",
|
||||
"add": "新增伺服器",
|
||||
"edit": "編輯伺服器",
|
||||
"domains": "網域",
|
||||
"expectIPs": "預期 IP",
|
||||
"unexpectIPs": "意外IP",
|
||||
"useSystemHosts": "使用系統Hosts",
|
||||
"useSystemHostsDesc": "使用已安裝系統的hosts檔案",
|
||||
"unexpectIPs": "意外 IP",
|
||||
"useSystemHosts": "使用系統 Hosts",
|
||||
"useSystemHostsDesc": "使用已安裝系統的 hosts 檔案",
|
||||
"serveStale": "提供過期結果",
|
||||
"serveStaleDesc": "在背景重新整理時傳回過期的快取結果",
|
||||
"serveExpiredTTL": "過期TTL",
|
||||
"serveExpiredTTL": "過期 TTL",
|
||||
"serveExpiredTTLDesc": "過期快取項目的有效期(秒);0 = 永不過期",
|
||||
"timeoutMs": "逾時 (毫秒)",
|
||||
"skipFallback": "跳過回退",
|
||||
@@ -1880,7 +1887,7 @@
|
||||
"hostsDomain": "網域 (例如 domain:example.com)",
|
||||
"hostsValues": "IP 或網域 — 輸入後按 Enter",
|
||||
"usePreset": "使用範本",
|
||||
"dnsPresetTitle": "DNS範本",
|
||||
"dnsPresetTitle": "DNS 範本",
|
||||
"dnsPresetFamily": "家庭",
|
||||
"clearAll": "全部刪除",
|
||||
"clearAllTitle": "刪除所有 DNS 伺服器?",
|
||||
@@ -2019,7 +2026,7 @@
|
||||
"noResult": "❗ 沒有結果!",
|
||||
"noQuery": "❌ 未找到查詢!請再次使用該命令!",
|
||||
"wentWrong": "❌ 出了點問題!",
|
||||
"noIpRecord": "❗ 沒有IP記錄!",
|
||||
"noIpRecord": "❗ 沒有 IP 記錄!",
|
||||
"noInbounds": "❗ 未找到入站!",
|
||||
"unlimited": "♾ 無限(重置)",
|
||||
"add": "添加",
|
||||
@@ -2043,8 +2050,8 @@
|
||||
"status": "✅ 機器人正常執行!",
|
||||
"usage": "❗ 請輸入要搜尋的文字!",
|
||||
"getID": "🆔 您的 ID 為:<code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "要重新啟動 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜尋客戶電子郵件:\r\n<code>/usage [電子郵件]</code>\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n<code>/inbound [備註]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n<code>/usage [電子郵件]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpAdminCommands": "要重新啟動 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜尋客戶電子郵件:\r\n<code>/usage [電子郵件]</code>\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n<code>/inbound [備註]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n<code>/usage [電子郵件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"restartUsage": "\r\n\r\n<code>/restart</code>",
|
||||
"restartSuccess": "✅ 操作成功!",
|
||||
"restartFailed": "❗ 操作錯誤。\r\n\r\n<code>錯誤: {{ .Error }}</code>.",
|
||||
|
||||
+7
-7
@@ -302,7 +302,7 @@ const (
|
||||
|
||||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||
// jobs) which the panel relies on for periodic maintenance and monitoring.
|
||||
func (s *Server) startTask(restartXray bool) {
|
||||
func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
if restartXray {
|
||||
err := s.xrayService.RestartXray(true)
|
||||
if err != nil {
|
||||
@@ -344,13 +344,13 @@ func (s *Server) startTask(restartXray bool) {
|
||||
|
||||
// Inbound traffic reset jobs
|
||||
// Run every hour
|
||||
_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
|
||||
_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly", loc))
|
||||
// Run once a day, midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily", loc))
|
||||
// Run once a week, midnight between Sat/Sun
|
||||
_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
|
||||
// Run once a month, midnight, first of month
|
||||
_, _ = s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
|
||||
_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly", loc))
|
||||
// Check monthly reset days at midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
||||
|
||||
// LDAP sync scheduling
|
||||
if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
|
||||
@@ -651,7 +651,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
}
|
||||
})
|
||||
|
||||
s.startTask(restartXray)
|
||||
s.startTask(restartXray, loc)
|
||||
|
||||
if startTgBot {
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
|
||||
Reference in New Issue
Block a user