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:
Benjamin Mitchell
2026-11-05 09:02:39 -08:00
committed by GitHub
parent 2541080c91
commit cd176faf4e
29 changed files with 895 additions and 17 deletions
+59 -6
View File
@@ -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
View File
@@ -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": [
+162 -1
View File
@@ -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": [
+5
View File
@@ -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}`;
}
+5 -5
View File
@@ -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) => {
+50 -1
View File
@@ -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',
+1
View File
@@ -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');
},
);
});
+22
View File
@@ -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);
},
);
});
+2
View File
@@ -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: {}},
+28 -3
View File
@@ -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())
}
})
}
}
}
+3
View File
@@ -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",
+1
View File
@@ -876,6 +876,7 @@
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.", "subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
"online": "متصل", "online": "متصل",
"email": "البريد", "email": "البريد",
"emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط",
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم", "emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم", "subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
"group": "المجموعة", "group": "المجموعة",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -876,6 +876,7 @@
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.", "subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
"online": "آنلاین", "online": "آنلاین",
"email": "ایمیل", "email": "ایمیل",
"emailReservedName": "نام کاربر نمی‌تواند فقط «.» یا «..» باشد",
"emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد", "emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
"subIdInvalidChars": "شناسه‌ی اشتراک نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد", "subIdInvalidChars": "شناسه‌ی اشتراک نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
"group": "گروه", "group": "گروه",
+1
View File
@@ -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",
+1
View File
@@ -876,6 +876,7 @@
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。", "subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
"online": "オンライン", "online": "オンライン",
"email": "メール", "email": "メール",
"emailReservedName": "クライアント名を「.」または「..」だけにすることはできません",
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません", "emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません", "subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
"group": "グループ", "group": "グループ",
+1
View File
@@ -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",
+1
View File
@@ -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": "Группа",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "Група",
+1
View File
@@ -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",
+1
View File
@@ -876,6 +876,7 @@
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。", "subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
"online": "在线", "online": "在线",
"email": "邮箱", "email": "邮箱",
"emailReservedName": "客户端名称不能仅为“.”或“..”",
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符", "emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符", "subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
"group": "分组", "group": "分组",
+1
View File
@@ -876,6 +876,7 @@
"subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。", "subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
"online": "上線", "online": "上線",
"email": "電子郵件", "email": "電子郵件",
"emailReservedName": "用戶端名稱不能僅為「.」或「..」",
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元", "emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元", "subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
"group": "群組", "group": "群組",