mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
fix(clients): reject dot names and recover legacy clients (#7062)
Reject reserved dot-segment client names and recover legacy records through query aliases. Preserve token scopes and path identity precedence, and update the generated API reference.
This commit is contained in:
committed by
GitHub
parent
2541080c91
commit
cd176faf4e
@@ -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'
|
||||||
@@ -48,10 +52,19 @@ _openapi:
|
|||||||
inbound. Body is the JSON client payload — supply the full set of fields
|
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).
|
you want to keep (the server replaces the row, it does not patch).
|
||||||
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch'
|
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch'
|
||||||
|
- depth: 2
|
||||||
|
title: Update an existing client by email. Changes propagate to every attached
|
||||||
|
inbound. Body is the JSON client payload — supply the full set of fields
|
||||||
|
you want to keep (the server replaces the row, it does not patch).
|
||||||
|
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch-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 +337,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
|
||||||
@@ -340,9 +356,17 @@ _openapi:
|
|||||||
fields you want to keep (the server replaces the row, it does not
|
fields you want to keep (the server replaces the row, it does not
|
||||||
patch).
|
patch).
|
||||||
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||||
|
- content: Update an existing client by email. Changes propagate to every attached
|
||||||
|
inbound. Body is the JSON client payload — supply the full set of
|
||||||
|
fields you want to keep (the server replaces the row, it does not
|
||||||
|
patch).
|
||||||
|
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch-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 +588,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 +599,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
|
||||||
@@ -650,6 +680,17 @@ _openapi:
|
|||||||
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||||
|
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
|
||||||
|
to manage legacy reserved names without URL dot-segment normalization.
|
||||||
|
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch-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 +701,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 +738,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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+162
-1
@@ -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": {
|
||||||
@@ -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 — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||||
|
"operationId": "post_panel_api_clients_update",
|
||||||
|
"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 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": [
|
||||||
|
|||||||
@@ -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": {
|
||||||
@@ -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 — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||||
|
"operationId": "post_panel_api_clients_update",
|
||||||
|
"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 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}`;
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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 — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
|
||||||
|
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 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',
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,7 +263,10 @@ 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)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
model.Client
|
model.Client
|
||||||
LimitHwid int `json:"limitHwid"`
|
LimitHwid int `json:"limitHwid"`
|
||||||
@@ -273,7 +295,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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -876,6 +876,7 @@
|
|||||||
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
|
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
|
||||||
"online": "متصل",
|
"online": "متصل",
|
||||||
"email": "البريد",
|
"email": "البريد",
|
||||||
|
"emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط",
|
||||||
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||||
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||||
"group": "المجموعة",
|
"group": "المجموعة",
|
||||||
|
|||||||
@@ -876,6 +876,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",
|
||||||
|
|||||||
@@ -876,6 +876,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",
|
||||||
|
|||||||
@@ -876,6 +876,7 @@
|
|||||||
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل میشود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
|
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل میشود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
|
||||||
"online": "آنلاین",
|
"online": "آنلاین",
|
||||||
"email": "ایمیل",
|
"email": "ایمیل",
|
||||||
|
"emailReservedName": "نام کاربر نمیتواند فقط «.» یا «..» باشد",
|
||||||
"emailInvalidChars": "ایمیل نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
"emailInvalidChars": "ایمیل نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||||
"subIdInvalidChars": "شناسهی اشتراک نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
"subIdInvalidChars": "شناسهی اشتراک نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||||
"group": "گروه",
|
"group": "گروه",
|
||||||
|
|||||||
@@ -876,6 +876,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",
|
||||||
|
|||||||
@@ -876,6 +876,7 @@
|
|||||||
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
|
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
|
||||||
"online": "オンライン",
|
"online": "オンライン",
|
||||||
"email": "メール",
|
"email": "メール",
|
||||||
|
"emailReservedName": "クライアント名を「.」または「..」だけにすることはできません",
|
||||||
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||||
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||||
"group": "グループ",
|
"group": "グループ",
|
||||||
|
|||||||
@@ -876,6 +876,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",
|
||||||
|
|||||||
@@ -876,6 +876,7 @@
|
|||||||
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
|
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
|
||||||
"online": "В сети",
|
"online": "В сети",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailReservedName": "Имя клиента не может состоять только из «.» или «..»",
|
||||||
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
|
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||||
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
|
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||||
"group": "Группа",
|
"group": "Группа",
|
||||||
|
|||||||
@@ -876,6 +876,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",
|
||||||
|
|||||||
@@ -876,6 +876,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",
|
||||||
|
|||||||
@@ -876,6 +876,7 @@
|
|||||||
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
|
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
|
||||||
"online": "У мережі",
|
"online": "У мережі",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailReservedName": "Ім’я клієнта не може складатися лише з «.» або «..»",
|
||||||
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
|
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
|
||||||
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
|
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
|
||||||
"group": "Група",
|
"group": "Група",
|
||||||
|
|||||||
@@ -876,6 +876,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",
|
||||||
|
|||||||
@@ -876,6 +876,7 @@
|
|||||||
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
|
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
|
"emailReservedName": "客户端名称不能仅为“.”或“..”",
|
||||||
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
||||||
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
||||||
"group": "分组",
|
"group": "分组",
|
||||||
|
|||||||
@@ -876,6 +876,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