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({