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
89 changed files with 290 additions and 3065 deletions
-9
View File
@@ -365,15 +365,6 @@ jobs:
- name: Build Storybook - name: Build Storybook
run: npm run build-storybook run: npm run build-storybook
working-directory: frontend 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 - name: Audit
run: npm run audit run: npm run audit
working-directory: frontend 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 title: Fetch one client by email, including the inbound IDs and external config
IDs it is attached to. IDs it is attached to.
url: '#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 - depth: 2
title: Fetch clients by subscription ID. title: Fetch clients by subscription ID.
url: '#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. title: Preview client auto-renewal dates without saving or resetting anything.
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything' url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
- depth: 2 - depth: 2
title: 'Update an existing client by email. Changes propagate to every attached title: Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their inbound. Body is the JSON client payload — supply the full set of fields
current values, and an explicit zero, empty or null value clears a you want to keep (the server replaces the row, it does not patch).
field, except as noted below.' 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'
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'
- depth: 2 - depth: 2
title: Delete a client by email. Removes it from every attached inbound and title: Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed. 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' 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 - depth: 2
title: Attach an existing client to one or more additional inbounds. Body is title: Attach an existing client to one or more additional inbounds. Body is
JSON. JSON.
@@ -339,9 +324,6 @@ _openapi:
- content: Fetch one client by email, including the inbound IDs and external - content: Fetch one client by email, including the inbound IDs and external
config IDs it is attached to. config IDs it is attached to.
id: 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. - content: Fetch clients by subscription ID.
id: fetch-clients-by-subscription-id id: fetch-clients-by-subscription-id
- content: Fetch clients by Telegram user ID. Returns an array since multiple - 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 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. - content: Preview client auto-renewal dates without saving or resetting anything.
id: 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 - content: Update an existing client by email. Changes propagate to every attached
attached inbound. Body is the JSON client payload: omitted fields keep inbound. Body is the JSON client payload — supply the full set of
their current values, and an explicit zero, empty or null value clears fields you want to keep (the server replaces the row, it does not
a field, except as noted below.' 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 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: '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: Delete a client by email. Removes it from every attached inbound and - content: Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed. 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 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 - content: Attach an existing client to one or more additional inbounds. Body is
JSON. JSON.
id: 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.' 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 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: 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. - content: Matches the exact subscription ID and orders results by client ID.
Returns an array of the same payloads as GET /clients/get/:email, Returns an array of the same payloads as GET /clients/get/:email,
since multiple clients can share a subscription ID. Includes disabled, 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. email lookup. Requires an admin token or a panel login session.
heading: fetch-clients-by-subscription-id heading: fetch-clients-by-subscription-id
- content: >- - content: >-
Client email must not be exactly `.` or `..`. Existing reserved-name Fields the server fills in when they are omitted — a valid value sent
clients can be read, renamed to a valid name, or deleted using the by the caller is never overwritten. Re-adding an email that already
query-parameter /get, /update and /del aliases. Fields the server exists, with its stored `subId`, reuses the stored `id`, `password`,
fills in when they are omitted — a valid value sent by the caller is `auth` and `secret` instead of minting new ones, so the identity stays
never overwritten. Re-adding an email that already exists, with its in sync across its inbounds.
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 - **VLESS / VMess** — `id`, a fresh UUID
@@ -673,11 +641,7 @@ _openapi:
preview is informational and does not reserve an allowance or preview is informational and does not reserve an allowance or
guarantee node availability. guarantee node availability.
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: 'An omitted `flow` leaves every attached inbound on its own flow. Send - content: 'The inbounds are applied concurrently and independently: one that
`"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 fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can 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 record is written after the inbounds, so a failure there is reported
without an `inbound <id>:` prefix and leaves the inbound edits in without an `inbound <id>:` prefix and leaves the inbound edits in
place.' 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 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: '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
- content: '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 fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures 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>:` inbounds, so a failure there is reported without an `inbound <id>:`
prefix and leaves the client already removed from every inbound.' 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 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 - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
instead of being given a fresh address, so the call fails with instead of being given a fresh address, so the call fails with
`inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address>
@@ -748,7 +685,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {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 - depth: 2
title: >- title: >-
Update an existing client by email. Changes propagate to every attached Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their inbound. Body is the JSON client payload — supply the full set of fields
current values, and an explicit zero, empty or null value clears a you want to keep (the server replaces the row, it does not patch).
field, except as noted below.
url: >- 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 - depth: 2
title: >- title: >-
Delete a client by email. Removes it from every attached inbound and 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 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: >- - content: >-
Update an existing client by email. Changes propagate to every Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep attached inbound. Body is the JSON client payload — supply the full
their current values, and an explicit zero, empty or null value clears set of fields you want to keep (the server replaces the row, it does
a field, except as noted below. not patch).
id: >- 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: >- - content: >-
Delete a client by email. Removes it from every attached inbound and Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed. drops its traffic record unless keepTraffic=1 is passed.
@@ -44,11 +44,10 @@ _openapi:
- depth: 2 - depth: 2
title: >- title: >-
Update an existing client by email. Changes propagate to every attached Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their inbound. Body is the JSON client payload — supply the full set of fields
current values, and an explicit zero, empty or null value clears a you want to keep (the server replaces the row, it does not patch).
field, except as noted below.
url: >- 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 - depth: 2
title: >- title: >-
Delete a client by email. Removes it from every attached inbound and 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 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: >- - content: >-
Update an existing client by email. Changes propagate to every Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep attached inbound. Body is the JSON client payload — supply the full
their current values, and an explicit zero, empty or null value clears set of fields you want to keep (the server replaces the row, it does
a field, except as noted below. not patch).
id: >- 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: >- - content: >-
Delete a client by email. Removes it from every attached inbound and Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed. 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 #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 - depth: 2
title: >- title: >-
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷: 按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。 请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
url: >- 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 - depth: 2
title: >- title: >-
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入 按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
@@ -292,10 +292,10 @@ _openapi:
id: >- 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 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: >- - content: >-
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷: 按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。 请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
id: >- 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: >- - content: >-
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入 按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
keepTraffic=1。 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}": { "/panel/api/clients/get/subId/{subId}": {
"get": { "get": {
"tags": [ "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.", "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add", "operationId": "post_panel_api_clients_add",
"description": "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": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -9053,9 +9011,9 @@
"tags": [ "tags": [
"Clients" "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", "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": [ "parameters": [
{ {
"name": "email", "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}": { "/panel/api/clients/del/{email}": {
"post": { "post": {
"tags": [ "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": { "/panel/api/clients/{email}/attach": {
"post": { "post": {
"tags": [ "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 CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
`build-storybook` on every PR (see `../.github/workflows/ci.yml`). `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 ### One-off: scan for deprecated APIs
Run this command to sweep the codebase for usages of APIs marked 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": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.91.0", "react-hook-form": "^7.91.0",
"react-i18next": "^17.1.10", "react-i18next": "^17.1.11",
"react-router": "^8.5.1", "react-router": "^8.5.1",
"swagger-ui-react": "^5.34.1", "swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32", "uplot": "^1.6.32",
@@ -9604,9 +9604,9 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.1.10", "version": "17.1.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.10.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.11.tgz",
"integrity": "sha512-XuUuA/rxukE4hXuR//PN308DuSQkWqKNNWBUj0dDTR087l3+6WLid3NC/w55ALrXA/dtWb7lychJ4rQLR3DCDg==", "integrity": "sha512-nQqkvaD+N4kSrM0bosDc7yW2IvaC3chHPhZb0FoRXX6Buf1JzfOLLLs2u7SfXcnAE3nKAIn0Kvu68lQGObPlyA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.7", "@babel/runtime": "^7.29.7",
+1 -1
View File
@@ -51,7 +51,7 @@
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.91.0", "react-hook-form": "^7.91.0",
"react-i18next": "^17.1.10", "react-i18next": "^17.1.11",
"react-router": "^8.5.1", "react-router": "^8.5.1",
"swagger-ui-react": "^5.34.1", "swagger-ui-react": "^5.34.1",
"uplot": "^1.6.32", "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}": { "/panel/api/clients/get/subId/{subId}": {
"get": { "get": {
"tags": [ "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.", "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add", "operationId": "post_panel_api_clients_add",
"description": "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": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -9053,9 +9011,9 @@
"tags": [ "tags": [
"Clients" "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", "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": [ "parameters": [
{ {
"name": "email", "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}": { "/panel/api/clients/del/{email}": {
"post": { "post": {
"tags": [ "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": { "/panel/api/clients/{email}/attach": {
"post": { "post": {
"tags": [ "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 }) => { play: async ({ canvas, userEvent }) => {
const remarkError = await canvas.findByText('Remark is required'); const remarkError = await canvas.findByText('Remark is required');
await waitFor(() => expect(remarkError).toBeVisible()); 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 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.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
await userEvent.click(canvas.getByRole('button', { name: 'Validate' })); await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
+9 -12
View File
@@ -1,5 +1,5 @@
import { cloneElement, useId } from 'react'; 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 { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form'; import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
import { Form } from 'antd'; import { Form } from 'antd';
@@ -50,10 +50,12 @@ export function FormField<T extends FieldValues = FieldValues>({
const dottedName = toDotted(name) as Path<T>; const dottedName = toDotted(name) as Path<T>;
const generatedId = useId(); const generatedId = useId();
const hintId = useId(); const hintId = useId();
const errorId = `${generatedId}-error`; const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
const extraId = `${generatedId}-extra`; .props;
const childOwnProps = (children as ReactElement<{ id?: string } & AriaAttributes>).props;
const fieldId = childOwnProps.id ?? generatedId; const fieldId = childOwnProps.id ?? generatedId;
const describedBy = tooltip
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
: undefined;
return ( return (
<Controller <Controller
@@ -65,14 +67,8 @@ export function FormField<T extends FieldValues = FieldValues>({
const help = fieldState.error?.message const help = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message }) ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined; : undefined;
const describedBy =
[childOwnProps['aria-describedby'], tooltip && hintId, extra && extraId, help && errorId]
.filter(Boolean)
.join(' ') || undefined;
const childProps: Record<string, unknown> = { const childProps: Record<string, unknown> = {
id: fieldId, id: fieldId,
'aria-describedby': describedBy,
'aria-invalid': fieldState.invalid || childOwnProps['aria-invalid'],
[valueProp]: displayValue, [valueProp]: displayValue,
onChange: (...args: unknown[]) => { onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp); const raw = normalizeAntdOnChange(args, valueProp);
@@ -83,15 +79,16 @@ export function FormField<T extends FieldValues = FieldValues>({
onBlur: field.onBlur, onBlur: field.onBlur,
ref: field.ref, ref: field.ref,
}; };
if (describedBy) childProps['aria-describedby'] = describedBy;
return ( return (
<Form.Item <Form.Item
label={label} label={label}
htmlFor={label ? fieldId : undefined} htmlFor={label ? fieldId : undefined}
tooltip={tooltip} tooltip={tooltip}
extra={extra ? <span id={extraId}>{extra}</span> : extra} extra={extra}
required={required} required={required}
validateStatus={fieldState.error ? 'error' : undefined} validateStatus={fieldState.error ? 'error' : undefined}
help={help ? <span id={errorId}>{help}</span> : undefined} help={help}
noStyle={noStyle} noStyle={noStyle}
className={className} className={className}
style={style} style={style}
+5 -5
View File
@@ -4,7 +4,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
import { HttpUtil, Msg } from '@/utils'; import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate'; import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import { clientManagementPath } from '@/api/client-management';
import { markLocalInvalidate } from '@/api/invalidationTracker'; import { markLocalInvalidate } from '@/api/invalidationTracker';
import { import {
ClientHydrateSchema, ClientHydrateSchema,
@@ -344,7 +343,7 @@ export function useClients(options: UseClientsOptions = {}) {
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => { const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
if (!email) return 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; if (!msg?.success || !msg.obj) return null;
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get'); const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
return validated.obj; return validated.obj;
@@ -376,7 +375,7 @@ export function useClients(options: UseClientsOptions = {}) {
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) => 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) => { onSuccess: (msg) => {
if (msg?.success) invalidateAll(); if (msg?.success) invalidateAll();
}, },
@@ -384,8 +383,9 @@ export function useClients(options: UseClientsOptions = {}) {
const removeMut = useMutation({ const removeMut = useMutation({
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => { mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
let url = clientManagementPath('del', email); const url = keepTraffic
if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`; ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
return HttpUtil.post(url); return HttpUtil.post(url);
}, },
onSuccess: (msg) => { onSuccess: (msg) => {
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { Button, Divider, Form, Input, Select } from 'antd'; import { Button, Divider, Form, Input, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -69,7 +68,7 @@ export default function CustomSockoptList({
<Input placeholder="decimal, e.g. 19" /> <Input placeholder="decimal, e.g. 19" />
</Form.Item> </Form.Item>
<Form.Item label="Type" name={[field.name, 'type']}> <Form.Item label="Type" name={[field.name, 'type']}>
<NativeSelect options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item label="Value" name={[field.name, 'value']}> <Form.Item label="Value" name={[field.name, 'value']}>
<Input placeholder="value" /> <Input placeholder="value" />
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useEffect, useId, useRef, useState } from 'react'; import { useEffect, useId, useRef, useState } from 'react';
import { import {
AutoComplete, AutoComplete,
@@ -408,7 +407,7 @@ function TcpMasksList({
} }
/> />
{allowPresets && ( {allowPresets && (
<NativeSelect <Select
id={`${uid}-tcp-presets`} id={`${uid}-tcp-presets`}
size="small" size="small"
style={{ minWidth: 160 }} style={{ minWidth: 160 }}
@@ -479,7 +478,7 @@ function TcpMaskItem({
</Divider> </Divider>
<Form.Item label="Type" name={[fieldName, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
<NativeSelect <Select
onChange={(v) => onChange={(v) =>
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v)) form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
} }
@@ -1124,7 +1123,7 @@ function UdpMaskItem({
</Divider> </Divider>
<Form.Item label="Type" name={[fieldName, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
<NativeSelect onChange={onTypeChange} options={options} /> <Select onChange={onTypeChange} options={options} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -1148,7 +1147,7 @@ function UdpMaskItem({
return ( return (
<> <>
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}> <Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'Original / AES-128-GCM' }, { value: '', label: 'Original / AES-128-GCM' },
{ value: 'dns', label: 'DNS' }, { value: 'dns', label: 'DNS' },
@@ -1328,7 +1327,7 @@ function SalamanderUdpMaskSettings({
: 'Scrambles each packet into random-looking bytes.' : 'Scrambles each packet into random-looking bytes.'
} }
> >
<NativeSelect <Select
id={`${uid}-salamander-mode`} id={`${uid}-salamander-mode`}
value={mode} value={mode}
onChange={(next) => { onChange={(next) => {
@@ -1948,7 +1947,7 @@ function ItemEditor({
<> <>
{allowVariables && ( {allowVariables && (
<Form.Item label="Kind" htmlFor={`${uid}-kind`}> <Form.Item label="Kind" htmlFor={`${uid}-kind`}>
<NativeSelect <Select
id={`${uid}-kind`} id={`${uid}-kind`}
value={kind} value={kind}
onChange={onKindChange} onChange={onKindChange}
@@ -1958,7 +1957,7 @@ function ItemEditor({
)} )}
{kind === 'bytes' && ( {kind === 'bytes' && (
<Form.Item label="Type" name={[fieldName, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
<NativeSelect <Select
onChange={onTypeChange} onChange={onTypeChange}
options={[ options={[
{ value: 'array', label: 'Array' }, { value: 'array', label: 'Array' },
@@ -2123,7 +2122,7 @@ function QuicParamsForm({
return ( return (
<> <>
<Form.Item label="Congestion" name={[...base, 'congestion']}> <Form.Item label="Congestion" name={[...base, 'congestion']}>
<NativeSelect <Select
options={[ options={[
{ value: 'reno', label: 'Reno' }, { value: 'reno', label: 'Reno' },
{ value: 'bbr', label: 'BBR' }, { value: 'bbr', label: 'BBR' },
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Typography } from 'antd'; import { Input, InputNumber, Select, Switch, Typography } from 'antd';
@@ -90,7 +89,7 @@ export default function XDriveForm() {
return ( return (
<> <>
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}> <FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
<NativeSelect options={serviceOptions} /> <Select options={serviceOptions} />
</FormField> </FormField>
<FormField <FormField
label={t('pages.inbounds.form.xdrive.remoteFolder')} label={t('pages.inbounds.form.xdrive.remoteFolder')}
+3 -52
View File
@@ -1149,19 +1149,6 @@ export const sections: readonly Section[] = [
response: 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}', '{\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', method: 'GET',
path: '/panel/api/clients/get/subId/:subId', path: '/panel/api/clients/get/subId/:subId',
@@ -1208,7 +1195,7 @@ export const sections: readonly Section[] = [
summary: summary:
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.', 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
description: description:
'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: [ params: [
{ {
name: 'client', name: 'client',
@@ -1276,9 +1263,9 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/clients/update/:email', path: '/panel/api/clients/update/:email',
summary: 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: 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: [ params: [
{ {
name: 'email', 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}', 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}', 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', method: 'POST',
path: '/panel/api/clients/del/:email', path: '/panel/api/clients/del/:email',
@@ -1326,24 +1295,6 @@ export const sections: readonly Section[] = [
], ],
response: '{\n "success": true,\n "msg": "Client deleted"\n}', 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', method: 'POST',
path: '/panel/api/clients/:email/attach', path: '/panel/api/clients/:email/attach',
@@ -95,7 +95,7 @@ export default function ClientBulkAdjustModal({
<Modal <Modal
open={open} open={open}
title={t('pages.clients.bulkAdjustTitle', { count })} title={t('pages.clients.bulkAdjustTitle', { count })}
okText={t('confirm')} okText={t('apply')}
cancelText={t('cancel')} cancelText={t('cancel')}
confirmLoading={submitting} confirmLoading={submitting}
onOk={handleOk} onOk={handleOk}
@@ -798,10 +798,10 @@ export default function ClientFormModal({
comment: values.comment, comment: values.comment,
enable: !!values.enable, 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() : ''; const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
clientPayload.reverse = reverseTagValue ? { tag: reverseTagValue } : null; if (reverseTagValue) {
clientPayload.reverse = { tag: reverseTagValue };
}
if (showWireguard || showAmneziawg) { if (showWireguard || showAmneziawg) {
// AmneziaWG peers are wire-identical to WireGuard peers (same // 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>
<Form.Item label={t('pages.clients.attachedInbounds')} htmlFor={`${uid}-inbounds`}> <Form.Item label={t('inbounds')} htmlFor={`${uid}-inbounds`}>
<Select <Select
id={`${uid}-inbounds`} id={`${uid}-inbounds`}
mode="multiple" mode="multiple"
@@ -136,7 +136,7 @@ export default function FilterDrawer({
value={filters.inboundIds} value={filters.inboundIds}
onChange={(v) => patch('inboundIds', v as number[])} onChange={(v) => patch('inboundIds', v as number[])}
options={inboundOptions} options={inboundOptions}
placeholder={t('pages.clients.attachedInbounds')} placeholder={t('inbounds')}
allowClear allowClear
showSearch={{ optionFilterProp: 'label' }} showSearch={{ optionFilterProp: 'label' }}
listHeight={220} 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 { useEffect, useRef, useState, useId } from 'react';
import type { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
Alert,
ConfigProvider,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Switch,
Tabs,
message,
} from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; 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 = 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])?)*$/; /^[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 { function isValidShareAddrInput(value: string): boolean {
const v = value.trim(); const v = value.trim();
if (v.length === 0) return true; if (v.length === 0) return true;
@@ -713,7 +677,7 @@ export default function InboundFormModal({
)} )}
<FormField name="protocol" label={t('pages.inbounds.protocol')}> <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>
<FormField <FormField
@@ -731,7 +695,7 @@ export default function InboundFormModal({
label={t('pages.inbounds.form.shareAddrStrategy')} label={t('pages.inbounds.form.shareAddrStrategy')}
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')} tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
> >
<NativeSelect <Select
options={SHARE_ADDR_STRATEGIES.filter( options={SHARE_ADDR_STRATEGIES.filter(
(strategy) => strategy !== 'node' || nodeShareOptionAvailable, (strategy) => strategy !== 'node' || nodeShareOptionAvailable,
).map((strategy) => ({ ).map((strategy) => ({
@@ -816,7 +780,7 @@ export default function InboundFormModal({
</Form.Item> </Form.Item>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}> <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<NativeSelect <Select
options={TRAFFIC_RESETS.map((r) => ({ options={TRAFFIC_RESETS.map((r) => ({
value: r, value: r,
label: t(`pages.inbounds.periodicTrafficReset.${r}`), label: t(`pages.inbounds.periodicTrafficReset.${r}`),
@@ -994,7 +958,7 @@ export default function InboundFormModal({
<> <>
{hasSelectableTransport && ( {hasSelectableTransport && (
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}> <Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
<NativeSelect <Select
id={`${uid}-network`} id={`${uid}-network`}
style={{ width: '75%' }} style={{ width: '75%' }}
value={network} value={network}
@@ -1070,11 +1034,8 @@ export default function InboundFormModal({
const securityTab = ( const securityTab = (
<> <>
<Form.Item <Form.Item label={t('pages.inbounds.securityTab')}>
label={<span id={`${uid}-security-label`}>{t('pages.inbounds.securityTab')}</span>}
>
<Radio.Group <Radio.Group
aria-labelledby={`${uid}-security-label`}
value={security} value={security}
buttonStyle="solid" buttonStyle="solid"
disabled={!tlsOk} disabled={!tlsOk}
@@ -1213,7 +1174,7 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />; const sniffingTab = <SniffingTab />;
return ( return (
<ConfigProvider virtual={false} getPopupContainer={inboundPopupContainer}> <>
{messageContextHolder} {messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Modal <Modal
@@ -1227,11 +1188,6 @@ export default function InboundFormModal({
onOk={submit} onOk={submit}
onCancel={onClose} onCancel={onClose}
destroyOnHidden destroyOnHidden
modalRender={(content) => (
<div role="presentation" onKeyDown={loopInboundFocus}>
{content}
</div>
)}
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<Form <Form
@@ -1317,6 +1273,6 @@ export default function InboundFormModal({
</Form> </Form>
</FormProvider> </FormProvider>
</Modal> </Modal>
</ConfigProvider> </>
); );
} }
@@ -1,7 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react'; import { useId } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
@@ -61,7 +60,7 @@ export default function HysteriaFields() {
{masq && ( {masq && (
<> <>
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}> <FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}>
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'default (404 page)' }, { value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' }, { value: 'proxy', label: 'proxy (reverse proxy)' },
@@ -1,6 +1,5 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, Switch } from 'antd'; import { Input, Select, Switch } from 'antd';
import { FormField } from '@/components/form/rhf'; import { FormField } from '@/components/form/rhf';
import AccountsList from './accounts-list'; import AccountsList from './accounts-list';
@@ -11,7 +10,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
<> <>
<AccountsList /> <AccountsList />
<FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}> <FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
<NativeSelect <Select
options={[ options={[
{ value: 'noauth', label: 'noauth' }, { value: 'noauth', label: 'noauth' },
{ value: 'password', label: 'password' }, { value: 'password', label: 'password' },
@@ -1,7 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react'; import { useId } from 'react';
import { useTranslation } from 'react-i18next'; 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 { ReloadOutlined } from '@ant-design/icons';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
@@ -26,9 +25,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string)); setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
}} }}
> >
<NativeSelect <Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))}
/>
</FormField> </FormField>
{isSSWith2022 && ( {isSSWith2022 && (
<Form.Item htmlFor={`${uid}-password`} label={t('password')}> <Form.Item htmlFor={`${uid}-password`} label={t('password')}>
@@ -51,7 +48,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
</Form.Item> </Form.Item>
)} )}
<FormField name={['settings', 'network']} label={t('pages.inbounds.network')}> <FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
<NativeSelect <Select
style={{ width: 120 }} style={{ width: 120 }}
options={[ options={[
{ value: 'tcp,udp', label: 'TCP, UDP' }, { value: 'tcp,udp', label: 'TCP, UDP' },
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react'; import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -69,7 +68,7 @@ export default function TuicFields() {
if (obj.webKeyFile) { if (obj.webKeyFile) {
setValue('settings.server.private_key', obj.webKeyFile); setValue('settings.server.private_key', obj.webKeyFile);
} }
messageApi.success(t('success')); messageApi.success(t('pages.inbounds.setSuccess'));
} catch { } catch {
messageApi.error(t('somethingWentWrong')); messageApi.error(t('somethingWentWrong'));
} finally { } finally {
@@ -112,7 +111,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.logLevel')} label={t('pages.xray.tuic.logLevel')}
tooltip={t('pages.xray.tuic.logLevelHint')} tooltip={t('pages.xray.tuic.logLevelHint')}
> >
<NativeSelect <Select
options={[ options={[
{ label: 'Info', value: 'info' }, { label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' }, { label: 'Warn', value: 'warn' },
@@ -200,7 +199,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.congestionControl')} label={t('pages.xray.tuic.congestionControl')}
tooltip={t('pages.xray.tuic.congestionControlHint')} tooltip={t('pages.xray.tuic.congestionControlHint')}
> >
<NativeSelect <Select
options={[ options={[
{ label: 'BBR', value: 'bbr' }, { label: 'BBR', value: 'bbr' },
{ label: 'CUBIC', value: 'cubic' }, { label: 'CUBIC', value: 'cubic' },
@@ -245,7 +244,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.udpRelayMode')} label={t('pages.xray.tuic.udpRelayMode')}
tooltip={t('pages.xray.tuic.udpRelayModeHint')} tooltip={t('pages.xray.tuic.udpRelayModeHint')}
> >
<NativeSelect <Select
options={[ options={[
{ label: 'Native (Recommended)', value: 'native' }, { label: 'Native (Recommended)', value: 'native' },
{ label: 'QUIC', value: 'quic' }, { label: 'QUIC', value: 'quic' },
@@ -1,6 +1,5 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Switch } from 'antd'; import { Input, InputNumber, Select, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf'; import { FormField } from '@/components/form/rhf';
@@ -22,7 +21,7 @@ export default function TunnelFields() {
name={['settings', 'allowedNetwork']} name={['settings', 'allowedNetwork']}
label={t('pages.inbounds.form.allowedNetwork')} label={t('pages.inbounds.form.allowedNetwork')}
> >
<NativeSelect <Select
options={[ options={[
{ value: 'tcp,udp', label: 'TCP, UDP' }, { value: 'tcp,udp', label: 'TCP, UDP' },
{ value: 'tcp', label: 'TCP' }, { value: 'tcp', label: 'TCP' },
@@ -1,7 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react'; import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next'; 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 { Controller, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf'; import { FormField } from '@/components/form/rhf';
@@ -51,7 +50,7 @@ export default function VlessFields({
</FormField> </FormField>
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}> <Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap> <Space size={8} wrap>
<NativeSelect <Select
id={`${uid}-authKind`} id={`${uid}-authKind`}
value={authKind} value={authKind}
onChange={(v) => setAuthKind(v)} onChange={(v) => setAuthKind(v)}
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react'; import { useState, useId } from 'react';
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -113,9 +112,7 @@ export default function RealityForm({
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']} name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS" label="uTLS"
> >
<NativeSelect <Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
</FormField> </FormField>
<Form.Item <Form.Item
htmlFor={`${uid}-target`} htmlFor={`${uid}-target`}
@@ -1,4 +1,3 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react'; import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -155,7 +154,7 @@ function CertRow({
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']} name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
label={t('pages.inbounds.form.usageOption')} label={t('pages.inbounds.form.usageOption')}
> >
<NativeSelect <Select
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))} options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/> />
@@ -213,7 +212,7 @@ function EchSockoptSection() {
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']} name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')} label={t('pages.xray.wireguard.domainStrategy')}
> >
<NativeSelect <Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))} options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
/> />
</FormField> </FormField>
@@ -355,14 +354,14 @@ export default function TlsForm({
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}> <Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block> <Space.Compact block>
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle> <FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<NativeSelect <Select
id={`${uid}-minVersion`} id={`${uid}-minVersion`}
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))} options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/> />
</FormField> </FormField>
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle> <FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<NativeSelect <Select
aria-label={t('pages.inbounds.form.maxVersion')} aria-label={t('pages.inbounds.form.maxVersion')}
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))} options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
@@ -371,7 +370,7 @@ export default function TlsForm({
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS"> <FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS">
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'None' }, { value: '', label: 'None' },
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })), ...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 { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd'; import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
@@ -208,13 +207,13 @@ export default function SockoptForm({
name={['streamSettings', 'sockopt', 'tcpcongestion']} name={['streamSettings', 'sockopt', 'tcpcongestion']}
label={t('pages.inbounds.form.tcpCongestion')} label={t('pages.inbounds.form.tcpCongestion')}
> >
<NativeSelect <Select
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))} options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
/> />
</FormField> </FormField>
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy"> <FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<NativeSelect <Select
style={{ width: '50%' }} style={{ width: '50%' }}
options={[ options={[
{ value: 'off', label: 'Off' }, { value: 'off', label: 'Off' },
@@ -1,6 +1,5 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next'; 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 { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
@@ -82,7 +81,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'mode']} name={['streamSettings', 'xhttpSettings', 'mode']}
label={t('pages.inbounds.info.mode')} label={t('pages.inbounds.info.mode')}
> >
<NativeSelect <Select
style={{ width: '50%' }} style={{ width: '50%' }}
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({ options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
value: m, value: m,
@@ -150,7 +149,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']} name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
label={t('pages.inbounds.form.uplinkHttpMethod')} label={t('pages.inbounds.form.uplinkHttpMethod')}
> >
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'Default (POST)' }, { value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' }, { value: 'POST', label: 'POST' },
@@ -188,7 +187,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']} name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
label={t('pages.inbounds.form.paddingPlacement')} label={t('pages.inbounds.form.paddingPlacement')}
> >
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'Default (queryInHeader)' }, { value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' }, { value: 'queryInHeader', label: 'queryInHeader' },
@@ -202,7 +201,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']} name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
label={t('pages.inbounds.form.paddingMethod')} label={t('pages.inbounds.form.paddingMethod')}
> >
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'Default (repeat-x)' }, { value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' }, { value: 'repeat-x', label: 'repeat-x' },
@@ -216,7 +215,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']} name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
label={t('pages.inbounds.form.sessionPlacement')} label={t('pages.inbounds.form.sessionPlacement')}
> >
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'Default (path)' }, { value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' }, { value: 'path', label: 'path' },
@@ -260,7 +259,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'seqPlacement']} name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
label={t('pages.inbounds.form.sequencePlacement')} label={t('pages.inbounds.form.sequencePlacement')}
> >
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'Default (path)' }, { value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' }, { value: 'path', label: 'path' },
@@ -284,7 +283,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']} name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
label={t('pages.inbounds.form.uplinkDataPlacement')} label={t('pages.inbounds.form.uplinkDataPlacement')}
> >
<NativeSelect <Select
options={[ options={[
{ value: '', label: 'Default (auto)' }, { value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' }, { value: 'auto', label: 'auto' },
+1 -1
View File
@@ -169,7 +169,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
if (!token) return; if (!token) return;
const ok = await ClipboardManager.copyText(token); const ok = await ClipboardManager.copyText(token);
if (ok) messageApi.success(t('copySuccess')); if (ok) messageApi.success(t('copySuccess'));
else messageApi.error(t('copyFail')); else messageApi.error(t('copyFail') ?? 'Copy failed');
} }
function openCreateModal() { function openCreateModal() {
@@ -291,7 +291,7 @@ export default function WarpModal({
interval: methods.getValues('updateInterval'), interval: methods.getValues('updateInterval'),
}); });
if (msg?.success) { if (msg?.success) {
messageApi.success(t('pages.settings.toasts.modifySettings')); messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
} }
} finally { } finally {
setLoading(false); setLoading(false);
-1
View File
@@ -319,7 +319,6 @@ export const ClientFormSchema = z.object({
.string() .string()
.trim() .trim()
.min(1, 'pages.clients.email') .min(1, 'pages.clients.email')
.refine((v) => v !== '.' && v !== '..', 'pages.clients.emailReservedName')
.refine((v) => !hasForbiddenClientChars(v), 'pages.clients.emailInvalidChars'), .refine((v) => !hasForbiddenClientChars(v), 'pages.clients.emailInvalidChars'),
subId: z.string().refine((v) => !hasForbiddenClientChars(v), 'pages.clients.subIdInvalidChars'), subId: z.string().refine((v) => !hasForbiddenClientChars(v), 'pages.clients.subIdInvalidChars'),
uuid: z.string(), 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', () => { it('no literal t() key resolves to an object', () => {
const leaves = new Set(enKeys); const objectKeys = Array.from(
const literalKeys = new Set( blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g),
Array.from(blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g), (m) => m[2]), (m) => m[2],
); ).filter((key) => enKeys.some((leaf) => leaf.startsWith(`${key}.`)));
const unresolved = [...literalKeys].filter((key) => !leaves.has(key));
expect( expect(
unresolved, objectKeys,
`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 ')}`, `t() keys naming a group, rendered as "key '…' returned an object instead of string.":\n ${objectKeys.join('\n ')}`,
).toEqual([]); ).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 { 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 InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound'; import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { Protocols } from '@/schemas/primitives';
import { import {
renderWithProviders, renderWithProviders,
fieldLabels, fieldLabels,
@@ -41,8 +40,20 @@ function renderModal() {
); );
} }
function chooseProtocol(optionText: string) { // The protocol dropdown is virtualized, so jsdom renders only its first options; step
chooseSelectOption('protocol', optionText); // 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 { function primaryButton(): HTMLElement {
@@ -104,6 +115,12 @@ async function renderCloneLikeEdit(dbInbound: DBInbound) {
} }
describe('InboundFormModal', () => { 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 () => { it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
renderModal(); renderModal();
const protocols = listSelectOptions('protocol'); const protocols = listSelectOptions('protocol');
@@ -126,8 +143,9 @@ describe('InboundFormModal', () => {
expect(distinctShapes.size).toBeGreaterThan(1); expect(distinctShapes.size).toBeGreaterThan(1);
// Spot-check a protocol-distinguishing field that must appear after the switch. // Spot-check a protocol-distinguishing field that must appear after the switch.
expect(labelsByProto.shadowsocks).toContain('Encryption method'); if (labelsByProto.shadowsocks) {
expect(labelsByProto.vless).not.toContain('Encryption method'); expect(labelsByProto.shadowsocks).toContain('Encryption method');
}
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners }, 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 () => { 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)); await new Promise((r) => setTimeout(r, 0));
}); });
}; };
const selectedStrategy = () => const strategyItem = (title: string) =>
within(screen.getByRole('combobox', { name: 'Share address strategy' })).getByRole('option', { document.querySelector(`.ant-select-content[title="${title}"]`);
selected: true,
}).textContent;
const modal = (nodes: never[], fetched: boolean) => ( const modal = (nodes: never[], fetched: boolean) => (
<AntdRootConfig> <ThemeProvider>
<ThemeProvider> <InboundFormModal
<InboundFormModal open
open mode="edit"
mode="edit" dbInbound={buildInbound()}
dbInbound={buildInbound()} dbInbounds={[]}
dbInbounds={[]} availableNodes={nodes}
availableNodes={nodes} availableNodesFetched={fetched}
availableNodesFetched={fetched} onClose={() => {}}
onClose={() => {}} onSaved={() => {}}
onSaved={() => {}} />
/> </ThemeProvider>
</ThemeProvider>
</AntdRootConfig>
); );
// Baseline: nodes already loaded, so the node option is offered and selected. // Baseline: nodes already loaded, so the node option is offered and selected.
render(modal([node], true)); render(modal([node], true));
await flush(); await flush();
expect(selectedStrategy()).toBe('Node address'); expect(strategyItem('Node address')).toBeTruthy();
cleanup(); cleanup();
// Race: the modal mounts before /nodes/list resolves (empty placeholder), // Race: the modal mounts before /nodes/list resolves (empty placeholder),
@@ -257,7 +271,8 @@ describe('InboundFormModal', () => {
await flush(); await flush();
rerender(modal([node], true)); rerender(modal([node], true));
await flush(); 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 () => { it('surfaces a Reality validation error and switches to its tab', async () => {
@@ -329,9 +344,16 @@ describe('InboundFormModal', () => {
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
fireEvent.click(screen.getByRole('tab', { name: 'Stream' })); fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' }); const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
const option = within(transport).getByRole('option', { name: 'XDRIVE' }) as HTMLOptionElement; (el) =>
fireEvent.change(transport, { target: { value: option.value } }); 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 [ for (const [label, value] of [
['Remote folder', 'xray-tunnel'], ['Remote folder', 'xray-tunnel'],
['Client ID', 'id.apps.googleusercontent.com'], ['Client ID', 'id.apps.googleusercontent.com'],
@@ -365,7 +387,7 @@ describe('InboundFormModal', () => {
post.mockClear(); post.mockClear();
renderModal(); renderModal();
chooseProtocol('masque'); chooseVirtualizedProtocol('masque');
await act(async () => { await act(async () => {
await new Promise((r) => setTimeout(r, 0)); 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) { 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'); const select = formItemByLabel(label)?.querySelector('.ant-select');
if (!select) throw new Error(`${label} select not found`); if (!select) throw new Error(`${label} select not found`);
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select); fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -94,10 +93,6 @@ describe('FormField', () => {
fireEvent.click(screen.getByText('Save')); fireEvent.click(screen.getByText('Save'));
await screen.findByText('email-required'); 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(); expect(onSubmit).not.toHaveBeenCalled();
}); });
}); });
-13
View File
@@ -49,12 +49,6 @@ function openDropdownOptions(): string[] {
} }
export function listSelectOptions(fieldId: string): 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); const select = selectRootForField(fieldId);
openSelect(select); openSelect(select);
const opts = openDropdownOptions(); const opts = openDropdownOptions();
@@ -63,13 +57,6 @@ export function listSelectOptions(fieldId: string): string[] {
} }
export function chooseSelectOption(fieldId: string, optionText: 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); const select = selectRootForField(fieldId);
openSelect(select); openSelect(select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find( 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-contrib/sessions v1.1.3
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.15 github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-playground/validator/v10 v10.30.6 github.com/go-playground/validator/v10 v10.30.5
github.com/goccy/go-json v0.12.0 github.com/goccy/go-json v0.12.0
github.com/goccy/go-yaml v1.19.2 github.com/goccy/go-yaml v1.19.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
+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/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 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.6 h1:E+mymlPNRzAMNz1FShrRvLeGWcprF5w305eLLgWieuo= github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.6/go.mod h1:jPLL8SKCXGe/g87QmEyNw3NO28nmGt5GCnZnufm7rj4= 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 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+2 -3
View File
@@ -37,9 +37,8 @@ func TestSubscriptionCalendarExpireInclusive(t *testing.T) {
{"Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00+08:00", 1, 1, true}, {"Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00+08:00", 1, 1, true},
{"New York daylight time", "true", "America/New_York", "2030-10-01T00:00:00-04:00", 1, 1, true}, {"New York daylight time", "true", "America/New_York", "2030-10-01T00:00:00-04:00", 1, 1, true},
{"New York standard time", "true", "America/New_York", "2030-02-01T00:00:00-05:00", 1, 1, true}, {"New York standard time", "true", "America/New_York", "2030-02-01T00:00:00-05:00", 1, 1, true},
// Past fallback on purpose: tzdata 2026f cancelled Cuba's 2026 one. {"Havana first midnight", "true", "America/Havana", "2026-11-01T00:00:00-04:00", 1, 1, true},
{"Havana first midnight", "true", "America/Havana", "2020-11-01T00:00:00-04:00", 1, 1, true}, {"Havana repeated midnight", "true", "America/Havana", "2026-11-01T00:00:00-05:00", 1, 1, false},
{"Havana repeated midnight", "true", "America/Havana", "2020-11-01T00:00:00-05:00", 1, 1, false},
{"UTC midnight is not Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00Z", 1, 1, false}, {"UTC midnight is not Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00Z", 1, 1, false},
{"midday unchanged", "true", "UTC", "2030-10-01T12:00:00Z", 1, 1, false}, {"midday unchanged", "true", "UTC", "2030-10-01T12:00:00Z", 1, 1, false},
{"other midnight unchanged", "true", "UTC", "2030-10-02T00:00:00Z", 1, 1, false}, {"other midnight unchanged", "true", "UTC", "2030-10-02T00:00:00Z", 1, 1, false},
-2
View File
@@ -105,10 +105,8 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/inbounds/:id/subSortIndex": {http.MethodPost: {}}, "/inbounds/:id/subSortIndex": {http.MethodPost: {}},
"/clients/add": {http.MethodPost: {}}, "/clients/add": {http.MethodPost: {}},
"/clients/del/:email": {http.MethodPost: {}}, "/clients/del/:email": {http.MethodPost: {}},
"/clients/del": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}}, "/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}}, "/clients/update/:email": {http.MethodPost: {}},
"/clients/update": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}}, "/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}}, "/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {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) { func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list) g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged) g.GET("/list/paged", a.listPaged)
g.GET("/get", a.get)
g.GET("/get/:email", a.get) g.GET("/get/:email", a.get)
g.GET("/get/subId/:subId", a.getBySubId) g.GET("/get/subId/:subId", a.getBySubId)
g.GET("/get/tgId/:tgId", a.getByTgId) 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("/add", a.create)
g.POST("/renewalPreview", a.renewalPreview) g.POST("/renewalPreview", a.renewalPreview)
g.POST("/update", a.update)
g.POST("/update/:email", a.update) g.POST("/update/:email", a.update)
g.POST("/del", a.delete)
g.POST("/del/:email", a.delete) g.POST("/del/:email", a.delete)
g.POST("/:email/attach", a.attach) g.POST("/:email/attach", a.attach)
g.POST("/:email/detach", a.detach) g.POST("/:email/detach", a.detach)
@@ -162,24 +159,8 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
}, nil }, 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) { func (a *ClientController) get(c *gin.Context) {
email, ok := clientEmailParam(c) email := c.Param("email")
if !ok {
return
}
rec, err := a.clientService.GetRecordByEmail(nil, email) rec, err := a.clientService.GetRecordByEmail(nil, email)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) 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) { func (a *ClientController) update(c *gin.Context) {
email, ok := clientEmailParam(c) email := c.Param("email")
if !ok { var req struct {
return model.Client
LimitHwid int `json:"limitHwid"`
} }
body, err := c.GetRawData() if err := c.ShouldBindJSON(&req); err != nil {
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds")) 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 // Flagged before the error check: a partly-applied edit leaves the change
// committed on the inbounds that succeeded, and those still need the restart. // committed on the inbounds that succeeded, and those still need the restart.
if needRestart { if needRestart {
@@ -292,10 +273,7 @@ func (a *ClientController) update(c *gin.Context) {
} }
func (a *ClientController) delete(c *gin.Context) { func (a *ClientController) delete(c *gin.Context) {
email, ok := clientEmailParam(c) email := c.Param("email")
if !ok {
return
}
keepTraffic := c.Query("keepTraffic") == "1" keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic) needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
// Flagged before the error check: a partly-applied delete already removed // 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)
}
})
}
}
@@ -10,12 +10,10 @@ func TestNextWeeklyRenewal_RepeatedMidnight(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// A past transition: tzdata 2026f cancelled Cuba's November 2026 fallback,
// so a future one makes the result depend on the host's tzdata version.
for _, tt := range []struct{ expiry, want string }{ for _, tt := range []struct{ expiry, want string }{
{"2020-10-25T00:00:00-04:00", "2020-11-01T00:00:00-04:00"}, {"2026-10-25T00:00:00-04:00", "2026-11-01T00:00:00-04:00"},
{"2020-11-01T00:00:00-04:00", "2020-11-08T00:00:00-05:00"}, {"2026-11-01T00:00:00-04:00", "2026-11-08T00:00:00-05:00"},
{"2020-11-01T00:00:00-05:00", "2020-11-08T00:00:00-05:00"}, {"2026-11-01T00:00:00-05:00", "2026-11-08T00:00:00-05:00"},
} { } {
from, err := time.Parse(time.RFC3339, tt.expiry) from, err := time.Parse(time.RFC3339, tt.expiry)
if err != nil { if err != nil {
-35
View File
@@ -37,9 +37,6 @@ func hasForbiddenClientChar(s string) bool {
} }
func validateClientEmail(email string) error { func validateClientEmail(email string) error {
if email == "." || email == ".." {
return common.NewError("client email must not be '.' or '..'")
}
if hasForbiddenClientChar(email) { if hasForbiddenClientChar(email) {
return common.NewError("client email contains an invalid character:", 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) { 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) existing, err := s.GetByID(id)
if err != nil { if err != nil {
return false, err return false, err
@@ -782,12 +773,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
updated.Secret = existing.Secret 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 { if updated.Email != existing.Email {
var collisionCount int64 var collisionCount int64
if err := database.GetDB().Model(&model.ClientRecord{}). 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) tunnelCount, tcErr := tunnelInboundCount(inboundIds)
if tcErr != nil { if tcErr != nil {
return false, tcErr return false, tcErr
@@ -848,9 +825,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
return false, err return false, err
} }
clientForInbound := updated clientForInbound := updated
if keepInboundFlows {
clientForInbound.Flow = inboundFlows[ibId]
}
if ips, ok := updated.AllowedIPsByInbound[ibId]; ok { if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
clientForInbound.AllowedIPs = ips clientForInbound.AllowedIPs = ips
} else if tunnelCount > 1 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) { } 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 { }).Error; err != nil {
return needRestart, err 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 := "" reverseStr := ""
@@ -7,10 +7,6 @@ func TestValidateClientEmail(t *testing.T) {
"alice", "alice",
"alice@example.com", "alice@example.com",
"user-123_test.name", "user-123_test.name",
"...",
".alice",
"alice.",
"alice..example",
"имя", "имя",
} }
for _, email := range valid { for _, email := range valid {
@@ -20,8 +16,6 @@ func TestValidateClientEmail(t *testing.T) {
} }
invalid := []string{ invalid := []string{
".",
"..",
"i6dui/", "i6dui/",
"a/b", "a/b",
"client with spaces", "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) { 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) { return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
c["expiryTime"] = expiry_time 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 { if totalGB < 0 {
return false, common.NewError("totalGB must be >= 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) { return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
c["totalGB"] = newTotal c["totalGB"] = totalGB * 1024 * 1024 * 1024
if reenable {
c["enable"] = true
}
}) })
} }
@@ -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 needRestart = nr
} }
// Restore quota-disabled clients after zeroing; preserve an operator // Enable only a client the quota switched off, once the counters are zero: a
// disable, including when no inbound remains. // hand-disabled one stays off, and a still-depleted one would be switched off again.
if !rec.Enable && len(quotaDisabled) > 0 { if !rec.Enable && len(quotaDisabled) > 0 {
// Inbound resets already restored hosted clients. Only an unhosted updated := rec.ToClient()
// record still needs the full update. updated.Enable = true
fresh, fErr := s.GetRecordByEmail(nil, email) nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
if fErr == nil && !fresh.Enable { if uErr != nil {
updated := fresh.ToClient() logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
updated.Enable = true }
nr, uErr := s.Update(inboundSvc, fresh.Id, *updated, fresh.LimitHwid) if nr {
if uErr != nil { needRestart = true
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
}
if nr {
needRestart = true
}
} }
} }
return needRestart, nil return needRestart, nil
@@ -67,7 +67,7 @@ func requireResetSurvivesTick(t *testing.T, reset func() error) {
tickDone <- err tickDone <- err
}() }()
deadline := time.Now().Add(5 * time.Second) 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) time.Sleep(time.Millisecond)
} }
close(release) 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 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 // 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. // 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) { 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). // 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) { func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty, justPushed bool) (bool, error) {
var structuralChange bool var structuralChange bool
// Node merges take the background lane: a per-client mutation the operator err := submitTrafficWrite(func() error {
// is waiting on must not queue behind the next batch of 5s snapshots.
err := submitTrafficWriteBackground(func() error {
var inner error var inner error
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed) structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed)
return inner return inner
+47 -64
View File
@@ -28,9 +28,7 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
var disabledNodeIDs []int var disabledNodeIDs []int
var remotePlans []trafficInboundUpdatePlan var remotePlans []trafficInboundUpdatePlan
var renewed []string var renewed []string
// The periodic poll takes the background lane: an operator's save or reset err = submitTrafficWrite(func() error {
// queued while a poll runs must not sit behind the next merge batch.
err = submitTrafficWriteBackground(func() error {
var inner error var inner error
needRestart, clientsDisabled, disabledNodeIDs, remotePlans, renewed, inner = s.addTrafficLocked(inboundTraffics, clientTraffics) needRestart, clientsDisabled, disabledNodeIDs, remotePlans, renewed, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
return inner return inner
@@ -722,61 +720,55 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
needRestart := false needRestart := false
var reenablePlan *trafficLocalApplyPlan var reenablePlan *trafficLocalApplyPlan
var reenableNodeID *int 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) traffic, err := s.GetClientTrafficByEmail(clientEmail)
if err != nil { if err != nil {
return false, nil, err 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}) keepOff, err := handDisabledEmails(database.GetDB(), []string{clientEmail})
if err != nil { if err != nil {
return false, nil, err return false, nil, err
} }
handDisabled := len(keepOff) > 0 handDisabled := len(keepOff) > 0
inbound, err := s.GetInbound(id)
if err != nil { if !traffic.Enable && !handDisabled {
return false, nil, err inbound, err := s.GetInbound(id)
} if err != nil {
clients, err := s.GetClients(inbound) return false, nil, err
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
} }
} clients, err := s.GetClients(inbound)
if entry != nil && !handDisabled && (!entry.Enable || !traffic.Enable) { if err != nil {
flipStoredEntry = true return false, nil, err
cipher := "" }
if string(inbound.Protocol) == "shadowsocks" { for _, client := range clients {
var oldSettings map[string]any if client.Email == clientEmail && client.Enable {
err = json.Unmarshal([]byte(inbound.Settings), &oldSettings) cipher := ""
if err != nil { if string(inbound.Protocol) == "shadowsocks" {
return false, nil, err 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() db := database.GetDB()
now := time.Now().UnixMilli() 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 := db.Transaction(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil { if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err return err
@@ -807,32 +803,16 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
Update("last_traffic_reset_time", now).Error; err != nil { Update("last_traffic_reset_time", now).Error; err != nil {
return err 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 { if reenableNodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *reenableNodeID) return (&NodeService{}).MarkNodeDirtyTx(tx, *reenableNodeID)
} }
if inbound.NodeID != nil { if inbound != nil && inbound.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID) return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
} }
return nil return nil
}); err != nil { }); err != nil {
return false, nil, err 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 { if reenablePlan != nil {
rt, err := s.runtimeFor(&reenablePlan.inbound) 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 // 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 nil
} }
return submitTrafficWriteBackground(func() error { return submitTrafficWrite(func() error {
db := database.GetDB() db := database.GetDB()
known := make([]string, 0, len(emails)) known := make([]string, 0, len(emails))
for _, batch := range chunkStrings(emails, sqlInChunk) { for _, batch := range chunkStrings(emails, sqlInChunk) {
+19 -79
View File
@@ -26,13 +26,11 @@ type trafficWriteRequest struct {
type serializedTxContextKey struct{} type serializedTxContextKey struct{}
var ( var (
twMu sync.Mutex twMu sync.Mutex
twAdminQueue chan *trafficWriteRequest twQueue chan *trafficWriteRequest
twBgQueue chan *trafficWriteRequest twCtx context.Context
twCtx context.Context twCancel context.CancelFunc
twCancel context.CancelFunc twDone chan struct{}
twDone chan struct{}
twApplyMu sync.Mutex
) )
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again // StartTrafficWriter spins up the serial writer goroutine. Safe to call again
@@ -55,18 +53,16 @@ func StartTrafficWriter() {
} }
} }
adminQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize) queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
bgQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{}) done := make(chan struct{})
twAdminQueue = adminQueue twQueue = queue
twBgQueue = bgQueue
twCtx = ctx twCtx = ctx
twCancel = cancel twCancel = cancel
twDone = done twDone = done
go runTrafficWriter(ctx, adminQueue, bgQueue, done) go runTrafficWriter(ctx, queue, done)
} }
// StopTrafficWriter cancels the writer context and waits for the goroutine to // StopTrafficWriter cancels the writer context and waits for the goroutine to
@@ -93,68 +89,31 @@ func StopTrafficWriter() {
} }
func clearTrafficWriterState() { func clearTrafficWriterState() {
twAdminQueue = nil twQueue = nil
twBgQueue = nil
twCtx = nil twCtx = nil
twCancel = nil twCancel = nil
twDone = nil twDone = nil
} }
// trafficWriterQueueLen is the test's view of both lanes' pending requests. func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
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{}) {
defer close(done) defer close(done)
for { for {
select { select {
case req := <-adminQueue: case req := <-queue:
req.done <- safeApply(req.apply) req.done <- safeApply(req.apply)
case <-ctx.Done(): case <-ctx.Done():
drainTrafficWriter(adminQueue, bgQueue) for {
return select {
default: case req := <-queue:
select { req.done <- safeApply(req.apply)
case req := <-adminQueue: default:
req.done <- safeApply(req.apply) return
case req := <-bgQueue: }
req.done <- safeApply(req.apply)
case <-ctx.Done():
drainTrafficWriter(adminQueue, bgQueue)
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 // runSerializedTx runs fn inside one DB transaction on the shared serial
// traffic-writer goroutine, so it can never execute concurrently with the // traffic-writer goroutine, so it can never execute concurrently with the
// @every 5s traffic poll (AddTraffic). Both touch the hot client_traffics and // @every 5s traffic poll (AddTraffic). Both touch the hot client_traffics and
@@ -198,11 +157,7 @@ func isSerializedTx(tx *gorm.DB) bool {
return serializedTxOf(tx) != nil return serializedTxOf(tx) != nil
} }
// safeApply holds twApplyMu so an inline fallback (no writer, or shutdown drain)
// never runs beside another write: overlapping resets switched a client back off.
func safeApply(fn func() error) (err error) { func safeApply(fn func() error) (err error) {
twApplyMu.Lock()
defer twApplyMu.Unlock()
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
err = fmt.Errorf("traffic writer panic: %v", r) err = fmt.Errorf("traffic writer panic: %v", r)
@@ -212,28 +167,13 @@ func safeApply(fn func() error) (err error) {
return fn() 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 { 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)} req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
twMu.Lock() twMu.Lock()
queue := twAdminQueue queue := twQueue
ctx := twCtx ctx := twCtx
done := twDone done := twDone
if background {
queue = twBgQueue
}
if queue == nil || ctx == nil || done == nil { if queue == nil || ctx == nil || done == nil {
twMu.Unlock() twMu.Unlock()
return safeApply(fn) return safeApply(fn)
+11 -144
View File
@@ -63,55 +63,6 @@ func TestTrafficWriterSubmitAfterStopRunsInline(t *testing.T) {
} }
} }
// The inline fallback ran beside other writes, so two resets of one client
// raced and the second one switched the client back off.
func TestTrafficWriterInlineFallbackNeverOverlapsAnotherWrite(t *testing.T) {
for _, tt := range []struct {
name string
setup func(t *testing.T, first func())
}{
{"writer never started", func(t *testing.T, first func()) {
go func() { _ = submitTrafficWrite(func() error { first(); return nil }) }()
}},
{"submitted while shutdown drains", func(t *testing.T, first func()) {
StartTrafficWriter()
go func() { _ = submitTrafficWrite(func() error { first(); return nil }) }()
twMu.Lock()
ctx := twCtx
twMu.Unlock()
go StopTrafficWriter()
<-ctx.Done()
}},
} {
t.Run(tt.name, func(t *testing.T) {
resetTrafficWriterForTest(t)
entered, secondStarted := make(chan struct{}), make(chan struct{})
var inFirst, overlapped atomic.Bool
tt.setup(t, func() {
inFirst.Store(true)
close(entered)
select {
case <-secondStarted:
case <-time.After(300 * time.Millisecond):
}
inFirst.Store(false)
})
<-entered
err := submitTrafficWrite(func() error {
overlapped.Store(inFirst.Load())
close(secondStarted)
return nil
})
if err != nil {
t.Fatalf("second submitTrafficWrite: %v", err)
}
if overlapped.Load() {
t.Fatal("inline write ran while another write was still applying")
}
})
}
}
func TestTrafficWriterStopDrainsQueuedWrite(t *testing.T) { func TestTrafficWriterStopDrainsQueuedWrite(t *testing.T) {
resetTrafficWriterForTest(t) resetTrafficWriterForTest(t)
@@ -210,7 +161,13 @@ func waitTrafficWriterQueued(t *testing.T) {
deadline := time.Now().Add(time.Second) deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if trafficWriterQueueLen() > 0 { twMu.Lock()
queued := 0
if twQueue != nil {
queued = len(twQueue)
}
twMu.Unlock()
if queued > 0 {
return return
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -260,7 +217,10 @@ func parkSerializedWriter(t *testing.T) (release func()) {
func waitForQueuedWrite() bool { func waitForQueuedWrite() bool {
deadline := time.Now().Add(time.Second) deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if trafficWriterQueueLen() > 0 { twMu.Lock()
queued := len(twQueue) > 0
twMu.Unlock()
if queued {
return true return true
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -268,99 +228,6 @@ func waitForQueuedWrite() bool {
return false 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 { func waitWriterResult(t *testing.T, done <-chan error) error {
t.Helper() t.Helper()
select { select {
+1 -2
View File
@@ -17,8 +17,7 @@ func (s *InboundService) AddTuicTrafficBatch(id string, traffic []*xray.ClientTr
if id == "" { if id == "" {
return errors.New("TUIC traffic batch has no ID") return errors.New("TUIC traffic batch has no ID")
} }
// The receipt journal is periodic accounting, not an operator action. return submitTrafficWrite(func() error {
return submitTrafficWriteBackground(func() error {
return database.GetDB().Transaction(func(tx *gorm.DB) error { return database.GetDB().Transaction(func(tx *gorm.DB) error {
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.TuicTrafficReceipt{ID: id}) result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.TuicTrafficReceipt{ID: id})
if result.Error != nil { if result.Error != nil {
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "غير محدد", "indefinite": "غير محدد",
"unlimited": "غير محدود", "unlimited": "غير محدود",
"none": "لا شيء", "none": "لا شيء",
"empty": "فارغ",
"qrCode": "كود QR", "qrCode": "كود QR",
"info": "معلومات أكتر", "info": "معلومات أكتر",
"edit": "تحرير", "edit": "تحرير",
@@ -48,7 +47,6 @@
"reset": "إعادة تعيين", "reset": "إعادة تعيين",
"noData": "لا توجد بيانات.", "noData": "لا توجد بيانات.",
"copySuccess": "اتنسخ بنجاح", "copySuccess": "اتنسخ بنجاح",
"copyFail": "فشل النسخ",
"sure": "متأكد؟", "sure": "متأكد؟",
"encryption": "تشفير", "encryption": "تشفير",
"transmission": "نقل", "transmission": "نقل",
@@ -878,7 +876,6 @@
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.", "subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
"online": "متصل", "online": "متصل",
"email": "البريد", "email": "البريد",
"emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط",
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم", "emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم", "subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
"group": "المجموعة", "group": "المجموعة",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Indefinite", "indefinite": "Indefinite",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"none": "None", "none": "None",
"empty": "Empty",
"qrCode": "QR Code", "qrCode": "QR Code",
"info": "More Information", "info": "More Information",
"edit": "Edit", "edit": "Edit",
@@ -48,7 +47,6 @@
"reset": "Reset", "reset": "Reset",
"noData": "Nothing here yet", "noData": "Nothing here yet",
"copySuccess": "Copied successfully", "copySuccess": "Copied successfully",
"copyFail": "Copy failed",
"sure": "Sure", "sure": "Sure",
"encryption": "Encryption", "encryption": "Encryption",
"transmission": "Transmission", "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.", "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", "online": "Online",
"email": "Email", "email": "Email",
"emailReservedName": "Client name cannot be exactly \".\" or \"..\"",
"emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters", "emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters",
"subIdInvalidChars": "Subscription ID cannot contain spaces, '/', '\\', or control characters", "subIdInvalidChars": "Subscription ID cannot contain spaces, '/', '\\', or control characters",
"group": "Group", "group": "Group",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Indefinido", "indefinite": "Indefinido",
"unlimited": "Ilimitado", "unlimited": "Ilimitado",
"none": "Ninguno", "none": "Ninguno",
"empty": "Vacío",
"qrCode": "Código QR", "qrCode": "Código QR",
"info": "Más Información", "info": "Más Información",
"edit": "Editar", "edit": "Editar",
@@ -48,7 +47,6 @@
"reset": "Restablecer", "reset": "Restablecer",
"noData": "Sin datos", "noData": "Sin datos",
"copySuccess": "Copiado exitosamente", "copySuccess": "Copiado exitosamente",
"copyFail": "Error al copiar",
"sure": "Seguro", "sure": "Seguro",
"encryption": "Encriptación", "encryption": "Encriptación",
"transmission": "Transmisió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.", "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", "online": "En línea",
"email": "Email", "email": "Email",
"emailReservedName": "El nombre del cliente no puede ser exactamente \".\" o \"..\"",
"emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control", "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", "subIdInvalidChars": "El ID de suscripción no puede contener espacios, '/', '\\' ni caracteres de control",
"group": "Grupo", "group": "Grupo",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "نامحدود", "indefinite": "نامحدود",
"unlimited": "نامحدود", "unlimited": "نامحدود",
"none": "هیچ", "none": "هیچ",
"empty": "خالی",
"qrCode": "QRکد", "qrCode": "QRکد",
"info": "اطلاعات بیشتر", "info": "اطلاعات بیشتر",
"edit": "ویرایش", "edit": "ویرایش",
@@ -48,7 +47,6 @@
"reset": "بازنشانی", "reset": "بازنشانی",
"noData": "داده‌ای وجود ندارد.", "noData": "داده‌ای وجود ندارد.",
"copySuccess": "باموفقیت کپی‌شد", "copySuccess": "باموفقیت کپی‌شد",
"copyFail": "کپی ناموفق بود",
"sure": "مطمئن", "sure": "مطمئن",
"encryption": "رمزگذاری", "encryption": "رمزگذاری",
"transmission": "راه‌اتصال", "transmission": "راه‌اتصال",
@@ -878,7 +876,6 @@
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.", "subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
"online": "آنلاین", "online": "آنلاین",
"email": "ایمیل", "email": "ایمیل",
"emailReservedName": "نام کاربر نمی‌تواند فقط «.» یا «..» باشد",
"emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد", "emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
"subIdInvalidChars": "شناسه‌ی اشتراک نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد", "subIdInvalidChars": "شناسه‌ی اشتراک نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
"group": "گروه", "group": "گروه",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Tak Terbatas", "indefinite": "Tak Terbatas",
"unlimited": "Tanpa Batas", "unlimited": "Tanpa Batas",
"none": "Tidak ada", "none": "Tidak ada",
"empty": "Kosong",
"qrCode": "Kode QR", "qrCode": "Kode QR",
"info": "Informasi Lebih Lanjut", "info": "Informasi Lebih Lanjut",
"edit": "Edit", "edit": "Edit",
@@ -48,7 +47,6 @@
"reset": "Reset", "reset": "Reset",
"noData": "Tidak ada data.", "noData": "Tidak ada data.",
"copySuccess": "Berhasil Disalin", "copySuccess": "Berhasil Disalin",
"copyFail": "Gagal Menyalin",
"sure": "Yakin", "sure": "Yakin",
"encryption": "Enkripsi", "encryption": "Enkripsi",
"transmission": "Transmisi", "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.", "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", "online": "Online",
"email": "Email", "email": "Email",
"emailReservedName": "Nama klien tidak boleh hanya \".\" atau \"..\"",
"emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol", "emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
"subIdInvalidChars": "ID langganan tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol", "subIdInvalidChars": "ID langganan tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
"group": "Grup", "group": "Grup",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "無期限", "indefinite": "無期限",
"unlimited": "無制限", "unlimited": "無制限",
"none": "なし", "none": "なし",
"empty": "空欄",
"qrCode": "QRコード", "qrCode": "QRコード",
"info": "詳細情報", "info": "詳細情報",
"edit": "編集", "edit": "編集",
@@ -48,7 +47,6 @@
"reset": "リセット", "reset": "リセット",
"noData": "データなし。", "noData": "データなし。",
"copySuccess": "コピー成功", "copySuccess": "コピー成功",
"copyFail": "コピー失敗",
"sure": "確定", "sure": "確定",
"encryption": "暗号化", "encryption": "暗号化",
"transmission": "伝送", "transmission": "伝送",
@@ -878,7 +876,6 @@
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。", "subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
"online": "オンライン", "online": "オンライン",
"email": "メール", "email": "メール",
"emailReservedName": "クライアント名を「.」または「..」だけにすることはできません",
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません", "emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません", "subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
"group": "グループ", "group": "グループ",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Indeterminado", "indefinite": "Indeterminado",
"unlimited": "Ilimitado", "unlimited": "Ilimitado",
"none": "Nenhum", "none": "Nenhum",
"empty": "Vazio",
"qrCode": "Código QR", "qrCode": "Código QR",
"info": "Mais Informações", "info": "Mais Informações",
"edit": "Editar", "edit": "Editar",
@@ -48,7 +47,6 @@
"reset": "Redefinir", "reset": "Redefinir",
"noData": "Sem dados.", "noData": "Sem dados.",
"copySuccess": "Copiado com Sucesso", "copySuccess": "Copiado com Sucesso",
"copyFail": "Falha ao Copiar",
"sure": "Certo", "sure": "Certo",
"encryption": "Criptografia", "encryption": "Criptografia",
"transmission": "Transmissão", "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.", "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", "online": "Online",
"email": "Email", "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", "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", "subIdInvalidChars": "O ID de assinatura não pode conter espaços, '/', '\\' ou caracteres de controle",
"group": "Grupo", "group": "Grupo",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Бесконечно", "indefinite": "Бесконечно",
"unlimited": "Безлимит", "unlimited": "Безлимит",
"none": "Нет", "none": "Нет",
"empty": "Пусто",
"qrCode": "QR-код", "qrCode": "QR-код",
"info": "Информация", "info": "Информация",
"edit": "Изменить", "edit": "Изменить",
@@ -48,7 +47,6 @@
"reset": "Сброс", "reset": "Сброс",
"noData": "Нет данных.", "noData": "Нет данных.",
"copySuccess": "Скопировано", "copySuccess": "Скопировано",
"copyFail": "Не удалось скопировать",
"sure": "Да", "sure": "Да",
"encryption": "Шифрование", "encryption": "Шифрование",
"transmission": "Транспорт", "transmission": "Транспорт",
@@ -878,7 +876,6 @@
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.", "subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
"online": "В сети", "online": "В сети",
"email": "Email", "email": "Email",
"emailReservedName": "Имя клиента не может состоять только из «.» или «..»",
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы", "emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы", "subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
"group": "Группа", "group": "Группа",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Bez časového obmedzenia", "indefinite": "Bez časového obmedzenia",
"unlimited": "Bez obmedzenia", "unlimited": "Bez obmedzenia",
"none": "Žiadne", "none": "Žiadne",
"empty": "Prázdne",
"qrCode": "QR kód", "qrCode": "QR kód",
"info": "Ďalšie informácie", "info": "Ďalšie informácie",
"edit": "Upraviť", "edit": "Upraviť",
@@ -48,7 +47,6 @@
"reset": "Vynulovať", "reset": "Vynulovať",
"noData": "Zatiaľ tu nič nie je", "noData": "Zatiaľ tu nič nie je",
"copySuccess": "Úspešne skopírované", "copySuccess": "Úspešne skopírované",
"copyFail": "Kopírovanie zlyhalo",
"sure": "Áno", "sure": "Áno",
"encryption": "Šifrovanie", "encryption": "Šifrovanie",
"transmission": "Prenos", "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ý.", "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", "online": "Online",
"email": "Názov klienta", "email": "Názov klienta",
"emailReservedName": "Názov klienta nesmie byť presne \".\" ani \"..\"",
"emailInvalidChars": "Názov klienta nesmie obsahovať medzery, '/', '\\' ani riadiace znaky", "emailInvalidChars": "Názov klienta nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
"subIdInvalidChars": "ID odberu nesmie obsahovať medzery, '/', '\\' ani riadiace znaky", "subIdInvalidChars": "ID odberu nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
"group": "Skupina", "group": "Skupina",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Belirsiz", "indefinite": "Belirsiz",
"unlimited": "Sınırsız", "unlimited": "Sınırsız",
"none": "Yok", "none": "Yok",
"empty": "Boş",
"qrCode": "QR Kod", "qrCode": "QR Kod",
"info": "Daha Fazla Bilgi", "info": "Daha Fazla Bilgi",
"edit": "Düzenle", "edit": "Düzenle",
@@ -48,7 +47,6 @@
"reset": "Sıfırla", "reset": "Sıfırla",
"noData": "Veri yok.", "noData": "Veri yok.",
"copySuccess": "Başarıyla kopyalandı", "copySuccess": "Başarıyla kopyalandı",
"copyFail": "Kopyalama başarısız",
"sure": "Emin misiniz?", "sure": "Emin misiniz?",
"encryption": "Şifreleme", "encryption": "Şifreleme",
"transmission": "İletim", "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.", "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", "online": "Çevrimiçi",
"email": "E-posta", "email": "E-posta",
"emailReservedName": "İstemci adı yalnızca \".\" veya \"..\" olamaz",
"emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez", "emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez",
"subIdInvalidChars": "Abonelik kimliği boşluk, '/', '\\' veya kontrol karakterleri içeremez", "subIdInvalidChars": "Abonelik kimliği boşluk, '/', '\\' veya kontrol karakterleri içeremez",
"group": "Grup", "group": "Grup",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Безстроково", "indefinite": "Безстроково",
"unlimited": "Безлімітний", "unlimited": "Безлімітний",
"none": "Немає", "none": "Немає",
"empty": "Порожньо",
"qrCode": "QR-Код", "qrCode": "QR-Код",
"info": "Більше інформації", "info": "Більше інформації",
"edit": "Змінити", "edit": "Змінити",
@@ -48,7 +47,6 @@
"reset": "Скидання", "reset": "Скидання",
"noData": "Немає даних.", "noData": "Немає даних.",
"copySuccess": "Скопійовано успішно", "copySuccess": "Скопійовано успішно",
"copyFail": "Не вдалося скопіювати",
"sure": "Звичайно", "sure": "Звичайно",
"encryption": "Шифрування", "encryption": "Шифрування",
"transmission": "Протокол передачи", "transmission": "Протокол передачи",
@@ -878,7 +876,6 @@
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.", "subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
"online": "У мережі", "online": "У мережі",
"email": "Email", "email": "Email",
"emailReservedName": "Ім’я клієнта не може складатися лише з «.» або «..»",
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи", "emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи", "subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
"group": "Група", "group": "Група",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "Không xác định", "indefinite": "Không xác định",
"unlimited": "Không giới hạn", "unlimited": "Không giới hạn",
"none": "Không", "none": "Không",
"empty": "Trống",
"qrCode": "Mã QR", "qrCode": "Mã QR",
"info": "Thông tin thêm", "info": "Thông tin thêm",
"edit": "Sửa", "edit": "Sửa",
@@ -48,7 +47,6 @@
"reset": "Đặt lại", "reset": "Đặt lại",
"noData": "Không có dữ liệu.", "noData": "Không có dữ liệu.",
"copySuccess": "Đã sao chép thành công", "copySuccess": "Đã sao chép thành công",
"copyFail": "Sao chép thất bại",
"sure": "Chắc chắn", "sure": "Chắc chắn",
"encryption": "Mã hóa", "encryption": "Mã hóa",
"transmission": "Truyền tải", "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.", "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", "online": "Trực tuyến",
"email": "Email", "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", "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", "subIdInvalidChars": "ID đăng ký không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
"group": "Nhóm", "group": "Nhóm",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "无限期", "indefinite": "无限期",
"unlimited": "无限制", "unlimited": "无限制",
"none": "无", "none": "无",
"empty": "空",
"qrCode": "二维码", "qrCode": "二维码",
"info": "更多信息", "info": "更多信息",
"edit": "编辑", "edit": "编辑",
@@ -48,7 +47,6 @@
"reset": "重置", "reset": "重置",
"noData": "无数据。", "noData": "无数据。",
"copySuccess": "复制成功", "copySuccess": "复制成功",
"copyFail": "复制失败",
"sure": "确定", "sure": "确定",
"encryption": "加密", "encryption": "加密",
"transmission": "传输", "transmission": "传输",
@@ -878,7 +876,6 @@
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。", "subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
"online": "在线", "online": "在线",
"email": "邮箱", "email": "邮箱",
"emailReservedName": "客户端名称不能仅为“.”或“..”",
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符", "emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符", "subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
"group": "分组", "group": "分组",
-3
View File
@@ -40,7 +40,6 @@
"indefinite": "無限期", "indefinite": "無限期",
"unlimited": "無限制", "unlimited": "無限制",
"none": "無", "none": "無",
"empty": "空",
"qrCode": "二維碼", "qrCode": "二維碼",
"info": "更多資訊", "info": "更多資訊",
"edit": "編輯", "edit": "編輯",
@@ -48,7 +47,6 @@
"reset": "重置", "reset": "重置",
"noData": "無數據。", "noData": "無數據。",
"copySuccess": "複製成功", "copySuccess": "複製成功",
"copyFail": "複製失敗",
"sure": "確定", "sure": "確定",
"encryption": "加密", "encryption": "加密",
"transmission": "傳輸", "transmission": "傳輸",
@@ -878,7 +876,6 @@
"subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。", "subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
"online": "上線", "online": "上線",
"email": "電子郵件", "email": "電子郵件",
"emailReservedName": "用戶端名稱不能僅為「.」或「..」",
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元", "emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元", "subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
"group": "群組", "group": "群組",