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:
MHSanaei
2026-06-15 23:50:04 +02:00
parent bbab83db17
commit d882d6aa74
18 changed files with 364 additions and 3 deletions
@@ -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();