Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 964cd6c124 chore(deps): bump react-i18next from 17.1.10 to 17.1.11 in /frontend
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.1.10 to 17.1.11.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.1.10...v17.1.11)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.1.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 09:08:32 +00:00
87 changed files with 285 additions and 3003 deletions
-9
View File
@@ -365,15 +365,6 @@ jobs:
- name: Build Storybook
run: npm run build-storybook
working-directory: frontend
- name: Inbound keyboard and accessibility tree
working-directory: frontend
run: |
set -euo pipefail
./node_modules/.bin/vite preview --outDir storybook-static --host 127.0.0.1 --port 6006 > /tmp/inbound-preview.log 2>&1 &
preview_pid=$!
trap 'kill "$preview_pid" 2>/dev/null || true' EXIT
curl --retry 30 --retry-connrefused --retry-delay 1 --retry-max-time 45 -fsS http://127.0.0.1:6006/iframe.html > /dev/null
node tools/check-inbound-keyboard.mjs http://127.0.0.1:6006
- name: Audit
run: npm run audit
working-directory: frontend
+17 -80
View File
@@ -28,10 +28,6 @@ _openapi:
title: Fetch one client by email, including the inbound IDs and external config
IDs it is attached to.
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
- depth: 2
title: Fetch one client by email, including the inbound IDs and external config
IDs it is attached to.
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1'
- depth: 2
title: Fetch clients by subscription ID.
url: '#fetch-clients-by-subscription-id'
@@ -48,25 +44,14 @@ _openapi:
title: Preview client auto-renewal dates without saving or resetting anything.
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
- depth: 2
title: 'Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.'
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below'
- depth: 2
title: 'Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.'
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1'
title: Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload — supply the full set of fields
you want to keep (the server replaces the row, it does not patch).
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch'
- depth: 2
title: Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed'
- depth: 2
title: Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1'
- depth: 2
title: Attach an existing client to one or more additional inbounds. Body is
JSON.
@@ -339,9 +324,6 @@ _openapi:
- content: Fetch one client by email, including the inbound IDs and external
config IDs it is attached to.
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
- content: Fetch one client by email, including the inbound IDs and external
config IDs it is attached to.
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1
- content: Fetch clients by subscription ID.
id: fetch-clients-by-subscription-id
- content: Fetch clients by Telegram user ID. Returns an array since multiple
@@ -353,22 +335,14 @@ _openapi:
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: Preview client auto-renewal dates without saving or resetting anything.
id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: 'Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.'
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- content: 'Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.'
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
- content: Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload — supply the full set of
fields you want to keep (the server replaces the row, it does not
patch).
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- content: Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
- content: Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1
- content: Attach an existing client to one or more additional inbounds. Body is
JSON.
id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
@@ -590,9 +564,6 @@ _openapi:
tunnel) contribute nothing.'
id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
contents:
- content: Query-parameter alias for the path endpoint. Use email=. or email=.. to
manage legacy reserved names without URL dot-segment normalization.
heading: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1
- content: Matches the exact subscription ID and orders results by client ID.
Returns an array of the same payloads as GET /clients/get/:email,
since multiple clients can share a subscription ID. Includes disabled,
@@ -601,14 +572,11 @@ _openapi:
email lookup. Requires an admin token or a panel login session.
heading: fetch-clients-by-subscription-id
- content: >-
Client email must not be exactly `.` or `..`. Existing reserved-name
clients can be read, renamed to a valid name, or deleted using the
query-parameter /get, /update and /del aliases. Fields the server
fills in when they are omitted — a valid value sent by the caller is
never overwritten. Re-adding an email that already exists, with its
stored `subId`, reuses the stored `id`, `password`, `auth` and
`secret` instead of minting new ones, so the identity stays in sync
across its inbounds.
Fields the server fills in when they are omitted — a valid value sent
by the caller is never overwritten. Re-adding an email that already
exists, with its stored `subId`, reuses the stored `id`, `password`,
`auth` and `secret` instead of minting new ones, so the identity stays
in sync across its inbounds.
- **VLESS / VMess** — `id`, a fresh UUID
@@ -673,11 +641,7 @@ _openapi:
preview is informational and does not reserve an allowance or
guarantee node availability.
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: 'An omitted `flow` leaves every attached inbound on its own flow. Send
`"reverse": null` to remove the reverse tag. Credentials (`id`,
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
`allowedIPs` are never cleared: an empty value keeps the stored one.
The inbounds are applied concurrently and independently: one that
- content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can
@@ -685,22 +649,7 @@ _openapi:
record is written after the inbounds, so a failure there is reported
without an `inbound <id>:` prefix and leaves the inbound edits in
place.'
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
to manage legacy reserved names without URL dot-segment normalization.
An omitted `flow` leaves every attached inbound on its own flow. Send
`"reverse": null` to remove the reverse tag. Credentials (`id`,
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
`allowedIPs` are never cleared: an empty value keeps the stored one.
The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can
still have applied the edit to the remaining inbounds. The client
record is written after the inbounds, so a failure there is reported
without an `inbound <id>:` prefix and leaves the inbound edits in
place.'
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
@@ -711,18 +660,6 @@ _openapi:
inbounds, so a failure there is reported without an `inbound <id>:`
prefix and leaves the client already removed from every inbound.'
heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
to manage legacy reserved names without URL dot-segment normalization.
The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can
still have removed the client from the remaining inbounds; the client
record is kept in that case, so re-running the call retries exactly
the leftovers. The record and traffic rows are dropped after the
inbounds, so a failure there is reported without an `inbound <id>:`
prefix and leaves the client already removed from every inbound.'
heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1
- content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
instead of being given a fresh address, so the call fails with
`inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address>
@@ -748,7 +685,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/update","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/del","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
</>
);
}
@@ -44,11 +44,10 @@ _openapi:
- depth: 2
title: >-
Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.
inbound. Body is the JSON client payload — supply the full set of fields
you want to keep (the server replaces the row, it does not patch).
url: >-
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- depth: 2
title: >-
Delete a client by email. Removes it from every attached inbound and
@@ -359,11 +358,11 @@ _openapi:
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: >-
Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.
attached inbound. Body is the JSON client payload — supply the full
set of fields you want to keep (the server replaces the row, it does
not patch).
id: >-
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- content: >-
Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
@@ -44,11 +44,10 @@ _openapi:
- depth: 2
title: >-
Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.
inbound. Body is the JSON client payload — supply the full set of fields
you want to keep (the server replaces the row, it does not patch).
url: >-
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- depth: 2
title: >-
Delete a client by email. Removes it from every attached inbound and
@@ -359,11 +358,11 @@ _openapi:
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: >-
Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.
attached inbound. Body is the JSON client payload — supply the full
set of fields you want to keep (the server replaces the row, it does
not patch).
id: >-
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- content: >-
Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
@@ -35,10 +35,10 @@ _openapi:
#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- depth: 2
title: >-
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
url: >-
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- depth: 2
title: >-
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
@@ -292,10 +292,10 @@ _openapi:
id: >-
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: >-
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
id: >-
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
- content: >-
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
keepTraffic=1。
+3 -164
View File
@@ -8722,48 +8722,6 @@
}
}
},
"/panel/api/clients/get": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
"operationId": "get_panel_api_clients_get",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Client email (unique identifier).",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/clients/get/subId/{subId}": {
"get": {
"tags": [
@@ -8887,7 +8845,7 @@
],
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add",
"description": "Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"requestBody": {
"required": true,
"content": {
@@ -9053,9 +9011,9 @@
"tags": [
"Clients"
],
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
"operationId": "post_panel_api_clients_update_email",
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
@@ -9112,70 +9070,6 @@
}
}
},
"/panel/api/clients/update": {
"post": {
"tags": [
"Clients"
],
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"operationId": "post_panel_api_clients_update",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Current client email (unique identifier).",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"email": "alice@example.com",
"totalGB": 107374182400,
"expiryTime": 1767225600000,
"limitHwid": 2,
"tgId": 123456789,
"enable": true
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": "Client updated"
}
}
}
}
}
}
},
"/panel/api/clients/del/{email}": {
"post": {
"tags": [
@@ -9231,61 +9125,6 @@
}
}
},
"/panel/api/clients/del": {
"post": {
"tags": [
"Clients"
],
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
"operationId": "post_panel_api_clients_del",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Client email (unique identifier).",
"schema": {
"type": "string"
}
},
{
"name": "keepTraffic",
"in": "query",
"required": true,
"description": "Pass 1 to retain the xray_client_traffic row after deletion.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": "Client deleted"
}
}
}
}
}
}
},
"/panel/api/clients/{email}/attach": {
"post": {
"tags": [
-27
View File
@@ -47,33 +47,6 @@ production-style links work without round-tripping through Go.
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
### Inbound accessibility verification
`npm test` includes the real inbound form and native-select regression fixtures.
After `npm run build-storybook`, serve the built stories with
`npx vite preview --outDir storybook-static --host 127.0.0.1 --port 6006` and, in
another terminal, run `node tools/check-inbound-keyboard.mjs`. CI runs this check
too. It uses real Chromium Tab/Arrow/Enter input, checks native selected values
in the accessibility tree, captures create/edit requests, and verifies focus
containment and return. Its HTTP boundary is stubbed; backend configuration
generation and server validation are unchanged.
Mobile viewport emulation and accessibility-tree checks do not prove TalkBack
or NVDA compatibility. On actual Android TalkBack/Chrome and Windows NVDA:
1. Open Add Inbound, explore Protocol, open the native picker, choose Shadowsocks
and confirm the control announces its selected value. Cancel another picker
and confirm the value and focus stay unchanged.
2. Visit Protocol, select an encryption method, then Stream; choose RAW,
WebSocket, gRPC and XHTTP and verify the dependent fields and their labels.
3. Check Security's None/TLS/REALITY radios; try certificate/uTLS selections and
the ALPN multi-select, searchable node/fallback fields, clearing and custom tags.
4. Trigger a custom-share-address validation error from another tab. Confirm
the form reveals/focuses the field and reads its hint/error. Correct it and save.
5. Edit the inbound, confirm Protocol remains disabled, save a transport change,
and close/reopen with keyboard and touch exploration. Focus must return to
the opener and Tab/Shift+Tab must stay inside the open dialog.
### One-off: scan for deprecated APIs
Run this command to sweep the codebase for usages of APIs marked
+4 -4
View File
@@ -24,7 +24,7 @@
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.10",
"react-i18next": "^17.1.11",
"react-router": "^8.5.1",
"swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32",
@@ -9604,9 +9604,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.1.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.10.tgz",
"integrity": "sha512-XuUuA/rxukE4hXuR//PN308DuSQkWqKNNWBUj0dDTR087l3+6WLid3NC/w55ALrXA/dtWb7lychJ4rQLR3DCDg==",
"version": "17.1.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.11.tgz",
"integrity": "sha512-nQqkvaD+N4kSrM0bosDc7yW2IvaC3chHPhZb0FoRXX6Buf1JzfOLLLs2u7SfXcnAE3nKAIn0Kvu68lQGObPlyA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
+1 -1
View File
@@ -51,7 +51,7 @@
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.10",
"react-i18next": "^17.1.11",
"react-router": "^8.5.1",
"swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32",
+3 -164
View File
@@ -8722,48 +8722,6 @@
}
}
},
"/panel/api/clients/get": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
"operationId": "get_panel_api_clients_get",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Client email (unique identifier).",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/clients/get/subId/{subId}": {
"get": {
"tags": [
@@ -8887,7 +8845,7 @@
],
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add",
"description": "Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"requestBody": {
"required": true,
"content": {
@@ -9053,9 +9011,9 @@
"tags": [
"Clients"
],
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
"operationId": "post_panel_api_clients_update_email",
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
@@ -9112,70 +9070,6 @@
}
}
},
"/panel/api/clients/update": {
"post": {
"tags": [
"Clients"
],
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"operationId": "post_panel_api_clients_update",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Current client email (unique identifier).",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"email": "alice@example.com",
"totalGB": 107374182400,
"expiryTime": 1767225600000,
"limitHwid": 2,
"tgId": 123456789,
"enable": true
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": "Client updated"
}
}
}
}
}
}
},
"/panel/api/clients/del/{email}": {
"post": {
"tags": [
@@ -9231,61 +9125,6 @@
}
}
},
"/panel/api/clients/del": {
"post": {
"tags": [
"Clients"
],
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
"operationId": "post_panel_api_clients_del",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Client email (unique identifier).",
"schema": {
"type": "string"
}
},
{
"name": "keepTraffic",
"in": "query",
"required": true,
"description": "Pass 1 to retain the xray_client_traffic row after deletion.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": "Client deleted"
}
}
}
}
}
}
},
"/panel/api/clients/{email}/attach": {
"post": {
"tags": [
-5
View File
@@ -1,5 +0,0 @@
export function clientManagementPath(action: 'get' | 'update' | 'del', email: string): string {
const base = `/panel/api/clients/${action}`;
const encoded = encodeURIComponent(email);
return email === '.' || email === '..' ? `${base}?email=${encoded}` : `${base}/${encoded}`;
}
@@ -1,14 +0,0 @@
.native-select {
padding-inline: 11px;
font-family: inherit;
cursor: pointer;
}
.native-select:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.native-select:disabled {
cursor: not-allowed;
}
@@ -1,67 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { NativeSelect } from './NativeSelect';
const meta = {
title: 'Form/NativeSelect',
component: NativeSelect,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Native single selection for short, fixed lists. Labels, values, keyboard interaction and the mobile picker belong to the browser. Index keys keep number and string values distinct in onChange; searchable, clearable and multi-select fields continue using Ant Design.',
},
},
},
argTypes: {
options: {
description: 'Ordered labels, typed string/number values and optional disabled choices.',
},
value: {
description: 'Controlled form value. Saved values absent from the list remain visible.',
},
onChange: {
description: 'Receives the original typed option value for RHF or Ant Design Form.',
},
placeholder: { description: 'Shown until the form has a value.' },
disabled: {
description: 'Uses native disabled behavior and preserves the controlled form value.',
},
size: {
description: 'Ant Design token size, without turning the control into a multi-row listbox.',
},
},
args: {
'aria-label': 'Protocol',
options: [
{ value: 'vless', label: 'VLESS' },
{ value: 'shadowsocks', label: 'Shadowsocks' },
{ value: 'tuic', label: 'TUIC', disabled: true },
],
},
} satisfies Meta<typeof NativeSelect>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SingleSelection: Story = {
render: function Render(args) {
const [value, setValue] = useState<string | number>('vless');
return <NativeSelect {...args} value={value} onChange={setValue} />;
},
play: async ({ canvas, userEvent }) => {
const select = canvas.getByRole('combobox', { name: 'Protocol' });
await expect(select).toHaveDisplayValue('VLESS');
await userEvent.selectOptions(
select,
within(select).getByRole('option', { name: 'Shadowsocks' }),
);
await expect(select).toHaveDisplayValue('Shadowsocks');
await expect(within(select).getByRole('option', { name: 'Shadowsocks' })).toHaveProperty(
'selected',
true,
);
},
};
@@ -1,70 +0,0 @@
import type { ComponentPropsWithRef } from 'react';
import { theme } from 'antd';
import './NativeSelect.css';
export interface NativeSelectProps<Value extends string | number> extends Omit<
ComponentPropsWithRef<'select'>,
'value' | 'defaultValue' | 'onChange' | 'multiple' | 'children' | 'size'
> {
value?: Value | null;
onChange?: (value: Value) => void;
options: readonly { value: Value; label: string | number; disabled?: boolean }[];
placeholder?: string;
size?: 'small' | 'middle' | 'large';
}
export function NativeSelect<Value extends string | number>({
value,
options,
onChange,
placeholder,
disabled,
className,
style,
size,
...props
}: NativeSelectProps<Value>) {
const { token } = theme.useToken();
const selected = options.findIndex((option) => Object.is(option.value, value));
const hasUnlistedValue = selected < 0 && value != null;
return (
<select
{...props}
className={['native-select', className].filter(Boolean).join(' ')}
disabled={disabled}
value={selected < 0 ? '' : String(selected)}
onChange={(event) => {
const option = options[Number(event.target.value)];
if (event.target.value !== '' && option && !option.disabled) onChange?.(option.value);
}}
style={{
width: '100%',
minWidth: 0,
height:
size === 'small'
? token.controlHeightSM
: size === 'large'
? token.controlHeightLG
: token.controlHeight,
border: `1px solid ${props['aria-invalid'] === true || props['aria-invalid'] === 'true' ? token.colorError : token.colorBorder}`,
borderRadius: token.borderRadius,
backgroundColor: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
color: disabled ? token.colorTextDisabled : token.colorText,
fontSize: token.fontSize,
...style,
}}
>
{selected < 0 && (
<option value="" disabled={!hasUnlistedValue} hidden={!hasUnlistedValue}>
{hasUnlistedValue ? String(value) : (placeholder ?? '')}
</option>
)}
{options.map((option, index) => (
<option key={String(index)} value={String(index)} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}
@@ -221,9 +221,6 @@ export const ValidationErrors: Story = {
play: async ({ canvas, userEvent }) => {
const remarkError = await canvas.findByText('Remark is required');
await waitFor(() => expect(remarkError).toBeVisible());
await expect(canvas.getByRole('textbox', { name: 'Remark' })).toHaveAccessibleDescription(
'Remark is required',
);
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
+9 -12
View File
@@ -1,5 +1,5 @@
import { cloneElement, useId } from 'react';
import type { AriaAttributes, CSSProperties, ReactElement, ReactNode } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
import { Form } from 'antd';
@@ -50,10 +50,12 @@ export function FormField<T extends FieldValues = FieldValues>({
const dottedName = toDotted(name) as Path<T>;
const generatedId = useId();
const hintId = useId();
const errorId = `${generatedId}-error`;
const extraId = `${generatedId}-extra`;
const childOwnProps = (children as ReactElement<{ id?: string } & AriaAttributes>).props;
const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
.props;
const fieldId = childOwnProps.id ?? generatedId;
const describedBy = tooltip
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
: undefined;
return (
<Controller
@@ -65,14 +67,8 @@ export function FormField<T extends FieldValues = FieldValues>({
const help = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined;
const describedBy =
[childOwnProps['aria-describedby'], tooltip && hintId, extra && extraId, help && errorId]
.filter(Boolean)
.join(' ') || undefined;
const childProps: Record<string, unknown> = {
id: fieldId,
'aria-describedby': describedBy,
'aria-invalid': fieldState.invalid || childOwnProps['aria-invalid'],
[valueProp]: displayValue,
onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp);
@@ -83,15 +79,16 @@ export function FormField<T extends FieldValues = FieldValues>({
onBlur: field.onBlur,
ref: field.ref,
};
if (describedBy) childProps['aria-describedby'] = describedBy;
return (
<Form.Item
label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip}
extra={extra ? <span id={extraId}>{extra}</span> : extra}
extra={extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help ? <span id={errorId}>{help}</span> : undefined}
help={help}
noStyle={noStyle}
className={className}
style={style}
+5 -5
View File
@@ -4,7 +4,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import { clientManagementPath } from '@/api/client-management';
import { markLocalInvalidate } from '@/api/invalidationTracker';
import {
ClientHydrateSchema,
@@ -344,7 +343,7 @@ export function useClients(options: UseClientsOptions = {}) {
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
if (!email) return null;
const msg = await HttpUtil.get(clientManagementPath('get', email));
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
if (!msg?.success || !msg.obj) return null;
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
return validated.obj;
@@ -376,7 +375,7 @@ export function useClients(options: UseClientsOptions = {}) {
const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(clientManagementPath('update', email), client, JSON_HEADERS),
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
@@ -384,8 +383,9 @@ export function useClients(options: UseClientsOptions = {}) {
const removeMut = useMutation({
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
let url = clientManagementPath('del', email);
if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`;
const url = keepTraffic
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
return HttpUtil.post(url);
},
onSuccess: (msg) => {
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { Button, Divider, Form, Input, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
@@ -69,7 +68,7 @@ export default function CustomSockoptList({
<Input placeholder="decimal, e.g. 19" />
</Form.Item>
<Form.Item label="Type" name={[field.name, 'type']}>
<NativeSelect options={TYPE_OPTIONS} />
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="Value" name={[field.name, 'value']}>
<Input placeholder="value" />
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useEffect, useId, useRef, useState } from 'react';
import {
AutoComplete,
@@ -408,7 +407,7 @@ function TcpMasksList({
}
/>
{allowPresets && (
<NativeSelect
<Select
id={`${uid}-tcp-presets`}
size="small"
style={{ minWidth: 160 }}
@@ -479,7 +478,7 @@ function TcpMaskItem({
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<NativeSelect
<Select
onChange={(v) =>
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
}
@@ -1124,7 +1123,7 @@ function UdpMaskItem({
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<NativeSelect onChange={onTypeChange} options={options} />
<Select onChange={onTypeChange} options={options} />
</Form.Item>
<Form.Item
@@ -1148,7 +1147,7 @@ function UdpMaskItem({
return (
<>
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
<NativeSelect
<Select
options={[
{ value: '', label: 'Original / AES-128-GCM' },
{ value: 'dns', label: 'DNS' },
@@ -1328,7 +1327,7 @@ function SalamanderUdpMaskSettings({
: 'Scrambles each packet into random-looking bytes.'
}
>
<NativeSelect
<Select
id={`${uid}-salamander-mode`}
value={mode}
onChange={(next) => {
@@ -1948,7 +1947,7 @@ function ItemEditor({
<>
{allowVariables && (
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
<NativeSelect
<Select
id={`${uid}-kind`}
value={kind}
onChange={onKindChange}
@@ -1958,7 +1957,7 @@ function ItemEditor({
)}
{kind === 'bytes' && (
<Form.Item label="Type" name={[fieldName, 'type']}>
<NativeSelect
<Select
onChange={onTypeChange}
options={[
{ value: 'array', label: 'Array' },
@@ -2123,7 +2122,7 @@ function QuicParamsForm({
return (
<>
<Form.Item label="Congestion" name={[...base, 'congestion']}>
<NativeSelect
<Select
options={[
{ value: 'reno', label: 'Reno' },
{ value: 'bbr', label: 'BBR' },
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
@@ -90,7 +89,7 @@ export default function XDriveForm() {
return (
<>
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
<NativeSelect options={serviceOptions} />
<Select options={serviceOptions} />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.remoteFolder')}
+3 -52
View File
@@ -1149,19 +1149,6 @@ 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": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
},
{
method: 'GET',
path: '/panel/api/clients/get',
summary:
'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
description:
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.',
params: [
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
],
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
},
{
method: 'GET',
path: '/panel/api/clients/get/subId/:subId',
@@ -1208,7 +1195,7 @@ export const sections: readonly Section[] = [
summary:
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
description:
'Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
params: [
{
name: 'client',
@@ -1276,9 +1263,9 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/update/:email',
summary:
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
description:
'An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
params: [
{
name: 'email',
@@ -1290,24 +1277,6 @@ export const sections: readonly Section[] = [
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
response: '{\n "success": true,\n "msg": "Client updated"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/update',
summary:
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
description:
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
params: [
{
name: 'email',
in: 'query',
type: 'string',
desc: 'Current client email (unique identifier).',
},
],
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
response: '{\n "success": true,\n "msg": "Client updated"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/del/:email',
@@ -1326,24 +1295,6 @@ export const sections: readonly Section[] = [
],
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/del',
summary:
'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
description:
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.',
params: [
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
{
name: 'keepTraffic',
in: 'query',
type: 'integer',
desc: 'Pass 1 to retain the xray_client_traffic row after deletion.',
},
],
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:email/attach',
@@ -95,7 +95,7 @@ export default function ClientBulkAdjustModal({
<Modal
open={open}
title={t('pages.clients.bulkAdjustTitle', { count })}
okText={t('confirm')}
okText={t('apply')}
cancelText={t('cancel')}
confirmLoading={submitting}
onOk={handleOk}
@@ -798,10 +798,10 @@ export default function ClientFormModal({
comment: values.comment,
enable: !!values.enable,
};
// Sent even when empty, so clearing the tag never rests on how the server
// reads an omitted key.
const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
clientPayload.reverse = reverseTagValue ? { tag: reverseTagValue } : null;
if (reverseTagValue) {
clientPayload.reverse = { tag: reverseTagValue };
}
if (showWireguard || showAmneziawg) {
// AmneziaWG peers are wire-identical to WireGuard peers (same
+2 -2
View File
@@ -128,7 +128,7 @@ export default function FilterDrawer({
/>
</Form.Item>
<Form.Item label={t('pages.clients.attachedInbounds')} htmlFor={`${uid}-inbounds`}>
<Form.Item label={t('inbounds')} htmlFor={`${uid}-inbounds`}>
<Select
id={`${uid}-inbounds`}
mode="multiple"
@@ -136,7 +136,7 @@ export default function FilterDrawer({
value={filters.inboundIds}
onChange={(v) => patch('inboundIds', v as number[])}
options={inboundOptions}
placeholder={t('pages.clients.attachedInbounds')}
placeholder={t('inbounds')}
allowClear
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
@@ -1,121 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within, waitFor } from 'storybook/test';
import { Button } from 'antd';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { setDatepicker } from '@/hooks/useDatepicker';
import InboundFormModal from './InboundFormModal';
const meta = {
title: 'Inbounds/InboundFormModal',
component: InboundFormModal,
tags: ['autodocs'],
beforeEach: () => {
setDatepicker('gregorian');
},
args: {
open: false,
mode: 'add',
dbInbound: null,
dbInbounds: [],
onClose: () => {},
onSaved: () => {},
},
parameters: {
docs: {
description: {
component:
'The real inbound form. Exercise named options, keyboard navigation, validation and modal focus here; verify TalkBack/Chrome and NVDA separately before claiming screen-reader compatibility.',
},
},
},
} satisfies Meta<typeof InboundFormModal>;
export default meta;
type Story = StoryObj<typeof meta>;
function InboundDemo({ edit = false }: { edit?: boolean }) {
const [open, setOpen] = useState(false);
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
);
const [inbound] = useState(
() =>
new DBInbound({
id: 41,
protocol: 'shadowsocks',
enable: true,
port: 14443,
settings: {
method: 'chacha20-ietf-poly1305',
password: 'example-password',
network: 'tcp,udp',
clients: [],
},
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
}),
);
return (
<QueryClientProvider client={queryClient}>
<AntdRootConfig>
<ThemeProvider>
<Button onClick={() => setOpen(true)}>{edit ? 'Edit inbound' : 'Add inbound'}</Button>
<InboundFormModal
open={open}
mode={edit ? 'edit' : 'add'}
dbInbound={edit ? inbound : null}
dbInbounds={[]}
availableNodes={[]}
onClose={() => setOpen(false)}
onSaved={() => {}}
/>
</ThemeProvider>
</AntdRootConfig>
</QueryClientProvider>
);
}
export const Create: Story = {
render: () => <InboundDemo />,
};
export const SelectableOptions: Story = {
...Create,
play: async ({ canvas, canvasElement, userEvent }) => {
const page = within(canvasElement.ownerDocument.body);
const opener = canvas.getByRole('button', { name: 'Add inbound' });
await userEvent.click(opener);
const dialog = await page.findByRole('dialog', { name: 'Add Inbound' });
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
await userEvent.selectOptions(
protocol,
within(protocol).getByRole('option', { name: 'shadowsocks' }),
);
await expect(protocol).toHaveDisplayValue('shadowsocks');
await expect(protocol).toHaveFocus();
await userEvent.click(within(dialog).getByRole('tab', { name: 'Protocol' }));
const cipher = within(dialog).getByRole('combobox', { name: 'Encryption method' });
await userEvent.selectOptions(
cipher,
within(cipher).getByRole('option', { name: 'chacha20-ietf-poly1305' }),
);
await userEvent.click(within(dialog).getByRole('tab', { name: 'Stream' }));
const transport = within(dialog).getByRole('combobox', { name: 'Transmission' });
await userEvent.selectOptions(
transport,
within(transport).getByRole('option', { name: 'WebSocket' }),
);
await expect(within(dialog).getByRole('textbox', { name: 'Path' })).toHaveValue('/');
await userEvent.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]);
await waitFor(() => expect(opener).toHaveFocus());
},
};
export const Edit: Story = {
render: () => <InboundDemo edit />,
};
@@ -1,22 +1,8 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useEffect, useRef, useState, useId } from 'react';
import type { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import {
Alert,
ConfigProvider,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Switch,
Tabs,
message,
} from 'antd';
import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
@@ -106,28 +92,6 @@ const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_HOSTNAME_RE =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
function inboundPopupContainer(trigger?: HTMLElement): HTMLElement {
return (
trigger?.closest<HTMLElement>('.ant-modal-body') ?? trigger?.parentElement ?? document.body
);
}
function loopInboundFocus(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== 'Tab') return;
const first = event.currentTarget.querySelector<HTMLButtonElement>(
'.ant-modal-close:not(:disabled)',
);
const last = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'.ant-modal-footer button:not(:disabled)',
),
).at(-1);
if (first && last && event.target === (event.shiftKey ? first : last)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
}
}
function isValidShareAddrInput(value: string): boolean {
const v = value.trim();
if (v.length === 0) return true;
@@ -713,7 +677,7 @@ export default function InboundFormModal({
)}
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<NativeSelect id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
</FormField>
<FormField
@@ -731,7 +695,7 @@ export default function InboundFormModal({
label={t('pages.inbounds.form.shareAddrStrategy')}
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
>
<NativeSelect
<Select
options={SHARE_ADDR_STRATEGIES.filter(
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
).map((strategy) => ({
@@ -816,7 +780,7 @@ export default function InboundFormModal({
</Form.Item>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<NativeSelect
<Select
options={TRAFFIC_RESETS.map((r) => ({
value: r,
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
@@ -994,7 +958,7 @@ export default function InboundFormModal({
<>
{hasSelectableTransport && (
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
<NativeSelect
<Select
id={`${uid}-network`}
style={{ width: '75%' }}
value={network}
@@ -1070,11 +1034,8 @@ export default function InboundFormModal({
const securityTab = (
<>
<Form.Item
label={<span id={`${uid}-security-label`}>{t('pages.inbounds.securityTab')}</span>}
>
<Form.Item label={t('pages.inbounds.securityTab')}>
<Radio.Group
aria-labelledby={`${uid}-security-label`}
value={security}
buttonStyle="solid"
disabled={!tlsOk}
@@ -1213,7 +1174,7 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />;
return (
<ConfigProvider virtual={false} getPopupContainer={inboundPopupContainer}>
<>
{messageContextHolder}
{modalContextHolder}
<Modal
@@ -1227,11 +1188,6 @@ export default function InboundFormModal({
onOk={submit}
onCancel={onClose}
destroyOnHidden
modalRender={(content) => (
<div role="presentation" onKeyDown={loopInboundFocus}>
{content}
</div>
)}
>
<FormProvider {...methods}>
<Form
@@ -1317,6 +1273,6 @@ export default function InboundFormModal({
</Form>
</FormProvider>
</Modal>
</ConfigProvider>
</>
);
}
@@ -1,7 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Switch } from 'antd';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
@@ -61,7 +60,7 @@ export default function HysteriaFields() {
{masq && (
<>
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}>
<NativeSelect
<Select
options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
@@ -1,6 +1,5 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { Input, Switch } from 'antd';
import { Input, Select, Switch } from 'antd';
import { FormField } from '@/components/form/rhf';
import AccountsList from './accounts-list';
@@ -11,7 +10,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
<>
<AccountsList />
<FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
<NativeSelect
<Select
options={[
{ value: 'noauth', label: 'noauth' },
{ value: 'password', label: 'password' },
@@ -1,7 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Space, Switch } from 'antd';
import { Button, Form, Input, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { useFormContext } from 'react-hook-form';
@@ -26,9 +25,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
}}
>
<NativeSelect
options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))}
/>
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
</FormField>
{isSSWith2022 && (
<Form.Item htmlFor={`${uid}-password`} label={t('password')}>
@@ -51,7 +48,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
</Form.Item>
)}
<FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
<NativeSelect
<Select
style={{ width: 120 }}
options={[
{ value: 'tcp,udp', label: 'TCP, UDP' },
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -69,7 +68,7 @@ export default function TuicFields() {
if (obj.webKeyFile) {
setValue('settings.server.private_key', obj.webKeyFile);
}
messageApi.success(t('success'));
messageApi.success(t('pages.inbounds.setSuccess'));
} catch {
messageApi.error(t('somethingWentWrong'));
} finally {
@@ -112,7 +111,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.logLevel')}
tooltip={t('pages.xray.tuic.logLevelHint')}
>
<NativeSelect
<Select
options={[
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
@@ -200,7 +199,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.congestionControl')}
tooltip={t('pages.xray.tuic.congestionControlHint')}
>
<NativeSelect
<Select
options={[
{ label: 'BBR', value: 'bbr' },
{ label: 'CUBIC', value: 'cubic' },
@@ -245,7 +244,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.udpRelayMode')}
tooltip={t('pages.xray.tuic.udpRelayModeHint')}
>
<NativeSelect
<Select
options={[
{ label: 'Native (Recommended)', value: 'native' },
{ label: 'QUIC', value: 'quic' },
@@ -1,6 +1,5 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Switch } from 'antd';
import { Input, InputNumber, Select, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
@@ -22,7 +21,7 @@ export default function TunnelFields() {
name={['settings', 'allowedNetwork']}
label={t('pages.inbounds.form.allowedNetwork')}
>
<NativeSelect
<Select
options={[
{ value: 'tcp,udp', label: 'TCP, UDP' },
{ value: 'tcp', label: 'TCP' },
@@ -1,7 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
@@ -51,7 +50,7 @@ export default function VlessFields({
</FormField>
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap>
<NativeSelect
<Select
id={`${uid}-authKind`}
value={authKind}
onChange={(v) => setAuthKind(v)}
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -113,9 +112,7 @@ export default function RealityForm({
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS"
>
<NativeSelect
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
</FormField>
<Form.Item
htmlFor={`${uid}-target`}
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -155,7 +154,7 @@ function CertRow({
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
label={t('pages.inbounds.form.usageOption')}
>
<NativeSelect
<Select
style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/>
@@ -213,7 +212,7 @@ function EchSockoptSection() {
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<NativeSelect
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
/>
</FormField>
@@ -355,14 +354,14 @@ export default function TlsForm({
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block>
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<NativeSelect
<Select
id={`${uid}-minVersion`}
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
</FormField>
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<NativeSelect
<Select
aria-label={t('pages.inbounds.form.maxVersion')}
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
@@ -371,7 +370,7 @@ export default function TlsForm({
</Space.Compact>
</Form.Item>
<FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS">
<NativeSelect
<Select
options={[
{ value: '', label: 'None' },
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
@@ -208,13 +207,13 @@ export default function SockoptForm({
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label={t('pages.inbounds.form.tcpCongestion')}
>
<NativeSelect
<Select
style={{ width: '50%' }}
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
/>
</FormField>
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<NativeSelect
<Select
style={{ width: '50%' }}
options={[
{ value: 'off', label: 'Off' },
@@ -1,6 +1,5 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Input, InputNumber, Switch } from 'antd';
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
@@ -82,7 +81,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'mode']}
label={t('pages.inbounds.info.mode')}
>
<NativeSelect
<Select
style={{ width: '50%' }}
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
value: m,
@@ -150,7 +149,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
label={t('pages.inbounds.form.uplinkHttpMethod')}
>
<NativeSelect
<Select
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
@@ -188,7 +187,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
label={t('pages.inbounds.form.paddingPlacement')}
>
<NativeSelect
<Select
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
@@ -202,7 +201,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
label={t('pages.inbounds.form.paddingMethod')}
>
<NativeSelect
<Select
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
@@ -216,7 +215,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
label={t('pages.inbounds.form.sessionPlacement')}
>
<NativeSelect
<Select
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
@@ -260,7 +259,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
label={t('pages.inbounds.form.sequencePlacement')}
>
<NativeSelect
<Select
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
@@ -284,7 +283,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
label={t('pages.inbounds.form.uplinkDataPlacement')}
>
<NativeSelect
<Select
options={[
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
+1 -1
View File
@@ -169,7 +169,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
if (!token) return;
const ok = await ClipboardManager.copyText(token);
if (ok) messageApi.success(t('copySuccess'));
else messageApi.error(t('copyFail'));
else messageApi.error(t('copyFail') ?? 'Copy failed');
}
function openCreateModal() {
@@ -291,7 +291,7 @@ export default function WarpModal({
interval: methods.getValues('updateInterval'),
});
if (msg?.success) {
messageApi.success(t('pages.settings.toasts.modifySettings'));
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
}
} finally {
setLoading(false);
-1
View File
@@ -319,7 +319,6 @@ export const ClientFormSchema = z.object({
.string()
.trim()
.min(1, 'pages.clients.email')
.refine((v) => v !== '.' && v !== '..', 'pages.clients.emailReservedName')
.refine((v) => !hasForbiddenClientChars(v), 'pages.clients.emailInvalidChars'),
subId: z.string().refine((v) => !hasForbiddenClientChars(v), 'pages.clients.subIdInvalidChars'),
uuid: z.string(),
@@ -1,120 +0,0 @@
import type { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupHttp } from '@/api/http-init';
import { useClients } from '@/hooks/useClients';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil } from '@/utils';
const ORIGIN = 'https://example.invalid';
const BASE = '/audit/panel/api/clients/';
describe('legacy dot-name client recovery', () => {
const requests: { url: URL; method: string; body: unknown }[] = [];
beforeAll(() => {
vi.mocked(HttpUtil.get).mockRestore();
vi.mocked(HttpUtil.post).mockRestore();
});
beforeEach(() => {
requests.length = 0;
window.X_UI_BASE_PATH = `${ORIGIN}/audit/`;
setupHttp();
vi.stubGlobal(
'fetch',
vi.fn(async (input: string, init?: RequestInit) => {
const url = new URL(input);
const method = init?.method ?? 'GET';
if (!url.pathname.startsWith(BASE)) {
const obj = url.pathname.endsWith('/csrf-token') ? 'audit-csrf' : {};
return Response.json({ success: true, obj });
}
if (url.pathname.endsWith('/options') || url.pathname.endsWith('/onlines')) {
return Response.json({ success: true, obj: [] });
}
const body = init?.body ? JSON.parse(String(init.body)) : null;
requests.push({ url, method, body });
const [action, segment] = url.pathname.slice(BASE.length).split('/');
const email =
url.searchParams.get('email') ?? (segment ? decodeURIComponent(segment) : null);
if (action === 'get' && email) {
return Response.json({
success: true,
obj: { client: { email, uuid: 'kept-uuid' }, inboundIds: [7] },
});
}
if (['update', 'del'].includes(action) && email) {
return Response.json({ success: true });
}
return Response.json({ success: false, msg: 'Missing client identity' }, { status: 404 });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.X_UI_BASE_PATH;
setupHttp();
});
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
}
it.each(['.', '..'])('loads the full record and renames a legacy %j client', async (email) => {
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
await act(async () => {
const full = await result.current.hydrate(email);
expect(full?.client.uuid).toBe('kept-uuid');
expect(full?.inboundIds).toEqual([7]);
const msg = await result.current.update(email, {
email: 'restored-client',
id: full?.client.uuid,
});
expect(msg.success).toBe(true);
});
const update = requests.find((r) => r.url.pathname === `${BASE}update`);
expect(update?.url.searchParams.get('email')).toBe(email);
expect(update?.body).toEqual({ email: 'restored-client', id: 'kept-uuid' });
});
it.each(['.', '..'])(
'individually deletes legacy %j while preserving keepTraffic',
async (email) => {
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
await act(async () => {
const msg = await result.current.remove(email, true);
expect(msg.success).toBe(true);
});
const request = requests.find((r) => r.url.pathname === `${BASE}del`);
expect(request?.method).toBe('POST');
expect(request?.url.searchParams.get('email')).toBe(email);
expect(request?.url.searchParams.get('keepTraffic')).toBe('1');
},
);
it.each(['...', '.alice', 'alice.', 'alice.example', 'alice..example'])(
'keeps the existing path endpoints for %s',
async (email) => {
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
await act(async () => {
expect((await result.current.hydrate(email))?.client.email).toBe(email);
expect((await result.current.update(email, { email })).success).toBe(true);
expect((await result.current.remove(email, true)).success).toBe(true);
});
for (const action of ['get', 'update', 'del']) {
const request = requests.find((r) => r.url.pathname === `${BASE}${action}/${email}`);
expect(request).toBeDefined();
expect(request?.url.searchParams.has('email')).toBe(false);
}
expect(
requests
.find((r) => r.url.pathname === `${BASE}del/${email}`)
?.url.searchParams.get('keepTraffic'),
).toBe('1');
},
);
});
@@ -1,64 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientFormModal from '@/pages/clients/ClientFormModal';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
const VLESS_INBOUND = {
id: 7,
port: 10443,
protocol: 'vless',
tag: 'in-10443-tcp',
enable: true,
} as unknown as InboundOption;
const CLIENT = {
email: 'reverse-user',
uuid: '11111111-1111-1111-1111-111111111111',
subId: 'subid-reverse',
enable: true,
reverse: { tag: 'back' },
} as unknown as ClientRecord;
async function saveEdited(edit?: (input: HTMLInputElement) => void) {
const save = vi.fn().mockResolvedValue({ success: true });
render(
<ThemeProvider>
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ClientFormModal
open
mode="edit"
client={CLIENT}
inbounds={[VLESS_INBOUND]}
attachedIds={[7]}
save={save}
onOpenChange={() => {}}
/>
</QueryClientProvider>
</ThemeProvider>,
);
const input = (await screen.findByPlaceholderText('Optional reverse tag')) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe('back'));
edit?.(input);
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
await waitFor(() => expect(save).toHaveBeenCalled());
return save.mock.calls[0][0] as Record<string, unknown>;
}
// A cleared tag goes out as an explicit null rather than a missing key, which a
// partial update would read as "keep the stored tag".
describe('ClientFormModal — reverse tag', () => {
it('sends an explicit null when the operator clears the tag', async () => {
const payload = await saveEdited((input) => fireEvent.change(input, { target: { value: '' } }));
expect(payload).toHaveProperty('reverse', null);
});
it('round-trips an unchanged tag', async () => {
const payload = await saveEdited();
expect(payload.reverse).toEqual({ tag: 'back' });
});
});
-22
View File
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ClientCreateFormSchema, ClientFormSchema } from '@/schemas/client';
describe('client name validation', () => {
it.each(['.', '..', ' . ', ' .. '])('rejects the reserved name %j on create and edit', (name) => {
for (const schema of [ClientCreateFormSchema, ClientFormSchema]) {
const result = schema.shape.email.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('pages.clients.emailReservedName');
}
}
});
it.each(['...', '.alice', 'alice.', 'alice.example', 'alice..example', 'alice@example.com'])(
'preserves ordinary dotted names: %s',
(name) => {
expect(ClientFormSchema.shape.email.parse(name)).toBe(name);
},
);
});
+7 -8
View File
@@ -82,15 +82,14 @@ describe('i18n keys', () => {
);
});
it('every literal t() key is an en-US string', () => {
const leaves = new Set(enKeys);
const literalKeys = new Set(
Array.from(blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g), (m) => m[2]),
);
const unresolved = [...literalKeys].filter((key) => !leaves.has(key));
it('no literal t() key resolves to an object', () => {
const objectKeys = Array.from(
blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g),
(m) => m[2],
).filter((key) => enKeys.some((leaf) => leaf.startsWith(`${key}.`)));
expect(
unresolved,
`t() keys that are not an en-US string (a missing key renders raw, a group renders "key '…' returned an object instead of string."):\n ${unresolved.join('\n ')}`,
objectKeys,
`t() keys naming a group, rendered as "key '…' returned an object instead of string.":\n ${objectKeys.join('\n ')}`,
).toEqual([]);
});
@@ -1,133 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfigProvider } from 'antd';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { HttpUtil } from '@/utils';
import { renderWithProviders } from './test-utils';
function renderInbound(mode: 'add' | 'edit' = 'add') {
const inbound = new DBInbound({
id: 41,
protocol: 'vless',
enable: true,
port: 14443,
settings: { clients: [], decryption: 'none', encryption: 'none' },
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
});
renderWithProviders(
<ConfigProvider theme={{ token: { motion: false } }}>
<InboundFormModal
open
mode={mode}
dbInbound={mode === 'edit' ? inbound : null}
dbInbounds={[]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>
</ConfigProvider>,
);
return userEvent.setup();
}
describe('Inbound screen-reader interaction', () => {
it('exposes the selected protocol as the closed control value', () => {
renderInbound();
expect(screen.getByRole('combobox', { name: 'Protocol' })).toHaveDisplayValue('vless');
});
it('exposes all visible protocol options as selectable items within its dialog', async () => {
const user = renderInbound();
const dialog = screen.getByRole('dialog', { name: 'Add Inbound' });
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
const shadowsocks = within(protocol).getByRole('option', { name: 'shadowsocks' });
expect(within(protocol).getByRole('option', { name: 'tuic' })).toBeInTheDocument();
expect(
within(protocol).getByRole('option', { name: 'vless', selected: true }),
).toBeInTheDocument();
await user.selectOptions(protocol, shadowsocks);
expect(protocol).toHaveDisplayValue('shadowsocks');
await user.click(screen.getByRole('tab', { name: 'Protocol' }));
expect(screen.getByRole('combobox', { name: 'Encryption method' })).toBeVisible();
});
it('selects transport by its option role and submits the actual create payload', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound();
await user.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' });
await user.selectOptions(
transport,
within(transport).getByRole('option', { name: 'WebSocket' }),
);
const path = screen.getByRole('textbox', { name: 'Path' });
await user.clear(path);
await user.type(path, '/accessible');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() =>
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/add', expect.anything()),
);
const payload = post.mock.calls.find(
([url]) => url === '/panel/api/inbounds/add',
)![1] as Record<string, unknown>;
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
network: 'ws',
security: 'none',
wsSettings: { path: '/accessible' },
});
});
it('preserves the disabled protocol in edit mode and exposes a named security group', async () => {
const user = renderInbound('edit');
expect(screen.getByRole('combobox', { name: 'Protocol' })).toBeDisabled();
await user.click(screen.getByRole('tab', { name: 'Security' }));
const security = screen.getByRole('radiogroup', { name: 'Security' });
expect(
within(security)
.getAllByRole('radio')
.map((radio) => (radio as HTMLInputElement).value),
).toEqual(['none', 'tls', 'reality']);
});
it('preserves edit serialization after a keyboard-accessible transport change', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound('edit');
await user.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' });
await user.selectOptions(transport, within(transport).getByRole('option', { name: 'gRPC' }));
await user.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() =>
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/update/41', expect.anything()),
);
const payload = post.mock.calls.find(
([url]) => url === '/panel/api/inbounds/update/41',
)![1] as Record<string, unknown>;
expect(payload).toMatchObject({ protocol: 'vless', port: 14443, enable: true });
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
network: 'grpc',
security: 'none',
grpcSettings: { serviceName: '' },
});
expect(JSON.parse(String(payload.settings))).not.toHaveProperty('clients');
});
it('reveals and describes a validation error on a hidden tab without submitting', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound();
const strategy = screen.getByRole('combobox', { name: 'Share address strategy' });
await user.selectOptions(strategy, within(strategy).getByRole('option', { name: 'Custom' }));
const address = screen.getByRole('textbox', { name: 'Custom share address' });
await user.type(address, 'https://example.test/path');
await user.click(screen.getByRole('tab', { name: 'Stream' }));
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(address).toBeVisible());
expect(address).toHaveAttribute('aria-invalid', 'true');
expect(address).toHaveAccessibleDescription(expect.stringContaining('without a scheme'));
await waitFor(() => expect(address).toHaveFocus());
expect(post.mock.calls.filter(([url]) => url === '/panel/api/inbounds/add')).toEqual([]);
});
});
+53 -31
View File
@@ -1,12 +1,11 @@
import { describe, it, expect, onTestFinished, vi } from 'vitest';
import { screen, act, render, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { HttpUtil } from '@/utils';
import { Protocols } from '@/schemas/primitives';
import {
renderWithProviders,
fieldLabels,
@@ -41,8 +40,20 @@ function renderModal() {
);
}
function chooseProtocol(optionText: string) {
chooseSelectOption('protocol', optionText);
// The protocol dropdown is virtualized, so jsdom renders only its first options; step
// the active option by keyboard from the current protocol in the options' own order.
function chooseVirtualizedProtocol(optionText: string) {
const input = document.getElementById('protocol') as HTMLElement;
const select = input.closest('.ant-select') as HTMLElement;
const current = (select.textContent ?? '').trim();
const order = Object.values(Protocols) as string[];
const steps = order.indexOf(optionText) - order.indexOf(current);
if (order.indexOf(optionText) < 0 || order.indexOf(current) < 0 || steps < 0) {
throw new Error(`cannot step from '${current}' to '${optionText}'`);
}
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
for (let i = 0; i < steps; i++) fireEvent.keyDown(input, { key: 'ArrowDown', keyCode: 40 });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 });
}
function primaryButton(): HTMLElement {
@@ -104,6 +115,12 @@ async function renderCloneLikeEdit(dbInbound: DBInbound) {
}
describe('InboundFormModal', () => {
it('renders add mode without crashing', () => {
renderModal();
expect(document.querySelector('.ant-modal')).toBeTruthy();
expect(fieldLabels().length).toBeGreaterThan(0);
});
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
renderModal();
const protocols = listSelectOptions('protocol');
@@ -126,8 +143,9 @@ describe('InboundFormModal', () => {
expect(distinctShapes.size).toBeGreaterThan(1);
// Spot-check a protocol-distinguishing field that must appear after the switch.
expect(labelsByProto.shadowsocks).toContain('Encryption method');
expect(labelsByProto.vless).not.toContain('Encryption method');
if (labelsByProto.shadowsocks) {
expect(labelsByProto.shadowsocks).toContain('Encryption method');
}
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
it('preserves custom share address strategy when editing a local inbound', async () => {
@@ -223,31 +241,27 @@ describe('InboundFormModal', () => {
await new Promise((r) => setTimeout(r, 0));
});
};
const selectedStrategy = () =>
within(screen.getByRole('combobox', { name: 'Share address strategy' })).getByRole('option', {
selected: true,
}).textContent;
const strategyItem = (title: string) =>
document.querySelector(`.ant-select-content[title="${title}"]`);
const modal = (nodes: never[], fetched: boolean) => (
<AntdRootConfig>
<ThemeProvider>
<InboundFormModal
open
mode="edit"
dbInbound={buildInbound()}
dbInbounds={[]}
availableNodes={nodes}
availableNodesFetched={fetched}
onClose={() => {}}
onSaved={() => {}}
/>
</ThemeProvider>
</AntdRootConfig>
<ThemeProvider>
<InboundFormModal
open
mode="edit"
dbInbound={buildInbound()}
dbInbounds={[]}
availableNodes={nodes}
availableNodesFetched={fetched}
onClose={() => {}}
onSaved={() => {}}
/>
</ThemeProvider>
);
// Baseline: nodes already loaded, so the node option is offered and selected.
render(modal([node], true));
await flush();
expect(selectedStrategy()).toBe('Node address');
expect(strategyItem('Node address')).toBeTruthy();
cleanup();
// Race: the modal mounts before /nodes/list resolves (empty placeholder),
@@ -257,7 +271,8 @@ describe('InboundFormModal', () => {
await flush();
rerender(modal([node], true));
await flush();
expect(selectedStrategy()).toBe('Node address');
expect(strategyItem('Node address')).toBeTruthy();
expect(strategyItem('Inbound listen')).toBeFalsy();
});
it('surfaces a Reality validation error and switches to its tab', async () => {
@@ -329,9 +344,16 @@ describe('InboundFormModal', () => {
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' });
const option = within(transport).getByRole('option', { name: 'XDRIVE' }) as HTMLOptionElement;
fireEvent.change(transport, { target: { value: option.value } });
const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
(el) =>
el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission',
);
const select = transmission?.querySelector('.ant-select') as HTMLElement;
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
(o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === 'XDRIVE',
) as HTMLElement;
fireEvent.click(option);
for (const [label, value] of [
['Remote folder', 'xray-tunnel'],
['Client ID', 'id.apps.googleusercontent.com'],
@@ -365,7 +387,7 @@ describe('InboundFormModal', () => {
post.mockClear();
renderModal();
chooseProtocol('masque');
chooseVirtualizedProtocol('masque');
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
-99
View File
@@ -1,99 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormProvider, useForm } from 'react-hook-form';
import { NativeSelect } from '@/components/form/NativeSelect';
import { FormField } from '@/components/form/rhf';
const options = [
{ value: 7, label: 'Number seven' },
{ value: '7', label: 'Text seven' },
{ value: 0, label: 'Zero' },
{ value: '', label: 'None' },
{ value: 9, label: 'Unavailable', disabled: true },
];
function Harness({
onSubmit,
disabled = false,
}: {
onSubmit: (values: { choice: string | number }) => void;
disabled?: boolean;
}) {
const methods = useForm({ defaultValues: { choice: 7 as string | number } });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormField name="choice" label="Choice">
<NativeSelect<string | number> disabled={disabled} options={options} />
</FormField>
<button type="submit">Save</button>
</form>
</FormProvider>
);
}
describe('NativeSelect form values', () => {
it('preserves numeric, string and empty values through RHF instead of coercing to DOM strings', async () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
const user = userEvent.setup();
const select = screen.getByRole('combobox', { name: 'Choice' });
expect(select).toHaveDisplayValue('Number seven');
for (const [label, value] of [
['Text seven', '7'],
['Zero', 0],
['None', ''],
] as const) {
await user.selectOptions(select, screen.getByRole('option', { name: label }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenLastCalledWith({ choice: value }, expect.anything()),
);
}
await user.selectOptions(select, screen.getByRole('option', { name: 'Unavailable' }));
expect(select).toHaveDisplayValue('None');
});
it('keeps disabled values intact and skips the control during tab navigation', async () => {
const onSubmit = vi.fn();
render(<Harness disabled onSubmit={onSubmit} />);
const user = userEvent.setup();
await user.tab();
expect(screen.getByRole('button', { name: 'Save' })).toHaveFocus();
await user.keyboard('{Enter}');
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ choice: 7 }, expect.anything()));
});
it('preserves an unlisted edit value and follows reordered dynamic options without changing it', async () => {
const onChange = vi.fn();
const view = render(
<NativeSelect
aria-label="Choice"
value="saved-custom"
options={[{ value: 'default', label: 'Default' }]}
onChange={onChange}
/>,
);
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('saved-custom');
view.rerender(
<NativeSelect
aria-label="Choice"
value="saved-custom"
options={[
{ value: 'new', label: 'New' },
{ value: 'saved-custom', label: 'Saved choice' },
]}
onChange={onChange}
/>,
);
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('Saved choice');
expect(onChange).not.toHaveBeenCalled();
await userEvent
.setup()
.selectOptions(screen.getByRole('combobox'), screen.getByRole('option', { name: 'New' }));
expect(onChange).toHaveBeenCalledWith('new');
});
});
@@ -31,13 +31,6 @@ function formItemByLabel(label: string): Element | undefined {
}
function chooseOptionByLabel(label: string, optionText: string) {
const native = formItemByLabel(label)?.querySelector('select');
if (native) {
const option = Array.from(native.options).find((item) => item.text === optionText);
if (!option) throw new Error(`Option '${optionText}' not found for ${label}`);
fireEvent.change(native, { target: { value: option.value } });
return;
}
const select = formItemByLabel(label)?.querySelector('.ant-select');
if (!select) throw new Error(`${label} select not found`);
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -94,10 +93,6 @@ describe('FormField', () => {
fireEvent.click(screen.getByText('Save'));
await screen.findByText('email-required');
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAccessibleDescription(
'email-required',
);
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAttribute('aria-invalid', 'true');
expect(onSubmit).not.toHaveBeenCalled();
});
});
-13
View File
@@ -49,12 +49,6 @@ function openDropdownOptions(): string[] {
}
export function listSelectOptions(fieldId: string): string[] {
const native = document.getElementById(fieldId);
if (native instanceof HTMLSelectElement) {
return Array.from(native.options)
.filter((option) => !option.hidden)
.map((option) => option.text);
}
const select = selectRootForField(fieldId);
openSelect(select);
const opts = openDropdownOptions();
@@ -63,13 +57,6 @@ export function listSelectOptions(fieldId: string): string[] {
}
export function chooseSelectOption(fieldId: string, optionText: string) {
const native = document.getElementById(fieldId);
if (native instanceof HTMLSelectElement) {
const option = Array.from(native.options).find((item) => item.text === optionText);
if (!option) throw new Error(`Option '${optionText}' not found for field '${fieldId}'`);
fireEvent.change(native, { target: { value: option.value } });
return;
}
const select = selectRootForField(fieldId);
openSelect(select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
-155
View File
@@ -1,155 +0,0 @@
import assert from 'node:assert/strict';
import { chromium, devices } from 'playwright';
const base = process.argv[2] || 'http://127.0.0.1:6006';
const browser = await chromium.launch({ headless: true });
try {
for (const mobile of [false, true]) {
const context = await browser.newContext(mobile ? devices['Pixel 7'] : {});
const page = await context.newPage();
const requests = [];
await page.route('**/panel/api/**', async (route) => {
const request = route.request();
if (/\/inbounds\/(add|update\/41)$/.test(new URL(request.url()).pathname)) {
requests.push({ url: new URL(request.url()).pathname, payload: request.postDataJSON() });
}
await route.fulfill({
json: { success: true, msg: '', obj: { id: 41, datepicker: 'gregorian' } },
});
});
async function tabTo(locator) {
for (let i = 0; i < 100; i++) {
if (await locator.evaluate((element) => element === document.activeElement)) return;
await page.keyboard.press('Tab');
const escaped = await page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]');
return dialog && !dialog.contains(document.activeElement)
? document.activeElement.outerHTML
: '';
});
assert.ok(!escaped, `Tab escaped the open inbound dialog: ${escaped.slice(0, 200)}`);
}
assert.fail('Control was not reachable with Tab');
}
async function activateTab(dialog, name) {
const tabs = dialog.getByRole('tab');
const names = await tabs.allTextContents();
const current = dialog.getByRole('tab', { selected: true });
const from = names.indexOf(await current.textContent());
const to = names.findIndex((text) => text.endsWith(name));
assert.ok(from >= 0 && to >= 0, `Missing named tab ${name}`);
await tabTo(current);
for (let i = 0; i < Math.abs(to - from); i++) {
await page.keyboard.press(to > from ? 'ArrowRight' : 'ArrowLeft');
await page.keyboard.press('Enter');
}
assert.equal(
await dialog
.getByRole('tab', { name: new RegExp(`${name}$`) })
.getAttribute('aria-selected'),
'true',
);
}
async function selectByKey(locator, key, label) {
await tabTo(locator);
if (key.length === 1) await page.keyboard.press(key);
else await page.keyboard.type(key);
assert.equal(await locator.evaluate((element) => element.selectedOptions[0].text), label);
assert.ok(
await locator.evaluate((element) => element === document.activeElement),
'Selection lost focus',
);
}
for (const edit of [false, true]) {
await page.goto(
`${base}/iframe.html?id=inbounds-inboundformmodal--${edit ? 'edit' : 'create'}&viewMode=story`,
);
const opener = page.getByRole('button', {
name: edit ? 'Edit inbound' : 'Add inbound',
exact: true,
});
await opener.waitFor();
await tabTo(opener);
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: edit ? 'Modify Inbound' : 'Add Inbound' });
await dialog.waitFor();
await page.waitForFunction(
() =>
!document
.querySelector('[role="dialog"]')
?.getAnimations()
.some((animation) => animation.playState === 'running'),
);
const protocol = dialog.getByRole('combobox', { name: 'Protocol', exact: true });
if (edit) assert.ok(await protocol.isDisabled());
else {
await activateTab(dialog, 'Security');
const group = dialog.getByRole('radiogroup', { name: 'Security' });
await tabTo(group.getByRole('radio', { name: 'None' }));
await page.keyboard.press('ArrowRight');
assert.ok(await group.getByRole('radio', { name: 'TLS' }).isChecked());
await page.keyboard.press('ArrowRight');
assert.ok(await group.getByRole('radio', { name: 'Reality' }).isChecked());
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
assert.ok(await group.getByRole('radio', { name: 'None' }).isChecked());
await activateTab(dialog, 'Basics');
if (!mobile) {
await tabTo(protocol);
await page.keyboard.press('Alt+ArrowDown');
await page.keyboard.press('Escape');
assert.ok(await dialog.isVisible(), 'Canceling a native picker closed the dialog');
}
await selectByKey(protocol, 's', 'shadowsocks');
await activateTab(dialog, 'Protocol');
await selectByKey(
dialog.getByRole('combobox', { name: 'Encryption method' }),
'chacha20-ietf-poly1305',
'chacha20-ietf-poly1305',
);
}
await activateTab(dialog, 'Stream');
await selectByKey(
dialog.getByRole('combobox', { name: 'Transmission' }),
edit ? 'g' : 'w',
edit ? 'gRPC' : 'WebSocket',
);
const cdp = await context.newCDPSession(page);
const ax = await cdp.send('Accessibility.getFullAXTree');
const transport = ax.nodes.find(
(node) => node.role?.value === 'combobox' && node.name?.value.trim() === 'Transmission',
);
assert.equal(transport?.value?.value, edit ? 'gRPC' : 'WebSocket');
const submit = dialog.getByRole('button', {
name: edit ? 'Save Changes' : 'Create',
exact: true,
});
await tabTo(submit);
await page.keyboard.press('Enter');
await dialog.waitFor({ state: 'hidden' });
await page.waitForFunction(
() =>
document.activeElement?.textContent === 'Add inbound' ||
document.activeElement?.textContent === 'Edit inbound',
);
const saved = requests.at(-1);
assert.equal(saved?.url, edit ? '/panel/api/inbounds/update/41' : '/panel/api/inbounds/add');
assert.equal(saved.payload.protocol, 'shadowsocks');
const settings = JSON.parse(saved.payload.settings);
assert.equal(settings.method, 'chacha20-ietf-poly1305');
assert.equal(JSON.parse(saved.payload.streamSettings).network, edit ? 'grpc' : 'ws');
if (edit) assert.ok(!('clients' in settings), 'Editing must preserve server-owned clients');
console.log(
`PASS ${mobile ? 'mobile viewport' : 'desktop'} ${edit ? 'edit' : 'create'}: keyboard, AX value, payload, focus return`,
);
}
await context.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/gin-contrib/sessions v1.1.3
github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-playground/validator/v10 v10.30.6
github.com/go-playground/validator/v10 v10.30.5
github.com/goccy/go-json v0.12.0
github.com/goccy/go-yaml v1.19.2
github.com/google/uuid v1.6.0
+2 -2
View File
@@ -51,8 +51,8 @@ github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.6 h1:E+mymlPNRzAMNz1FShrRvLeGWcprF5w305eLLgWieuo=
github.com/go-playground/validator/v10 v10.30.6/go.mod h1:jPLL8SKCXGe/g87QmEyNw3NO28nmGt5GCnZnufm7rj4=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
-2
View File
@@ -105,10 +105,8 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/inbounds/:id/subSortIndex": {http.MethodPost: {}},
"/clients/add": {http.MethodPost: {}},
"/clients/del/:email": {http.MethodPost: {}},
"/clients/del": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}},
"/clients/update": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}},
+8 -30
View File
@@ -53,7 +53,6 @@ func NewClientController(g *gin.RouterGroup) *ClientController {
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get", a.get)
g.GET("/get/:email", a.get)
g.GET("/get/subId/:subId", a.getBySubId)
g.GET("/get/tgId/:tgId", a.getByTgId)
@@ -64,9 +63,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/add", a.create)
g.POST("/renewalPreview", a.renewalPreview)
g.POST("/update", a.update)
g.POST("/update/:email", a.update)
g.POST("/del", a.delete)
g.POST("/del/:email", a.delete)
g.POST("/:email/attach", a.attach)
g.POST("/:email/detach", a.detach)
@@ -162,24 +159,8 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
}, nil
}
// Query aliases keep legacy dot-segment names addressable without changing path clients.
func clientEmailParam(c *gin.Context) (string, bool) {
email := c.Param("email")
if email == "" {
email = c.Query("email")
}
if email == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), errors.New("client email is required"))
return "", false
}
return email, true
}
func (a *ClientController) get(c *gin.Context) {
email, ok := clientEmailParam(c)
if !ok {
return
}
email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
@@ -263,17 +244,17 @@ func (a *ClientController) create(c *gin.Context) {
}
func (a *ClientController) update(c *gin.Context) {
email, ok := clientEmailParam(c)
if !ok {
return
email := c.Param("email")
var req struct {
model.Client
LimitHwid int `json:"limitHwid"`
}
body, err := c.GetRawData()
if err != nil {
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmailJSON(&a.inboundService, email, body, inboundFilter...)
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
// Flagged before the error check: a partly-applied edit leaves the change
// committed on the inbounds that succeeded, and those still need the restart.
if needRestart {
@@ -292,10 +273,7 @@ func (a *ClientController) update(c *gin.Context) {
}
func (a *ClientController) delete(c *gin.Context) {
email, ok := clientEmailParam(c)
if !ok {
return
}
email := c.Param("email")
keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
// Flagged before the error check: a partly-applied delete already removed
@@ -1,216 +0,0 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func seedLegacyDotClient(t *testing.T, email string) (*gin.Engine, model.ClientRecord, model.Inbound) {
t.Helper()
dir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dir)
dbtest.InitDB(t, filepath.Join(dir, "x-ui.db"))
db := database.GetDB()
rec := model.ClientRecord{Email: email, UUID: "11111111-2222-3333-4444-555555555555", SubID: "legacy-dot-sub", Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatal(err)
}
settings, err := json.Marshal(map[string]any{
"clients": []model.Client{*rec.ToClient()},
"decryption": "none",
})
if err != nil {
t.Fatal(err)
}
ib := model.Inbound{Tag: "legacy-dot", Port: 443, Protocol: model.VLESS, Settings: string(settings), StreamSettings: `{"network":"tcp","security":"none"}`}
if err := db.Create(&ib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{Email: email, InboundId: ib.Id, Up: 111, Down: 222, Enable: true}).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
c.Next()
})
NewClientController(router.Group("/base/panel/api/clients"))
return router, rec, ib
}
func dotClientRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, "/base/panel/api/clients/"+path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func assertDotClientSuccess(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatal(err)
}
if w.Code != http.StatusOK || !msg.Success {
t.Fatalf("status/body = %d/%s", w.Code, w.Body.String())
}
}
func TestClientQueryAliasRecoversLegacyDotName(t *testing.T) {
for _, email := range []string{".", ".."} {
t.Run(email, func(t *testing.T) {
router, rec, ib := seedLegacyDotClient(t, email)
query := "?email=" + url.QueryEscape(email)
w := dotClientRequest(t, router, http.MethodGet, "get"+query, nil)
assertDotClientSuccess(t, w)
var full struct {
Obj struct {
Client model.ClientRecord `json:"client"`
InboundIDs []int `json:"inboundIds"`
UsedTraffic int64 `json:"usedTraffic"`
} `json:"obj"`
}
if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil {
t.Fatal(err)
}
if full.Obj.Client.Email != email || full.Obj.Client.UUID != rec.UUID || len(full.Obj.InboundIDs) != 1 || full.Obj.InboundIDs[0] != ib.Id || full.Obj.UsedTraffic != 333 {
t.Fatalf("unexpected legacy payload: %s", w.Body.String())
}
updated := rec.ToClient()
updated.Email = "restored-client"
w = dotClientRequest(t, router, http.MethodPost, "update"+query, updated)
assertDotClientSuccess(t, w)
db := database.GetDB()
var after model.ClientRecord
if err := db.First(&after, rec.Id).Error; err != nil {
t.Fatal(err)
}
if after.Email != updated.Email || after.UUID != rec.UUID || after.SubID != rec.SubID {
t.Fatalf("rename changed identity: %+v", after)
}
if err := db.First(&ib, ib.Id).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
t.Fatal(err)
}
if len(settings.Clients) != 1 || settings.Clients[0].Email != updated.Email || settings.Clients[0].ID != rec.UUID {
t.Fatalf("rename lost inbound credentials: %s", ib.Settings)
}
var traffic xray.ClientTraffic
if err := db.Where("email = ?", updated.Email).First(&traffic).Error; err != nil {
t.Fatal(err)
}
if traffic.Up != 111 || traffic.Down != 222 {
t.Fatalf("rename lost usage: %+v", traffic)
}
})
}
}
func TestClientQueryAliasDeletesLegacyDotName(t *testing.T) {
for _, email := range []string{".", ".."} {
for _, keep := range []bool{false, true} {
t.Run(email+"/keep="+strconv.FormatBool(keep), func(t *testing.T) {
router, rec, ib := seedLegacyDotClient(t, email)
path := "del?email=" + url.QueryEscape(email)
if keep {
path += "&keepTraffic=1"
}
w := dotClientRequest(t, router, http.MethodPost, path, nil)
assertDotClientSuccess(t, w)
db := database.GetDB()
var count int64
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("client remains: count=%d err=%v", count, err)
}
if err := db.Model(&model.ClientInbound{}).Where("client_id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("attachment remains: count=%d err=%v", count, err)
}
if err := db.First(&ib, ib.Id).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil || len(settings.Clients) != 0 {
t.Fatalf("inbound client remains: %s err=%v", ib.Settings, err)
}
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&count).Error; err != nil {
t.Fatal(err)
}
var want int64
if keep {
want = 1
}
if count != want {
t.Fatalf("retained traffic count=%d keep=%v", count, keep)
}
})
}
}
}
func TestClientQueryAliasRequiresEmail(t *testing.T) {
router, _, _ := seedLegacyDotClient(t, ".")
for _, route := range []struct {
method, path string
}{
{http.MethodGet, "get"}, {http.MethodPost, "update"}, {http.MethodPost, "del"},
} {
w := dotClientRequest(t, router, route.method, route.path, map[string]any{"email": "must-not-be-used-as-target"})
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatal(err)
}
if msg.Success || !strings.Contains(msg.Msg, "client email is required") {
t.Fatalf("missing query accepted: %s", w.Body.String())
}
}
}
func TestClientPathEmailTakesPrecedenceOverQuery(t *testing.T) {
router, legacy, _ := seedLegacyDotClient(t, ".")
normal := model.ClientRecord{Email: "audit-client", UUID: "normal-uuid", SubID: "normal-sub", Enable: true}
db := database.GetDB()
if err := db.Create(&normal).Error; err != nil {
t.Fatal(err)
}
w := dotClientRequest(t, router, http.MethodPost, "del/audit-client?email=.", nil)
assertDotClientSuccess(t, w)
var count int64
if err := db.Model(&model.ClientRecord{}).Where("id = ?", normal.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("path client remains: count=%d err=%v", count, err)
}
if err := db.Model(&model.ClientRecord{}).Where("id = ?", legacy.Id).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("query overrode the path identity: count=%d err=%v", count, err)
}
}
@@ -152,43 +152,3 @@ func TestClientGetBySubIDRequiresAdmin(t *testing.T) {
})
}
}
func TestClientQueryAliasTokenScopes(t *testing.T) {
router, api := newAPIAuthTestEngine(t)
NewClientController(router.Group("/base/panel/api/clients", api.checkAPIAuth, api.enforceTokenScope))
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
if err := database.GetDB().Create(&model.ApiToken{Name: scope, Token: crypto.HashTokenSHA256(scope), Enabled: true, Scope: scope}).Error; err != nil {
t.Fatal(err)
}
}
for _, route := range []struct {
method string
path string
}{
{http.MethodGet, "get"},
{http.MethodPost, "update"},
{http.MethodPost, "del"},
} {
for _, scope := range []string{"", model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
t.Run(route.path+"/scope="+scope, func(t *testing.T) {
req := httptest.NewRequest(route.method, "/base/panel/api/clients/"+route.path+"?email=missing", strings.NewReader(`{"email":"restored-client"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if scope != "" {
req.Header.Set("Authorization", "Bearer "+scope)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
want := http.StatusOK
if scope == "" {
want = http.StatusUnauthorized
} else if scope == model.ApiScopeMonitor || (route.method == http.MethodGet && scope != model.ApiScopeAdmin) {
want = http.StatusForbidden
}
if w.Code != want {
t.Fatalf("status = %d, want %d, body = %s", w.Code, want, w.Body.String())
}
})
}
}
}
@@ -1,74 +0,0 @@
package controller
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// A handler that binds the body straight into model.Client turns every omitted
// field into its zero: this expiry-only body would disable the client.
func TestUpdateHandlerKeepsFieldsTheBodyOmits(t *testing.T) {
const email = "partial-body@example.com"
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
db := database.GetDB()
ib := &model.Inbound{
UserId: 1, Enable: true, Port: 43350, Tag: "in-partial-body",
Protocol: model.VLESS, Settings: `{"clients": []}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if _, err := (&service.ClientService{}).Create(&service.InboundService{}, &service.ClientCreatePayload{
Client: model.Client{
Email: email, ID: "11111111-2222-3333-4444-555555555555", Enable: true,
LimitIP: 2, TotalGB: 10 << 30, TgID: 42, Comment: "phone",
},
InboundIds: []int{ib.Id},
}); err != nil {
t.Fatalf("seed client: %v", err)
}
a := &ClientController{}
c, w := postCtx(t, email, map[string]any{"email": email, "expiryTime": 1893456000000})
a.update(c)
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("reload client: %v (response %s)", err, w.Body.String())
}
if rec.ExpiryTime != 1893456000000 {
t.Fatalf("expiry not applied: %d (response %s)", rec.ExpiryTime, w.Body.String())
}
if !rec.Enable || rec.LimitIP != 2 || rec.TotalGB != 10<<30 || rec.TgID != 42 || rec.Comment != "phone" {
t.Fatalf("handler reset fields the body omitted: %+v", rec)
}
}
func TestClientQueryAliasPartialUpdateKeepsOmittedFields(t *testing.T) {
for _, email := range []string{".", ".."} {
t.Run(email, func(t *testing.T) {
router, rec, _ := seedLegacyDotClient(t, email)
db := database.GetDB()
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Updates(map[string]any{"limit_ip": 2, "total_gb": int64(10 << 30), "comment": "retained"}).Error; err != nil {
t.Fatal(err)
}
w := dotClientRequest(t, router, "POST", "update?email="+email, map[string]any{"email": "recovered-partial", "expiryTime": int64(1893456000000)})
assertDotClientSuccess(t, w)
var after model.ClientRecord
if err := db.First(&after, rec.Id).Error; err != nil {
t.Fatal(err)
}
if after.Email != "recovered-partial" || after.ExpiryTime != 1893456000000 || !after.Enable || after.UUID != rec.UUID || after.SubID != rec.SubID || after.LimitIP != 2 || after.TotalGB != 10<<30 || after.Comment != "retained" {
t.Fatalf("query alias partial update lost client state: %+v", after)
}
})
}
}
-35
View File
@@ -37,9 +37,6 @@ func hasForbiddenClientChar(s string) bool {
}
func validateClientEmail(email string) error {
if email == "." || email == ".." {
return common.NewError("client email must not be '.' or '..'")
}
if hasForbiddenClientChar(email) {
return common.NewError("client email contains an invalid character:", email)
}
@@ -675,12 +672,6 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
return s.update(inboundSvc, id, updated, limitHwid, false, inboundFilter)
}
// keepInboundFlows: the caller sent no flow, so every inbound keeps its own and an
// unlinked record keeps its stored one.
func (s *ClientService) update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, keepInboundFlows bool, inboundFilter []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
@@ -782,12 +773,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
updated.Secret = existing.Secret
}
// A client the quota loop switched off comes back on when this update
// restores a usable window, as BulkAdjust already does on a renewal (#5619).
if !updated.Enable && s.renewalReenables(existing.Email, updated.TotalGB, updated.ExpiryTime) {
updated.Enable = true
}
if updated.Email != existing.Email {
var collisionCount int64
if err := database.GetDB().Model(&model.ClientRecord{}).
@@ -812,14 +797,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
}
}
var inboundFlows map[int]string
if keepInboundFlows {
updated.Flow = existing.Flow
if inboundFlows, err = inboundFlowOverrides(id); err != nil {
return false, err
}
}
tunnelCount, tcErr := tunnelInboundCount(inboundIds)
if tcErr != nil {
return false, tcErr
@@ -848,9 +825,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
return false, err
}
clientForInbound := updated
if keepInboundFlows {
clientForInbound.Flow = inboundFlows[ibId]
}
if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
clientForInbound.AllowedIPs = ips
} else if tunnelCount > 1 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
@@ -942,15 +916,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
}).Error; err != nil {
return needRestart, err
}
// No inbound left, so UpdateClientStat never runs; re-assert the
// enforcement row's off switch here or a re-enabled client stays off.
if merged.Enable {
if err := database.GetDB().Model(xray.ClientTraffic{}).
Where("email = ?", existing.Email).
UpdateColumn("enable", true).Error; err != nil {
return needRestart, err
}
}
}
reverseStr := ""
@@ -7,10 +7,6 @@ func TestValidateClientEmail(t *testing.T) {
"alice",
"alice@example.com",
"user-123_test.name",
"...",
".alice",
"alice.",
"alice..example",
"имя",
}
for _, email := range valid {
@@ -20,8 +16,6 @@ func TestValidateClientEmail(t *testing.T) {
}
invalid := []string{
".",
"..",
"i6dui/",
"a/b",
"client with spaces",
+1 -28
View File
@@ -1537,31 +1537,9 @@ func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, cl
})
}
// renewalReenableFor projects the post-renewal limits for a single-field
// renewal (nil side keeps the stored limit; no row means no quota state).
func (s *ClientService) renewalReenableFor(email string, setExpiry *int64, setTotal *int64) bool {
total, expiry, ok := storedTrafficLimits(email)
if !ok {
return false
}
if setExpiry != nil {
expiry = *setExpiry
}
if setTotal != nil {
total = *setTotal
}
return s.renewalReenables(email, total, expiry)
}
func (s *ClientService) ResetClientExpiryTimeByEmail(inboundSvc *InboundService, clientEmail string, expiry_time int64) (bool, error) {
reenable := s.renewalReenableFor(clientEmail, &expiry_time, nil)
return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
c["expiryTime"] = expiry_time
if reenable {
// A renewed client that the quota loop had switched off comes back
// with the renewal instead of waiting for a manual enable (#5619).
c["enable"] = true
}
})
}
@@ -1569,12 +1547,7 @@ func (s *ClientService) ResetClientTrafficLimitByEmail(inboundSvc *InboundServic
if totalGB < 0 {
return false, common.NewError("totalGB must be >= 0")
}
newTotal := int64(totalGB) * 1024 * 1024 * 1024
reenable := s.renewalReenableFor(clientEmail, nil, &newTotal)
return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
c["totalGB"] = newTotal
if reenable {
c["enable"] = true
}
c["totalGB"] = totalGB * 1024 * 1024 * 1024
})
}
@@ -1,130 +0,0 @@
package service
import (
"encoding/json"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
// UpdateByEmailJSON applies a POST /clients/update body: an omitted key keeps the
// stored value and an explicit zero, empty or null value clears it.
func (s *ClientService) UpdateByEmailJSON(inboundSvc *InboundService, email string, body []byte, inboundFilter ...int) (bool, error) {
var req clientPayloadWithHwid
if err := json.Unmarshal(body, &req); err != nil {
return false, err
}
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
keys := clientPayloadKeys(body)
fillAbsentClientFields(rec, &req.Client, &req.LimitHwid, keys)
return s.update(inboundSvc, rec.Id, req.Client, req.LimitHwid, !keys.has("flow"), inboundFilter)
}
// payloadKeys is a struct so tools/openapigen, which exports every named
// non-struct type of this package, leaves it out of the generated frontend types.
type payloadKeys struct{ names []string }
func clientPayloadKeys(body []byte) payloadKeys {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return payloadKeys{}
}
keys := payloadKeys{names: make([]string, 0, len(raw))}
for k := range raw {
keys.names = append(keys.names, k)
}
return keys
}
// has matches keys the way encoding/json binds them to fields: case-insensitively,
// so a body sending "limitIP" counts as carrying limitIp.
func (k payloadKeys) has(name string) bool {
for _, key := range k.names {
if strings.EqualFold(key, name) {
return true
}
}
return false
}
// fillAbsentClientFields leaves flow to update, which keeps it per inbound.
func fillAbsentClientFields(rec *model.ClientRecord, updated *model.Client, limitHwid *int, keys payloadKeys) {
stored := rec.ToClient()
if !keys.has("email") {
updated.Email = stored.Email
}
if !keys.has("security") {
updated.Security = stored.Security
}
if !keys.has("limitIp") {
updated.LimitIP = stored.LimitIP
}
if !keys.has("totalGB") {
updated.TotalGB = stored.TotalGB
}
if !keys.has("expiryTime") {
updated.ExpiryTime = stored.ExpiryTime
}
if !keys.has("enable") {
updated.Enable = stored.Enable
}
if !keys.has("tgId") {
updated.TgID = stored.TgID
}
if !keys.has("group") {
updated.Group = stored.Group
}
if !keys.has("comment") {
updated.Comment = stored.Comment
}
if !keys.has("reset") {
updated.Reset = stored.Reset
}
if !keys.has("resetDay") {
updated.ResetDay = stored.ResetDay
}
if !keys.has("resetWeekday") {
updated.ResetWeekday = stored.ResetWeekday
}
if !keys.has("resetMax") {
updated.ResetMax = stored.ResetMax
}
if !keys.has("trafficReset") {
updated.TrafficReset = stored.TrafficReset
}
if !keys.has("trafficResetDay") {
updated.TrafficResetDay = stored.TrafficResetDay
}
if !keys.has("adTag") {
updated.AdTag = stored.AdTag
}
if !keys.has("reverse") {
updated.Reverse = stored.Reverse
}
if !keys.has("limitHwid") {
*limitHwid = rec.LimitHwid
}
}
// inboundFlowOverrides returns each linked inbound's flow_override: the reliable
// per-inbound flow, unlike clients.flow (see EffectiveFlow).
func inboundFlowOverrides(recordId int) (map[int]string, error) {
var links []model.ClientInbound
if err := database.GetDB().Select("inbound_id", "flow_override").
Where("client_id = ?", recordId).Find(&links).Error; err != nil {
return nil, err
}
flows := make(map[int]string, len(links))
for _, link := range links {
flows[link.InboundId] = link.FlowOverride
}
return flows, nil
}
@@ -1,160 +0,0 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
const partialTestPSK = "Pm4Hn0f8i8b6v3gJ0vZQ3yHq4mUe7qkq0m3o2l9sY9c="
func updateByEmailJSON(t *testing.T, svc *ClientService, email, body string) {
t.Helper()
if _, err := svc.UpdateByEmailJSON(&InboundService{}, email, []byte(body)); err != nil {
t.Fatalf("UpdateByEmailJSON(%s): %v", body, err)
}
}
func inboundClientByEmail(t *testing.T, inboundId int, email string) model.Client {
t.Helper()
inboundSvc := &InboundService{}
ib, err := inboundSvc.GetInbound(inboundId)
if err != nil {
t.Fatalf("GetInbound(%d): %v", inboundId, err)
}
clients, err := inboundSvc.GetClients(ib)
if err != nil {
t.Fatalf("GetClients(%d): %v", inboundId, err)
}
for _, c := range clients {
if c.Email == email {
return c
}
}
t.Fatalf("inbound %d has no client %q", inboundId, email)
return model.Client{}
}
// A body carrying only email and expiryTime used to disable the client and zero its
// limits, Telegram id, comment, group, renewal settings and limitHwid.
func TestUpdateByEmailJSONKeepsOmittedFields(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
ib := mkInbound(t, 52111, model.AmneziaWG, `{"server":{"privateKey":"server","publicKey":"server-pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
seed := model.Client{
Email: "full@awg", Enable: true,
PrivateKey: "client-private", PublicKey: "client-public",
PreSharedKey: partialTestPSK,
AllowedIPs: []string{"10.8.1.8/32"},
LimitIP: 3, TotalGB: 50 << 30, ExpiryTime: 1700000000000,
TgID: 123456, Comment: "laptop", Group: "family",
Reset: 30, ResetMax: 4,
}
seed.AllowedIPsByInbound = map[int][]string{ib.Id: seed.AllowedIPs}
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
t.Fatalf("seed client: %v", err)
}
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
UpdateColumn("limit_hwid", 2).Error; err != nil {
t.Fatal(err)
}
updateByEmailJSON(t, svc, seed.Email, `{"email": "full@awg", "expiryTime": 1893456000000}`)
rec := lookupClientRecord(t, seed.Email)
if !rec.Enable {
t.Fatal("partial update disabled the client")
}
if rec.ExpiryTime != 1893456000000 {
t.Fatalf("expiry not applied: %d", rec.ExpiryTime)
}
if rec.LimitIP != 3 || rec.TotalGB != 50<<30 || rec.TgID != 123456 || rec.Comment != "laptop" ||
rec.Group != "family" || rec.Reset != 30 || rec.ResetMax != 4 || rec.LimitHwid != 2 {
t.Fatalf("partial update reset omitted fields: %+v", rec)
}
peer := inboundClientByEmail(t, ib.Id, seed.Email)
if !peer.Enable || peer.PreSharedKey != partialTestPSK || peer.PrivateKey != seed.PrivateKey {
t.Fatalf("inbound peer lost its state: %+v", peer)
}
updateByEmailJSON(t, svc, seed.Email, `{"enable": false, "comment": "", "totalGB": 0}`)
rec = lookupClientRecord(t, seed.Email)
if rec.Enable || rec.Comment != "" || rec.TotalGB != 0 {
t.Fatalf("explicit zeros were not applied: %+v", rec)
}
if rec.ExpiryTime != 1893456000000 || rec.LimitIP != 3 || rec.Group != "family" || rec.LimitHwid != 2 {
t.Fatalf("second partial update reset omitted fields: %+v", rec)
}
}
// encoding/json binds "limitIP" to limitIp; a case-sensitive presence check then
// overwrote the decoded zero with the stored value.
func TestUpdateByEmailJSONMatchesKeysCaseInsensitively(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
ib := mkInbound(t, 52112, model.VLESS, `{"clients":[]}`)
seed := model.Client{
Email: "case@vless", ID: "0d1b6f8e-3c2a-4f61-9a43-5b7f0c2e9d11", Enable: true,
LimitIP: 3, TgID: 123456, ExpiryTime: 1700000000000,
}
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
t.Fatalf("seed client: %v", err)
}
updateByEmailJSON(t, svc, seed.Email, `{"limitIP": 0, "tgID": 0, "expirytime": 1893456000000}`)
rec := lookupClientRecord(t, seed.Email)
if rec.LimitIP != 0 || rec.TgID != 0 || rec.ExpiryTime != 1893456000000 {
t.Fatalf("differently-cased keys were not applied: limitIp=%d tgId=%d expiry=%d", rec.LimitIP, rec.TgID, rec.ExpiryTime)
}
if !rec.Enable {
t.Fatal("partial update disabled the client")
}
}
// clients.flow is unreliable for a multi-inbound client (#4792): an omitted flow
// must keep each inbound's own flow instead of copying that column everywhere.
func TestUpdateByEmailJSONOmittedFlowKeepsEachInboundsFlow(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
const vision = "xtls-rprx-vision"
reality := mkInboundStream(t, 52113, model.VLESS, `{"clients":[]}`, realityStream)
ws := mkInboundStream(t, 52114, model.VLESS, `{"clients":[]}`, wsStream)
seed := model.Client{
Email: "flow@vless", ID: "6a0f2c1e-8b4d-4e7a-9c35-1d2e3f4a5b6c", Enable: true, Flow: vision,
Reverse: &model.ClientReverse{Tag: "back"},
}
if _, err := svc.Create(&InboundService{}, &ClientCreatePayload{Client: seed, InboundIds: []int{reality.Id, ws.Id}}); err != nil {
t.Fatalf("seed client: %v", err)
}
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
t.Fatal("seed did not store the reverse tag")
}
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
UpdateColumn("flow", "").Error; err != nil {
t.Fatalf("zero clients.flow: %v", err)
}
updateByEmailJSON(t, svc, seed.Email, `{"expiryTime": 1893456000000}`)
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != vision || got.ExpiryTime != 1893456000000 {
t.Fatalf("Reality inbound: flow=%q expiry=%d, want %q and the new expiry", got.Flow, got.ExpiryTime, vision)
}
if got := inboundClientByEmail(t, ws.Id, seed.Email); got.Flow != "" {
t.Fatalf("WS inbound gained flow %q", got.Flow)
}
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
t.Fatal("an omitted reverse must keep the tag")
}
updateByEmailJSON(t, svc, seed.Email, `{"reverse": null}`)
if rec := lookupClientRecord(t, seed.Email); rec.Reverse != "" {
t.Fatalf("an explicit null reverse must clear the tag: %q", rec.Reverse)
}
updateByEmailJSON(t, svc, seed.Email, `{"flow": ""}`)
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != "" {
t.Fatalf("an explicit empty flow must clear Vision, got %q", got.Flow)
}
}
@@ -1,60 +0,0 @@
package service
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// renewalReenables reports whether the quota loop switched the client off and
// this renewal (newTotal/newExpiry, usage unchanged) clears the depletion (#5619).
func (s *ClientService) renewalReenables(email string, newTotal, newExpiry int64) bool {
db := database.GetDB()
var traffic xray.ClientTraffic
if err := db.Where("email = ?", email).First(&traffic).Error; err != nil {
return false // no enforcement row: the quota loop never switched this client off
}
if traffic.Enable {
return false
}
cond, condArgs := depletedCond(db)
var wasDepleted int64
if err := db.Model(xray.ClientTraffic{}).
Where(cond+" AND email = ?", append(append([]any{}, condArgs...), email)...).
Count(&wasDepleted).Error; err != nil || wasDepleted == 0 {
return false // off for another reason than depletion: the operator's off stands
}
// Project the post-renewal row: total/expiry move, up/down and a fresh
// cross-panel push can still deplete it.
now := time.Now().UnixMilli()
used := traffic.Up + traffic.Down
if newTotal > 0 && used >= newTotal {
return false // still over quota
}
if newExpiry > 0 && newExpiry <= now {
return false // still expired
}
if newTotal > 0 {
var globalDepleted int64
if err := db.Model(&model.ClientGlobalTraffic{}).
Where("email = ? AND updated_at >= ? AND up + down >= ?", email, globalTrafficFreshSince(time.Now()), newTotal).
Count(&globalDepleted).Error; err == nil && globalDepleted > 0 {
return false // a master's pushed usage still depletes the fresh quota
}
}
return true
}
// storedTrafficLimits reads a client's enforcement row; ok is false when the
// client has no row, in which case no quota state can be projected for it.
func storedTrafficLimits(email string) (total, expiry int64, ok bool) {
var row xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
return 0, 0, false
}
return row.Total, row.ExpiryTime, true
}
+10 -15
View File
@@ -50,22 +50,17 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
needRestart = nr
}
// Restore quota-disabled clients after zeroing; preserve an operator
// disable, including when no inbound remains.
// Enable only a client the quota switched off, once the counters are zero: a
// hand-disabled one stays off, and a still-depleted one would be switched off again.
if !rec.Enable && len(quotaDisabled) > 0 {
// Inbound resets already restored hosted clients. Only an unhosted
// record still needs the full update.
fresh, fErr := s.GetRecordByEmail(nil, email)
if fErr == nil && !fresh.Enable {
updated := fresh.ToClient()
updated.Enable = true
nr, uErr := s.Update(inboundSvc, fresh.Id, *updated, fresh.LimitHwid)
if uErr != nil {
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
}
if nr {
needRestart = true
}
updated := rec.ToClient()
updated.Enable = true
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
if uErr != nil {
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
}
if nr {
needRestart = true
}
}
return needRestart, nil
@@ -67,7 +67,7 @@ func requireResetSurvivesTick(t *testing.T, reset func() error) {
tickDone <- err
}()
deadline := time.Now().Add(5 * time.Second)
for trafficWriterQueueLen() < 2 && time.Now().Before(deadline) {
for len(twQueue) < 2 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
close(release)
@@ -1,284 +0,0 @@
package service
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// renewalReenableDay keeps the seeds one day past (or before) their boundary.
const renewalReenableDay = int64(24 * 60 * 60 * 1000)
// seedRenewableDepletedClient seeds a local inbound whose client the quota loop
// switched off: record off, inbound JSON off, and the traffic row off and depleted.
func seedRenewableDepletedClient(t *testing.T, svc *ClientService, port int, email string, total, expiry, up, down int64) *model.Inbound {
t.Helper()
ib := seedLocalDisabledClient(t, svc, port, "", email, total, expiry, up, down)
if got := trafficOf(t, email); got.Enable {
t.Fatalf("seed %s: want an off traffic row, got %+v", email, got)
}
return ib
}
func TestUpdate_ReenablesDepletedClient_WhenExpiryExtended(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-exp@x"
ib := seedRenewableDepletedClient(t, svc, 53101, email, 0, now-2*renewalReenableDay, 0, 0)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now + 30*renewalReenableDay
// The editor round-trips the switch as the stored off state; the renewal
// must turn it back on by itself.
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
if got := trafficOf(t, email).ExpiryTime; got != now+30*renewalReenableDay {
t.Fatalf("%s: expiry = %d, want %d", email, got, now+30*renewalReenableDay)
}
}
func TestUpdate_ReenablesDepletedClient_WhenTotalRaised(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-total@x"
ib := seedRenewableDepletedClient(t, svc, 53102, email, 100, 0, 60, 40)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.TotalGB = 300
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
if got := trafficOf(t, email).Total; got != 300 {
t.Fatalf("%s: total = %d, want 300", email, got)
}
}
func TestUpdate_KeepsDisabled_StillExpiredAfterUpdate(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-sml@x"
ib := seedRenewableDepletedClient(t, svc, 53103, email, 0, now-10*renewalReenableDay, 0, 0)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now - 5*renewalReenableDay // an extension that still lands in the past
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestUpdate_KeepsDisabled_StillOverQuotaAfterUpdate(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-quota@x"
ib := seedRenewableDepletedClient(t, svc, 53104, email, 100, 0, 60, 40)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.TotalGB = 80 // still below the 100 bytes of usage
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestUpdate_KeepsDisabled_ManuallyDisabledNotDepleted(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-manual@x"
ib := seedRenewableDepletedClient(t, svc, 53105, email, 0, now+30*renewalReenableDay, 0, 0)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now + 60*renewalReenableDay
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
// The quota loop never switched this client off — it reads under quota —
// so a manual switch-off survives a renewal.
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestUpdate_ReenablesDepletedClient_SkipsStillDepletedGlobalUsage(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-global@x"
ib := seedRenewableDepletedClient(t, svc, 53106, email, 100, now+30*renewalReenableDay, 0, 0)
// A fresh master push depletes the local limits even though up/down read 0.
global := model.ClientGlobalTraffic{MasterGuid: "master-a", Email: email, Up: 90, Down: 60}
if err := database.GetDB().Create(&global).Error; err != nil {
t.Fatalf("seed global traffic: %v", err)
}
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.TotalGB = 100
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
// Raising the quota above the pushed usage re-enables it.
updated.TotalGB = 1000
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
}
func TestUpdate_ReenablesDepletedClient_WithoutInbound(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-noib@x"
rec := &model.ClientRecord{
Email: email,
UUID: "44444444-4444-4444-4444-444444444444",
SubID: email,
}
if err := database.GetDB().Create(rec).Error; err != nil {
t.Fatalf("create record: %v", err)
}
forceRecordDisabled(t, svc, email)
if err := database.GetDB().Create(&xray.ClientTraffic{
Email: email, Enable: false, ExpiryTime: now - 2*renewalReenableDay,
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now + 30*renewalReenableDay
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
if got := recordEnableOf(t, svc, email); !got {
t.Fatalf("%s: client_records.enable = false, want true", email)
}
if got := trafficOf(t, email).Enable; !got {
t.Fatalf("%s: client_traffics.enable = false, want true (no-inbound re-enable gap)", email)
}
}
func TestResetClientExpiryTimeByEmail_ReenablesDepletedClient(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-setter@x"
ib := seedRenewableDepletedClient(t, svc, 53107, email, 0, now-2*renewalReenableDay, 0, 0)
if _, err := svc.ResetClientExpiryTimeByEmail(inboundSvc, email, now+30*renewalReenableDay); err != nil {
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
}
func TestResetClientExpiryTimeByEmail_KeepsDisabled_ManuallyDisabledNotDepleted(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-setter-off@x"
ib := seedRenewableDepletedClient(t, svc, 53108, email, 0, now+30*renewalReenableDay, 0, 0)
if _, err := svc.ResetClientExpiryTimeByEmail(inboundSvc, email, now+60*renewalReenableDay); err != nil {
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestResetClientTrafficLimitByEmail_ReenablesDepletedClient(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-setter-total@x"
ib := seedRenewableDepletedClient(t, svc, 53109, email, 100, 0, 60, 40)
if _, err := svc.ResetClientTrafficLimitByEmail(inboundSvc, email, 1); err != nil {
t.Fatalf("ResetClientTrafficLimitByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
if got := trafficOf(t, email).Total; got != 1<<30 {
t.Fatalf("%s: total = %d, want %d", email, got, 1<<30)
}
}
// A client living on several inbounds must come back on every one of them,
// not only the inbound whose reset ran first.
func TestResetTrafficByEmail_ReenablesEveryInboundOfTheClient(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-multi@x"
c := model.Client{
Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email,
Enable: false, TotalGB: 1000,
}
ibA := mkInbound(t, 53201, model.VLESS, clientsSettings(t, []model.Client{c}))
ibB := mkInbound(t, 53202, model.VLESS, clientsSettings(t, []model.Client{c}))
for _, ib := range []*model.Inbound{ibA, ibB} {
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
t.Fatalf("SyncInbound %d: %v", ib.Id, err)
}
}
mkTraffic(t, ibA.Id, email, 600, 500, 1000, 0, false)
forceRecordDisabled(t, svc, email)
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
t.Fatalf("ResetTrafficByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ibA.Id, email, true)
assertEnableEverywhere(t, svc, inboundSvc, ibB.Id, email, true)
if got := trafficOf(t, email); !got.Enable || got.Up+got.Down != 0 {
t.Fatalf("%s: traffic row enable=%v used=%d, want enabled at 0", email, got.Enable, got.Up+got.Down)
}
}
-42
View File
@@ -275,48 +275,6 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
return &snapshot, &ib, nil
}
// markClientEnabledInSettings is the re-enable twin of
// markClientsDisabledInSettings: flip one email's stored entry back on.
func (s *InboundService) markClientEnabledInSettings(tx *gorm.DB, inboundID int, email string) error {
var ib model.Inbound
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID).First(&ib).Error; err != nil {
return err
}
settings := map[string]any{}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
return err
}
clients, _ := settings["clients"].([]any)
now := time.Now().Unix() * 1000
mutated := false
for i := range clients {
entry, ok := clients[i].(map[string]any)
if !ok {
continue
}
if e, _ := entry["email"].(string); e != email {
continue
}
if cur, _ := entry["enable"].(bool); cur {
continue
}
entry["enable"] = true
entry["updated_at"] = now
clients[i] = entry
mutated = true
}
if !mutated {
return nil
}
settings["clients"] = clients
bs, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return tx.Model(&model.Inbound{}).Where("id = ?", inboundID).
Update("settings", string(bs)).Error
}
// trafficDisabledEmails reports which emails have a switched-off stats row. The
// table is email-keyed and its inbound_id goes stale, so never filter on it.
func trafficDisabledEmails(db *gorm.DB, emails []string) (map[string]struct{}, error) {
+1 -3
View File
@@ -390,9 +390,7 @@ func (s *InboundService) SnapshotHasUnadoptedInbounds(nodeID int, snap *runtime.
// config push just landed, whose snapshot may still predate it (#6228).
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty, justPushed bool) (bool, error) {
var structuralChange bool
// Node merges take the background lane: a per-client mutation the operator
// is waiting on must not queue behind the next batch of 5s snapshots.
err := submitTrafficWriteBackground(func() error {
err := submitTrafficWrite(func() error {
var inner error
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed)
return inner
+47 -64
View File
@@ -28,9 +28,7 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
var disabledNodeIDs []int
var remotePlans []trafficInboundUpdatePlan
var renewed []string
// The periodic poll takes the background lane: an operator's save or reset
// queued while a poll runs must not sit behind the next merge batch.
err = submitTrafficWriteBackground(func() error {
err = submitTrafficWrite(func() error {
var inner error
needRestart, clientsDisabled, disabledNodeIDs, remotePlans, renewed, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
return inner
@@ -722,61 +720,55 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
needRestart := false
var reenablePlan *trafficLocalApplyPlan
var reenableNodeID *int
// This inbound's stored entry read off and this reset flips it back on,
// in the same transaction as the zeroing.
flipStoredEntry := false
traffic, err := s.GetClientTrafficByEmail(clientEmail)
if err != nil {
return false, nil, err
}
// A reset lifts the quota gate only: a client switched off by hand stays off.
keepOff, err := handDisabledEmails(database.GetDB(), []string{clientEmail})
if err != nil {
return false, nil, err
}
handDisabled := len(keepOff) > 0
inbound, err := s.GetInbound(id)
if err != nil {
return false, nil, err
}
clients, err := s.GetClients(inbound)
if err != nil {
return false, nil, err
}
// Check each stored entry: the first reset already enables the shared
// row. A row-only check would leave sibling inbounds disabled.
entry := (*model.Client)(nil)
for i := range clients {
if clients[i].Email == clientEmail {
entry = &clients[i]
break
if !traffic.Enable && !handDisabled {
inbound, err := s.GetInbound(id)
if err != nil {
return false, nil, err
}
}
if entry != nil && !handDisabled && (!entry.Enable || !traffic.Enable) {
flipStoredEntry = true
cipher := ""
if string(inbound.Protocol) == "shadowsocks" {
var oldSettings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
if err != nil {
return false, nil, err
clients, err := s.GetClients(inbound)
if err != nil {
return false, nil, err
}
for _, client := range clients {
if client.Email == clientEmail && client.Enable {
cipher := ""
if string(inbound.Protocol) == "shadowsocks" {
var oldSettings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
if err != nil {
return false, nil, err
}
cipher, _ = oldSettings["method"].(string)
}
clientMap := map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"reverse": client.Reverse,
}
if inbound.NodeID != nil {
reenableNodeID = inbound.NodeID
} else {
reenablePlan = &trafficLocalApplyPlan{action: trafficAddUser, inbound: *inbound, client: clientMap}
}
break
}
cipher, _ = oldSettings["method"].(string)
}
clientMap := map[string]any{
"email": entry.Email,
"id": entry.ID,
"auth": entry.Auth,
"security": entry.Security,
"flow": entry.Flow,
"password": entry.Password,
"cipher": cipher,
"reverse": entry.Reverse,
}
if inbound.NodeID != nil {
reenableNodeID = inbound.NodeID
} else {
reenablePlan = &trafficLocalApplyPlan{action: trafficAddUser, inbound: *inbound, client: clientMap}
}
}
@@ -786,6 +778,10 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
db := database.GetDB()
now := time.Now().UnixMilli()
inbound, err := s.GetInbound(id)
if err != nil {
return false, nil, err
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err
@@ -807,32 +803,16 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
Update("last_traffic_reset_time", now).Error; err != nil {
return err
}
if flipStoredEntry {
// Restore quota-disabled entries with the counters; an operator
// disable has already been excluded.
if err := s.markClientEnabledInSettings(tx, id, clientEmail); err != nil {
return err
}
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "updated_at": now}).Error; err != nil {
return err
}
}
if reenableNodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *reenableNodeID)
}
if inbound.NodeID != nil {
if inbound != nil && inbound.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
}
return nil
}); err != nil {
return false, nil, err
}
if flipStoredEntry {
// The entry left the subscription in its disabled state; a just
// re-enabled client must not keep missing from it.
InvalidateSubData()
}
if reenablePlan != nil {
rt, err := s.runtimeFor(&reenablePlan.inbound)
@@ -846,7 +826,10 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
}
}
return needRestart, inbound.NodeID, nil
if inbound != nil {
return needRestart, inbound.NodeID, nil
}
return needRestart, nil, nil
}
// ResetAllTraffics zeroes every inbound's counters and brings back the inbounds a traffic
@@ -37,7 +37,7 @@ func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray
return nil
}
return submitTrafficWriteBackground(func() error {
return submitTrafficWrite(func() error {
db := database.GetDB()
known := make([]string, 0, len(emails))
for _, batch := range chunkStrings(emails, sqlInChunk) {
+19 -74
View File
@@ -26,12 +26,11 @@ type trafficWriteRequest struct {
type serializedTxContextKey struct{}
var (
twMu sync.Mutex
twAdminQueue chan *trafficWriteRequest
twBgQueue chan *trafficWriteRequest
twCtx context.Context
twCancel context.CancelFunc
twDone chan struct{}
twMu sync.Mutex
twQueue chan *trafficWriteRequest
twCtx context.Context
twCancel context.CancelFunc
twDone chan struct{}
)
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
@@ -54,18 +53,16 @@ func StartTrafficWriter() {
}
}
adminQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
bgQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
twAdminQueue = adminQueue
twBgQueue = bgQueue
twQueue = queue
twCtx = ctx
twCancel = cancel
twDone = done
go runTrafficWriter(ctx, adminQueue, bgQueue, done)
go runTrafficWriter(ctx, queue, done)
}
// StopTrafficWriter cancels the writer context and waits for the goroutine to
@@ -92,68 +89,31 @@ func StopTrafficWriter() {
}
func clearTrafficWriterState() {
twAdminQueue = nil
twBgQueue = nil
twQueue = nil
twCtx = nil
twCancel = nil
twDone = nil
}
// trafficWriterQueueLen is the test's view of both lanes' pending requests.
func trafficWriterQueueLen() int {
twMu.Lock()
defer twMu.Unlock()
return len(twAdminQueue) + len(twBgQueue)
}
// One goroutine serves both lanes (no two writes side by side — the Postgres
// deadlock guard c5d31de4e; the admin lane is preferred over the 5s poll and node merges, #6035).
func runTrafficWriter(ctx context.Context, adminQueue, bgQueue chan *trafficWriteRequest, done chan struct{}) {
func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
defer close(done)
for {
select {
case req := <-adminQueue:
case req := <-queue:
req.done <- safeApply(req.apply)
case <-ctx.Done():
drainTrafficWriter(adminQueue, bgQueue)
return
default:
select {
case req := <-adminQueue:
req.done <- safeApply(req.apply)
case req := <-bgQueue:
req.done <- safeApply(req.apply)
case <-ctx.Done():
drainTrafficWriter(adminQueue, bgQueue)
return
for {
select {
case req := <-queue:
req.done <- safeApply(req.apply)
default:
return
}
}
}
}
}
// drainTrafficWriter finishes every request already queued on shutdown, as the
// single-queue writer used to: both lanes drain until empty, so no write is dropped.
func drainTrafficWriter(adminQueue, bgQueue chan *trafficWriteRequest) {
for {
drained := false
select {
case req := <-adminQueue:
req.done <- safeApply(req.apply)
drained = true
default:
}
select {
case req := <-bgQueue:
req.done <- safeApply(req.apply)
drained = true
default:
}
if !drained {
return
}
}
}
// runSerializedTx runs fn inside one DB transaction on the shared serial
// traffic-writer goroutine, so it can never execute concurrently with the
// @every 5s traffic poll (AddTraffic). Both touch the hot client_traffics and
@@ -207,28 +167,13 @@ func safeApply(fn func() error) (err error) {
return fn()
}
// submitTrafficWrite runs fn on the serial writer's admin lane, ahead of any
// queued periodic work (the traffic poll, the node sync merges).
func submitTrafficWrite(fn func() error) error {
return submitTrafficWriteLane(fn, false)
}
// submitTrafficWriteBackground runs fn on the periodic lane (the 5s traffic poll
// and node snapshot merges), served only when no admin-lane write is queued.
func submitTrafficWriteBackground(fn func() error) error {
return submitTrafficWriteLane(fn, true)
}
func submitTrafficWriteLane(fn func() error, background bool) error {
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
twMu.Lock()
queue := twAdminQueue
queue := twQueue
ctx := twCtx
done := twDone
if background {
queue = twBgQueue
}
if queue == nil || ctx == nil || done == nil {
twMu.Unlock()
return safeApply(fn)
+11 -95
View File
@@ -161,7 +161,13 @@ func waitTrafficWriterQueued(t *testing.T) {
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if trafficWriterQueueLen() > 0 {
twMu.Lock()
queued := 0
if twQueue != nil {
queued = len(twQueue)
}
twMu.Unlock()
if queued > 0 {
return
}
time.Sleep(10 * time.Millisecond)
@@ -211,7 +217,10 @@ func parkSerializedWriter(t *testing.T) (release func()) {
func waitForQueuedWrite() bool {
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if trafficWriterQueueLen() > 0 {
twMu.Lock()
queued := len(twQueue) > 0
twMu.Unlock()
if queued {
return true
}
time.Sleep(10 * time.Millisecond)
@@ -219,99 +228,6 @@ func waitForQueuedWrite() bool {
return false
}
// A write the operator is waiting on must not queue behind the periodic work
// that keeps arriving every 5s (#6035): with both lanes filled, admin runs first.
func TestTrafficWriterAdminLanePreemptsBackgroundQueue(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
parkStarted := make(chan struct{})
releasePark := make(chan struct{})
go func() {
_ = submitTrafficWrite(func() error {
close(parkStarted)
<-releasePark
return nil
})
}()
waitTrafficWriterSignal(t, parkStarted, "parking write did not start")
order := make(chan string, 2)
bgDone := make(chan error, 1)
go func() {
bgDone <- submitTrafficWriteBackground(func() error {
order <- "background"
return nil
})
}()
adminDone := make(chan error, 1)
go func() {
adminDone <- submitTrafficWrite(func() error {
order <- "admin"
return nil
})
}()
deadline := time.Now().Add(time.Second)
for trafficWriterQueueLen() < 2 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if trafficWriterQueueLen() < 2 {
t.Fatal("both writes did not reach the queues")
}
close(releasePark)
if got := <-order; got != "admin" {
t.Fatalf("first write served after the parked one = %q, want admin: the lane the operator waits on", got)
}
if got := <-order; got != "background" {
t.Fatalf("second write served = %q, want background", got)
}
if err := <-adminDone; err != nil {
t.Fatalf("admin write: %v", err)
}
if err := <-bgDone; err != nil {
t.Fatalf("background write: %v", err)
}
}
// A stop must drain every queued write on both lanes, not one per lane: a
// submitter blocked behind two queued writes must not read "stopped".
func TestTrafficWriterStopDrainsEveryQueuedWrite(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
parkStarted := make(chan struct{})
releasePark := make(chan struct{})
go func() {
_ = submitTrafficWrite(func() error {
close(parkStarted)
<-releasePark
return nil
})
}()
waitTrafficWriterSignal(t, parkStarted, "parking write did not start")
results := make(chan error, 3)
go func() { results <- submitTrafficWrite(func() error { return nil }) }()
go func() { results <- submitTrafficWrite(func() error { return nil }) }()
go func() { results <- submitTrafficWriteBackground(func() error { return nil }) }()
deadline := time.Now().Add(time.Second)
for trafficWriterQueueLen() < 3 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if trafficWriterQueueLen() < 3 {
t.Fatal("queued writes did not all reach the lanes")
}
close(releasePark)
StopTrafficWriter()
for range 3 {
if err := waitTrafficWriterErr(t, results); err != nil {
t.Fatalf("a queued write was dropped on stop: %v", err)
}
}
}
func waitWriterResult(t *testing.T, done <-chan error) error {
t.Helper()
select {
+1 -2
View File
@@ -17,8 +17,7 @@ func (s *InboundService) AddTuicTrafficBatch(id string, traffic []*xray.ClientTr
if id == "" {
return errors.New("TUIC traffic batch has no ID")
}
// The receipt journal is periodic accounting, not an operator action.
return submitTrafficWriteBackground(func() error {
return submitTrafficWrite(func() error {
return database.GetDB().Transaction(func(tx *gorm.DB) error {
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.TuicTrafficReceipt{ID: id})
if result.Error != nil {
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "غير محدد",
"unlimited": "غير محدود",
"none": "لا شيء",
"empty": "فارغ",
"qrCode": "كود QR",
"info": "معلومات أكتر",
"edit": "تحرير",
@@ -48,7 +47,6 @@
"reset": "إعادة تعيين",
"noData": "لا توجد بيانات.",
"copySuccess": "اتنسخ بنجاح",
"copyFail": "فشل النسخ",
"sure": "متأكد؟",
"encryption": "تشفير",
"transmission": "نقل",
@@ -878,7 +876,6 @@
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
"online": "متصل",
"email": "البريد",
"emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط",
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
"group": "المجموعة",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Indefinite",
"unlimited": "Unlimited",
"none": "None",
"empty": "Empty",
"qrCode": "QR Code",
"info": "More Information",
"edit": "Edit",
@@ -48,7 +47,6 @@
"reset": "Reset",
"noData": "Nothing here yet",
"copySuccess": "Copied successfully",
"copyFail": "Copy failed",
"sure": "Sure",
"encryption": "Encryption",
"transmission": "Transmission",
@@ -878,7 +876,6 @@
"subIdDesc": "Also works as this client's Telegram bot invite code: whoever sends it to the bot is linked to this client. Keep it long and random — a short or guessable ID can be claimed by someone else.",
"online": "Online",
"email": "Email",
"emailReservedName": "Client name cannot be exactly \".\" or \"..\"",
"emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters",
"subIdInvalidChars": "Subscription ID cannot contain spaces, '/', '\\', or control characters",
"group": "Group",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Indefinido",
"unlimited": "Ilimitado",
"none": "Ninguno",
"empty": "Vacío",
"qrCode": "Código QR",
"info": "Más Información",
"edit": "Editar",
@@ -48,7 +47,6 @@
"reset": "Restablecer",
"noData": "Sin datos",
"copySuccess": "Copiado exitosamente",
"copyFail": "Error al copiar",
"sure": "Seguro",
"encryption": "Encriptación",
"transmission": "Transmisión",
@@ -878,7 +876,6 @@
"subIdDesc": "También sirve como código de invitación de este cliente en el bot de Telegram: quien lo envíe al bot queda vinculado a este cliente. Mantenlo largo y aleatorio: un ID corto o fácil de adivinar puede ser reclamado por otra persona.",
"online": "En línea",
"email": "Email",
"emailReservedName": "El nombre del cliente no puede ser exactamente \".\" o \"..\"",
"emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control",
"subIdInvalidChars": "El ID de suscripción no puede contener espacios, '/', '\\' ni caracteres de control",
"group": "Grupo",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "نامحدود",
"unlimited": "نامحدود",
"none": "هیچ",
"empty": "خالی",
"qrCode": "QRکد",
"info": "اطلاعات بیشتر",
"edit": "ویرایش",
@@ -48,7 +47,6 @@
"reset": "بازنشانی",
"noData": "داده‌ای وجود ندارد.",
"copySuccess": "باموفقیت کپی‌شد",
"copyFail": "کپی ناموفق بود",
"sure": "مطمئن",
"encryption": "رمزگذاری",
"transmission": "راه‌اتصال",
@@ -878,7 +876,6 @@
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
"online": "آنلاین",
"email": "ایمیل",
"emailReservedName": "نام کاربر نمی‌تواند فقط «.» یا «..» باشد",
"emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
"subIdInvalidChars": "شناسه‌ی اشتراک نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
"group": "گروه",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Tak Terbatas",
"unlimited": "Tanpa Batas",
"none": "Tidak ada",
"empty": "Kosong",
"qrCode": "Kode QR",
"info": "Informasi Lebih Lanjut",
"edit": "Edit",
@@ -48,7 +47,6 @@
"reset": "Reset",
"noData": "Tidak ada data.",
"copySuccess": "Berhasil Disalin",
"copyFail": "Gagal Menyalin",
"sure": "Yakin",
"encryption": "Enkripsi",
"transmission": "Transmisi",
@@ -878,7 +876,6 @@
"subIdDesc": "Juga berfungsi sebagai kode undangan bot Telegram untuk klien ini: siapa pun yang mengirimkannya ke bot akan ditautkan ke klien ini. Buat panjang dan acak — ID yang pendek atau mudah ditebak bisa diklaim orang lain.",
"online": "Online",
"email": "Email",
"emailReservedName": "Nama klien tidak boleh hanya \".\" atau \"..\"",
"emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
"subIdInvalidChars": "ID langganan tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
"group": "Grup",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "無期限",
"unlimited": "無制限",
"none": "なし",
"empty": "空欄",
"qrCode": "QRコード",
"info": "詳細情報",
"edit": "編集",
@@ -48,7 +47,6 @@
"reset": "リセット",
"noData": "データなし。",
"copySuccess": "コピー成功",
"copyFail": "コピー失敗",
"sure": "確定",
"encryption": "暗号化",
"transmission": "伝送",
@@ -878,7 +876,6 @@
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
"online": "オンライン",
"email": "メール",
"emailReservedName": "クライアント名を「.」または「..」だけにすることはできません",
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
"group": "グループ",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Indeterminado",
"unlimited": "Ilimitado",
"none": "Nenhum",
"empty": "Vazio",
"qrCode": "Código QR",
"info": "Mais Informações",
"edit": "Editar",
@@ -48,7 +47,6 @@
"reset": "Redefinir",
"noData": "Sem dados.",
"copySuccess": "Copiado com Sucesso",
"copyFail": "Falha ao Copiar",
"sure": "Certo",
"encryption": "Criptografia",
"transmission": "Transmissão",
@@ -878,7 +876,6 @@
"subIdDesc": "Também funciona como código de convite deste cliente no bot do Telegram: quem enviá-lo ao bot fica vinculado a este cliente. Mantenha-o longo e aleatório — um ID curto ou fácil de adivinhar pode ser reivindicado por outra pessoa.",
"online": "Online",
"email": "Email",
"emailReservedName": "O nome do cliente não pode ser exatamente \".\" ou \"..\"",
"emailInvalidChars": "O e-mail não pode conter espaços, '/', '\\' ou caracteres de controle",
"subIdInvalidChars": "O ID de assinatura não pode conter espaços, '/', '\\' ou caracteres de controle",
"group": "Grupo",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Бесконечно",
"unlimited": "Безлимит",
"none": "Нет",
"empty": "Пусто",
"qrCode": "QR-код",
"info": "Информация",
"edit": "Изменить",
@@ -48,7 +47,6 @@
"reset": "Сброс",
"noData": "Нет данных.",
"copySuccess": "Скопировано",
"copyFail": "Не удалось скопировать",
"sure": "Да",
"encryption": "Шифрование",
"transmission": "Транспорт",
@@ -878,7 +876,6 @@
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
"online": "В сети",
"email": "Email",
"emailReservedName": "Имя клиента не может состоять только из «.» или «..»",
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
"group": "Группа",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Bez časového obmedzenia",
"unlimited": "Bez obmedzenia",
"none": "Žiadne",
"empty": "Prázdne",
"qrCode": "QR kód",
"info": "Ďalšie informácie",
"edit": "Upraviť",
@@ -48,7 +47,6 @@
"reset": "Vynulovať",
"noData": "Zatiaľ tu nič nie je",
"copySuccess": "Úspešne skopírované",
"copyFail": "Kopírovanie zlyhalo",
"sure": "Áno",
"encryption": "Šifrovanie",
"transmission": "Prenos",
@@ -878,7 +876,6 @@
"subIdDesc": "Slúži aj ako pozývací kód klienta do Telegram bota. Kto ho pošle botovi, priradí sa k tomuto klientovi. Použite dlhé náhodné ID. Krátke alebo odhadnuteľné ID si môže privlastniť niekto iný.",
"online": "Online",
"email": "Názov klienta",
"emailReservedName": "Názov klienta nesmie byť presne \".\" ani \"..\"",
"emailInvalidChars": "Názov klienta nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
"subIdInvalidChars": "ID odberu nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
"group": "Skupina",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Belirsiz",
"unlimited": "Sınırsız",
"none": "Yok",
"empty": "Boş",
"qrCode": "QR Kod",
"info": "Daha Fazla Bilgi",
"edit": "Düzenle",
@@ -48,7 +47,6 @@
"reset": "Sıfırla",
"noData": "Veri yok.",
"copySuccess": "Başarıyla kopyalandı",
"copyFail": "Kopyalama başarısız",
"sure": "Emin misiniz?",
"encryption": "Şifreleme",
"transmission": "İletim",
@@ -878,7 +876,6 @@
"subIdDesc": "Bu istemcinin Telegram botu davet kodu olarak da kullanılır: bunu bota gönderen kişi bu istemciye bağlanır. Uzun ve rastgele tutun — kısa veya tahmin edilebilir bir kimlik başkası tarafından sahiplenilebilir.",
"online": "Çevrimiçi",
"email": "E-posta",
"emailReservedName": "İstemci adı yalnızca \".\" veya \"..\" olamaz",
"emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez",
"subIdInvalidChars": "Abonelik kimliği boşluk, '/', '\\' veya kontrol karakterleri içeremez",
"group": "Grup",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Безстроково",
"unlimited": "Безлімітний",
"none": "Немає",
"empty": "Порожньо",
"qrCode": "QR-Код",
"info": "Більше інформації",
"edit": "Змінити",
@@ -48,7 +47,6 @@
"reset": "Скидання",
"noData": "Немає даних.",
"copySuccess": "Скопійовано успішно",
"copyFail": "Не вдалося скопіювати",
"sure": "Звичайно",
"encryption": "Шифрування",
"transmission": "Протокол передачи",
@@ -878,7 +876,6 @@
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
"online": "У мережі",
"email": "Email",
"emailReservedName": "Ім’я клієнта не може складатися лише з «.» або «..»",
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
"group": "Група",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Không xác định",
"unlimited": "Không giới hạn",
"none": "Không",
"empty": "Trống",
"qrCode": "Mã QR",
"info": "Thông tin thêm",
"edit": "Sửa",
@@ -48,7 +47,6 @@
"reset": "Đặt lại",
"noData": "Không có dữ liệu.",
"copySuccess": "Đã sao chép thành công",
"copyFail": "Sao chép thất bại",
"sure": "Chắc chắn",
"encryption": "Mã hóa",
"transmission": "Truyền tải",
@@ -878,7 +876,6 @@
"subIdDesc": "Cũng được dùng làm mã mời bot Telegram của client này: ai gửi mã này cho bot sẽ được liên kết với client. Hãy để mã dài và ngẫu nhiên — ID ngắn hoặc dễ đoán có thể bị người khác chiếm.",
"online": "Trực tuyến",
"email": "Email",
"emailReservedName": "Tên máy khách không được chỉ là \".\" hoặc \"..\"",
"emailInvalidChars": "Email không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
"subIdInvalidChars": "ID đăng ký không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
"group": "Nhóm",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "无限期",
"unlimited": "无限制",
"none": "无",
"empty": "空",
"qrCode": "二维码",
"info": "更多信息",
"edit": "编辑",
@@ -48,7 +47,6 @@
"reset": "重置",
"noData": "无数据。",
"copySuccess": "复制成功",
"copyFail": "复制失败",
"sure": "确定",
"encryption": "加密",
"transmission": "传输",
@@ -878,7 +876,6 @@
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
"online": "在线",
"email": "邮箱",
"emailReservedName": "客户端名称不能仅为“.”或“..”",
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
"group": "分组",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "無限期",
"unlimited": "無限制",
"none": "無",
"empty": "空",
"qrCode": "二維碼",
"info": "更多資訊",
"edit": "編輯",
@@ -48,7 +47,6 @@
"reset": "重置",
"noData": "無數據。",
"copySuccess": "複製成功",
"copyFail": "複製失敗",
"sure": "確定",
"encryption": "加密",
"transmission": "傳輸",
@@ -878,7 +876,6 @@
"subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
"online": "上線",
"email": "電子郵件",
"emailReservedName": "用戶端名稱不能僅為「.」或「..」",
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
"group": "群組",