mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b54325dcc9 | |||
| f4030c889c | |||
| dbb6212988 | |||
| e4935be861 | |||
| cd176faf4e | |||
| 2541080c91 |
@@ -365,6 +365,15 @@ 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
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ _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'
|
||||||
@@ -44,14 +48,25 @@ _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 — supply the full set of fields
|
inbound. Body is the JSON client payload: omitted fields keep their
|
||||||
you want to keep (the server replaces the row, it does not patch).
|
current values, and an explicit zero, empty or null value clears a
|
||||||
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'
|
field, except as noted below.'
|
||||||
|
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below'
|
||||||
|
- depth: 2
|
||||||
|
title: 'Update an existing client by email. Changes propagate to every attached
|
||||||
|
inbound. Body is the JSON client payload: omitted fields keep their
|
||||||
|
current values, and an explicit zero, empty or null value clears a
|
||||||
|
field, except as noted below.'
|
||||||
|
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1'
|
||||||
- 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.
|
||||||
@@ -324,6 +339,9 @@ _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
|
||||||
@@ -335,14 +353,22 @@ _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 attached
|
- content: 'Update an existing client by email. Changes propagate to every
|
||||||
inbound. Body is the JSON client payload — supply the full set of
|
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||||
fields you want to keep (the server replaces the row, it does not
|
their current values, and an explicit zero, empty or null value clears
|
||||||
patch).
|
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
|
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||||
|
- content: 'Update an existing client by email. Changes propagate to every
|
||||||
|
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||||
|
their current values, and an explicit zero, empty or null value clears
|
||||||
|
a field, except as noted below.'
|
||||||
|
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
|
||||||
- content: 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
|
||||||
@@ -564,6 +590,9 @@ _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,
|
||||||
@@ -572,11 +601,14 @@ _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: >-
|
||||||
Fields the server fills in when they are omitted — a valid value sent
|
Client email must not be exactly `.` or `..`. Existing reserved-name
|
||||||
by the caller is never overwritten. Re-adding an email that already
|
clients can be read, renamed to a valid name, or deleted using the
|
||||||
exists, with its stored `subId`, reuses the stored `id`, `password`,
|
query-parameter /get, /update and /del aliases. Fields the server
|
||||||
`auth` and `secret` instead of minting new ones, so the identity stays
|
fills in when they are omitted — a valid value sent by the caller is
|
||||||
in sync across its inbounds.
|
never overwritten. Re-adding an email that already exists, with its
|
||||||
|
stored `subId`, reuses the stored `id`, `password`, `auth` and
|
||||||
|
`secret` instead of minting new ones, so the identity stays in sync
|
||||||
|
across its inbounds.
|
||||||
|
|
||||||
|
|
||||||
- **VLESS / VMess** — `id`, a fresh UUID
|
- **VLESS / VMess** — `id`, a fresh UUID
|
||||||
@@ -641,7 +673,11 @@ _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: 'The inbounds are applied concurrently and independently: one that
|
- content: 'An omitted `flow` leaves every attached inbound on its own flow. Send
|
||||||
|
`"reverse": null` to remove the reverse tag. Credentials (`id`,
|
||||||
|
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
|
||||||
|
`allowedIPs` are never cleared: an empty value keeps the stored one.
|
||||||
|
The inbounds are applied concurrently and independently: one that
|
||||||
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
|
||||||
@@ -649,7 +685,22 @@ _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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||||
|
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
|
||||||
|
to manage legacy reserved names without URL dot-segment normalization.
|
||||||
|
An omitted `flow` leaves every attached inbound on its own flow. Send
|
||||||
|
`"reverse": null` to remove the reverse tag. Credentials (`id`,
|
||||||
|
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
|
||||||
|
`allowedIPs` are never cleared: an empty value keeps the stored one.
|
||||||
|
The inbounds are applied concurrently and independently: one that
|
||||||
|
fails no longer stops the others. Every inbound error names the
|
||||||
|
inbound it came from (`inbound 7: <message>`), and several failures
|
||||||
|
are reported together, one per line. So a `success:false` response can
|
||||||
|
still have applied the edit to the remaining inbounds. The client
|
||||||
|
record is written after the inbounds, so a failure there is reported
|
||||||
|
without an `inbound <id>:` prefix and leaves the inbound edits in
|
||||||
|
place.'
|
||||||
|
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
|
||||||
- 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
|
||||||
@@ -660,6 +711,18 @@ _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>
|
||||||
@@ -685,7 +748,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/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 />
|
<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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,10 +44,11 @@ _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 — supply the full set of fields
|
inbound. Body is the JSON client payload: omitted fields keep their
|
||||||
you want to keep (the server replaces the row, it does not patch).
|
current values, and an explicit zero, empty or null value clears a
|
||||||
|
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
#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
|
- 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
|
||||||
@@ -358,11 +359,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 — supply the full
|
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||||
set of fields you want to keep (the server replaces the row, it does
|
their current values, and an explicit zero, empty or null value clears
|
||||||
not patch).
|
a field, except as noted below.
|
||||||
id: >-
|
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
|
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||||
- content: >-
|
- 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,10 +44,11 @@ _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 — supply the full set of fields
|
inbound. Body is the JSON client payload: omitted fields keep their
|
||||||
you want to keep (the server replaces the row, it does not patch).
|
current values, and an explicit zero, empty or null value clears a
|
||||||
|
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
#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
|
- 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
|
||||||
@@ -358,11 +359,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 — supply the full
|
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||||
set of fields you want to keep (the server replaces the row, it does
|
their current values, and an explicit zero, empty or null value clears
|
||||||
not patch).
|
a field, except as noted below.
|
||||||
id: >-
|
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
|
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||||
- content: >-
|
- 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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
#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
|
- 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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||||
- content: >-
|
- content: >-
|
||||||
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
|
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
|
||||||
keepTraffic=1。
|
keepTraffic=1。
|
||||||
|
|||||||
+164
-3
@@ -8722,6 +8722,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": [
|
||||||
@@ -8845,7 +8887,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": "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": "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.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -9011,9 +9053,9 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"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).",
|
"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_email",
|
"operationId": "post_panel_api_clients_update_email",
|
||||||
"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.",
|
"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.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "email",
|
"name": "email",
|
||||||
@@ -9070,6 +9112,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": [
|
||||||
@@ -9125,6 +9231,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": [
|
||||||
|
|||||||
@@ -47,6 +47,33 @@ 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
|
||||||
|
|||||||
@@ -8722,6 +8722,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": [
|
||||||
@@ -8845,7 +8887,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": "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": "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.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -9011,9 +9053,9 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"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).",
|
"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_email",
|
"operationId": "post_panel_api_clients_update_email",
|
||||||
"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.",
|
"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.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "email",
|
"name": "email",
|
||||||
@@ -9070,6 +9112,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": [
|
||||||
@@ -9125,6 +9231,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": [
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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,6 +221,9 @@ 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' }));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cloneElement, useId } from 'react';
|
import { cloneElement, useId } from 'react';
|
||||||
import type { CSSProperties, ReactElement, ReactNode } from 'react';
|
import type { AriaAttributes, 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,12 +50,10 @@ 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 childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
|
const errorId = `${generatedId}-error`;
|
||||||
.props;
|
const extraId = `${generatedId}-extra`;
|
||||||
|
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
|
||||||
@@ -67,8 +65,14 @@ 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);
|
||||||
@@ -79,16 +83,15 @@ 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}
|
extra={extra ? <span id={extraId}>{extra}</span> : extra}
|
||||||
required={required}
|
required={required}
|
||||||
validateStatus={fieldState.error ? 'error' : undefined}
|
validateStatus={fieldState.error ? 'error' : undefined}
|
||||||
help={help}
|
help={help ? <span id={errorId}>{help}</span> : undefined}
|
||||||
noStyle={noStyle}
|
noStyle={noStyle}
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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,
|
||||||
@@ -343,7 +344,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(`/panel/api/clients/get/${encodeURIComponent(email)}`);
|
const msg = await HttpUtil.get(clientManagementPath('get', 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;
|
||||||
@@ -375,7 +376,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(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
HttpUtil.post(clientManagementPath('update', email), client, JSON_HEADERS),
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
if (msg?.success) invalidateAll();
|
if (msg?.success) invalidateAll();
|
||||||
},
|
},
|
||||||
@@ -383,9 +384,8 @@ 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 }) => {
|
||||||
const url = keepTraffic
|
let url = clientManagementPath('del', email);
|
||||||
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
|
if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`;
|
||||||
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
|
|
||||||
return HttpUtil.post(url);
|
return HttpUtil.post(url);
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -68,7 +69,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']}>
|
||||||
<Select options={TYPE_OPTIONS} />
|
<NativeSelect 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,3 +1,4 @@
|
|||||||
|
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||||
import { useEffect, useId, useRef, useState } from 'react';
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
@@ -407,7 +408,7 @@ function TcpMasksList({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{allowPresets && (
|
{allowPresets && (
|
||||||
<Select
|
<NativeSelect
|
||||||
id={`${uid}-tcp-presets`}
|
id={`${uid}-tcp-presets`}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ minWidth: 160 }}
|
style={{ minWidth: 160 }}
|
||||||
@@ -478,7 +479,7 @@ function TcpMaskItem({
|
|||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||||
<Select
|
<NativeSelect
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
|
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
|
||||||
}
|
}
|
||||||
@@ -1123,7 +1124,7 @@ function UdpMaskItem({
|
|||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||||
<Select onChange={onTypeChange} options={options} />
|
<NativeSelect onChange={onTypeChange} options={options} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -1147,7 +1148,7 @@ function UdpMaskItem({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
|
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Original / AES-128-GCM' },
|
{ value: '', label: 'Original / AES-128-GCM' },
|
||||||
{ value: 'dns', label: 'DNS' },
|
{ value: 'dns', label: 'DNS' },
|
||||||
@@ -1327,7 +1328,7 @@ function SalamanderUdpMaskSettings({
|
|||||||
: 'Scrambles each packet into random-looking bytes.'
|
: 'Scrambles each packet into random-looking bytes.'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
id={`${uid}-salamander-mode`}
|
id={`${uid}-salamander-mode`}
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
@@ -1947,7 +1948,7 @@ function ItemEditor({
|
|||||||
<>
|
<>
|
||||||
{allowVariables && (
|
{allowVariables && (
|
||||||
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
|
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
|
||||||
<Select
|
<NativeSelect
|
||||||
id={`${uid}-kind`}
|
id={`${uid}-kind`}
|
||||||
value={kind}
|
value={kind}
|
||||||
onChange={onKindChange}
|
onChange={onKindChange}
|
||||||
@@ -1957,7 +1958,7 @@ function ItemEditor({
|
|||||||
)}
|
)}
|
||||||
{kind === 'bytes' && (
|
{kind === 'bytes' && (
|
||||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||||
<Select
|
<NativeSelect
|
||||||
onChange={onTypeChange}
|
onChange={onTypeChange}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'array', label: 'Array' },
|
{ value: 'array', label: 'Array' },
|
||||||
@@ -2122,7 +2123,7 @@ function QuicParamsForm({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item label="Congestion" name={[...base, 'congestion']}>
|
<Form.Item label="Congestion" name={[...base, 'congestion']}>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: 'reno', label: 'Reno' },
|
{ value: 'reno', label: 'Reno' },
|
||||||
{ value: 'bbr', label: 'BBR' },
|
{ value: 'bbr', label: 'BBR' },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -89,7 +90,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']}>
|
||||||
<Select options={serviceOptions} />
|
<NativeSelect options={serviceOptions} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label={t('pages.inbounds.form.xdrive.remoteFolder')}
|
label={t('pages.inbounds.form.xdrive.remoteFolder')}
|
||||||
|
|||||||
@@ -1149,6 +1149,19 @@ 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',
|
||||||
@@ -1195,7 +1208,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:
|
||||||
'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.',
|
'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.',
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
name: 'client',
|
name: 'client',
|
||||||
@@ -1263,9 +1276,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 — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
|
'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:
|
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.',
|
'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: [
|
params: [
|
||||||
{
|
{
|
||||||
name: 'email',
|
name: 'email',
|
||||||
@@ -1277,6 +1290,24 @@ 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',
|
||||||
@@ -1295,6 +1326,24 @@ 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('apply')}
|
okText={t('confirm')}
|
||||||
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() : '';
|
||||||
if (reverseTagValue) {
|
clientPayload.reverse = reverseTagValue ? { tag: reverseTagValue } : null;
|
||||||
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
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function FilterDrawer({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t('inbounds')} htmlFor={`${uid}-inbounds`}>
|
<Form.Item label={t('pages.clients.attachedInbounds')} 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('inbounds')}
|
placeholder={t('pages.clients.attachedInbounds')}
|
||||||
allowClear
|
allowClear
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
listHeight={220}
|
listHeight={220}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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,8 +1,22 @@
|
|||||||
|
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 { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
|
import {
|
||||||
|
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';
|
||||||
@@ -92,6 +106,28 @@ 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;
|
||||||
@@ -677,7 +713,7 @@ export default function InboundFormModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
||||||
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
<NativeSelect id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -695,7 +731,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={SHARE_ADDR_STRATEGIES.filter(
|
options={SHARE_ADDR_STRATEGIES.filter(
|
||||||
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
|
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
|
||||||
).map((strategy) => ({
|
).map((strategy) => ({
|
||||||
@@ -780,7 +816,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')}>
|
||||||
<Select
|
<NativeSelect
|
||||||
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}`),
|
||||||
@@ -958,7 +994,7 @@ export default function InboundFormModal({
|
|||||||
<>
|
<>
|
||||||
{hasSelectableTransport && (
|
{hasSelectableTransport && (
|
||||||
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
|
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
|
||||||
<Select
|
<NativeSelect
|
||||||
id={`${uid}-network`}
|
id={`${uid}-network`}
|
||||||
style={{ width: '75%' }}
|
style={{ width: '75%' }}
|
||||||
value={network}
|
value={network}
|
||||||
@@ -1034,8 +1070,11 @@ export default function InboundFormModal({
|
|||||||
|
|
||||||
const securityTab = (
|
const securityTab = (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('pages.inbounds.securityTab')}>
|
<Form.Item
|
||||||
|
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}
|
||||||
@@ -1174,7 +1213,7 @@ export default function InboundFormModal({
|
|||||||
const sniffingTab = <SniffingTab />;
|
const sniffingTab = <SniffingTab />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfigProvider virtual={false} getPopupContainer={inboundPopupContainer}>
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -1188,6 +1227,11 @@ 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
|
||||||
@@ -1273,6 +1317,6 @@ export default function InboundFormModal({
|
|||||||
</Form>
|
</Form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
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, Select, Switch } from 'antd';
|
import { Form, Input, InputNumber, 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';
|
||||||
@@ -60,7 +61,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']}>
|
||||||
<Select
|
<NativeSelect
|
||||||
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,5 +1,6 @@
|
|||||||
|
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Input, Select, Switch } from 'antd';
|
import { Input, 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';
|
||||||
@@ -10,7 +11,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')}>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: 'noauth', label: 'noauth' },
|
{ value: 'noauth', label: 'noauth' },
|
||||||
{ value: 'password', label: 'password' },
|
{ value: 'password', label: 'password' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
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, Select, Space, Switch } from 'antd';
|
import { Button, Form, Input, 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';
|
||||||
|
|
||||||
@@ -25,7 +26,9 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
|
|||||||
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
|
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
|
<NativeSelect
|
||||||
|
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')}>
|
||||||
@@ -48,7 +51,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')}>
|
||||||
<Select
|
<NativeSelect
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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 {
|
||||||
@@ -68,7 +69,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('pages.inbounds.setSuccess'));
|
messageApi.success(t('success'));
|
||||||
} catch {
|
} catch {
|
||||||
messageApi.error(t('somethingWentWrong'));
|
messageApi.error(t('somethingWentWrong'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -111,7 +112,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Info', value: 'info' },
|
{ label: 'Info', value: 'info' },
|
||||||
{ label: 'Warn', value: 'warn' },
|
{ label: 'Warn', value: 'warn' },
|
||||||
@@ -199,7 +200,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ label: 'BBR', value: 'bbr' },
|
{ label: 'BBR', value: 'bbr' },
|
||||||
{ label: 'CUBIC', value: 'cubic' },
|
{ label: 'CUBIC', value: 'cubic' },
|
||||||
@@ -244,7 +245,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Native (Recommended)', value: 'native' },
|
{ label: 'Native (Recommended)', value: 'native' },
|
||||||
{ label: 'QUIC', value: 'quic' },
|
{ label: 'QUIC', value: 'quic' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Input, InputNumber, Select, Switch } from 'antd';
|
import { Input, InputNumber, 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';
|
||||||
@@ -21,7 +22,7 @@ export default function TunnelFields() {
|
|||||||
name={['settings', 'allowedNetwork']}
|
name={['settings', 'allowedNetwork']}
|
||||||
label={t('pages.inbounds.form.allowedNetwork')}
|
label={t('pages.inbounds.form.allowedNetwork')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
||||||
{ value: 'tcp', label: 'TCP' },
|
{ value: 'tcp', label: 'TCP' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
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, Select, Space, Typography } from 'antd';
|
import { Button, Form, Input, InputNumber, 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';
|
||||||
@@ -50,7 +51,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>
|
||||||
<Select
|
<NativeSelect
|
||||||
id={`${uid}-authKind`}
|
id={`${uid}-authKind`}
|
||||||
value={authKind}
|
value={authKind}
|
||||||
onChange={(v) => setAuthKind(v)}
|
onChange={(v) => setAuthKind(v)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -112,7 +113,9 @@ export default function RealityForm({
|
|||||||
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
|
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
|
||||||
label="uTLS"
|
label="uTLS"
|
||||||
>
|
>
|
||||||
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
|
<NativeSelect
|
||||||
|
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
htmlFor={`${uid}-target`}
|
htmlFor={`${uid}-target`}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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 {
|
||||||
@@ -154,7 +155,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
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 }))}
|
||||||
/>
|
/>
|
||||||
@@ -212,7 +213,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
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>
|
||||||
@@ -354,14 +355,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>
|
||||||
<Select
|
<NativeSelect
|
||||||
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>
|
||||||
<Select
|
<NativeSelect
|
||||||
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 }))}
|
||||||
@@ -370,7 +371,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">
|
||||||
<Select
|
<NativeSelect
|
||||||
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,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -207,13 +208,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
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">
|
||||||
<Select
|
<NativeSelect
|
||||||
style={{ width: '50%' }}
|
style={{ width: '50%' }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'off', label: 'Off' },
|
{ value: 'off', label: 'Off' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
|
import { AutoComplete, Input, InputNumber, 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';
|
||||||
@@ -81,7 +82,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
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,
|
||||||
@@ -149,7 +150,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Default (POST)' },
|
{ value: '', label: 'Default (POST)' },
|
||||||
{ value: 'POST', label: 'POST' },
|
{ value: 'POST', label: 'POST' },
|
||||||
@@ -187,7 +188,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Default (queryInHeader)' },
|
{ value: '', label: 'Default (queryInHeader)' },
|
||||||
{ value: 'queryInHeader', label: 'queryInHeader' },
|
{ value: 'queryInHeader', label: 'queryInHeader' },
|
||||||
@@ -201,7 +202,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Default (repeat-x)' },
|
{ value: '', label: 'Default (repeat-x)' },
|
||||||
{ value: 'repeat-x', label: 'repeat-x' },
|
{ value: 'repeat-x', label: 'repeat-x' },
|
||||||
@@ -215,7 +216,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Default (path)' },
|
{ value: '', label: 'Default (path)' },
|
||||||
{ value: 'path', label: 'path' },
|
{ value: 'path', label: 'path' },
|
||||||
@@ -259,7 +260,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Default (path)' },
|
{ value: '', label: 'Default (path)' },
|
||||||
{ value: 'path', label: 'path' },
|
{ value: 'path', label: 'path' },
|
||||||
@@ -283,7 +284,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')}
|
||||||
>
|
>
|
||||||
<Select
|
<NativeSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Default (auto)' },
|
{ value: '', label: 'Default (auto)' },
|
||||||
{ value: 'auto', label: 'auto' },
|
{ value: 'auto', label: 'auto' },
|
||||||
|
|||||||
@@ -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') ?? 'Copy failed');
|
else messageApi.error(t('copyFail'));
|
||||||
}
|
}
|
||||||
|
|
||||||
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.setting.toasts.saveSuccess', 'Settings saved successfully'));
|
messageApi.success(t('pages.settings.toasts.modifySettings'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ 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(),
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -82,14 +82,15 @@ describe('i18n keys', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no literal t() key resolves to an object', () => {
|
it('every literal t() key is an en-US string', () => {
|
||||||
const objectKeys = Array.from(
|
const leaves = new Set(enKeys);
|
||||||
blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g),
|
const literalKeys = new Set(
|
||||||
(m) => m[2],
|
Array.from(blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g), (m) => m[2]),
|
||||||
).filter((key) => enKeys.some((leaf) => leaf.startsWith(`${key}.`)));
|
);
|
||||||
|
const unresolved = [...literalKeys].filter((key) => !leaves.has(key));
|
||||||
expect(
|
expect(
|
||||||
objectKeys,
|
unresolved,
|
||||||
`t() keys naming a group, rendered as "key '…' returned an object instead of string.":\n ${objectKeys.join('\n ')}`,
|
`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 ')}`,
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, it, expect, onTestFinished, vi } from 'vitest';
|
import { describe, it, expect, onTestFinished, vi } from 'vitest';
|
||||||
import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
import { screen, act, render, cleanup, fireEvent, waitFor, within } 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,
|
||||||
@@ -40,20 +41,8 @@ function renderModal() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The protocol dropdown is virtualized, so jsdom renders only its first options; step
|
function chooseProtocol(optionText: string) {
|
||||||
// the active option by keyboard from the current protocol in the options' own order.
|
chooseSelectOption('protocol', optionText);
|
||||||
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 {
|
||||||
@@ -115,12 +104,6 @@ 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');
|
||||||
@@ -143,9 +126,8 @@ 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.
|
||||||
if (labelsByProto.shadowsocks) {
|
expect(labelsByProto.shadowsocks).toContain('Encryption method');
|
||||||
expect(labelsByProto.shadowsocks).toContain('Encryption method');
|
expect(labelsByProto.vless).not.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 () => {
|
||||||
@@ -241,27 +223,31 @@ describe('InboundFormModal', () => {
|
|||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const strategyItem = (title: string) =>
|
const selectedStrategy = () =>
|
||||||
document.querySelector(`.ant-select-content[title="${title}"]`);
|
within(screen.getByRole('combobox', { name: 'Share address strategy' })).getByRole('option', {
|
||||||
|
selected: true,
|
||||||
|
}).textContent;
|
||||||
const modal = (nodes: never[], fetched: boolean) => (
|
const modal = (nodes: never[], fetched: boolean) => (
|
||||||
<ThemeProvider>
|
<AntdRootConfig>
|
||||||
<InboundFormModal
|
<ThemeProvider>
|
||||||
open
|
<InboundFormModal
|
||||||
mode="edit"
|
open
|
||||||
dbInbound={buildInbound()}
|
mode="edit"
|
||||||
dbInbounds={[]}
|
dbInbound={buildInbound()}
|
||||||
availableNodes={nodes}
|
dbInbounds={[]}
|
||||||
availableNodesFetched={fetched}
|
availableNodes={nodes}
|
||||||
onClose={() => {}}
|
availableNodesFetched={fetched}
|
||||||
onSaved={() => {}}
|
onClose={() => {}}
|
||||||
/>
|
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(strategyItem('Node address')).toBeTruthy();
|
expect(selectedStrategy()).toBe('Node address');
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
// Race: the modal mounts before /nodes/list resolves (empty placeholder),
|
// Race: the modal mounts before /nodes/list resolves (empty placeholder),
|
||||||
@@ -271,8 +257,7 @@ describe('InboundFormModal', () => {
|
|||||||
await flush();
|
await flush();
|
||||||
rerender(modal([node], true));
|
rerender(modal([node], true));
|
||||||
await flush();
|
await flush();
|
||||||
expect(strategyItem('Node address')).toBeTruthy();
|
expect(selectedStrategy()).toBe('Node address');
|
||||||
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 () => {
|
||||||
@@ -344,16 +329,9 @@ 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 transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
|
const transport = screen.getByRole('combobox', { name: 'Transmission' });
|
||||||
(el) =>
|
const option = within(transport).getByRole('option', { name: 'XDRIVE' }) as HTMLOptionElement;
|
||||||
el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission',
|
fireEvent.change(transport, { target: { value: option.value } });
|
||||||
);
|
|
||||||
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'],
|
||||||
@@ -387,7 +365,7 @@ describe('InboundFormModal', () => {
|
|||||||
post.mockClear();
|
post.mockClear();
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
chooseVirtualizedProtocol('masque');
|
chooseProtocol('masque');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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,6 +31,13 @@ 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,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -93,6 +94,10 @@ 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ 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();
|
||||||
@@ -57,6 +63,13 @@ 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(
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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.5
|
github.com/go-playground/validator/v10 v10.30.6
|
||||||
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
|
||||||
|
|||||||
@@ -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.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
github.com/go-playground/validator/v10 v10.30.6 h1:E+mymlPNRzAMNz1FShrRvLeGWcprF5w305eLLgWieuo=
|
||||||
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
|
github.com/go-playground/validator/v10 v10.30.6/go.mod h1:jPLL8SKCXGe/g87QmEyNw3NO28nmGt5GCnZnufm7rj4=
|
||||||
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=
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ 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: {}},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ 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)
|
||||||
@@ -63,7 +64,9 @@ 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)
|
||||||
@@ -159,8 +162,24 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) get(c *gin.Context) {
|
// Query aliases keep legacy dot-segment names addressable without changing path clients.
|
||||||
|
func clientEmailParam(c *gin.Context) (string, bool) {
|
||||||
email := c.Param("email")
|
email := c.Param("email")
|
||||||
|
if email == "" {
|
||||||
|
email = c.Query("email")
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), errors.New("client email is required"))
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return email, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) get(c *gin.Context) {
|
||||||
|
email, ok := clientEmailParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -244,17 +263,17 @@ func (a *ClientController) create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) update(c *gin.Context) {
|
func (a *ClientController) update(c *gin.Context) {
|
||||||
email := c.Param("email")
|
email, ok := clientEmailParam(c)
|
||||||
var req struct {
|
if !ok {
|
||||||
model.Client
|
return
|
||||||
LimitHwid int `json:"limitHwid"`
|
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
body, err := c.GetRawData()
|
||||||
|
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.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
|
needRestart, err := a.clientService.UpdateByEmailJSON(&a.inboundService, email, body, 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 {
|
||||||
@@ -273,7 +292,10 @@ func (a *ClientController) update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) delete(c *gin.Context) {
|
func (a *ClientController) delete(c *gin.Context) {
|
||||||
email := c.Param("email")
|
email, ok := clientEmailParam(c)
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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,3 +152,43 @@ 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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -672,6 +675,12 @@ 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
|
||||||
@@ -773,6 +782,12 @@ 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{}).
|
||||||
@@ -797,6 +812,14 @@ 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
|
||||||
@@ -825,6 +848,9 @@ 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) {
|
||||||
@@ -916,6 +942,15 @@ 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,6 +7,10 @@ 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 {
|
||||||
@@ -16,6 +20,8 @@ func TestValidateClientEmail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invalid := []string{
|
invalid := []string{
|
||||||
|
".",
|
||||||
|
"..",
|
||||||
"i6dui/",
|
"i6dui/",
|
||||||
"a/b",
|
"a/b",
|
||||||
"client with spaces",
|
"client with spaces",
|
||||||
|
|||||||
@@ -1537,9 +1537,31 @@ 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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1547,7 +1569,12 @@ 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"] = totalGB * 1024 * 1024 * 1024
|
c["totalGB"] = newTotal
|
||||||
|
if reenable {
|
||||||
|
c["enable"] = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -50,17 +50,22 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
|
|||||||
needRestart = nr
|
needRestart = nr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable only a client the quota switched off, once the counters are zero: a
|
// Restore quota-disabled clients after zeroing; preserve an operator
|
||||||
// hand-disabled one stays off, and a still-depleted one would be switched off again.
|
// disable, including when no inbound remains.
|
||||||
if !rec.Enable && len(quotaDisabled) > 0 {
|
if !rec.Enable && len(quotaDisabled) > 0 {
|
||||||
updated := rec.ToClient()
|
// Inbound resets already restored hosted clients. Only an unhosted
|
||||||
updated.Enable = true
|
// record still needs the full update.
|
||||||
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
|
fresh, fErr := s.GetRecordByEmail(nil, email)
|
||||||
if uErr != nil {
|
if fErr == nil && !fresh.Enable {
|
||||||
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
|
updated := fresh.ToClient()
|
||||||
}
|
updated.Enable = true
|
||||||
if nr {
|
nr, uErr := s.Update(inboundSvc, fresh.Id, *updated, fresh.LimitHwid)
|
||||||
needRestart = true
|
if uErr != nil {
|
||||||
|
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 len(twQueue) < 2 && time.Now().Before(deadline) {
|
for trafficWriterQueueLen() < 2 && time.Now().Before(deadline) {
|
||||||
time.Sleep(time.Millisecond)
|
time.Sleep(time.Millisecond)
|
||||||
}
|
}
|
||||||
close(release)
|
close(release)
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,6 +275,48 @@ 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) {
|
||||||
|
|||||||
@@ -390,7 +390,9 @@ 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
|
||||||
err := submitTrafficWrite(func() error {
|
// Node merges take the background lane: a per-client mutation the operator
|
||||||
|
// is waiting on must not queue behind the next batch of 5s snapshots.
|
||||||
|
err := submitTrafficWriteBackground(func() error {
|
||||||
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
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ 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
|
||||||
err = submitTrafficWrite(func() error {
|
// The periodic poll takes the background lane: an operator's save or reset
|
||||||
|
// queued while a poll runs must not sit behind the next merge batch.
|
||||||
|
err = submitTrafficWriteBackground(func() error {
|
||||||
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
|
||||||
@@ -720,55 +722,61 @@ 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 !traffic.Enable && !handDisabled {
|
if err != nil {
|
||||||
inbound, err := s.GetInbound(id)
|
return false, nil, err
|
||||||
if err != nil {
|
}
|
||||||
return false, nil, err
|
clients, err := s.GetClients(inbound)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
// Check each stored entry: the first reset already enables the shared
|
||||||
|
// row. A row-only check would leave sibling inbounds disabled.
|
||||||
|
entry := (*model.Client)(nil)
|
||||||
|
for i := range clients {
|
||||||
|
if clients[i].Email == clientEmail {
|
||||||
|
entry = &clients[i]
|
||||||
|
break
|
||||||
}
|
}
|
||||||
clients, err := s.GetClients(inbound)
|
}
|
||||||
if err != nil {
|
if entry != nil && !handDisabled && (!entry.Enable || !traffic.Enable) {
|
||||||
return false, nil, err
|
flipStoredEntry = true
|
||||||
}
|
cipher := ""
|
||||||
for _, client := range clients {
|
if string(inbound.Protocol) == "shadowsocks" {
|
||||||
if client.Email == clientEmail && client.Enable {
|
var oldSettings map[string]any
|
||||||
cipher := ""
|
err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
|
||||||
if string(inbound.Protocol) == "shadowsocks" {
|
if err != nil {
|
||||||
var oldSettings map[string]any
|
return false, nil, err
|
||||||
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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,10 +786,6 @@ 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
|
||||||
@@ -803,16 +807,32 @@ 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 != nil && inbound.NodeID != nil {
|
if 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)
|
||||||
@@ -826,10 +846,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if inbound != nil {
|
return needRestart, inbound.NodeID, 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 submitTrafficWrite(func() error {
|
return submitTrafficWriteBackground(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) {
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ type trafficWriteRequest struct {
|
|||||||
type serializedTxContextKey struct{}
|
type serializedTxContextKey struct{}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
twMu sync.Mutex
|
twMu sync.Mutex
|
||||||
twQueue chan *trafficWriteRequest
|
twAdminQueue chan *trafficWriteRequest
|
||||||
twCtx context.Context
|
twBgQueue chan *trafficWriteRequest
|
||||||
twCancel context.CancelFunc
|
twCtx context.Context
|
||||||
twDone chan struct{}
|
twCancel context.CancelFunc
|
||||||
|
twDone chan struct{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
|
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
|
||||||
@@ -53,16 +54,18 @@ func StartTrafficWriter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
adminQueue := 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{})
|
||||||
|
|
||||||
twQueue = queue
|
twAdminQueue = adminQueue
|
||||||
|
twBgQueue = bgQueue
|
||||||
twCtx = ctx
|
twCtx = ctx
|
||||||
twCancel = cancel
|
twCancel = cancel
|
||||||
twDone = done
|
twDone = done
|
||||||
|
|
||||||
go runTrafficWriter(ctx, queue, done)
|
go runTrafficWriter(ctx, adminQueue, bgQueue, done)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopTrafficWriter cancels the writer context and waits for the goroutine to
|
// StopTrafficWriter cancels the writer context and waits for the goroutine to
|
||||||
@@ -89,31 +92,68 @@ func StopTrafficWriter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clearTrafficWriterState() {
|
func clearTrafficWriterState() {
|
||||||
twQueue = nil
|
twAdminQueue = nil
|
||||||
|
twBgQueue = nil
|
||||||
twCtx = nil
|
twCtx = nil
|
||||||
twCancel = nil
|
twCancel = nil
|
||||||
twDone = nil
|
twDone = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
|
// trafficWriterQueueLen is the test's view of both lanes' pending requests.
|
||||||
|
func trafficWriterQueueLen() int {
|
||||||
|
twMu.Lock()
|
||||||
|
defer twMu.Unlock()
|
||||||
|
return len(twAdminQueue) + len(twBgQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One goroutine serves both lanes (no two writes side by side — the Postgres
|
||||||
|
// deadlock guard c5d31de4e; the admin lane is preferred over the 5s poll and node merges, #6035).
|
||||||
|
func runTrafficWriter(ctx context.Context, adminQueue, bgQueue chan *trafficWriteRequest, done chan struct{}) {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case req := <-queue:
|
case req := <-adminQueue:
|
||||||
req.done <- safeApply(req.apply)
|
req.done <- safeApply(req.apply)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
for {
|
drainTrafficWriter(adminQueue, bgQueue)
|
||||||
select {
|
return
|
||||||
case req := <-queue:
|
default:
|
||||||
req.done <- safeApply(req.apply)
|
select {
|
||||||
default:
|
case req := <-adminQueue:
|
||||||
return
|
req.done <- safeApply(req.apply)
|
||||||
}
|
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
|
||||||
@@ -167,13 +207,28 @@ 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 := twQueue
|
queue := twAdminQueue
|
||||||
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)
|
||||||
|
|||||||
@@ -161,13 +161,7 @@ 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) {
|
||||||
twMu.Lock()
|
if trafficWriterQueueLen() > 0 {
|
||||||
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)
|
||||||
@@ -217,10 +211,7 @@ 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) {
|
||||||
twMu.Lock()
|
if trafficWriterQueueLen() > 0 {
|
||||||
queued := len(twQueue) > 0
|
|
||||||
twMu.Unlock()
|
|
||||||
if queued {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -228,6 +219,99 @@ 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 {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ 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")
|
||||||
}
|
}
|
||||||
return submitTrafficWrite(func() error {
|
// The receipt journal is periodic accounting, not an operator action.
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"indefinite": "غير محدد",
|
"indefinite": "غير محدد",
|
||||||
"unlimited": "غير محدود",
|
"unlimited": "غير محدود",
|
||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
|
"empty": "فارغ",
|
||||||
"qrCode": "كود QR",
|
"qrCode": "كود QR",
|
||||||
"info": "معلومات أكتر",
|
"info": "معلومات أكتر",
|
||||||
"edit": "تحرير",
|
"edit": "تحرير",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"reset": "إعادة تعيين",
|
"reset": "إعادة تعيين",
|
||||||
"noData": "لا توجد بيانات.",
|
"noData": "لا توجد بيانات.",
|
||||||
"copySuccess": "اتنسخ بنجاح",
|
"copySuccess": "اتنسخ بنجاح",
|
||||||
|
"copyFail": "فشل النسخ",
|
||||||
"sure": "متأكد؟",
|
"sure": "متأكد؟",
|
||||||
"encryption": "تشفير",
|
"encryption": "تشفير",
|
||||||
"transmission": "نقل",
|
"transmission": "نقل",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
|
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
|
||||||
"online": "متصل",
|
"online": "متصل",
|
||||||
"email": "البريد",
|
"email": "البريد",
|
||||||
|
"emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط",
|
||||||
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||||
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||||
"group": "المجموعة",
|
"group": "المجموعة",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"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",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"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",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"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",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"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",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"indefinite": "نامحدود",
|
"indefinite": "نامحدود",
|
||||||
"unlimited": "نامحدود",
|
"unlimited": "نامحدود",
|
||||||
"none": "هیچ",
|
"none": "هیچ",
|
||||||
|
"empty": "خالی",
|
||||||
"qrCode": "QRکد",
|
"qrCode": "QRکد",
|
||||||
"info": "اطلاعات بیشتر",
|
"info": "اطلاعات بیشتر",
|
||||||
"edit": "ویرایش",
|
"edit": "ویرایش",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"reset": "بازنشانی",
|
"reset": "بازنشانی",
|
||||||
"noData": "دادهای وجود ندارد.",
|
"noData": "دادهای وجود ندارد.",
|
||||||
"copySuccess": "باموفقیت کپیشد",
|
"copySuccess": "باموفقیت کپیشد",
|
||||||
|
"copyFail": "کپی ناموفق بود",
|
||||||
"sure": "مطمئن",
|
"sure": "مطمئن",
|
||||||
"encryption": "رمزگذاری",
|
"encryption": "رمزگذاری",
|
||||||
"transmission": "راهاتصال",
|
"transmission": "راهاتصال",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل میشود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
|
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل میشود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
|
||||||
"online": "آنلاین",
|
"online": "آنلاین",
|
||||||
"email": "ایمیل",
|
"email": "ایمیل",
|
||||||
|
"emailReservedName": "نام کاربر نمیتواند فقط «.» یا «..» باشد",
|
||||||
"emailInvalidChars": "ایمیل نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
"emailInvalidChars": "ایمیل نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||||
"subIdInvalidChars": "شناسهی اشتراک نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
"subIdInvalidChars": "شناسهی اشتراک نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||||
"group": "گروه",
|
"group": "گروه",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"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",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"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",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"indefinite": "無期限",
|
"indefinite": "無期限",
|
||||||
"unlimited": "無制限",
|
"unlimited": "無制限",
|
||||||
"none": "なし",
|
"none": "なし",
|
||||||
|
"empty": "空欄",
|
||||||
"qrCode": "QRコード",
|
"qrCode": "QRコード",
|
||||||
"info": "詳細情報",
|
"info": "詳細情報",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"reset": "リセット",
|
"reset": "リセット",
|
||||||
"noData": "データなし。",
|
"noData": "データなし。",
|
||||||
"copySuccess": "コピー成功",
|
"copySuccess": "コピー成功",
|
||||||
|
"copyFail": "コピー失敗",
|
||||||
"sure": "確定",
|
"sure": "確定",
|
||||||
"encryption": "暗号化",
|
"encryption": "暗号化",
|
||||||
"transmission": "伝送",
|
"transmission": "伝送",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
|
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
|
||||||
"online": "オンライン",
|
"online": "オンライン",
|
||||||
"email": "メール",
|
"email": "メール",
|
||||||
|
"emailReservedName": "クライアント名を「.」または「..」だけにすることはできません",
|
||||||
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||||
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||||
"group": "グループ",
|
"group": "グループ",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"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",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"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",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"indefinite": "Бесконечно",
|
"indefinite": "Бесконечно",
|
||||||
"unlimited": "Безлимит",
|
"unlimited": "Безлимит",
|
||||||
"none": "Нет",
|
"none": "Нет",
|
||||||
|
"empty": "Пусто",
|
||||||
"qrCode": "QR-код",
|
"qrCode": "QR-код",
|
||||||
"info": "Информация",
|
"info": "Информация",
|
||||||
"edit": "Изменить",
|
"edit": "Изменить",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"reset": "Сброс",
|
"reset": "Сброс",
|
||||||
"noData": "Нет данных.",
|
"noData": "Нет данных.",
|
||||||
"copySuccess": "Скопировано",
|
"copySuccess": "Скопировано",
|
||||||
|
"copyFail": "Не удалось скопировать",
|
||||||
"sure": "Да",
|
"sure": "Да",
|
||||||
"encryption": "Шифрование",
|
"encryption": "Шифрование",
|
||||||
"transmission": "Транспорт",
|
"transmission": "Транспорт",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
|
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
|
||||||
"online": "В сети",
|
"online": "В сети",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailReservedName": "Имя клиента не может состоять только из «.» или «..»",
|
||||||
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
|
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||||
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
|
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||||
"group": "Группа",
|
"group": "Группа",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"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ť",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"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",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"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",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"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",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"indefinite": "Безстроково",
|
"indefinite": "Безстроково",
|
||||||
"unlimited": "Безлімітний",
|
"unlimited": "Безлімітний",
|
||||||
"none": "Немає",
|
"none": "Немає",
|
||||||
|
"empty": "Порожньо",
|
||||||
"qrCode": "QR-Код",
|
"qrCode": "QR-Код",
|
||||||
"info": "Більше інформації",
|
"info": "Більше інформації",
|
||||||
"edit": "Змінити",
|
"edit": "Змінити",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"reset": "Скидання",
|
"reset": "Скидання",
|
||||||
"noData": "Немає даних.",
|
"noData": "Немає даних.",
|
||||||
"copySuccess": "Скопійовано успішно",
|
"copySuccess": "Скопійовано успішно",
|
||||||
|
"copyFail": "Не вдалося скопіювати",
|
||||||
"sure": "Звичайно",
|
"sure": "Звичайно",
|
||||||
"encryption": "Шифрування",
|
"encryption": "Шифрування",
|
||||||
"transmission": "Протокол передачи",
|
"transmission": "Протокол передачи",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
|
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
|
||||||
"online": "У мережі",
|
"online": "У мережі",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailReservedName": "Ім’я клієнта не може складатися лише з «.» або «..»",
|
||||||
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
|
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
|
||||||
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
|
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
|
||||||
"group": "Група",
|
"group": "Група",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"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",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"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",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"indefinite": "无限期",
|
"indefinite": "无限期",
|
||||||
"unlimited": "无限制",
|
"unlimited": "无限制",
|
||||||
"none": "无",
|
"none": "无",
|
||||||
|
"empty": "空",
|
||||||
"qrCode": "二维码",
|
"qrCode": "二维码",
|
||||||
"info": "更多信息",
|
"info": "更多信息",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"noData": "无数据。",
|
"noData": "无数据。",
|
||||||
"copySuccess": "复制成功",
|
"copySuccess": "复制成功",
|
||||||
|
"copyFail": "复制失败",
|
||||||
"sure": "确定",
|
"sure": "确定",
|
||||||
"encryption": "加密",
|
"encryption": "加密",
|
||||||
"transmission": "传输",
|
"transmission": "传输",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
|
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
|
"emailReservedName": "客户端名称不能仅为“.”或“..”",
|
||||||
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
||||||
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
||||||
"group": "分组",
|
"group": "分组",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"indefinite": "無限期",
|
"indefinite": "無限期",
|
||||||
"unlimited": "無限制",
|
"unlimited": "無限制",
|
||||||
"none": "無",
|
"none": "無",
|
||||||
|
"empty": "空",
|
||||||
"qrCode": "二維碼",
|
"qrCode": "二維碼",
|
||||||
"info": "更多資訊",
|
"info": "更多資訊",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"noData": "無數據。",
|
"noData": "無數據。",
|
||||||
"copySuccess": "複製成功",
|
"copySuccess": "複製成功",
|
||||||
|
"copyFail": "複製失敗",
|
||||||
"sure": "確定",
|
"sure": "確定",
|
||||||
"encryption": "加密",
|
"encryption": "加密",
|
||||||
"transmission": "傳輸",
|
"transmission": "傳輸",
|
||||||
@@ -876,6 +878,7 @@
|
|||||||
"subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
|
"subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
|
||||||
"online": "上線",
|
"online": "上線",
|
||||||
"email": "電子郵件",
|
"email": "電子郵件",
|
||||||
|
"emailReservedName": "用戶端名稱不能僅為「.」或「..」",
|
||||||
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
|
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
|
||||||
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
|
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
|
||||||
"group": "群組",
|
"group": "群組",
|
||||||
|
|||||||
Reference in New Issue
Block a user