mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 21:30:59 +00:00
feat(inbounds): add Real client IP presets to capture visitor IP behind CDN/relay
Surface the existing sockopt knobs (acceptProxyProtocol, trustedXForwardedFor) as a guided 'Real client IP' preset selector in the inbound form, so the real visitor IP is recovered behind Cloudflare CDN or an L4 tunnel/relay instead of recording the intermediary address. Presets are mutually exclusive, warn on incompatible transports, and add tooltips, docs, and translations for all locales.
This commit is contained in:
@@ -818,7 +818,7 @@ export default function InboundFormModal({
|
||||
<ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
|
||||
)}
|
||||
|
||||
<SockoptForm toggleSockopt={toggleSockopt} />
|
||||
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
|
||||
|
||||
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
|
||||
its stream tab is just sockopt + TProxy. */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Space, Switch } from 'antd';
|
||||
|
||||
import {
|
||||
Address_Port_Strategy,
|
||||
@@ -8,12 +8,68 @@ import {
|
||||
} from '@/schemas/primitives';
|
||||
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
|
||||
// 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.
|
||||
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp'];
|
||||
|
||||
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
|
||||
|
||||
export default function SockoptForm({
|
||||
toggleSockopt,
|
||||
network,
|
||||
}: {
|
||||
toggleSockopt: (on: boolean) => void;
|
||||
network: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 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) or the
|
||||
// PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
|
||||
const applyRealClientIpPreset = (
|
||||
preset: RealClientIpPreset,
|
||||
getFieldValue: (name: (string | number)[]) => unknown,
|
||||
setFieldValue: (name: (string | number)[], value: unknown) => void,
|
||||
) => {
|
||||
const sockopt = getFieldValue(['streamSettings', 'sockopt']);
|
||||
const sockoptOn =
|
||||
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
|
||||
if (preset !== 'off' && !sockoptOn) {
|
||||
toggleSockopt(true);
|
||||
}
|
||||
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||
|
||||
if (preset === 'off') {
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset === 'cloudflare') {
|
||||
const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
|
||||
const list = Array.isArray(current) ? [...(current as string[])] : [];
|
||||
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||
if (transportField) setFieldValue(['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).
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
noStyle
|
||||
@@ -33,6 +89,89 @@ export default function SockoptForm({
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => {
|
||||
type ProxyWatch = {
|
||||
streamSettings?: {
|
||||
sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
|
||||
tcpSettings?: { acceptProxyProtocol?: unknown };
|
||||
wsSettings?: { acceptProxyProtocol?: unknown };
|
||||
httpupgradeSettings?: { acceptProxyProtocol?: unknown };
|
||||
};
|
||||
};
|
||||
const pick = (v: ProxyWatch) => {
|
||||
const s = v.streamSettings;
|
||||
return JSON.stringify([
|
||||
s?.sockopt?.trustedXForwardedFor,
|
||||
s?.sockopt?.acceptProxyProtocol,
|
||||
s?.tcpSettings?.acceptProxyProtocol,
|
||||
s?.wsSettings?.acceptProxyProtocol,
|
||||
s?.httpupgradeSettings?.acceptProxyProtocol,
|
||||
]);
|
||||
};
|
||||
return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||
const transportPP = transportField
|
||||
? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
|
||||
: false;
|
||||
const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
|
||||
const trusted = Array.isArray(sockopt.trustedXForwardedFor)
|
||||
? (sockopt.trustedXForwardedFor as string[])
|
||||
: [];
|
||||
const value: 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={t('pages.inbounds.form.realClientIp')}
|
||||
tooltip={t('pages.inbounds.form.realClientIpHint')}
|
||||
>
|
||||
<Segmented
|
||||
value={value}
|
||||
onChange={(v) =>
|
||||
applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
|
||||
}
|
||||
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') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{trustedMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
{proxyMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
@@ -67,6 +206,7 @@ export default function SockoptForm({
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
||||
label={t('pages.inbounds.form.proxyProtocol')}
|
||||
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
@@ -139,6 +279,7 @@ export default function SockoptForm({
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||||
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
|
||||
@@ -80,6 +80,7 @@ exports[`inbound transport forms > RawForm field structure is stable 1`] = `
|
||||
exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
|
||||
[
|
||||
"Sockopt",
|
||||
"Real client IP",
|
||||
"Route Mark",
|
||||
"TCP Keep Alive Interval",
|
||||
"TCP Keep Alive Idle",
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('inbound transport forms', () => {
|
||||
|
||||
it('SockoptForm field structure is stable (enabled + happy eyeballs)', () => {
|
||||
renderInForm(
|
||||
() => <SockoptForm toggleSockopt={noop} />,
|
||||
() => <SockoptForm toggleSockopt={noop} network="tcp" />,
|
||||
{ streamSettings: { sockopt: { happyEyeballs: {} } } },
|
||||
);
|
||||
expect(fieldLabels()).toMatchSnapshot();
|
||||
|
||||
Reference in New Issue
Block a user