fix(api): preserve omitted client fields in partial updates (#7042)

Keep omitted client fields and per-inbound flows unchanged while honoring explicit clears. Support the query aliases used to recover legacy dot-name clients and regenerate the API reference. Add an HTTP regression covering partial updates through those aliases.
This commit is contained in:
Aleksey Fedorov
2026-11-06 03:06:16 +10:00
committed by GitHub
parent cd176faf4e
commit e4935be861
14 changed files with 525 additions and 71 deletions
+40 -30
View File
@@ -48,15 +48,17 @@ _openapi:
title: Preview client auto-renewal dates without saving or resetting anything.
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
- 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'
title: 'Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.'
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below'
- 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'
title: 'Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.'
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1'
- depth: 2
title: Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
@@ -351,16 +353,16 @@ _openapi:
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: Preview client auto-renewal dates without saving or resetting anything.
id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: Update an existing client by email. Changes propagate to every attached
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: 'Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.'
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- content: 'Update an existing client by email. Changes propagate to every
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.'
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
- content: Delete a client by email. Removes it from every attached inbound and
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
@@ -671,17 +673,10 @@ _openapi:
preview is informational and does not reserve an allowance or
guarantee node availability.
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- 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: <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
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
to manage legacy reserved names without URL dot-segment normalization.
- content: 'An omitted `flow` leaves every attached inbound on its own flow. Send
`"reverse": null` to remove the reverse tag. Credentials (`id`,
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
`allowedIPs` are never cleared: an empty value keeps the stored one.
The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
@@ -690,7 +685,22 @@ _openapi:
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
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
to manage legacy reserved names without URL dot-segment normalization.
An omitted `flow` leaves every attached inbound on its own flow. Send
`"reverse": null` to remove the reverse tag. Credentials (`id`,
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
`allowedIPs` are never cleared: an empty value keeps the stored one.
The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
are reported together, one per line. So a `success:false` response can
still have applied the edit to the remaining inbounds. The client
record is written after the inbounds, so a failure there is reported
without an `inbound <id>:` prefix and leaves the inbound edits in
place.'
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
- content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures
@@ -44,10 +44,11 @@ _openapi:
- 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).
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.
url: >-
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- depth: 2
title: >-
Delete a client by email. Removes it from every attached inbound and
@@ -358,11 +359,11 @@ _openapi:
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- 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).
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.
id: >-
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- content: >-
Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
@@ -44,10 +44,11 @@ _openapi:
- 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).
inbound. Body is the JSON client payload: omitted fields keep their
current values, and an explicit zero, empty or null value clears a
field, except as noted below.
url: >-
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- depth: 2
title: >-
Delete a client by email. Removes it from every attached inbound and
@@ -358,11 +359,11 @@ _openapi:
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- 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).
attached inbound. Body is the JSON client payload: omitted fields keep
their current values, and an explicit zero, empty or null value clears
a field, except as noted below.
id: >-
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- content: >-
Delete a client by email. Removes it from every attached inbound and
drops its traffic record unless keepTraffic=1 is passed.
@@ -35,10 +35,10 @@ _openapi:
#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- depth: 2
title: >-
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
url: >-
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- depth: 2
title: >-
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
@@ -292,10 +292,10 @@ _openapi:
id: >-
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: >-
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
id: >-
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
- content: >-
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
keepTraffic=1。
+4 -4
View File
@@ -9053,9 +9053,9 @@
"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).",
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"operationId": "post_panel_api_clients_update_email",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
@@ -9117,9 +9117,9 @@
"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).",
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"operationId": "post_panel_api_clients_update",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
+4 -4
View File
@@ -9053,9 +9053,9 @@
"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).",
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"operationId": "post_panel_api_clients_update_email",
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
@@ -9117,9 +9117,9 @@
"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).",
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
"operationId": "post_panel_api_clients_update",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
+4 -4
View File
@@ -1276,9 +1276,9 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/update/:email',
summary:
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
description:
'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
'An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
params: [
{
name: 'email',
@@ -1294,9 +1294,9 @@ export const sections: readonly Section[] = [
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).',
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
description:
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. 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.',
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
params: [
{
name: 'email',
@@ -798,10 +798,10 @@ export default function ClientFormModal({
comment: values.comment,
enable: !!values.enable,
};
// Sent even when empty, so clearing the tag never rests on how the server
// reads an omitted key.
const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
if (reverseTagValue) {
clientPayload.reverse = { tag: reverseTagValue };
}
clientPayload.reverse = reverseTagValue ? { tag: reverseTagValue } : null;
if (showWireguard || showAmneziawg) {
// AmneziaWG peers are wire-identical to WireGuard peers (same
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientFormModal from '@/pages/clients/ClientFormModal';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
const VLESS_INBOUND = {
id: 7,
port: 10443,
protocol: 'vless',
tag: 'in-10443-tcp',
enable: true,
} as unknown as InboundOption;
const CLIENT = {
email: 'reverse-user',
uuid: '11111111-1111-1111-1111-111111111111',
subId: 'subid-reverse',
enable: true,
reverse: { tag: 'back' },
} as unknown as ClientRecord;
async function saveEdited(edit?: (input: HTMLInputElement) => void) {
const save = vi.fn().mockResolvedValue({ success: true });
render(
<ThemeProvider>
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ClientFormModal
open
mode="edit"
client={CLIENT}
inbounds={[VLESS_INBOUND]}
attachedIds={[7]}
save={save}
onOpenChange={() => {}}
/>
</QueryClientProvider>
</ThemeProvider>,
);
const input = (await screen.findByPlaceholderText('Optional reverse tag')) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe('back'));
edit?.(input);
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
await waitFor(() => expect(save).toHaveBeenCalled());
return save.mock.calls[0][0] as Record<string, unknown>;
}
// A cleared tag goes out as an explicit null rather than a missing key, which a
// partial update would read as "keep the stored tag".
describe('ClientFormModal — reverse tag', () => {
it('sends an explicit null when the operator clears the tag', async () => {
const payload = await saveEdited((input) => fireEvent.change(input, { target: { value: '' } }));
expect(payload).toHaveProperty('reverse', null);
});
it('round-trips an unchanged tag', async () => {
const payload = await saveEdited();
expect(payload.reverse).toEqual({ tag: 'back' });
});
});
+3 -6
View File
@@ -267,16 +267,13 @@ func (a *ClientController) update(c *gin.Context) {
if !ok {
return
}
var req struct {
model.Client
LimitHwid int `json:"limitHwid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
body, err := c.GetRawData()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
needRestart, err := a.clientService.UpdateByEmailJSON(&a.inboundService, email, body, inboundFilter...)
// Flagged before the error check: a partly-applied edit leaves the change
// committed on the inbounds that succeeded, and those still need the restart.
if needRestart {
@@ -0,0 +1,74 @@
package controller
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// A handler that binds the body straight into model.Client turns every omitted
// field into its zero: this expiry-only body would disable the client.
func TestUpdateHandlerKeepsFieldsTheBodyOmits(t *testing.T) {
const email = "partial-body@example.com"
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
db := database.GetDB()
ib := &model.Inbound{
UserId: 1, Enable: true, Port: 43350, Tag: "in-partial-body",
Protocol: model.VLESS, Settings: `{"clients": []}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if _, err := (&service.ClientService{}).Create(&service.InboundService{}, &service.ClientCreatePayload{
Client: model.Client{
Email: email, ID: "11111111-2222-3333-4444-555555555555", Enable: true,
LimitIP: 2, TotalGB: 10 << 30, TgID: 42, Comment: "phone",
},
InboundIds: []int{ib.Id},
}); err != nil {
t.Fatalf("seed client: %v", err)
}
a := &ClientController{}
c, w := postCtx(t, email, map[string]any{"email": email, "expiryTime": 1893456000000})
a.update(c)
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("reload client: %v (response %s)", err, w.Body.String())
}
if rec.ExpiryTime != 1893456000000 {
t.Fatalf("expiry not applied: %d (response %s)", rec.ExpiryTime, w.Body.String())
}
if !rec.Enable || rec.LimitIP != 2 || rec.TotalGB != 10<<30 || rec.TgID != 42 || rec.Comment != "phone" {
t.Fatalf("handler reset fields the body omitted: %+v", rec)
}
}
func TestClientQueryAliasPartialUpdateKeepsOmittedFields(t *testing.T) {
for _, email := range []string{".", ".."} {
t.Run(email, func(t *testing.T) {
router, rec, _ := seedLegacyDotClient(t, email)
db := database.GetDB()
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Updates(map[string]any{"limit_ip": 2, "total_gb": int64(10 << 30), "comment": "retained"}).Error; err != nil {
t.Fatal(err)
}
w := dotClientRequest(t, router, "POST", "update?email="+email, map[string]any{"email": "recovered-partial", "expiryTime": int64(1893456000000)})
assertDotClientSuccess(t, w)
var after model.ClientRecord
if err := db.First(&after, rec.Id).Error; err != nil {
t.Fatal(err)
}
if after.Email != "recovered-partial" || after.ExpiryTime != 1893456000000 || !after.Enable || after.UUID != rec.UUID || after.SubID != rec.SubID || after.LimitIP != 2 || after.TotalGB != 10<<30 || after.Comment != "retained" {
t.Fatalf("query alias partial update lost client state: %+v", after)
}
})
}
}
+17
View File
@@ -675,6 +675,12 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
return s.update(inboundSvc, id, updated, limitHwid, false, inboundFilter)
}
// keepInboundFlows: the caller sent no flow, so every inbound keeps its own and an
// unlinked record keeps its stored one.
func (s *ClientService) update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, keepInboundFlows bool, inboundFilter []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
@@ -800,6 +806,14 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
}
}
var inboundFlows map[int]string
if keepInboundFlows {
updated.Flow = existing.Flow
if inboundFlows, err = inboundFlowOverrides(id); err != nil {
return false, err
}
}
tunnelCount, tcErr := tunnelInboundCount(inboundIds)
if tcErr != nil {
return false, tcErr
@@ -828,6 +842,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
return false, err
}
clientForInbound := updated
if keepInboundFlows {
clientForInbound.Flow = inboundFlows[ibId]
}
if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
clientForInbound.AllowedIPs = ips
} else if tunnelCount > 1 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
@@ -0,0 +1,130 @@
package service
import (
"encoding/json"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
// UpdateByEmailJSON applies a POST /clients/update body: an omitted key keeps the
// stored value and an explicit zero, empty or null value clears it.
func (s *ClientService) UpdateByEmailJSON(inboundSvc *InboundService, email string, body []byte, inboundFilter ...int) (bool, error) {
var req clientPayloadWithHwid
if err := json.Unmarshal(body, &req); err != nil {
return false, err
}
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
keys := clientPayloadKeys(body)
fillAbsentClientFields(rec, &req.Client, &req.LimitHwid, keys)
return s.update(inboundSvc, rec.Id, req.Client, req.LimitHwid, !keys.has("flow"), inboundFilter)
}
// payloadKeys is a struct so tools/openapigen, which exports every named
// non-struct type of this package, leaves it out of the generated frontend types.
type payloadKeys struct{ names []string }
func clientPayloadKeys(body []byte) payloadKeys {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return payloadKeys{}
}
keys := payloadKeys{names: make([]string, 0, len(raw))}
for k := range raw {
keys.names = append(keys.names, k)
}
return keys
}
// has matches keys the way encoding/json binds them to fields: case-insensitively,
// so a body sending "limitIP" counts as carrying limitIp.
func (k payloadKeys) has(name string) bool {
for _, key := range k.names {
if strings.EqualFold(key, name) {
return true
}
}
return false
}
// fillAbsentClientFields leaves flow to update, which keeps it per inbound.
func fillAbsentClientFields(rec *model.ClientRecord, updated *model.Client, limitHwid *int, keys payloadKeys) {
stored := rec.ToClient()
if !keys.has("email") {
updated.Email = stored.Email
}
if !keys.has("security") {
updated.Security = stored.Security
}
if !keys.has("limitIp") {
updated.LimitIP = stored.LimitIP
}
if !keys.has("totalGB") {
updated.TotalGB = stored.TotalGB
}
if !keys.has("expiryTime") {
updated.ExpiryTime = stored.ExpiryTime
}
if !keys.has("enable") {
updated.Enable = stored.Enable
}
if !keys.has("tgId") {
updated.TgID = stored.TgID
}
if !keys.has("group") {
updated.Group = stored.Group
}
if !keys.has("comment") {
updated.Comment = stored.Comment
}
if !keys.has("reset") {
updated.Reset = stored.Reset
}
if !keys.has("resetDay") {
updated.ResetDay = stored.ResetDay
}
if !keys.has("resetWeekday") {
updated.ResetWeekday = stored.ResetWeekday
}
if !keys.has("resetMax") {
updated.ResetMax = stored.ResetMax
}
if !keys.has("trafficReset") {
updated.TrafficReset = stored.TrafficReset
}
if !keys.has("trafficResetDay") {
updated.TrafficResetDay = stored.TrafficResetDay
}
if !keys.has("adTag") {
updated.AdTag = stored.AdTag
}
if !keys.has("reverse") {
updated.Reverse = stored.Reverse
}
if !keys.has("limitHwid") {
*limitHwid = rec.LimitHwid
}
}
// inboundFlowOverrides returns each linked inbound's flow_override: the reliable
// per-inbound flow, unlike clients.flow (see EffectiveFlow).
func inboundFlowOverrides(recordId int) (map[int]string, error) {
var links []model.ClientInbound
if err := database.GetDB().Select("inbound_id", "flow_override").
Where("client_id = ?", recordId).Find(&links).Error; err != nil {
return nil, err
}
flows := make(map[int]string, len(links))
for _, link := range links {
flows[link.InboundId] = link.FlowOverride
}
return flows, nil
}
@@ -0,0 +1,160 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
const partialTestPSK = "Pm4Hn0f8i8b6v3gJ0vZQ3yHq4mUe7qkq0m3o2l9sY9c="
func updateByEmailJSON(t *testing.T, svc *ClientService, email, body string) {
t.Helper()
if _, err := svc.UpdateByEmailJSON(&InboundService{}, email, []byte(body)); err != nil {
t.Fatalf("UpdateByEmailJSON(%s): %v", body, err)
}
}
func inboundClientByEmail(t *testing.T, inboundId int, email string) model.Client {
t.Helper()
inboundSvc := &InboundService{}
ib, err := inboundSvc.GetInbound(inboundId)
if err != nil {
t.Fatalf("GetInbound(%d): %v", inboundId, err)
}
clients, err := inboundSvc.GetClients(ib)
if err != nil {
t.Fatalf("GetClients(%d): %v", inboundId, err)
}
for _, c := range clients {
if c.Email == email {
return c
}
}
t.Fatalf("inbound %d has no client %q", inboundId, email)
return model.Client{}
}
// A body carrying only email and expiryTime used to disable the client and zero its
// limits, Telegram id, comment, group, renewal settings and limitHwid.
func TestUpdateByEmailJSONKeepsOmittedFields(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
ib := mkInbound(t, 52111, model.AmneziaWG, `{"server":{"privateKey":"server","publicKey":"server-pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
seed := model.Client{
Email: "full@awg", Enable: true,
PrivateKey: "client-private", PublicKey: "client-public",
PreSharedKey: partialTestPSK,
AllowedIPs: []string{"10.8.1.8/32"},
LimitIP: 3, TotalGB: 50 << 30, ExpiryTime: 1700000000000,
TgID: 123456, Comment: "laptop", Group: "family",
Reset: 30, ResetMax: 4,
}
seed.AllowedIPsByInbound = map[int][]string{ib.Id: seed.AllowedIPs}
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
t.Fatalf("seed client: %v", err)
}
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
UpdateColumn("limit_hwid", 2).Error; err != nil {
t.Fatal(err)
}
updateByEmailJSON(t, svc, seed.Email, `{"email": "full@awg", "expiryTime": 1893456000000}`)
rec := lookupClientRecord(t, seed.Email)
if !rec.Enable {
t.Fatal("partial update disabled the client")
}
if rec.ExpiryTime != 1893456000000 {
t.Fatalf("expiry not applied: %d", rec.ExpiryTime)
}
if rec.LimitIP != 3 || rec.TotalGB != 50<<30 || rec.TgID != 123456 || rec.Comment != "laptop" ||
rec.Group != "family" || rec.Reset != 30 || rec.ResetMax != 4 || rec.LimitHwid != 2 {
t.Fatalf("partial update reset omitted fields: %+v", rec)
}
peer := inboundClientByEmail(t, ib.Id, seed.Email)
if !peer.Enable || peer.PreSharedKey != partialTestPSK || peer.PrivateKey != seed.PrivateKey {
t.Fatalf("inbound peer lost its state: %+v", peer)
}
updateByEmailJSON(t, svc, seed.Email, `{"enable": false, "comment": "", "totalGB": 0}`)
rec = lookupClientRecord(t, seed.Email)
if rec.Enable || rec.Comment != "" || rec.TotalGB != 0 {
t.Fatalf("explicit zeros were not applied: %+v", rec)
}
if rec.ExpiryTime != 1893456000000 || rec.LimitIP != 3 || rec.Group != "family" || rec.LimitHwid != 2 {
t.Fatalf("second partial update reset omitted fields: %+v", rec)
}
}
// encoding/json binds "limitIP" to limitIp; a case-sensitive presence check then
// overwrote the decoded zero with the stored value.
func TestUpdateByEmailJSONMatchesKeysCaseInsensitively(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
ib := mkInbound(t, 52112, model.VLESS, `{"clients":[]}`)
seed := model.Client{
Email: "case@vless", ID: "0d1b6f8e-3c2a-4f61-9a43-5b7f0c2e9d11", Enable: true,
LimitIP: 3, TgID: 123456, ExpiryTime: 1700000000000,
}
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
t.Fatalf("seed client: %v", err)
}
updateByEmailJSON(t, svc, seed.Email, `{"limitIP": 0, "tgID": 0, "expirytime": 1893456000000}`)
rec := lookupClientRecord(t, seed.Email)
if rec.LimitIP != 0 || rec.TgID != 0 || rec.ExpiryTime != 1893456000000 {
t.Fatalf("differently-cased keys were not applied: limitIp=%d tgId=%d expiry=%d", rec.LimitIP, rec.TgID, rec.ExpiryTime)
}
if !rec.Enable {
t.Fatal("partial update disabled the client")
}
}
// clients.flow is unreliable for a multi-inbound client (#4792): an omitted flow
// must keep each inbound's own flow instead of copying that column everywhere.
func TestUpdateByEmailJSONOmittedFlowKeepsEachInboundsFlow(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
const vision = "xtls-rprx-vision"
reality := mkInboundStream(t, 52113, model.VLESS, `{"clients":[]}`, realityStream)
ws := mkInboundStream(t, 52114, model.VLESS, `{"clients":[]}`, wsStream)
seed := model.Client{
Email: "flow@vless", ID: "6a0f2c1e-8b4d-4e7a-9c35-1d2e3f4a5b6c", Enable: true, Flow: vision,
Reverse: &model.ClientReverse{Tag: "back"},
}
if _, err := svc.Create(&InboundService{}, &ClientCreatePayload{Client: seed, InboundIds: []int{reality.Id, ws.Id}}); err != nil {
t.Fatalf("seed client: %v", err)
}
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
t.Fatal("seed did not store the reverse tag")
}
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
UpdateColumn("flow", "").Error; err != nil {
t.Fatalf("zero clients.flow: %v", err)
}
updateByEmailJSON(t, svc, seed.Email, `{"expiryTime": 1893456000000}`)
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != vision || got.ExpiryTime != 1893456000000 {
t.Fatalf("Reality inbound: flow=%q expiry=%d, want %q and the new expiry", got.Flow, got.ExpiryTime, vision)
}
if got := inboundClientByEmail(t, ws.Id, seed.Email); got.Flow != "" {
t.Fatalf("WS inbound gained flow %q", got.Flow)
}
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
t.Fatal("an omitted reverse must keep the tag")
}
updateByEmailJSON(t, svc, seed.Email, `{"reverse": null}`)
if rec := lookupClientRecord(t, seed.Email); rec.Reverse != "" {
t.Fatalf("an explicit null reverse must clear the tag: %q", rec.Reverse)
}
updateByEmailJSON(t, svc, seed.Email, `{"flow": ""}`)
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != "" {
t.Fatalf("an explicit empty flow must clear Vision, got %q", got.Flow)
}
}