mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-05 21:52: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:
@@ -60,12 +60,11 @@ _openapi:
|
|||||||
at most once.
|
at most once.
|
||||||
url: '#delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once'
|
url: '#delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Replace an inbound’s configuration. Body shape mirrors /add. Clients the
|
title: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
|
||||||
inbound already holds keep their stored enable, expiryTime, totalGB,
|
inbound keeps its stored client list and enable flag: settings.clients
|
||||||
reset, resetDay, resetWeekday and resetMax — change those through the
|
and enable in the body are ignored. Manage clients through the
|
||||||
/panel/api/clients endpoints. Heavy on inbounds with thousands of
|
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
|
||||||
clients — prefer /setEnable for enable-only flips.
|
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable'
|
||||||
url: '#replace-an-inbounds-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-panelapiclients-endpoints-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips'
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Toggle only the enable flag without serialising the whole settings JSON.
|
title: Toggle only the enable flag without serialising the whole settings JSON.
|
||||||
Recommended for UI switches on large inbounds.
|
Recommended for UI switches on large inbounds.
|
||||||
@@ -153,12 +152,11 @@ _openapi:
|
|||||||
failures are reported per id and the rest still proceed. Restarts xray
|
failures are reported per id and the rest still proceed. Restarts xray
|
||||||
at most once.
|
at most once.
|
||||||
id: delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once
|
id: delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once
|
||||||
- content: Replace an inbound’s configuration. Body shape mirrors /add. Clients
|
- content: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
|
||||||
the inbound already holds keep their stored enable, expiryTime,
|
inbound keeps its stored client list and enable flag: settings.clients
|
||||||
totalGB, reset, resetDay, resetWeekday and resetMax — change those
|
and enable in the body are ignored. Manage clients through the
|
||||||
through the /panel/api/clients endpoints. Heavy on inbounds with
|
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
|
||||||
thousands of clients — prefer /setEnable for enable-only flips.
|
id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable
|
||||||
id: replace-an-inbounds-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-panelapiclients-endpoints-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips
|
|
||||||
- content: Toggle only the enable flag without serialising the whole settings
|
- content: Toggle only the enable flag without serialising the whole settings
|
||||||
JSON. Recommended for UI switches on large inbounds.
|
JSON. Recommended for UI switches on large inbounds.
|
||||||
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
||||||
|
|||||||
@@ -5603,7 +5603,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Inbounds"
|
"Inbounds"
|
||||||
],
|
],
|
||||||
"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.",
|
"summary": "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.",
|
||||||
"operationId": "post_panel_api_inbounds_update_id",
|
"operationId": "post_panel_api_inbounds_update_id",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5603,7 +5603,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Inbounds"
|
"Inbounds"
|
||||||
],
|
],
|
||||||
"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.",
|
"summary": "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.",
|
||||||
"operationId": "post_panel_api_inbounds_update_id",
|
"operationId": "post_panel_api_inbounds_update_id",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>;
|
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);
|
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
||||||
}
|
}
|
||||||
let streamPruned = values.streamSettings
|
let streamPruned = values.streamSettings
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/update/:id',
|
path: '/panel/api/inbounds/update/:id',
|
||||||
summary:
|
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.' }],
|
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
|
||||||
body: inboundBody,
|
body: inboundBody,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
|||||||
|
|
||||||
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||||
import type { RealityScanResult } from '@/generated/types';
|
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 { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
||||||
import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
|
import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
|
||||||
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
|
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
|
||||||
@@ -439,7 +443,9 @@ export default function InboundFormModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const initial =
|
const initial =
|
||||||
mode === 'edit' && dbInbound ? rawInboundToFormValues(dbInbound) : buildAddModeValues();
|
mode === 'edit' && dbInbound
|
||||||
|
? withoutClients(rawInboundToFormValues(dbInbound))
|
||||||
|
: buildAddModeValues();
|
||||||
methods.reset(initial);
|
methods.reset(initial);
|
||||||
setScanResult(null);
|
setScanResult(null);
|
||||||
setActiveTab('basic');
|
setActiveTab('basic');
|
||||||
@@ -556,13 +562,8 @@ export default function InboundFormModal({
|
|||||||
}, [mode, methods]);
|
}, [mode, methods]);
|
||||||
|
|
||||||
const saveValues = async () => {
|
const saveValues = async () => {
|
||||||
/*
|
// settings.fallbacks has no bound field; shouldUnregister=false keeps it from
|
||||||
* getValues() returns the entire form store, including settings.clients and
|
// the reset object. An edit sends no clients: the server keeps the stored ones.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
const values = methods.getValues() as InboundFormValues;
|
const values = methods.getValues() as InboundFormValues;
|
||||||
const parsed = InboundFormSchema.safeParse(values);
|
const parsed = InboundFormSchema.safeParse(values);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -577,7 +578,7 @@ export default function InboundFormModal({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = formValuesToWirePayload(parsed.data);
|
const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' });
|
||||||
const url =
|
const url =
|
||||||
mode === 'edit' && dbInbound
|
mode === 'edit' && dbInbound
|
||||||
? `/panel/api/inbounds/update/${dbInbound.id}`
|
? `/panel/api/inbounds/update/${dbInbound.id}`
|
||||||
@@ -615,9 +616,11 @@ export default function InboundFormModal({
|
|||||||
|
|
||||||
const basicTab = (
|
const basicTab = (
|
||||||
<>
|
<>
|
||||||
|
{mode === 'add' && (
|
||||||
<FormField name="enable" label={t('enable')} valueProp="checked">
|
<FormField name="enable" label={t('enable')} valueProp="checked">
|
||||||
<Switch />
|
<Switch id="inbound-enable" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField name="remark" label={t('pages.inbounds.remark')}>
|
<FormField name="remark" label={t('pages.inbounds.remark')}>
|
||||||
<Input />
|
<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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ func TestClientRenewalWriteValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, _, err = inboundSvc.AddInbound(&update)
|
_, _, err = inboundSvc.AddInbound(&update)
|
||||||
case "update inbound":
|
case "update inbound":
|
||||||
_, _, err = inboundSvc.UpdateInbound(&update)
|
// A panel save keeps the stored clients; only a master's push writes them.
|
||||||
|
_, _, err = (&InboundService{FromNodeSync: true}).UpdateInbound(&update)
|
||||||
case "add inbound client":
|
case "add inbound client":
|
||||||
client.Email = "invalid-new-client"
|
client.Email = "invalid-new-client"
|
||||||
update.Settings = clientsSettings(t, []model.Client{client})
|
update.Settings = clientsSettings(t, []model.Client{client})
|
||||||
|
|||||||
@@ -1684,6 +1684,35 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
|||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) error {
|
||||||
|
clients, err := s.GetClients(inbound)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateClientsRenewal(clients); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
switch inbound.Protocol {
|
||||||
|
case model.Hysteria:
|
||||||
|
if client.Auth == "" {
|
||||||
|
return common.NewError("empty client ID")
|
||||||
|
}
|
||||||
|
case model.TUIC:
|
||||||
|
if client.ID == "" {
|
||||||
|
return common.NewError("empty client ID")
|
||||||
|
}
|
||||||
|
if client.Password == "" {
|
||||||
|
return common.NewError("tuic client requires a password")
|
||||||
|
}
|
||||||
|
if client.Email == "" {
|
||||||
|
return common.NewError("empty client email")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
legacyShareAddr := legacyMtprotoShareAddr(inbound)
|
legacyShareAddr := legacyMtprotoShareAddr(inbound)
|
||||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||||
@@ -1706,34 +1735,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
}
|
}
|
||||||
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
|
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
|
||||||
|
|
||||||
clients, err := s.GetClients(inbound)
|
|
||||||
if err != nil {
|
|
||||||
return inbound, false, err
|
|
||||||
}
|
|
||||||
if err := validateClientsRenewal(clients); err != nil {
|
|
||||||
return inbound, false, err
|
|
||||||
}
|
|
||||||
if inbound.Protocol == model.Hysteria {
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.Auth == "" {
|
|
||||||
return inbound, false, common.NewError("empty client ID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inbound.Protocol == model.TUIC {
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.ID == "" {
|
|
||||||
return inbound, false, common.NewError("empty client ID")
|
|
||||||
}
|
|
||||||
if client.Password == "" {
|
|
||||||
return inbound, false, common.NewError("tuic client requires a password")
|
|
||||||
}
|
|
||||||
if client.Email == "" {
|
|
||||||
return inbound, false, common.NewError("empty client email")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grandfather a row that was already stored incomplete so it stays editable;
|
// Grandfather a row that was already stored incomplete so it stays editable;
|
||||||
// only a save that breaks a previously valid TLS block is refused.
|
// only a save that breaks a previously valid TLS block is refused.
|
||||||
if !s.FromNodeSync {
|
if !s.FromNodeSync {
|
||||||
@@ -1776,8 +1777,15 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
oldInbound = stored
|
oldInbound = stored
|
||||||
|
// The form posts back the clients and enable it loaded; both have their
|
||||||
|
// own endpoints, so only a master's push may change them here.
|
||||||
if !s.FromNodeSync {
|
if !s.FromNodeSync {
|
||||||
inbound.Settings = keepStoredClientLifecycle(inbound.Settings, stored.Settings)
|
inbound.Settings = keepStoredClients(inbound.Settings, stored.Settings)
|
||||||
|
inbound.Enable = stored.Enable
|
||||||
|
}
|
||||||
|
// On the clients actually saved: a protocol switch keeps the stored ones.
|
||||||
|
if err := s.validateUpdatedInboundClients(inbound); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
|
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
|
||||||
if cErr != nil {
|
if cErr != nil {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// An inbound save keeps the stored clients, so switching to Hysteria is judged
|
||||||
|
// on them: ones with no auth would leave an inbound nobody can connect to.
|
||||||
func TestUpdateInbound_RejectsHysteriaClientWithoutAuth(t *testing.T) {
|
func TestUpdateInbound_RejectsHysteriaClientWithoutAuth(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
seedInboundConflict(t, "in-45001-tcp", "0.0.0.0", 45001, model.VLESS,
|
seedInboundConflict(t, "in-45001-tcp", "0.0.0.0", 45001, model.VLESS,
|
||||||
`{"network":"tcp"}`, `{"clients":[]}`)
|
`{"network":"tcp"}`, `{"clients":[{"email":"hysteria@x","enable":true,"password":"not-hysteria-auth"}]}`)
|
||||||
|
|
||||||
var existing model.Inbound
|
var existing model.Inbound
|
||||||
if err := database.GetDB().Where("tag = ?", "in-45001-tcp").First(&existing).Error; err != nil {
|
if err := database.GetDB().Where("tag = ?", "in-45001-tcp").First(&existing).Error; err != nil {
|
||||||
@@ -20,7 +22,7 @@ func TestUpdateInbound_RejectsHysteriaClientWithoutAuth(t *testing.T) {
|
|||||||
|
|
||||||
update := existing
|
update := existing
|
||||||
update.Protocol = model.Hysteria
|
update.Protocol = model.Hysteria
|
||||||
update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"not-hysteria-auth"}]}`
|
update.Settings = `{"clients":[]}`
|
||||||
|
|
||||||
svc := &InboundService{}
|
svc := &InboundService{}
|
||||||
if _, _, err := svc.UpdateInbound(&update); err == nil || !strings.Contains(err.Error(), "empty client ID") {
|
if _, _, err := svc.UpdateInbound(&update); err == nil || !strings.Contains(err.Error(), "empty client ID") {
|
||||||
@@ -54,7 +56,8 @@ func TestUpdateInbound_PreservesHysteriaClientAuth(t *testing.T) {
|
|||||||
update := existing
|
update := existing
|
||||||
update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"` + password + `","auth":"` + wantAuth + `"}]}`
|
update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"` + password + `","auth":"` + wantAuth + `"}]}`
|
||||||
|
|
||||||
svc := &InboundService{}
|
// Only a master's push still carries clients through an inbound save.
|
||||||
|
svc := &InboundService{FromNodeSync: true}
|
||||||
if _, _, err := svc.UpdateInbound(&update); err != nil {
|
if _, _, err := svc.UpdateInbound(&update); err != nil {
|
||||||
t.Fatalf("UpdateInbound: %v", err)
|
t.Fatalf("UpdateInbound: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ func TestUpdateInboundMtprotoUnchangedDoesNotRestart(t *testing.T) {
|
|||||||
if !strings.Contains(update.Settings, mtprotoTestSecretD) {
|
if !strings.Contains(update.Settings, mtprotoTestSecretD) {
|
||||||
t.Fatal("fixture must contain the re-keyed secret")
|
t.Fatal("fixture must contain the re-keyed secret")
|
||||||
}
|
}
|
||||||
_, needRestart, err := svc.UpdateInbound(&update)
|
// Clients reach an inbound save only as a master's push to its node.
|
||||||
|
_, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateInbound: %v", err)
|
t.Fatalf("UpdateInbound: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,44 +140,22 @@ func mergeClientLists(base, ours, current []any) []any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client limits and state the inbound form never owns: the client endpoints and
|
// keepStoredClients puts the stored client list back into an inbound save's
|
||||||
// traffic jobs change them, so a form opened earlier must not write them back.
|
// payload: clients change through the client endpoints, never this form.
|
||||||
var storedClientLifecycleKeys = []string{"enable", "expiryTime", "totalGB", "reset", "resetDay", "resetWeekday", "resetMax"}
|
func keepStoredClients(payload, stored string) string {
|
||||||
|
|
||||||
// keepStoredClientLifecycle copies those keys from the stored settings onto every
|
|
||||||
// payload client the inbound already holds; new clients keep what they carry.
|
|
||||||
func keepStoredClientLifecycle(payload, stored string) string {
|
|
||||||
var payloadM, storedM map[string]any
|
var payloadM, storedM map[string]any
|
||||||
if json.Unmarshal([]byte(payload), &payloadM) != nil || json.Unmarshal([]byte(stored), &storedM) != nil {
|
if json.Unmarshal([]byte(payload), &payloadM) != nil || json.Unmarshal([]byte(stored), &storedM) != nil {
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
storedClients, _ := storedM["clients"].([]any)
|
storedClients, has := storedM["clients"]
|
||||||
storedBy := indexClientsByEmail(storedClients)
|
if reflect.DeepEqual(payloadM["clients"], storedClients) {
|
||||||
payloadClients, _ := payloadM["clients"].([]any)
|
|
||||||
changed := false
|
|
||||||
for _, entry := range payloadClients {
|
|
||||||
p, email := clientEntryEmail(entry)
|
|
||||||
s, known := storedBy[email]
|
|
||||||
if !known {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, key := range storedClientLifecycleKeys {
|
|
||||||
sv, inStored := s[key]
|
|
||||||
pv, inPayload := p[key]
|
|
||||||
if inStored == inPayload && reflect.DeepEqual(sv, pv) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
if inStored {
|
|
||||||
p[key] = sv
|
|
||||||
} else {
|
|
||||||
delete(p, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
if has {
|
||||||
|
payloadM["clients"] = storedClients
|
||||||
|
} else {
|
||||||
|
delete(payloadM, "clients")
|
||||||
|
}
|
||||||
b, err := json.MarshalIndent(payloadM, "", " ")
|
b, err := json.MarshalIndent(payloadM, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -92,3 +93,78 @@ func TestInboundUpdateKeepsTrafficAddedMidEdit(t *testing.T) {
|
|||||||
t.Fatalf("inbound after edit: up=%d down=%d remark=%q, want 100/50/edited", saved.Up, saved.Down, saved.Remark)
|
t.Fatalf("inbound after edit: up=%d down=%d remark=%q, want 100/50/edited", saved.Up, saved.Down, saved.Remark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inboundLinksEmail(t *testing.T, inboundId int, email string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := database.GetDB().Table("client_inbounds").
|
||||||
|
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||||
|
Where("client_inbounds.inbound_id = ? AND clients.email = ?", inboundId, email).
|
||||||
|
Count(&n).Error; err != nil {
|
||||||
|
t.Fatalf("count links: %v", err)
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client added while the modal was open is not in the list it posts back;
|
||||||
|
// saving the inbound must not detach it.
|
||||||
|
func TestInboundFormSaveKeepsClientAddedWhileOpen(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
ib := seedRenewableNeighbour(t, 23204, nil)
|
||||||
|
form := *ib
|
||||||
|
|
||||||
|
if _, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{
|
||||||
|
Id: ib.Id, Settings: clientsSettings(t, []model.Client{{Email: "z@stale", ID: "aaaaaaaa-0000-0000-0000-00000000000c", Enable: true}}),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddInboundClient: %v", err)
|
||||||
|
}
|
||||||
|
form.Remark = "edited"
|
||||||
|
if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil {
|
||||||
|
t.Fatalf("UpdateInbound: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := settingsClient(t, ib.Id, "z@stale"); !ok || !inboundLinksEmail(t, ib.Id, "z@stale") {
|
||||||
|
t.Fatalf("client added while the form was open was dropped: in settings=%v linked=%v", ok, inboundLinksEmail(t, ib.Id, "z@stale"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client deleted while the modal was open is still in the list it posts
|
||||||
|
// back; saving must not restore its access.
|
||||||
|
func TestInboundFormSaveDoesNotResurrectDeletedClient(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
ib := seedRenewableNeighbour(t, 23205, nil)
|
||||||
|
form := *ib
|
||||||
|
|
||||||
|
if _, err := (&ClientService{}).DelInboundClientByEmail(&InboundService{}, ib.Id, "x@stale", false, true); err != nil {
|
||||||
|
t.Fatalf("DelInboundClientByEmail: %v", err)
|
||||||
|
}
|
||||||
|
form.Remark = "edited"
|
||||||
|
if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil {
|
||||||
|
t.Fatalf("UpdateInbound: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := settingsClient(t, ib.Id, "x@stale"); ok || inboundLinksEmail(t, ib.Id, "x@stale") {
|
||||||
|
t.Fatalf("deleted client came back: in settings=%v linked=%v", ok, inboundLinksEmail(t, ib.Id, "x@stale"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An inbound switched off while the modal was open stays off when the form,
|
||||||
|
// which still holds enable=true, is saved.
|
||||||
|
func TestInboundFormSaveKeepsEnableToggledWhileOpen(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
ib := seedRenewableNeighbour(t, 23206, nil)
|
||||||
|
form := *ib
|
||||||
|
|
||||||
|
if _, err := (&InboundService{}).SetInboundEnable(ib.Id, false); err != nil {
|
||||||
|
t.Fatalf("SetInboundEnable: %v", err)
|
||||||
|
}
|
||||||
|
form.Remark = "edited"
|
||||||
|
if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil {
|
||||||
|
t.Fatalf("UpdateInbound: %v", err)
|
||||||
|
}
|
||||||
|
saved, err := (&InboundService{}).GetInbound(ib.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInbound: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Enable || saved.Remark != "edited" {
|
||||||
|
t.Fatalf("after save: enable=%v remark=%q, want disabled and edited", saved.Enable, saved.Remark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user