fix(hosts): let the Host form clear its SNI (#7095)

Removing the last SNI tag in Hosts → Edit Host and saving did nothing: the
submit fell back to the hidden legacy `sni` field, so the old name was sent
again and the backend rebuilt the pool from it. It came back on the next edit
and stayed in every share link built from that host.

The tag list is the only SNI input, so the primary SNI is now the first tag or
empty. The dynamic ECH helper read the same stale field and follows the list
too. Hosts saved with only the legacy field still load, because the form seeds
the tag list from it.
This commit is contained in:
PluS98
2026-11-13 02:01:07 +03:00
committed by GitHub
parent 7da8fd23be
commit 8438981ef1
2 changed files with 49 additions and 4 deletions
+2 -4
View File
@@ -121,8 +121,7 @@ export default function HostFormModal({
watchedSnis && watchedSnis.filter((s) => typeof s === 'string' && s.trim() !== '').length > 1,
);
const watchedSingleSni = useWatch({ control: methods.control, name: 'sni' }) ?? '';
const hostSni = (watchedSnis && watchedSnis.length > 0 ? watchedSnis[0] : '') || watchedSingleSni;
const hostSni = watchedSnis?.[0] ?? '';
const hostEchConfig = useWatch({ control: methods.control, name: 'echConfigList' }) ?? '';
const showTls = security === 'tls' || security === 'reality' || security === 'same';
const showTlsExtras = security === 'tls' || security === 'same';
@@ -208,11 +207,10 @@ export default function HostFormModal({
const { enable, ...rest } = values;
const isDisabled = !enable;
const cleanedSnis = (rest.snis || []).map((s) => s.trim()).filter(Boolean);
const primarySni = cleanedSnis.length > 0 ? cleanedSnis[0] : rest.sni || '';
const payload: BulkAddHostValues = {
...rest,
hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
sni: primarySni,
sni: cleanedSnis[0] ?? '',
snis: cleanedSnis,
sniSharedRandom: cleanedSnis.length > 1 && Boolean(rest.sniSharedRandom),
isDisabled,
+47
View File
@@ -0,0 +1,47 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import HostFormModal from '@/pages/hosts/HostFormModal';
import { HttpUtil, Msg } from '@/utils';
import { renderWithProviders } from './test-utils';
const host = {
groupId: 'g1',
remark: 'DE WS',
inboundIds: [1],
hosts: ['cdn.example.com'],
security: 'same',
sni: 'dash.example.com',
snis: ['dash.example.com'],
} as HostRecord;
describe('Host form SNI', () => {
beforeEach(() => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
});
it('saves an empty SNI after the last SNI tag is removed', async () => {
const save = vi.fn().mockResolvedValue({ success: true });
renderWithProviders(
<HostFormModal
open
mode="edit"
host={host}
inboundOptions={[{ id: 1, remark: 'in', tag: 'in', protocol: 'vless' }]}
existingHosts={[host]}
save={save}
onOpenChange={() => {}}
/>,
);
const tag = await screen.findByTitle('dash.example.com');
const remove = tag.querySelector('.ant-select-selection-item-remove');
if (!remove) throw new Error('missing SNI tag remove button');
fireEvent.click(remove);
await waitFor(() => expect(screen.queryByTitle('dash.example.com')).toBeNull());
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(save.mock.calls[0][0]).toMatchObject({ sni: '', snis: [] });
});
});