Files
3x-ui/frontend/src/pages/inbounds/form/transport/sockopt.tsx
T
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00

255 lines
9.9 KiB
TypeScript

import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { SockoptCustomField } from '@/lib/xray/forms/fields';
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
/* Transport key that carries its own acceptProxyProtocol field (mirrored
alongside the sockopt-level one so the PROXY preset never silently no-ops). */
const TRANSPORT_PROXY_FIELD: Record<string, string> = {
tcp: 'tcpSettings',
ws: 'wsSettings',
httpupgrade: 'httpupgradeSettings',
};
/* Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
instead of the old x-real-ip gRPC metadata. */
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp', 'grpc'];
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
export default function SockoptForm({
toggleSockopt,
network,
}: {
toggleSockopt: (on: boolean) => void;
network: string;
}) {
const { t } = useTranslation();
const uid = useId();
const { control, getValues, setValue } = useFormContext();
const sock = useWatch({ control, name: 'streamSettings.sockopt' });
const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
const transportField = TRANSPORT_PROXY_FIELD[network];
const sockAcceptPP = useWatch({ control, name: 'streamSettings.sockopt.acceptProxyProtocol' });
const sockTrusted = useWatch({ control, name: 'streamSettings.sockopt.trustedXForwardedFor' });
const transportAcceptPP = useWatch({
control,
name: transportField
? `streamSettings.${transportField}.acceptProxyProtocol`
: 'streamSettings.__noTransportProxyField',
});
/* Presets write the same sockopt fields the user could set by hand below,
picking the mechanism xray-core actually honors for the chosen transport:
CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
PROXY-protocol header via acceptProxyProtocol (every transport but mKCP). */
const applyRealClientIpPreset = (preset: RealClientIpPreset) => {
const sockopt = getValues('streamSettings.sockopt');
const sockoptOn =
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
if (preset !== 'off' && !sockoptOn) {
toggleSockopt(true);
}
if (preset === 'off') {
setValue('streamSettings.sockopt.trustedXForwardedFor', []);
setValue('streamSettings.sockopt.acceptProxyProtocol', false);
if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
return;
}
if (preset === 'cloudflare') {
const current = getValues('streamSettings.sockopt.trustedXForwardedFor');
const list = Array.isArray(current) ? [...(current as string[])] : [];
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
setValue('streamSettings.sockopt.trustedXForwardedFor', list);
setValue('streamSettings.sockopt.acceptProxyProtocol', false);
if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
return;
}
/* proxy — clear trustedXForwardedFor so a lingering header can't override the
PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc). */
setValue('streamSettings.sockopt.trustedXForwardedFor', []);
setValue('streamSettings.sockopt.acceptProxyProtocol', true);
if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, true);
};
const transportPP = transportField ? transportAcceptPP === true : false;
const proxyOn = sockAcceptPP === true || transportPP;
const trusted = Array.isArray(sockTrusted) ? (sockTrusted as string[]) : [];
const presetValue: RealClientIpPreset = proxyOn
? 'proxy'
: trusted.length > 0
? 'cloudflare'
: 'off';
const trustedMismatch = trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
const proxyMismatch = proxyOn && network === 'kcp';
return (
<>
<Form.Item label="Sockopt">
<Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
</Form.Item>
{on && (
<>
<Form.Item
label={t('pages.inbounds.form.realClientIp')}
tooltip={t('pages.inbounds.form.realClientIpHint')}
>
<Segmented
aria-label={t('pages.inbounds.form.realClientIp')}
aria-describedby={`${uid}-real-client-ip-hint`}
value={presetValue}
onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
options={[
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
{
value: 'cloudflare',
label: t('pages.inbounds.form.realClientIpPresetCloudflare'),
},
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
]}
/>
<span id={`${uid}-real-client-ip-hint`} hidden>
{t('pages.inbounds.form.realClientIpHint')}
</span>
</Form.Item>
{trustedMismatch && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
/>
)}
{proxyMismatch && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
/>
)}
<FormField
name={['streamSettings', 'sockopt', 'mark']}
label={t('pages.inbounds.form.routeMark')}
>
<InputNumber min={0} />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
label={t('pages.inbounds.form.tcpKeepAliveInterval')}
>
<InputNumber min={0} />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
label={t('pages.inbounds.form.tcpKeepAliveIdle')}
>
<InputNumber min={0} />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
label={t('pages.inbounds.form.tcpMaxSeg')}
>
<InputNumber min={0} />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
label={t('pages.inbounds.form.tcpUserTimeout')}
>
<InputNumber min={0} />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
label={t('pages.inbounds.form.tcpWindowClamp')}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
>
<InputNumber min={0} />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
label={t('pages.inbounds.form.proxyProtocol')}
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
label={t('pages.inbounds.form.tcpFastOpen')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'penetrate']}
label={t('pages.inbounds.form.penetrate')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'V6Only']}
label={t('pages.inbounds.form.v6Only')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label={t('pages.inbounds.form.tcpCongestion')}
>
<Select
style={{ width: '50%' }}
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
/>
</FormField>
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<Select
style={{ width: '50%' }}
options={[
{ value: 'off', label: 'Off' },
{ value: 'redirect', label: 'Redirect' },
{ value: 'tproxy', label: 'TProxy' },
]}
/>
</FormField>
<FormField
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
label={t('pages.inbounds.form.trustedXForwardedFor')}
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
>
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',']}
options={[
{ value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
{ value: 'X-Real-IP', label: 'X-Real-IP' },
{ value: 'True-Client-IP', label: 'True-Client-IP' },
{ value: 'X-Client-IP', label: 'X-Client-IP' },
]}
/>
</FormField>
<Controller
control={control}
name="streamSettings.sockopt.customSockopt"
render={({ field }) => (
<SockoptCustomField value={field.value} onChange={field.onChange} />
)}
/>
</>
)}
</>
);
}