fix(inbounds): keep the stored client list and enable on inbound save

Invariant: saving an inbound's configuration never changes which clients it
holds nor whether it is enabled; both have their own endpoints. The edit
modal posts back the clients and the enable flag it loaded when it opened.
A client added meanwhile (another admin, the bot, the API, LDAP) was
detached and its stats deleted; a client deleted meanwhile came back with
its credentials, restoring access that had been revoked; an inbound
switched off meanwhile was switched back on.

For every save but a master's node-sync push, UpdateInbound now takes the
client list and enable from the row it re-reads inside the writer; this
replaces the lifecycle-only carry from the previous commit. Client
validation (renewal schedule, Hysteria auth, TUIC credentials) moves after
that swap so it judges the clients actually saved: a protocol switch keeps
the stored clients, and #6268's refusal must apply to them.

The edit form no longer loads or sends clients, so neither the JSON editor
nor validation sees a copy the server ignores, and the enable switch shows
only when adding; the list toggle (/setEnable) covers existing inbounds.

Tests that added or re-keyed clients through a panel inbound save pinned
the old rule; they now drive the master-push path, where payload clients
still apply.
This commit is contained in:
MHSanaei
2026-09-28 13:23:48 +02:00
parent fb7418f7bd
commit 823db05966
13 changed files with 207 additions and 96 deletions
+15 -2
View File
@@ -353,9 +353,22 @@ export function dropLegacyOptionalEmpties(
}
}
export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
// An existing inbound's clients change only through the client endpoints, so
// the edit form neither loads them nor sends them back.
export function withoutClients(values: InboundFormValues): InboundFormValues {
const settings = { ...(values.settings as Record<string, unknown> | undefined) };
delete settings.clients;
return { ...values, settings } as InboundFormValues;
}
export function formValuesToWirePayload(
values: InboundFormValues,
options: { omitClients?: boolean } = {},
): WireInboundPayload {
const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
if (Array.isArray(settingsPruned.clients)) {
if (options.omitClients) {
delete settingsPruned.clients;
} else if (Array.isArray(settingsPruned.clients)) {
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
}
let streamPruned = values.streamSettings
+1 -1
View File
@@ -325,7 +325,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/inbounds/update/:id',
summary:
'Replace an inbound’s configuration. Body shape mirrors /add. Clients the inbound already holds keep their stored enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax — change those through the /panel/api/clients endpoints. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.',
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
body: inboundBody,
},
@@ -19,7 +19,11 @@ import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types';
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import {
rawInboundToFormValues,
formValuesToWirePayload,
withoutClients,
} from '@/lib/xray/inbound-form-adapter';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
@@ -439,7 +443,9 @@ export default function InboundFormModal({
useEffect(() => {
if (!open) return;
const initial =
mode === 'edit' && dbInbound ? rawInboundToFormValues(dbInbound) : buildAddModeValues();
mode === 'edit' && dbInbound
? withoutClients(rawInboundToFormValues(dbInbound))
: buildAddModeValues();
methods.reset(initial);
setScanResult(null);
setActiveTab('basic');
@@ -556,13 +562,8 @@ export default function InboundFormModal({
}, [mode, methods]);
const saveValues = async () => {
/*
* getValues() returns the entire form store, including settings.clients and
* settings.fallbacks which have no bound field (clients are managed via the
* standalone Client modal, not this inbound modal). With shouldUnregister
* false those pass-through sub-trees survive from the reset object, so the
* update wire payload never silently drops every client on save.
*/
// settings.fallbacks has no bound field; shouldUnregister=false keeps it from
// the reset object. An edit sends no clients: the server keeps the stored ones.
const values = methods.getValues() as InboundFormValues;
const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) {
@@ -577,7 +578,7 @@ export default function InboundFormModal({
}
setSaving(true);
try {
const payload = formValuesToWirePayload(parsed.data);
const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' });
const url =
mode === 'edit' && dbInbound
? `/panel/api/inbounds/update/${dbInbound.id}`
@@ -615,9 +616,11 @@ export default function InboundFormModal({
const basicTab = (
<>
<FormField name="enable" label={t('enable')} valueProp="checked">
<Switch />
</FormField>
{mode === 'add' && (
<FormField name="enable" label={t('enable')} valueProp="checked">
<Switch id="inbound-enable" />
</FormField>
)}
<FormField name="remark" label={t('pages.inbounds.remark')}>
<Input />
@@ -317,4 +317,34 @@ describe('InboundFormModal', () => {
);
});
});
// Clients and enable change through their own endpoints; the server keeps the
// stored ones, so the edit form must neither send nor validate its stale copy.
it('edit save neither sends nor validates the clients it loaded', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
const dbInbound = cloneLikeVlessInbound('example.com:443');
const legacy = new DBInbound({
...dbInbound,
settings: {
...(dbInbound.settings as Record<string, unknown>),
clients: [{ email: 'legacy', id: '' }],
},
});
renderCloneLikeEdit(legacy);
fireEvent.click(primaryButton());
await waitFor(() => expect(post).toHaveBeenCalled());
const payload = post.mock.calls[0][1] as { settings: string };
expect(JSON.parse(payload.settings)).not.toHaveProperty('clients');
});
it('offers the enable switch when adding an inbound but not when editing one', () => {
renderModal();
expect(document.getElementById('inbound-enable')).not.toBeNull();
cleanup();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
expect(document.getElementById('inbound-enable')).toBeNull();
});
});