diff --git a/docs/real-client-ip.md b/docs/real-client-ip.md new file mode 100644 index 000000000..d2ce0fae2 --- /dev/null +++ b/docs/real-client-ip.md @@ -0,0 +1,102 @@ +# Capturing the Real Client IP + +When an Xray inbound sits behind an intermediary — a CDN like Cloudflare, an L4 tunnel/relay, +or another panel — the IP that Xray sees is the **intermediary's** address, not the visitor's. +That intermediary IP is what shows up in the panel's online/IP view and what the per-client +**IP limit** counts against, which makes both useless behind a proxy. + +Xray-core can recover the real visitor IP. 3x-ui exposes the two mechanisms in the inbound form +and feeds the recovered IP into the same pipeline that drives IP-limit enforcement, the online +list, and multi-node sync — so once it is set, everything downstream just works. + +## Where to set it + +Open an inbound → **Transport / Stream Settings** → enable **Sockopt** → use the +**Real client IP** preset selector: + +| Preset | What it does | Use for | +|---|---|---| +| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. | +| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). | +| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. | + +The raw `Proxy Protocol` switch and `Trusted X-Forwarded-For` list stay visible below the preset +selector for manual / advanced tuning — the presets just fill them in for you. + +## Scenario 1 — Cloudflare CDN + +Cloudflare's CDN (the orange cloud) forwards the visitor's IP in the `CF-Connecting-IP` request +header. Xray reads it when the transport is **WebSocket**, **HTTPUpgrade**, or **XHTTP** and +the header name is listed in `sockopt.trustedXForwardedFor`. + +```json +"streamSettings": { + "network": "ws", + "sockopt": { "trustedXForwardedFor": ["CF-Connecting-IP"] } +} +``` + +Pick the **Cloudflare CDN** preset. You can add `X-Real-IP`, `True-Client-IP`, or `X-Client-IP` +to the list if a different upstream uses those. + +> This is **not** the same as Cloudflare Spectrum. The free/CDN tier forwards HTTP headers — use +> this scenario. Spectrum (a TCP/L4 product) can send the PROXY protocol — use Scenario 2. + +## Scenario 2 — L4 tunnel / relay or Cloudflare Spectrum (PROXY protocol) + +For a TCP-level front (HAProxy, gost, nginx `stream`, an Xray dokodemo-door relay, or Cloudflare +Spectrum), the real IP is carried in the **PROXY protocol** header. Enable +`acceptProxyProtocol` and make sure the **upstream emits PROXY protocol** — otherwise the +connection will fail. + +```json +"streamSettings": { + "network": "tcp", + "sockopt": { "acceptProxyProtocol": true } +} +``` + +Pick the **L4 relay / Spectrum (PROXY)** preset. Works on TCP/RAW, WebSocket, HTTPUpgrade, gRPC +and XHTTP; **not** on mKCP. The front must be configured to send the header, e.g.: + +- **HAProxy**: `server backend 127.0.0.1:443 send-proxy` (or `send-proxy-v2`). +- **nginx** (`stream {}` block): `proxy_protocol on;` on the `server`, and on the upstream side + `proxy_protocol on;` in the `server` that connects to Xray. + +## Transport support matrix + +| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP | +|---|:--:|:--:|:--:|:--:|:--:|:--:| +| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ | +| `acceptProxyProtocol` (PROXY) | ✅ | – | ✅ | ✅ | ✅ | ✅ | + +The form shows a warning when you select a preset that the current transport cannot honor. + +> **Use one, not both.** `acceptProxyProtocol` and `trustedXForwardedFor` are independent — the +> first reads the real IP from the L4 PROXY header, the second from an HTTP request header. On +> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header *last*, so a stale +> `trustedXForwardedFor` would override (and defeat) a PROXY-protocol setup. The presets are +> mutually exclusive and clear the other field for you; only mix them by hand if you know your +> upstream chain needs it. + +## Multi-node + +No extra configuration is needed. The inbound's `streamSettings` (including these sockopt +fields) is pushed to child nodes verbatim, so the node's Xray records the real IP, and the +parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP +shows up on the parent automatically. + +## Security note + +Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are +stripped from subscription output, so they never reach clients. Only enable +`trustedXForwardedFor` when the inbound is genuinely behind a trusted proxy that sets the +header; otherwise a client could spoof the header and forge its own source IP. + +## Verifying + +1. Set the preset and save the inbound. +2. Inspect the generated Xray config and confirm `streamSettings.sockopt` carries the expected + field (`trustedXForwardedFor` or `acceptProxyProtocol`). +3. Connect through the intermediary, then open the client's IPs / online view in the panel — it + should show the real visitor IP rather than the CDN/relay address. diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 9db5d7233..74766e4f6 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -818,7 +818,7 @@ export default function InboundFormModal({ )} - + {/* Transport masks don't apply to tunnel (a transparent forwarder), so its stream tab is just sockopt + TProxy. */} diff --git a/frontend/src/pages/inbounds/form/transport/sockopt.tsx b/frontend/src/pages/inbounds/form/transport/sockopt.tsx index dcff60cc3..c4d014226 100644 --- a/frontend/src/pages/inbounds/form/transport/sockopt.tsx +++ b/frontend/src/pages/inbounds/form/transport/sockopt.tsx @@ -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 = { + 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 ( {on && ( <> + { + 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 ( + <> + + + 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') }, + ]} + /> + + {trustedMismatch && ( + + )} + {proxyMismatch && ( + + )} + + ); + }} + @@ -67,6 +206,7 @@ export default function SockoptForm({ @@ -139,6 +279,7 @@ export default function SockoptForm({