mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-02 12:12:08 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user