diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index ab790f938..bf70abb01 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -28,6 +28,10 @@ _openapi: title: Fetch one client by email, including the inbound IDs and external config IDs it is attached to. url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to' + - depth: 2 + title: Fetch one client by email, including the inbound IDs and external config + IDs it is attached to. + url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1' - depth: 2 title: Fetch clients by subscription ID. url: '#fetch-clients-by-subscription-id' @@ -48,10 +52,19 @@ _openapi: 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 title: Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed. url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed' + - depth: 2 + title: Delete a client by email. Removes it from every attached inbound and + drops its traffic record unless keepTraffic=1 is passed. + url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1' - depth: 2 title: Attach an existing client to one or more additional inbounds. Body is JSON. @@ -324,6 +337,9 @@ _openapi: - content: Fetch one client by email, including the inbound IDs and external config IDs it is attached to. id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to + - content: Fetch one client by email, including the inbound IDs and external + config IDs it is attached to. + id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1 - content: Fetch clients by subscription ID. id: fetch-clients-by-subscription-id - content: Fetch clients by Telegram user ID. Returns an array since multiple @@ -340,9 +356,17 @@ _openapi: 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 drops its traffic record unless keepTraffic=1 is passed. id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed + - content: Delete a client by email. Removes it from every attached inbound and + drops its traffic record unless keepTraffic=1 is passed. + id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1 - content: Attach an existing client to one or more additional inbounds. Body is JSON. id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json @@ -564,6 +588,9 @@ _openapi: tunnel) contribute nothing.' id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing contents: + - content: Query-parameter alias for the path endpoint. Use email=. or email=.. to + manage legacy reserved names without URL dot-segment normalization. + heading: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1 - content: Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, @@ -572,11 +599,14 @@ _openapi: email lookup. Requires an admin token or a panel login session. heading: fetch-clients-by-subscription-id - content: >- - 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. + 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. - **VLESS / VMess** — `id`, a fresh UUID @@ -650,6 +680,17 @@ _openapi: without an `inbound :` 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 + - 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: `), 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 :` 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 fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: `), and several failures @@ -660,6 +701,18 @@ _openapi: inbounds, so a failure there is reported without an `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 + - 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: `), 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 :` + prefix and leaves the client already removed from every inbound.' + heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1 - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound instead of being given a fresh address, so the call fails with `inbound : wireguard: allowedIPs entry overlaps
@@ -685,7 +738,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index c999960f5..557c7a764 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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}": { "get": { "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.", "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 : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
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 ` 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: `), 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 : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
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 ` 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: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.", "requestBody": { "required": true, "content": { @@ -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: `), 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 :` prefix and leaves the inbound edits in place.", + "parameters": [ + { + "name": "email", + "in": "query", + "required": true, + "description": "Current client email (unique identifier).", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "email": "alice@example.com", + "totalGB": 107374182400, + "expiryTime": 1767225600000, + "limitHwid": 2, + "tgId": 123456789, + "enable": true + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "Client updated" + } + } + } + } + } + } + }, "/panel/api/clients/del/{email}": { "post": { "tags": [ @@ -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: `), 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 :` prefix and leaves the client already removed from every inbound.", + "parameters": [ + { + "name": "email", + "in": "query", + "required": true, + "description": "Client email (unique identifier).", + "schema": { + "type": "string" + } + }, + { + "name": "keepTraffic", + "in": "query", + "required": true, + "description": "Pass 1 to retain the xray_client_traffic row after deletion.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "Client deleted" + } + } + } + } + } + } + }, "/panel/api/clients/{email}/attach": { "post": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c999960f5..557c7a764 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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}": { "get": { "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.", "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 : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
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 ` 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: `), 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 : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
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 ` 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: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.", "requestBody": { "required": true, "content": { @@ -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: `), 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 :` prefix and leaves the inbound edits in place.", + "parameters": [ + { + "name": "email", + "in": "query", + "required": true, + "description": "Current client email (unique identifier).", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "email": "alice@example.com", + "totalGB": 107374182400, + "expiryTime": 1767225600000, + "limitHwid": 2, + "tgId": 123456789, + "enable": true + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "Client updated" + } + } + } + } + } + } + }, "/panel/api/clients/del/{email}": { "post": { "tags": [ @@ -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: `), 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 :` prefix and leaves the client already removed from every inbound.", + "parameters": [ + { + "name": "email", + "in": "query", + "required": true, + "description": "Client email (unique identifier).", + "schema": { + "type": "string" + } + }, + { + "name": "keepTraffic", + "in": "query", + "required": true, + "description": "Pass 1 to retain the xray_client_traffic row after deletion.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "Client deleted" + } + } + } + } + } + } + }, "/panel/api/clients/{email}/attach": { "post": { "tags": [ diff --git a/frontend/src/api/client-management.ts b/frontend/src/api/client-management.ts new file mode 100644 index 000000000..0ba8f6b63 --- /dev/null +++ b/frontend/src/api/client-management.ts @@ -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}`; +} diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 199a47a2e..7020762ec 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -4,6 +4,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import { HttpUtil, Msg } from '@/utils'; import { parseMsg } from '@/utils/zodValidate'; import { keys } from '@/api/queryKeys'; +import { clientManagementPath } from '@/api/client-management'; import { markLocalInvalidate } from '@/api/invalidationTracker'; import { ClientHydrateSchema, @@ -343,7 +344,7 @@ export function useClients(options: UseClientsOptions = {}) { const hydrate = useCallback(async (email: string): Promise => { 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; const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get'); return validated.obj; @@ -375,7 +376,7 @@ export function useClients(options: UseClientsOptions = {}) { const updateMut = useMutation({ 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) => { if (msg?.success) invalidateAll(); }, @@ -383,9 +384,8 @@ export function useClients(options: UseClientsOptions = {}) { const removeMut = useMutation({ mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => { - const url = keepTraffic - ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1` - : `/panel/api/clients/del/${encodeURIComponent(email)}`; + let url = clientManagementPath('del', email); + if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`; return HttpUtil.post(url); }, onSuccess: (msg) => { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 3f5b44bfa..c968b440e 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1149,6 +1149,19 @@ export const sections: readonly Section[] = [ response: '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}', }, + { + method: 'GET', + path: '/panel/api/clients/get', + summary: + 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.', + description: + 'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.', + params: [ + { name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' }, + ], + response: + '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}', + }, { method: 'GET', path: '/panel/api/clients/get/subId/:subId', @@ -1195,7 +1208,7 @@ export const sections: readonly Section[] = [ summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.', description: - '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 : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
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 ` 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: `), 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 : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
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 ` 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: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.', params: [ { name: 'client', @@ -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}', 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: `), 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 :` prefix and leaves the inbound edits in place.', + params: [ + { + name: 'email', + in: 'query', + type: 'string', + desc: 'Current client email (unique identifier).', + }, + ], + body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}', + response: '{\n "success": true,\n "msg": "Client updated"\n}', + }, { method: 'POST', path: '/panel/api/clients/del/:email', @@ -1295,6 +1326,24 @@ export const sections: readonly Section[] = [ ], response: '{\n "success": true,\n "msg": "Client deleted"\n}', }, + { + method: 'POST', + path: '/panel/api/clients/del', + summary: + 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.', + description: + 'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: `), 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 :` prefix and leaves the client already removed from every inbound.', + params: [ + { name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' }, + { + name: 'keepTraffic', + in: 'query', + type: 'integer', + desc: 'Pass 1 to retain the xray_client_traffic row after deletion.', + }, + ], + response: '{\n "success": true,\n "msg": "Client deleted"\n}', + }, { method: 'POST', path: '/panel/api/clients/:email/attach', diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 2f7894a3a..9ff2a45b7 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -319,6 +319,7 @@ export const ClientFormSchema = z.object({ .string() .trim() .min(1, 'pages.clients.email') + .refine((v) => v !== '.' && v !== '..', 'pages.clients.emailReservedName') .refine((v) => !hasForbiddenClientChars(v), 'pages.clients.emailInvalidChars'), subId: z.string().refine((v) => !hasForbiddenClientChars(v), 'pages.clients.subIdInvalidChars'), uuid: z.string(), diff --git a/frontend/src/test/client-dot-name-recovery.test.tsx b/frontend/src/test/client-dot-name-recovery.test.tsx new file mode 100644 index 000000000..9e3190bd4 --- /dev/null +++ b/frontend/src/test/client-dot-name-recovery.test.tsx @@ -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 {children}; + } + + 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'); + }, + ); +}); diff --git a/frontend/src/test/client-name.test.ts b/frontend/src/test/client-name.test.ts new file mode 100644 index 000000000..52cce93a1 --- /dev/null +++ b/frontend/src/test/client-name.test.ts @@ -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); + }, + ); +}); diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 4c9038f16..d046ed23c 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -105,8 +105,10 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{ "/inbounds/:id/subSortIndex": {http.MethodPost: {}}, "/clients/add": {http.MethodPost: {}}, "/clients/del/:email": {http.MethodPost: {}}, + "/clients/del": {http.MethodPost: {}}, "/clients/:email/detach": {http.MethodPost: {}}, "/clients/update/:email": {http.MethodPost: {}}, + "/clients/update": {http.MethodPost: {}}, "/server/restartXrayService": {http.MethodPost: {}}, "/server/getWebCertFiles": {http.MethodGet: {}}, "/server/descendants": {http.MethodGet: {}}, diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 2c7c5e3ef..97c924410 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -53,6 +53,7 @@ func NewClientController(g *gin.RouterGroup) *ClientController { func (a *ClientController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) g.GET("/list/paged", a.listPaged) + g.GET("/get", a.get) g.GET("/get/:email", a.get) g.GET("/get/subId/:subId", a.getBySubId) g.GET("/get/tgId/:tgId", a.getByTgId) @@ -63,7 +64,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) { g.POST("/add", a.create) g.POST("/renewalPreview", a.renewalPreview) + g.POST("/update", a.update) g.POST("/update/:email", a.update) + g.POST("/del", a.delete) g.POST("/del/:email", a.delete) g.POST("/:email/attach", a.attach) g.POST("/:email/detach", a.detach) @@ -159,8 +162,24 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e }, 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") + 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) if err != nil { 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) { - email := c.Param("email") + email, ok := clientEmailParam(c) + if !ok { + return + } var req struct { model.Client LimitHwid int `json:"limitHwid"` @@ -273,7 +295,10 @@ func (a *ClientController) update(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" needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic) // Flagged before the error check: a partly-applied delete already removed diff --git a/internal/web/controller/client_dot_name_test.go b/internal/web/controller/client_dot_name_test.go new file mode 100644 index 000000000..272b73a85 --- /dev/null +++ b/internal/web/controller/client_dot_name_test.go @@ -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) + } +} diff --git a/internal/web/controller/client_lookup_test.go b/internal/web/controller/client_lookup_test.go index 1e7cd892c..815049d3d 100644 --- a/internal/web/controller/client_lookup_test.go +++ b/internal/web/controller/client_lookup_test.go @@ -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()) + } + }) + } + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 392fa563a..7f1687fba 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -37,6 +37,9 @@ func hasForbiddenClientChar(s string) bool { } func validateClientEmail(email string) error { + if email == "." || email == ".." { + return common.NewError("client email must not be '.' or '..'") + } if hasForbiddenClientChar(email) { return common.NewError("client email contains an invalid character:", email) } diff --git a/internal/web/service/client_email_validation_test.go b/internal/web/service/client_email_validation_test.go index 4e01b2079..2f849d37a 100644 --- a/internal/web/service/client_email_validation_test.go +++ b/internal/web/service/client_email_validation_test.go @@ -7,6 +7,10 @@ func TestValidateClientEmail(t *testing.T) { "alice", "alice@example.com", "user-123_test.name", + "...", + ".alice", + "alice.", + "alice..example", "имя", } for _, email := range valid { @@ -16,6 +20,8 @@ func TestValidateClientEmail(t *testing.T) { } invalid := []string{ + ".", + "..", "i6dui/", "a/b", "client with spaces", diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index fb570a464..194eca6fc 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -876,6 +876,7 @@ "subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.", "online": "متصل", "email": "البريد", + "emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط", "emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم", "subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم", "group": "المجموعة", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index a11d6d4a0..ed3c13da0 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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.", "online": "Online", "email": "Email", + "emailReservedName": "Client name cannot be exactly \".\" or \"..\"", "emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters", "subIdInvalidChars": "Subscription ID cannot contain spaces, '/', '\\', or control characters", "group": "Group", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 3bb0f0ce2..4515a5e97 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -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.", "online": "En línea", "email": "Email", + "emailReservedName": "El nombre del cliente no puede ser exactamente \".\" o \"..\"", "emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control", "subIdInvalidChars": "El ID de suscripción no puede contener espacios, '/', '\\' ni caracteres de control", "group": "Grupo", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 957fb8c2e..16c5a1cc2 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -876,6 +876,7 @@ "subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.", "online": "آنلاین", "email": "ایمیل", + "emailReservedName": "نام کاربر نمی‌تواند فقط «.» یا «..» باشد", "emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد", "subIdInvalidChars": "شناسه‌ی اشتراک نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد", "group": "گروه", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index e14b66968..9afc4339b 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -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.", "online": "Online", "email": "Email", + "emailReservedName": "Nama klien tidak boleh hanya \".\" atau \"..\"", "emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol", "subIdInvalidChars": "ID langganan tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol", "group": "Grup", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index f238906b5..9472664b0 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -876,6 +876,7 @@ "subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。", "online": "オンライン", "email": "メール", + "emailReservedName": "クライアント名を「.」または「..」だけにすることはできません", "emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません", "subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません", "group": "グループ", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index b014a82e7..3df23658d 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -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.", "online": "Online", "email": "Email", + "emailReservedName": "O nome do cliente não pode ser exatamente \".\" ou \"..\"", "emailInvalidChars": "O e-mail não pode conter espaços, '/', '\\' ou caracteres de controle", "subIdInvalidChars": "O ID de assinatura não pode conter espaços, '/', '\\' ou caracteres de controle", "group": "Grupo", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 86adf35cd..2fc6a751c 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -876,6 +876,7 @@ "subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.", "online": "В сети", "email": "Email", + "emailReservedName": "Имя клиента не может состоять только из «.» или «..»", "emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы", "subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы", "group": "Группа", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index c21a2c7ca..bd49a3bb1 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -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ý.", "online": "Online", "email": "Názov klienta", + "emailReservedName": "Názov klienta nesmie byť presne \".\" ani \"..\"", "emailInvalidChars": "Názov klienta nesmie obsahovať medzery, '/', '\\' ani riadiace znaky", "subIdInvalidChars": "ID odberu nesmie obsahovať medzery, '/', '\\' ani riadiace znaky", "group": "Skupina", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 99c1183be..593e4932c 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -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.", "online": "Çevrimiçi", "email": "E-posta", + "emailReservedName": "İstemci adı yalnızca \".\" veya \"..\" olamaz", "emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez", "subIdInvalidChars": "Abonelik kimliği boşluk, '/', '\\' veya kontrol karakterleri içeremez", "group": "Grup", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 690efd610..506df1d42 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -876,6 +876,7 @@ "subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.", "online": "У мережі", "email": "Email", + "emailReservedName": "Ім’я клієнта не може складатися лише з «.» або «..»", "emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи", "subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи", "group": "Група", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 54bf57deb..1723feeec 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -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.", "online": "Trực tuyến", "email": "Email", + "emailReservedName": "Tên máy khách không được chỉ là \".\" hoặc \"..\"", "emailInvalidChars": "Email không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển", "subIdInvalidChars": "ID đăng ký không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển", "group": "Nhóm", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index ef97d0351..5e98edf8c 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -876,6 +876,7 @@ "subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。", "online": "在线", "email": "邮箱", + "emailReservedName": "客户端名称不能仅为“.”或“..”", "emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符", "subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符", "group": "分组", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 2a49cc8cb..1d522f9cf 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -876,6 +876,7 @@ "subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。", "online": "上線", "email": "電子郵件", + "emailReservedName": "用戶端名稱不能僅為「.」或「..」", "emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元", "subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元", "group": "群組",