Compare commits

...

15 Commits

Author SHA1 Message Date
Shichao Song 17e6b5a460 inbounds: allow custom monthly traffic reset days (#6071) 2026-07-28 23:27:09 +02:00
Intervence 34d2591e50 fix (install.sh): use realpath instead of script name (#6075)
* fix (install.sh): use realpath instead of script name

###Description:
During arch() sctipt tries to delete itself in case no compatible arch found. This may lead to unexpected file deletion if executed outside root dir; also cur_dir is declared but doesn't seem to be used anywhere

###Way to reproduce:
```bash
cd "/some/other_dir_with_install_sh"
/3x-ui/project/dir/install.sh
```

* fix(install): quote the script path before the self-delete

realpath was handed an unquoted $0, so a script living under a path that
contains spaces was split into several arguments: realpath printed a
partial path plus an error, and rm -f then targeted a name matching
nothing at all. The unsupported-arch branch silently kept the script it
means to remove — the very case the surrounding fix exists for.
2026-07-28 23:11:27 +02:00
PathGao ca6955d88b feat(ui): validate the REALITY client version range at save time (#6126)
* feat(ui): validate the REALITY client version range at save time

The impossible range from PR #6125 — a max below the effective minimum
— could still be saved; the tooltip only helps a user who hovers it.
Add save-time validation mirroring xray-core's parser (up to three
dot-separated parts, each 0-255) on both fields, plus a cross-field
check that a non-empty max is not below a non-empty min. Errors are
field-level i18n keys following the REALITY target precedent, so the
modal stays open and points at the offending field instead of storing
a config that rejects every client.

A malformed min is reported by its own field and skipped by the max
comparison, so the user sees one precise error per field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): reject untrimmed client versions and revalidate max on min edits

From review: the validators trimmed but the save path ships the value
verbatim, and xray-core's part parser accepts no surrounding
whitespace — so a green form could still save a config the core
refuses to load. Reject any value that differs from its trimmed form.

Also revalidate the max field after a min edit when max already
shows an error, so correcting the min clears the stale cross-field
message without waiting for the next submit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:04:03 +02:00
PathGao 411271b454 refactor(ui): share one onNumber handler for numeric setting inputs (#6127)
* refactor(ui): share one onNumber handler for numeric setting inputs

The Number(v) || 0 idiom in InputNumber onChange handlers is the root
pattern behind the cleared-port bug (#6121): AntD reports a cleared
field as null, and || 0 turns that into a stored zero or a min-clamp.
The port fields got an inline null-guard; the other sixteen numeric
settings kept the idiom, so every new field is a chance to
reintroduce the bug.

Extract the guard into onNumber(apply): null, empty and NaN change
events are ignored so a cleared field snaps back to its stored value
on blur, and numeric events pass through unchanged. Convert all
sixteen sites in the settings and xray pages. Two sites keep their
deliberate different semantics: smtpPort falls back to 587 on clear,
and the Telegram notify interval clamps through Math.max.

For the non-port fields this changes clearing from storing 0 to
keeping the stored value; zero remains reachable by typing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ui): fold the remaining hand-rolled numeric guards into onNumber

From review: ObservatorySettingsTab's sampling field hand-rolled the
same ignore-null semantic and smtpPort kept a fallback-to-587 on
clear that nothing documents as intentional and that silently
overwrites a configured non-standard port — both now go through the
shared helper, leaving the Telegram interval clamp as the one
deliberate exception.

Also from review: narrow the helper to numbers only (no stringMode
input exists in the repo, and the string branch codified a guarantee
the number-typed callback cannot honour), soften the docblock to
describe behavior rather than promise prevention, add a GeneralTab
component test covering the clear-vs-typed-zero semantics, and assert
the blur snap-back in both settings tests so a display/state desync
cannot ship unnoticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:03:13 +02:00
Tosd e862d81c60 fix(sub): omit hyphen for empty remark variables (#6101)
* fix(sub): omit hyphen for empty remark variables

The default INBOUND-EMAIL template left a leading hyphen when an inbound had no remark after display remarks became template-driven in b0c1156dd. Treat a hyphen between adjacent variables as their separator and drop it when it would lead the output or when the value after it is empty, so an empty variable in the middle of a template still leaves a single separator between its neighbours. Literal leading hyphens written into the template are preserved.

* fix(sub): elide the remark separator after leading decoration

The separator between two adjacent tokens was kept as soon as any text had
reached the segment, so a template opening with decoration still rendered
"🌐-john" for an inbound with no remark. Track whether a token has produced
a value rather than testing the accumulated output, so the hyphen is elided
for any prefix that carries no token value of its own, and the builder is no
longer rescanned once per token.
2026-07-28 23:02:00 +02:00
Kim Fom 6af2995930 feat(api): add GET endpoint to look up clients by Telegram ID (#5945)
* feat(api): add GET endpoint to look up clients by Telegram ID

GET /panel/api/clients/getByTgId/:tgId returns all clients matching the given Telegram user ID. tgId is not unique, so the response is an array of {client, inboundIds, externalLinks, usedTraffic} objects.

* fix: guard tgId=0 sentinel, index tg_id, deduplicate enrichment in getByTgId

Three issues from the code review on the new GET /panel/api/clients/getByTgId/:tgId
endpoint: the lookup did not short-circuit tgId <= 0 (this codebase's sentinel
for 'no Telegram ID'), had no index on clients.tg_id causing a full table scan
on every call, and duplicated the per-record enrichment (inbound IDs, external
links, effective flow, traffic) identically between get and getByTgId.

- Reject tgId <= 0 in GetRecordsByTgId with a clear error, matching the
  '0 = none' convention used elsewhere in the codebase.
- Add index:idx_clients_tg_id to ClientRecord.TgID (struct tag + idempotent
  startup migration for existing databases).
- Extract buildClientPayload helper used by both get and getByTgId.
- Update client_lookup_test.go to verify sentinel rejection instead of
  expecting tgId=0 to be a valid lookup.

* refactor(api): move Telegram client lookup under /get/tgId/:tgId

Nest the Telegram-ID lookup beside the email lookup as /get/tgId/:tgId
instead of the flat /getByTgId/:tgId, so both client fetch routes share the
/get prefix. Gin resolves the static tgId segment ahead of the :email
wildcard, so /get/:email keeps matching plain email lookups, including a
literal 'tgId' email. The endpoint is unreleased, so no compatibility
concern.
2026-07-28 22:38:44 +02:00
Maksim Alekseev 041476a317 feat(sub): Add XHTTP session field compatibility in share links and subscriptions (#5929)
* ✨ Add sessionKey and sessionPlacement compatability for previous clients

* ✨ Add sessionKey and sessionPlacement compatability for previous clients on backend
2026-07-28 22:15:28 +02:00
Mr. Nickson ff954ec48c fix: stop deleting client_traffics for detached-but-alive clients (#6110)
* fix: stop deleting client_traffics for detached-but-alive clients

MigrationRemoveOrphanedTraffics keyed "orphaned" off presence in some
inbound's settings.clients[] JSON, a definition that predates #4469's
standalone clients table. ClientService.Detach intentionally keeps a
client's traffic row when it drops its last inbound attachment (so it
can be re-attached later without losing stats/expiry), but that client
has no entry in any inbound's JSON anymore - so every x-ui migrate run
or backup restore deleted its traffic row anyway, even though the
client itself was untouched and still listed. Scope the query to the
clients table instead, which is the function's actual intent.

Separately, frontend/src/hooks/useClients.ts recomputed the clients
summary from the client_stats WS snapshot as soon as it arrived, even
when that snapshot held fewer rows than the server's own total (e.g.
exactly the gap above, or any other client with no client_traffics
row). The recompute can only bucket the clients it was given, so the
missing ones silently fell out of every bucket while the headline
total still counted them - the Ended/Disabled cards read 0 and their
hover lists were empty even though the table below listed those rows,
leaving the Filter drawer as the only way to reach them. Extracted the
decision into pickClientsSummary and added the guard: fall back to the
server summary (built from the clients table, always sums to total)
whenever the snapshot doesn't cover every client.

Fixes #6102.

* fix: union both keep-sets instead of replacing (review feedback)

Address the automated review on this PR: switching
MigrationRemoveOrphanedTraffics to key solely off the clients table
traded the original bug for a worse one. The one-shot ClientsTable
seeder (internal/database/db.go) skips a client it fails to unmarshal
and never retries, so a client still live in an inbound's
settings.clients[] JSON can have no clients row at all - the new
predicate deleted its traffic row too, and an empty clients table
would have emptied client_traffics outright. Union both keep-sets: a
row survives if it's referenced by either the clients table or any
inbound's JSON, and is removed only when it's in neither.

Log the delete's outcome instead of discarding it silently, since a
whole-table wipe would otherwise leave no trace.

Rewrote the migration test as a table of all four combinations, driven
through real ClientService calls (SyncInbound, Detach) rather than
hand-built rows wherever a real path produces the state, so it tracks
actual behavior instead of an assumption about it. Added the missing
case the review flagged: a client live in JSON only, with no clients
row, must survive.

Also stripped the // comments this PR had added - CLAUDE.md states
committed Go/TS carries none, which the review separately flagged.
2026-07-28 22:14:01 +02:00
H-TTTTT 8f49327efb feat(sub): allow identity tokens on every subscription link (#5935)
Keep usage tokens first-link-only while adding an opt-in setting for repeating EMAIL and USERNAME in subscription-body remarks.

Co-authored-by: x06579 <x06579@ai-dashboard>
2026-07-28 22:12:52 +02:00
n0liu 8bbca76bdd fix(sub): drop duplicated fingerprint in external-proxy tlsSettings (#6096)
applyExternalProxyTLSToStream wrote the external proxy fingerprint both to
tlsSettings.fingerprint and to tlsSettings.settings.fingerprint, so the
generated JSON subscription for an XHTTP Host group carried the same
fingerprint twice. Every other field in this function writes a single
location, and tlsData already emits fingerprint at the top level, so keep
only tlsSettings.fingerprint.
2026-07-28 22:10:47 +02:00
PathGao a2774bf212 fix(ui): explain the REALITY client version gate and drop the impossible placeholder (#6125)
* fix(ui): explain the REALITY client version gate and drop the impossible placeholder

An empty Min Client Ver looks unrestricted, but Xray-core silently
falls back to a built-in minimum (currently 26.3.27) that rejects
third-party cores such as Mihomo and sing-box with a bare REALITY
verification failure, and nothing in the panel points at the field.
Add tooltips to both version fields explaining the fallback and its
TLS-fingerprint-freshness rationale.

The Max Client Ver placeholder (25.9.11) sat below the built-in
minimum, so filling in both placeholders produced a range that
rejects every client. Remove it; empty genuinely means no upper
limit for that field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(reality): warn that an empty min client version rejects old cores

Common pitfalls covered bad targets, SNI mismatches, leaked keys and
wrong flow, but not the client version gate that currently bites
Mihomo and sing-box users. Add it to all four doc languages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): word the version hints against the effective minimum

Address the automated review: the Max Client Ver hint said only 'not
lower than Min Client Ver', which re-establishes the empty-means-unset
mental model when the effective floor is the core's built-in minimum.
Both hints now name the effective minimum and tie the quoted 26.3.27
to the core build the panel runs, since operators can install any
Xray-core version.

Also from review: full-width quotes and a missing verb in the zh doc
bullet, the idiomatic Arabic opening, and a format-only x.y.z
placeholder on Max Client Ver so the field still conveys its shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:08:39 +02:00
Jingyue Yao 48675ff197 style(i18n): normalize Chinese-English spacing (#6076)
Add consistent spacing between Chinese text and Latin terms in the Simplified and Traditional Chinese translations to improve readability without changing keys or placeholders.
2026-07-28 21:01:21 +02:00
PathGao 604986598f fix(ui): commit date-picker selections immediately instead of on confirm (#6122)
* fix(ui): commit date-picker selections immediately instead of on confirm

With showTime, Ant Design's DatePicker stages a clicked date until the
OK button confirms it. Closing the dropdown any other way - clicking
elsewhere in the form or hitting Create/Save directly - discarded the
staged date without a hint, so an inbound saved this way ended up with
expiryTime=0 (never expires). The Now shortcut commits in one click,
which made it look like only the current time could ever be set.

Drop the confirm step (needConfirm=false) and propagate every calendar
selection through onCalendarChange, so the picked date reaches the form
state the moment it is clicked and can no longer be lost to a race with
the submit button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ui): pin calendar clicks committing without a confirm press

A clicked day cell must reach onChange with the exact selected
timestamp while the dropdown is still open, and the footer must not
render a confirm button. Pins the needConfirm-free behavior so a picker
dependency bump cannot silently bring the staged-value discard back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:57:46 +02:00
Sanaei b6473004ac fix(ci): harden the conflict resolver against the branch it checks out
resolve-conflicts is the one job that puts a pull request's own tree in
the working directory while holding CLAUDE_CODE_OAUTH_TOKEN,
CLAUDE_BOT_PAT and a write-scoped token, which is what CodeQL alert 101
(actions/untrusted-checkout) points at. Nothing in the job executes that
tree and the trigger is gated on the repository owner, so the alert is
not reachable as written, but two of its guards were weaker than they
read.

Git hooks were neutered only after gh pr checkout had already run, so
the guard sat one step behind the checkout it exists to cover; it now
precedes it. The conflicted paths are concatenated into the
--allowedTools value handed to the model, so a path carrying a comma or
a parenthesis would widen that allowlist. Only both-modified paths reach
that code today, which means they already exist in the base repository,
but the merge is now handed back to the maintainer unless every
conflicted path is plain [A-Za-z0-9._/-].

The file's header comment block is dropped.
2026-07-28 20:11:22 +02:00
PathGao 579acbc669 fix(settings): keep the stored port when a port field is cleared (#6121)
* fix(settings): keep the stored port when a port field is cleared

Clearing the panel-port, subscription-port or LDAP-port InputNumber
fired onChange(null), which the handlers coerced to 0; on blur Ant
Design clamped the empty field to min=1 and the next save silently
persisted port 1. For subPort that breaks the generated subscription
links; for webPort it moves the panel itself to port 1 and locks the
admin out until the port is fixed via the x-ui CLI.

Ignore null changes so clearing a port field snaps back to the last
valid value instead of committing a bogus port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(settings): pin cleared port fields to the stored value

Clearing the subscription-port field must not reach updateSetting at
all, while typed ports still pass through unchanged. Pins the fix so a
handler refactor cannot silently reintroduce the clamped port 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:10:21 +02:00
83 changed files with 1415 additions and 198 deletions
+9 -20
View File
@@ -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
+2 -2
View File
@@ -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 |
+3
View File
@@ -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>
+7
View File
@@ -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>
+3
View File
@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
به‌صورت اختیاری می‌توانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
تعیین کنید، و یک زمان‌بندی **بازنشانی ترافیک** دوره‌ای انتخاب کنید: `never`
(پیش‌فرض)، `hourly`، `daily`، `weekly` یا `monthly`.
برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاه‌تر
وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام می‌شود.
</Step>
</Steps>
+7
View File
@@ -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>
+3
View File
@@ -41,6 +41,9 @@ icon: ArrowDownToLine
При необходимости ограничьте общий объём трафика и установите дату истечения для
входящего подключения, а также выберите расписание периодического **сброса трафика**:
`never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
коротком месяце, сброс выполняется в последний день этого месяца.
</Step>
</Steps>
+8
View File
@@ -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>
+3
View File
@@ -37,6 +37,9 @@ icon: ArrowDownToLine
可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
`never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
则在该月最后一天重置。
</Step>
</Steps>
+1
View File
@@ -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>
+8
View File
@@ -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",
+58
View File
@@ -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}
+3
View File
@@ -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": {
+16
View File
@@ -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"
+3
View File
@@ -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;
}
+3
View File
@@ -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>;
+17 -10
View File
@@ -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,
+10 -2
View File
@@ -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];
+3
View File
@@ -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 = "";
+1
View File
@@ -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 = '';
+10
View File
@@ -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',
+1 -4
View File
@@ -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' }}>
+2 -1
View File
@@ -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')}>
+10 -9
View File
@@ -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>
+2 -1
View File
@@ -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'),
+1
View File
@@ -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(),
+26 -2
View File
@@ -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();
});
});
+40
View File
@@ -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', () => {
+70
View File
@@ -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');
});
});
+23
View File
@@ -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 });
+16
View File
@@ -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
View File
@@ -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
}
+14
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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
+72
View File
@@ -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
View File
@@ -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 {
+50
View File
@@ -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{
+44 -11
View File
@@ -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) {
+6 -5
View File
@@ -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"`
+24 -3
View File
@@ -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)
}
})
}
}
+3
View File
@@ -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
}
+6 -3
View File
@@ -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)
+10
View File
@@ -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))
}
})
}
+10
View File
@@ -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
+9 -2
View File
@@ -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()
+5 -1
View File
@@ -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)
}
}
}
+5
View File
@@ -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)
}
}
+7
View File
@@ -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": "يجب أن يبدأ المسار بالرمز /"
},
+7
View File
@@ -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.",
+7
View File
@@ -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 /"
},
+7
View File
@@ -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": "وظایف برنامه ریزی شده بر اساس این تقویم اجرا می‌شود",
+7
View File
@@ -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 /"
},
+7
View File
@@ -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": "パスは / で始まる必要があります"
},
+7
View File
@@ -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 /"
},
+7
View File
@@ -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": "Путь должен начинаться с /"
},
+7
View File
@@ -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"
},
+7
View File
@@ -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": "Шлях має починатися з /"
},
+7
View File
@@ -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 /"
},
+42 -35
View File
@@ -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>.",
+42 -35
View File
@@ -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
View File
@@ -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()