1
0

4 Revīzijas dc781b28c4 ... f3eba04ed8

Autors SHA1 Ziņojums Datums
  MHSanaei f3eba04ed8 ci: use .nvmrc for setup-node version in codeql/release workflows 10 stundas atpakaļ
  MHSanaei 9385b6c609 feat(nodes): per-node client IP attribution for IP-limit 10 stundas atpakaļ
  MHSanaei d882d6aa74 feat(inbounds): add Real client IP presets to capture visitor IP behind CDN/relay 10 stundas atpakaļ
  MHSanaei bbab83db17 refactor(frontend): stack client credential fields and use label hints on inbound form 12 stundas atpakaļ
35 mainītis faili ar 1031 papildinājumiem un 122 dzēšanām
  1. 1 1
      .github/workflows/codeql.yml
  2. 2 2
      .github/workflows/release.yml
  3. 102 0
      docs/real-client-ip.md
  4. 1 0
      frontend/src/generated/types.ts
  5. 3 0
      frontend/src/generated/zod.ts
  6. 33 0
      frontend/src/lib/clients/ip-log.ts
  7. 55 68
      frontend/src/pages/clients/ClientFormModal.tsx
  8. 8 5
      frontend/src/pages/clients/ClientInfoModal.tsx
  9. 16 9
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  10. 142 1
      frontend/src/pages/inbounds/form/transport/sockopt.tsx
  11. 1 0
      frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap
  12. 1 1
      frontend/src/test/inbound-form-blocks.test.tsx
  13. 1 0
      internal/database/db.go
  14. 1 0
      internal/database/migrate_data.go
  15. 27 0
      internal/database/model/node_client_ip.go
  16. 8 35
      internal/web/controller/client.go
  17. 34 0
      internal/web/job/check_client_ip_job.go
  18. 11 0
      internal/web/job/node_traffic_sync_job.go
  19. 18 0
      internal/web/runtime/remote.go
  20. 269 0
      internal/web/service/inbound_node_ips.go
  21. 168 0
      internal/web/service/inbound_node_ips_test.go
  22. 12 0
      internal/web/service/node.go
  23. 9 0
      internal/web/translation/ar-EG.json
  24. 9 0
      internal/web/translation/en-US.json
  25. 9 0
      internal/web/translation/es-ES.json
  26. 9 0
      internal/web/translation/fa-IR.json
  27. 9 0
      internal/web/translation/id-ID.json
  28. 9 0
      internal/web/translation/ja-JP.json
  29. 9 0
      internal/web/translation/pt-BR.json
  30. 9 0
      internal/web/translation/ru-RU.json
  31. 9 0
      internal/web/translation/tr-TR.json
  32. 9 0
      internal/web/translation/uk-UA.json
  33. 9 0
      internal/web/translation/vi-VN.json
  34. 9 0
      internal/web/translation/zh-CN.json
  35. 9 0
      internal/web/translation/zh-TW.json

+ 1 - 1
.github/workflows/codeql.yml

@@ -51,7 +51,7 @@ jobs:
         if: matrix.language == 'go'
         if: matrix.language == 'go'
         uses: actions/setup-node@v6
         uses: actions/setup-node@v6
         with:
         with:
-          node-version: '22'
+          node-version-file: .nvmrc
           cache: 'npm'
           cache: 'npm'
           cache-dependency-path: frontend/package-lock.json
           cache-dependency-path: frontend/package-lock.json
 
 

+ 2 - 2
.github/workflows/release.yml

@@ -59,7 +59,7 @@ jobs:
       - name: Setup Node.js
       - name: Setup Node.js
         uses: actions/setup-node@v6
         uses: actions/setup-node@v6
         with:
         with:
-          node-version: '22'
+          node-version-file: .nvmrc
           cache: 'npm'
           cache: 'npm'
           cache-dependency-path: frontend/package-lock.json
           cache-dependency-path: frontend/package-lock.json
 
 
@@ -210,7 +210,7 @@ jobs:
       - name: Setup Node.js
       - name: Setup Node.js
         uses: actions/setup-node@v6
         uses: actions/setup-node@v6
         with:
         with:
-          node-version: '22'
+          node-version-file: .nvmrc
           cache: 'npm'
           cache: 'npm'
           cache-dependency-path: frontend/package-lock.json
           cache-dependency-path: frontend/package-lock.json
 
 

+ 102 - 0
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.

+ 1 - 0
frontend/src/generated/types.ts

@@ -3,6 +3,7 @@ export type OnlineAPISupport = number;
 export type ProcessState = string;
 export type ProcessState = string;
 export type Protocol = string;
 export type Protocol = string;
 export type SubLinkProvider = unknown;
 export type SubLinkProvider = unknown;
+export type staticEgressResolver = string;
 export type transportBits = number;
 export type transportBits = number;
 
 
 export interface AllSetting {
 export interface AllSetting {

+ 3 - 0
frontend/src/generated/zod.ts

@@ -12,6 +12,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
 export const SubLinkProviderSchema = z.unknown();
 export const SubLinkProviderSchema = z.unknown();
 export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
 export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
 
 
+export const staticEgressResolverSchema = z.string();
+export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
+
 export const transportBitsSchema = z.number().int();
 export const transportBitsSchema = z.number().int();
 export type transportBits = z.infer<typeof transportBitsSchema>;
 export type transportBits = z.infer<typeof transportBitsSchema>;
 
 

+ 33 - 0
frontend/src/lib/clients/ip-log.ts

@@ -0,0 +1,33 @@
+// Shape of one entry in a client's IP log, as returned by
+// POST /panel/api/clients/ips/:email. `node` is the name of the node the IP is
+// connecting through, or '' when it is on this local panel (or unattributed).
+export type ClientIpInfo = {
+  ip: string;
+  time: string;
+  node: string;
+};
+
+// normalizeClientIps accepts the API payload and returns typed entries. It also
+// tolerates the legacy shape (a plain array of "ip (time)" strings) so the UI
+// keeps working against older panels.
+export function normalizeClientIps(obj: unknown): ClientIpInfo[] {
+  if (!Array.isArray(obj)) return [];
+  const out: ClientIpInfo[] = [];
+  for (const x of obj) {
+    if (typeof x === 'string') {
+      if (x.length > 0) out.push({ ip: x, time: '', node: '' });
+      continue;
+    }
+    if (x && typeof x === 'object') {
+      const o = x as Record<string, unknown>;
+      const ip = typeof o.ip === 'string' ? o.ip : '';
+      if (!ip) continue;
+      out.push({
+        ip,
+        time: typeof o.time === 'string' ? o.time : '',
+        node: typeof o.node === 'string' ? o.node : '',
+      });
+    }
+  }
+  return out;
+}

+ 55 - 68
frontend/src/pages/clients/ClientFormModal.tsx

@@ -24,6 +24,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import { HttpUtil, RandomUtil } from '@/utils';
 import { HttpUtil, RandomUtil } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { formatInboundLabel } from '@/lib/inbounds/label';
+import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
@@ -177,7 +178,7 @@ export default function ClientFormModal({
   const [form, setForm] = useState<FormState>(emptyForm);
   const [form, setForm] = useState<FormState>(emptyForm);
   const [submitting, setSubmitting] = useState(false);
   const [submitting, setSubmitting] = useState(false);
   const [resetting, setResetting] = useState(false);
   const [resetting, setResetting] = useState(false);
-  const [clientIps, setClientIps] = useState<string[]>([]);
+  const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
@@ -355,8 +356,7 @@ export default function ClientFormModal({
     try {
     try {
       const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
       const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
       if (!msg?.success) { setClientIps([]); return; }
       if (!msg?.success) { setClientIps([]); return; }
-      const arr = Array.isArray(msg.obj) ? msg.obj : [];
-      setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
+      setClientIps(normalizeClientIps(msg.obj));
     } finally {
     } finally {
       setIpsLoading(false);
       setIpsLoading(false);
     }
     }
@@ -678,71 +678,55 @@ export default function ClientFormModal({
                 label: t('pages.clients.tabCredentials'),
                 label: t('pages.clients.tabCredentials'),
                 children: (
                 children: (
                   <>
                   <>
-                    <Row gutter={16}>
-                      <Col xs={24} md={12}>
-                        <Form.Item label={t('pages.clients.uuid')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
-                            <Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
-                          </Space.Compact>
-                        </Form.Item>
-                      </Col>
-                      <Col xs={24} md={12}>
-                        <Form.Item label={t('pages.clients.password')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
-                            <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
-                          </Space.Compact>
-                        </Form.Item>
-                      </Col>
-                    </Row>
+                    <Form.Item label={t('pages.clients.uuid')}>
+                      <Space.Compact style={{ display: 'flex' }}>
+                        <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
+                        <Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
+                      </Space.Compact>
+                    </Form.Item>
 
 
-                    <Row gutter={16}>
-                      <Col xs={24} md={12}>
-                        <Form.Item label={t('pages.clients.subId')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
-                            <Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
-                          </Space.Compact>
-                        </Form.Item>
-                      </Col>
-                      <Col xs={24} md={12}>
-                        <Form.Item label={t('pages.clients.hysteriaAuth')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
-                            <Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
-                          </Space.Compact>
-                        </Form.Item>
-                      </Col>
-                    </Row>
+                    <Form.Item label={t('pages.clients.password')}>
+                      <Space.Compact style={{ display: 'flex' }}>
+                        <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
+                        <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
+                      </Space.Compact>
+                    </Form.Item>
 
 
-                    <Row gutter={16}>
-                      {showFlow && (
-                        <Col xs={24} md={12}>
-                          <Form.Item label={t('pages.clients.flow')}>
-                            <Select
-                              value={form.flow}
-                              onChange={(v) => update('flow', v)}
-                              options={[
-                                { value: '', label: t('none') },
-                                ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
-                              ]}
-                            />
-                          </Form.Item>
-                        </Col>
-                      )}
-                      {showSecurity && (
-                        <Col xs={24} md={12}>
-                          <Form.Item label={t('pages.clients.vmessSecurity')}>
-                            <Select
-                              value={form.security}
-                              onChange={(v) => update('security', v)}
-                              options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
-                            />
-                          </Form.Item>
-                        </Col>
-                      )}
-                    </Row>
+                    <Form.Item label={t('pages.clients.subId')}>
+                      <Space.Compact style={{ display: 'flex' }}>
+                        <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
+                        <Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
+                      </Space.Compact>
+                    </Form.Item>
+
+                    <Form.Item label={t('pages.clients.hysteriaAuth')}>
+                      <Space.Compact style={{ display: 'flex' }}>
+                        <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
+                        <Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
+                      </Space.Compact>
+                    </Form.Item>
+
+                    {showFlow && (
+                      <Form.Item label={t('pages.clients.flow')}>
+                        <Select
+                          value={form.flow}
+                          onChange={(v) => update('flow', v)}
+                          options={[
+                            { value: '', label: t('none') },
+                            ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                          ]}
+                        />
+                      </Form.Item>
+                    )}
+                    {showSecurity && (
+                      <Form.Item label={t('pages.clients.vmessSecurity')}>
+                        <Select
+                          value={form.security}
+                          onChange={(v) => update('security', v)}
+                          options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
+                        />
+                      </Form.Item>
+                    )}
                   </>
                   </>
                 ),
                 ),
               },
               },
@@ -822,7 +806,7 @@ export default function ClientFormModal({
       >
       >
         {clientIps.length > 0 ? (
         {clientIps.length > 0 ? (
           <div style={{ maxHeight: 360, overflowY: 'auto' }}>
           <div style={{ maxHeight: 360, overflowY: 'auto' }}>
-            {clientIps.map((ip, idx) => (
+            {clientIps.map((entry, idx) => (
               <Tag
               <Tag
                 key={idx}
                 key={idx}
                 color="blue"
                 color="blue"
@@ -835,7 +819,10 @@ export default function ClientFormModal({
                   fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
                   fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
                 }}
                 }}
               >
               >
-                {ip}
+                {entry.ip}{entry.time ? ` (${entry.time})` : ''}
+                {entry.node ? (
+                  <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
+                ) : null}
               </Tag>
               </Tag>
             ))}
             ))}
           </div>
           </div>

+ 8 - 5
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -5,6 +5,7 @@ import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-
 
 
 import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { formatInboundLabel } from '@/lib/inbounds/label';
+import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
@@ -80,7 +81,7 @@ export default function ClientInfoModal({
   const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
   const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
   const [messageApi, messageContextHolder] = message.useMessage();
   const [messageApi, messageContextHolder] = message.useMessage();
   const [links, setLinks] = useState<string[]>([]);
   const [links, setLinks] = useState<string[]>([]);
-  const [clientIps, setClientIps] = useState<string[]>([]);
+  const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
@@ -144,8 +145,7 @@ export default function ClientInfoModal({
     try {
     try {
       const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
       const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
       if (!msg?.success) { setClientIps([]); return; }
       if (!msg?.success) { setClientIps([]); return; }
-      const arr = Array.isArray(msg.obj) ? msg.obj : [];
-      setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
+      setClientIps(normalizeClientIps(msg.obj));
     } finally {
     } finally {
       setIpsLoading(false);
       setIpsLoading(false);
     }
     }
@@ -503,7 +503,7 @@ export default function ClientInfoModal({
       >
       >
         {clientIps.length > 0 ? (
         {clientIps.length > 0 ? (
           <div style={{ maxHeight: 360, overflowY: 'auto' }}>
           <div style={{ maxHeight: 360, overflowY: 'auto' }}>
-            {clientIps.map((ip, idx) => (
+            {clientIps.map((entry, idx) => (
               <Tag
               <Tag
                 key={idx}
                 key={idx}
                 color="blue"
                 color="blue"
@@ -516,7 +516,10 @@ export default function ClientInfoModal({
                   fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
                   fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
                 }}
                 }}
               >
               >
-                {ip}
+                {entry.ip}{entry.time ? ` (${entry.time})` : ''}
+                {entry.node ? (
+                  <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
+                ) : null}
               </Tag>
               </Tag>
             ))}
             ))}
           </div>
           </div>

+ 16 - 9
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -1,5 +1,6 @@
 import { useEffect, useRef, useState } from 'react';
 import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { QuestionCircleOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import dayjs from 'dayjs';
 import {
 import {
   Alert,
   Alert,
@@ -83,6 +84,16 @@ import type { DBInbound } from '@/models/dbinbound';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 
 
 
 
+// Render a field label with a hover tooltip icon instead of an `extra` help line below.
+const labelWithHint = (label: string, hint: string) => (
+  <span>
+    {label}
+    <Tooltip title={hint}>
+      <QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
+    </Tooltip>
+  </span>
+);
+
 const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
 const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
 const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
 const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
 const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
 const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
@@ -538,16 +549,14 @@ export default function InboundFormModal({
 
 
       <Form.Item
       <Form.Item
         name="listen"
         name="listen"
-        label={t('pages.inbounds.address')}
-        extra={t('pages.inbounds.form.listenHelp')}
+        label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
       >
       >
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
       </Form.Item>
       </Form.Item>
 
 
       <Form.Item
       <Form.Item
         name="shareAddrStrategy"
         name="shareAddrStrategy"
-        label={t('pages.inbounds.form.shareAddrStrategy')}
-        extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
+        label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
       >
       >
         <Select
         <Select
           options={SHARE_ADDR_STRATEGIES
           options={SHARE_ADDR_STRATEGIES
@@ -562,8 +571,7 @@ export default function InboundFormModal({
       {shareAddrStrategy === 'custom' && (
       {shareAddrStrategy === 'custom' && (
         <Form.Item
         <Form.Item
           name="shareAddr"
           name="shareAddr"
-          label={t('pages.inbounds.form.shareAddr')}
-          extra={t('pages.inbounds.form.shareAddrHelp')}
+          label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
           rules={[{
           rules={[{
             validator: (_, value) => (
             validator: (_, value) => (
               isValidShareAddrInput(String(value ?? ''))
               isValidShareAddrInput(String(value ?? ''))
@@ -578,8 +586,7 @@ export default function InboundFormModal({
 
 
       <Form.Item
       <Form.Item
         name="subSortIndex"
         name="subSortIndex"
-        label={t('pages.inbounds.form.subSortIndex')}
-        extra={t('pages.inbounds.form.subSortIndexHelp')}
+        label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
       >
       >
         <InputNumber min={1} />
         <InputNumber min={1} />
       </Form.Item>
       </Form.Item>
@@ -811,7 +818,7 @@ export default function InboundFormModal({
         <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
         <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
       )}
       )}
 
 
-      <SockoptForm toggleSockopt={toggleSockopt} />
+      <SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
 
 
       {/* Transport masks don't apply to tunnel (a transparent forwarder), so
       {/* Transport masks don't apply to tunnel (a transparent forwarder), so
           its stream tab is just sockopt + TProxy. */}
           its stream tab is just sockopt + TProxy. */}

+ 142 - 1
frontend/src/pages/inbounds/form/transport/sockopt.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
 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 {
 import {
   Address_Port_Strategy,
   Address_Port_Strategy,
@@ -8,12 +8,68 @@ import {
 } from '@/schemas/primitives';
 } from '@/schemas/primitives';
 import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
 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({
 export default function SockoptForm({
   toggleSockopt,
   toggleSockopt,
+  network,
 }: {
 }: {
   toggleSockopt: (on: boolean) => void;
   toggleSockopt: (on: boolean) => void;
+  network: string;
 }) {
 }) {
   const { t } = useTranslation();
   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 (
   return (
     <Form.Item
     <Form.Item
       noStyle
       noStyle
@@ -33,6 +89,89 @@ export default function SockoptForm({
             </Form.Item>
             </Form.Item>
             {on && (
             {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')}>
                 <Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
                   <InputNumber min={0} />
                   <InputNumber min={0} />
                 </Form.Item>
                 </Form.Item>
@@ -67,6 +206,7 @@ export default function SockoptForm({
                 <Form.Item
                 <Form.Item
                   name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
                   name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
                   label={t('pages.inbounds.form.proxyProtocol')}
                   label={t('pages.inbounds.form.proxyProtocol')}
+                  tooltip={t('pages.inbounds.form.proxyProtocolHint')}
                   valuePropName="checked"
                   valuePropName="checked"
                 >
                 >
                   <Switch />
                   <Switch />
@@ -139,6 +279,7 @@ export default function SockoptForm({
                 <Form.Item
                 <Form.Item
                   name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
                   name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
                   label={t('pages.inbounds.form.trustedXForwardedFor')}
                   label={t('pages.inbounds.form.trustedXForwardedFor')}
+                  tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
                 >
                 >
                   <Select
                   <Select
                     mode="tags"
                     mode="tags"

+ 1 - 0
frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap

@@ -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`] = `
 exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
 [
 [
   "Sockopt",
   "Sockopt",
+  "Real client IP",
   "Route Mark",
   "Route Mark",
   "TCP Keep Alive Interval",
   "TCP Keep Alive Interval",
   "TCP Keep Alive Idle",
   "TCP Keep Alive Idle",

+ 1 - 1
frontend/src/test/inbound-form-blocks.test.tsx

@@ -89,7 +89,7 @@ describe('inbound transport forms', () => {
 
 
   it('SockoptForm field structure is stable (enabled + happy eyeballs)', () => {
   it('SockoptForm field structure is stable (enabled + happy eyeballs)', () => {
     renderInForm(
     renderInForm(
-      () => <SockoptForm toggleSockopt={noop} />,
+      () => <SockoptForm toggleSockopt={noop} network="tcp" />,
       { streamSettings: { sockopt: { happyEyeballs: {} } } },
       { streamSettings: { sockopt: { happyEyeballs: {} } } },
     );
     );
     expect(fieldLabels()).toMatchSnapshot();
     expect(fieldLabels()).toMatchSnapshot();

+ 1 - 0
internal/database/db.go

@@ -73,6 +73,7 @@ func initModels() error {
 		&model.ClientGroup{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
 		&model.NodeClientTraffic{},
+		&model.NodeClientIp{},
 		&model.ClientGlobalTraffic{},
 		&model.ClientGlobalTraffic{},
 		&model.OutboundSubscription{},
 		&model.OutboundSubscription{},
 	}
 	}

+ 1 - 0
internal/database/migrate_data.go

@@ -50,6 +50,7 @@ func migrationModels() []any {
 		&model.ClientExternalLink{},
 		&model.ClientExternalLink{},
 		&model.InboundFallback{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
 		&model.NodeClientTraffic{},
+		&model.NodeClientIp{},
 		&model.OutboundSubscription{},
 		&model.OutboundSubscription{},
 	}
 	}
 }
 }

+ 27 - 0
internal/database/model/node_client_ip.go

@@ -0,0 +1,27 @@
+package model
+
+// ClientIpEntry is the wire/JSON shape of a single observed client IP with the
+// last time it was seen (unix seconds). It mirrors job.IPWithTimestamp and the
+// service-internal clientIpEntry so the per-node attribution blob round-trips
+// with the existing inbound_client_ips storage.
+type ClientIpEntry struct {
+	IP        string `json:"ip"`
+	Timestamp int64  `json:"timestamp"`
+}
+
+// NodeClientIp records which panel (identified by its stable panelGuid) observed
+// a client's IPs on its own Xray. Unlike InboundClientIps (a flattened,
+// cluster-wide union used for IP-limit counting and that is pushed back to every
+// node), this table preserves attribution: it never mixes in IPs a parent pushed
+// down, so the master can tell exactly which node a given IP is connecting to.
+//
+// Rows under the local panel's own panelGuid are written by check_client_ip_job
+// from local Xray observations; rows under remote guids are merged in by the node
+// sync job from each node's clientIpsByGuid report (its own panelGuid subtree plus
+// any descendants), so attribution survives across a chain of nodes.
+type NodeClientIp struct {
+	Id       int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	NodeGuid string `json:"nodeGuid" gorm:"uniqueIndex:idx_nodeip_guid_email,priority:1;not null"`
+	Email    string `json:"email" gorm:"uniqueIndex:idx_nodeip_guid_email,priority:2;not null"`
+	Ips      string `json:"ips"`
+}

+ 8 - 35
internal/web/controller/client.go

@@ -1,11 +1,8 @@
 package controller
 package controller
 
 
 import (
 import (
-	"encoding/json"
-	"fmt"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
-	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -74,6 +71,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/clearIps/:email", a.clearIps)
 	g.POST("/clearIps/:email", a.clearIps)
 	g.POST("/onlines", a.onlines)
 	g.POST("/onlines", a.onlines)
 	g.POST("/onlinesByGuid", a.onlinesByGuid)
 	g.POST("/onlinesByGuid", a.onlinesByGuid)
+	g.POST("/clientIpsByGuid", a.clientIpsByGuid)
 	g.POST("/activeInbounds", a.activeInbounds)
 	g.POST("/activeInbounds", a.activeInbounds)
 	g.POST("/lastOnline", a.lastOnline)
 	g.POST("/lastOnline", a.lastOnline)
 }
 }
@@ -402,38 +400,13 @@ func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
 
 
 func (a *ClientController) getIps(c *gin.Context) {
 func (a *ClientController) getIps(c *gin.Context) {
 	email := c.Param("email")
 	email := c.Param("email")
-	ips, err := a.inboundService.GetInboundClientIps(email)
-	if err != nil || ips == "" {
-		jsonObj(c, "No IP Record", nil)
-		return
-	}
-	type ipWithTimestamp struct {
-		IP        string `json:"ip"`
-		Timestamp int64  `json:"timestamp"`
-	}
-	var ipsWithTime []ipWithTimestamp
-	if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
-		formatted := make([]string, 0, len(ipsWithTime))
-		for _, item := range ipsWithTime {
-			if item.IP == "" {
-				continue
-			}
-			if item.Timestamp > 0 {
-				ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
-				formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
-				continue
-			}
-			formatted = append(formatted, item.IP)
-		}
-		jsonObj(c, formatted, nil)
-		return
-	}
-	var oldIps []string
-	if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
-		jsonObj(c, oldIps, nil)
-		return
-	}
-	jsonObj(c, ips, nil)
+	infos, err := a.inboundService.GetClientIpsWithNodes(email)
+	jsonObj(c, infos, err)
+}
+
+func (a *ClientController) clientIpsByGuid(c *gin.Context) {
+	data, err := a.inboundService.GetClientIpsByGuid()
+	jsonObj(c, data, err)
 }
 }
 
 
 func (a *ClientController) clearIps(c *gin.Context) {
 func (a *ClientController) clearIps(c *gin.Context) {

+ 34 - 0
internal/web/job/check_client_ip_job.go

@@ -252,6 +252,10 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 // hours ago is still live even though its timestamp is old.
 // hours ago is still live even though its timestamp is old.
 func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
 func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
 	shouldCleanLog := false
 	shouldCleanLog := false
+	now := time.Now().Unix()
+	// attribution accumulates this scan's local observations per email so they can
+	// be recorded under this panel's own guid for cross-node IP attribution.
+	attribution := make(map[string][]model.ClientIpEntry, len(observed))
 	for email, ipTimestamps := range observed {
 	for email, ipTimestamps := range observed {
 
 
 		// The observations can still reference a client that was just renamed
 		// The observations can still reference a client that was just renamed
@@ -271,8 +275,20 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
 
 
 		// Convert to IPWithTimestamp slice
 		// Convert to IPWithTimestamp slice
 		ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
 		ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
+		attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
 		for ip, timestamp := range ipTimestamps {
 		for ip, timestamp := range ipTimestamps {
 			ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
 			ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
+			// Live API observations may carry an old lastSeen (connection start),
+			// so stamp attribution with now; otherwise the stale cutoff would evict
+			// an IP that is connected right now.
+			attrTs := timestamp
+			if observedAreLive {
+				attrTs = now
+			}
+			attrEntries = append(attrEntries, model.ClientIpEntry{IP: ip, Timestamp: attrTs})
+		}
+		if len(attrEntries) > 0 {
+			attribution[email] = attrEntries
 		}
 		}
 
 
 		clientIpsRecord, err := j.getInboundClientIps(email)
 		clientIpsRecord, err := j.getInboundClientIps(email)
@@ -284,9 +300,27 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
 		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
 		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
 	}
 	}
 
 
+	j.recordLocalAttribution(attribution)
+
 	return shouldCleanLog
 	return shouldCleanLog
 }
 }
 
 
+// recordLocalAttribution stores this scan's local observations under this panel's
+// own guid so a parent panel can attribute each IP to the node it is on.
+// Best-effort: attribution is advisory and must never block IP-limit enforcement.
+func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model.ClientIpEntry) {
+	if len(attribution) == 0 {
+		return
+	}
+	guid, err := (&service.SettingService{}).GetPanelGuid()
+	if err != nil || guid == "" {
+		return
+	}
+	if err := (&service.InboundService{}).RecordLocalClientIps(guid, attribution); err != nil {
+		logger.Debug("[LimitIP] record local ip attribution failed:", err)
+	}
+}
+
 // mergeClientIps folds this scan's observations into the persisted set,
 // mergeClientIps folds this scan's observations into the persisted set,
 // dropping entries older than staleCutoff. newAlwaysLive exempts the new
 // dropping entries older than staleCutoff. newAlwaysLive exempts the new
 // entries from that cutoff: an API-observed IP is a live connection by
 // entries from that cutoff: an API-observed IP is a live connection by

+ 11 - 0
internal/web/job/node_traffic_sync_job.go

@@ -294,4 +294,15 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 			logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
 			logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
 		}
 		}
 	}
 	}
+
+	// Per-node IP attribution: pull the node's guid-keyed subtree (its own
+	// observations plus any descendants) so the master can tell which node each
+	// IP is on. Old nodes without the endpoint just return an error — skip them.
+	if guidTrees, err := rt.FetchClientIpsByGuid(ctx); err != nil {
+		logger.Debug("node traffic sync: fetch client ip attribution from", n.Name, "failed:", err)
+	} else if len(guidTrees) > 0 {
+		if err := j.inboundService.MergeClientIpsByGuid(guidTrees); err != nil {
+			logger.Warning("node traffic sync: merge client ip attribution from", n.Name, "failed:", err)
+		}
+	}
 }
 }

+ 18 - 0
internal/web/runtime/remote.go

@@ -617,3 +617,21 @@ func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClient
 	_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
 	_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
 	return err
 	return err
 }
 }
+
+// FetchClientIpsByGuid pulls the node's per-node IP attribution subtree
+// (guid -> email -> observed IPs). Unlike FetchAllClientIps (the flat union the
+// master also pushes back), this preserves which physical node each IP is on.
+// Returns an empty map for older nodes that lack the endpoint.
+func (r *Remote) FetchClientIpsByGuid(ctx context.Context) (map[string]map[string][]model.ClientIpEntry, error) {
+	env, err := r.do(ctx, http.MethodPost, "panel/api/clients/clientIpsByGuid", nil)
+	if err != nil {
+		return nil, err
+	}
+	out := map[string]map[string][]model.ClientIpEntry{}
+	if len(env.Obj) > 0 {
+		if err := json.Unmarshal(env.Obj, &out); err != nil {
+			return nil, fmt.Errorf("decode client ips by guid: %w", err)
+		}
+	}
+	return out, nil
+}

+ 269 - 0
internal/web/service/inbound_node_ips.go

@@ -0,0 +1,269 @@
+package service
+
+import (
+	"encoding/json"
+	"sort"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/gorm/clause"
+)
+
+// node_client_ips.go implements per-node client-IP attribution. The flat
+// inbound_client_ips table is a cluster-wide union (used for IP-limit counting
+// and pushed back to every node), so it cannot tell which node a given IP is
+// on. NodeClientIp keeps that attribution: each panel records its own Xray
+// observations under its panelGuid, and the master merges every node's
+// guid-keyed report — never mixing in IPs a parent pushed down.
+
+// mergeModelClientIpEntries unions old and incoming observations, drops anything
+// older than cutoff, keeps the newest timestamp per IP, and sorts newest-first.
+// It mirrors mergeClientIpEntries but operates on the exported wire type.
+func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry {
+	ipMap := make(map[string]int64, len(old)+len(incoming))
+	for _, e := range old {
+		if e.IP == "" || e.Timestamp < cutoff {
+			continue
+		}
+		ipMap[e.IP] = e.Timestamp
+	}
+	for _, e := range incoming {
+		if e.IP == "" || e.Timestamp < cutoff {
+			continue
+		}
+		if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur {
+			ipMap[e.IP] = e.Timestamp
+		}
+	}
+	out := make([]model.ClientIpEntry, 0, len(ipMap))
+	for ip, ts := range ipMap {
+		out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
+	return out
+}
+
+// upsertNodeClientIps folds a guid's per-email observations into NodeClientIp,
+// merging with whatever is already stored for that (guid, email) and dropping
+// stale entries. Empty merged results delete the row so the table stays bounded.
+func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry) error {
+	if guid == "" || len(perEmail) == 0 {
+		return nil
+	}
+	db := database.GetDB()
+	cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
+
+	var existing []model.NodeClientIp
+	if err := db.Where("node_guid = ?", guid).Find(&existing).Error; err != nil {
+		return err
+	}
+	existingByEmail := make(map[string]*model.NodeClientIp, len(existing))
+	for i := range existing {
+		existingByEmail[existing[i].Email] = &existing[i]
+	}
+
+	tx := db.Begin()
+	defer func() {
+		if r := recover(); r != nil {
+			tx.Rollback()
+		}
+	}()
+
+	for email, incoming := range perEmail {
+		if email == "" {
+			continue
+		}
+		var old []model.ClientIpEntry
+		if cur, ok := existingByEmail[email]; ok && cur.Ips != "" {
+			_ = json.Unmarshal([]byte(cur.Ips), &old)
+		}
+		merged := mergeModelClientIpEntries(old, incoming, cutoff)
+		if len(merged) == 0 {
+			// Nothing fresh: drop any stale row so attribution doesn't linger.
+			if _, ok := existingByEmail[email]; ok {
+				if err := tx.Where("node_guid = ? AND email = ?", guid, email).
+					Delete(&model.NodeClientIp{}).Error; err != nil {
+					tx.Rollback()
+					return err
+				}
+			}
+			continue
+		}
+		b, _ := json.Marshal(merged)
+		row := model.NodeClientIp{NodeGuid: guid, Email: email, Ips: string(b)}
+		if err := tx.Clauses(clause.OnConflict{
+			Columns:   []clause.Column{{Name: "node_guid"}, {Name: "email"}},
+			DoUpdates: clause.AssignmentColumns([]string{"ips"}),
+		}).Create(&row).Error; err != nil {
+			tx.Rollback()
+			return err
+		}
+	}
+	return tx.Commit().Error
+}
+
+// RecordLocalClientIps stores this panel's own Xray observations under its
+// panelGuid. Called by check_client_ip_job each scan with the live per-email IPs
+// the local core reported.
+func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[string][]model.ClientIpEntry) error {
+	return upsertNodeClientIps(panelGuid, observed)
+}
+
+// MergeClientIpsByGuid folds a node's guid-keyed attribution report (its own
+// panelGuid subtree plus any descendants) into the local table, preserving which
+// physical node each IP is on across a chain.
+func (s *InboundService) MergeClientIpsByGuid(trees map[string]map[string][]model.ClientIpEntry) error {
+	for guid, perEmail := range trees {
+		if err := upsertNodeClientIps(guid, perEmail); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// GetClientIpsByGuid returns this panel's full attribution subtree (guid -> email
+// -> fresh IPs), dropping stale entries. It is what the clientIpsByGuid endpoint
+// serves to a parent panel.
+func (s *InboundService) GetClientIpsByGuid() (map[string]map[string][]model.ClientIpEntry, error) {
+	db := database.GetDB()
+	var rows []model.NodeClientIp
+	if err := db.Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
+	out := make(map[string]map[string][]model.ClientIpEntry)
+	for _, row := range rows {
+		if row.NodeGuid == "" || row.Email == "" || row.Ips == "" {
+			continue
+		}
+		var entries []model.ClientIpEntry
+		if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
+			continue
+		}
+		fresh := mergeModelClientIpEntries(nil, entries, cutoff)
+		if len(fresh) == 0 {
+			continue
+		}
+		if out[row.NodeGuid] == nil {
+			out[row.NodeGuid] = make(map[string][]model.ClientIpEntry)
+		}
+		out[row.NodeGuid][row.Email] = fresh
+	}
+	return out, nil
+}
+
+// GetClientIpNodeAttribution returns, for one client email, a map of IP -> the
+// guid that most recently observed it (within the stale window). Used to label
+// each IP in the panel with the node it is connecting to.
+func (s *InboundService) GetClientIpNodeAttribution(email string) (map[string]string, error) {
+	db := database.GetDB()
+	var rows []model.NodeClientIp
+	if err := db.Where("email = ?", email).Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
+	ipGuid := make(map[string]string)
+	ipTs := make(map[string]int64)
+	for _, row := range rows {
+		if row.NodeGuid == "" || row.Ips == "" {
+			continue
+		}
+		var entries []model.ClientIpEntry
+		if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
+			continue
+		}
+		for _, e := range entries {
+			if e.IP == "" || e.Timestamp < cutoff {
+				continue
+			}
+			if cur, ok := ipTs[e.IP]; !ok || e.Timestamp > cur {
+				ipTs[e.IP] = e.Timestamp
+				ipGuid[e.IP] = row.NodeGuid
+			}
+		}
+	}
+	return ipGuid, nil
+}
+
+// ClientIpInfo is one IP shown in the panel's per-client IP log, labelled with
+// the node it is connecting through ("" = this local panel).
+type ClientIpInfo struct {
+	IP   string `json:"ip"`
+	Time string `json:"time"`
+	Node string `json:"node"`
+}
+
+// GetClientIpsWithNodes returns a client's recorded IPs (from the flat
+// inbound_client_ips display set) annotated with the node each IP is on, using
+// the per-node attribution table. Local IPs (and any IP without attribution)
+// carry an empty Node.
+func (s *InboundService) GetClientIpsWithNodes(email string) ([]ClientIpInfo, error) {
+	raw, err := s.GetInboundClientIps(email)
+	if err != nil || raw == "" {
+		// Record-not-found (or empty) is "no IPs", not an error for the UI.
+		return []ClientIpInfo{}, nil
+	}
+
+	var entries []model.ClientIpEntry
+	if jerr := json.Unmarshal([]byte(raw), &entries); jerr != nil || len(entries) == 0 {
+		// Legacy shape: a plain JSON array of IP strings.
+		var oldIps []string
+		if json.Unmarshal([]byte(raw), &oldIps) == nil {
+			entries = entries[:0]
+			for _, ip := range oldIps {
+				entries = append(entries, model.ClientIpEntry{IP: ip})
+			}
+		}
+	}
+	if len(entries) == 0 {
+		return []ClientIpInfo{}, nil
+	}
+
+	attr, _ := s.GetClientIpNodeAttribution(email)
+	guidName := s.nodeGuidNameMap()
+	localGuid, _ := (&SettingService{}).GetPanelGuid()
+
+	out := make([]ClientIpInfo, 0, len(entries))
+	for _, e := range entries {
+		if e.IP == "" {
+			continue
+		}
+		info := ClientIpInfo{IP: e.IP}
+		if e.Timestamp > 0 {
+			info.Time = time.Unix(e.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
+		}
+		if guid, ok := attr[e.IP]; ok && guid != "" && guid != localGuid {
+			info.Node = guidName[guid]
+		}
+		out = append(out, info)
+	}
+	return out, nil
+}
+
+// nodeGuidNameMap maps each known node's stable guid to its display name.
+func (s *InboundService) nodeGuidNameMap() map[string]string {
+	db := database.GetDB()
+	var nodes []model.Node
+	if err := db.Model(&model.Node{}).Find(&nodes).Error; err != nil {
+		return map[string]string{}
+	}
+	m := make(map[string]string, len(nodes))
+	for _, n := range nodes {
+		if n.Guid != "" {
+			m[n.Guid] = n.Name
+		}
+	}
+	return m
+}
+
+// DeleteNodeClientIpsByGuid removes all attribution rows for a guid (e.g. when a
+// node is deleted) so its IPs stop being reported and counted.
+func (s *InboundService) DeleteNodeClientIpsByGuid(guid string) error {
+	if guid == "" {
+		return nil
+	}
+	db := database.GetDB()
+	return db.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error
+}

+ 168 - 0
internal/web/service/inbound_node_ips_test.go

@@ -0,0 +1,168 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) {
+	setupClientIpTestDB(t)
+	now := time.Now().Unix()
+	svc := &InboundService{}
+
+	if err := svc.RecordLocalClientIps("guid-A", map[string][]model.ClientIpEntry{
+		"u@x": {{IP: "1.1.1.1", Timestamp: now}, {IP: "2.2.2.2", Timestamp: now - 10}},
+	}); err != nil {
+		t.Fatalf("record: %v", err)
+	}
+
+	trees, err := svc.GetClientIpsByGuid()
+	if err != nil {
+		t.Fatalf("byGuid: %v", err)
+	}
+	got := trees["guid-A"]["u@x"]
+	if len(got) != 2 {
+		t.Fatalf("want 2 entries, got %v", got)
+	}
+	if got[0].IP != "1.1.1.1" { // newest-first ordering
+		t.Fatalf("want newest first, got %v", got)
+	}
+}
+
+func TestRecordLocalClientIps_MergesAndDropsStale(t *testing.T) {
+	setupClientIpTestDB(t)
+	now := time.Now().Unix()
+	svc := &InboundService{}
+
+	if err := svc.RecordLocalClientIps("g", map[string][]model.ClientIpEntry{
+		"u@x": {{IP: "keep", Timestamp: now - 60}},
+	}); err != nil {
+		t.Fatalf("record 1: %v", err)
+	}
+	// Second scan refreshes keep, adds a stale entry (must be dropped) and a fresh one.
+	if err := svc.RecordLocalClientIps("g", map[string][]model.ClientIpEntry{
+		"u@x": {{IP: "keep", Timestamp: now}, {IP: "stale", Timestamp: now - 4000}, {IP: "new", Timestamp: now - 5}},
+	}); err != nil {
+		t.Fatalf("record 2: %v", err)
+	}
+
+	trees, _ := svc.GetClientIpsByGuid()
+	got := map[string]int64{}
+	for _, e := range trees["g"]["u@x"] {
+		got[e.IP] = e.Timestamp
+	}
+	if got["keep"] != now {
+		t.Fatalf("keep should refresh to now: %v", got)
+	}
+	if _, ok := got["stale"]; ok {
+		t.Fatalf("stale entry should be dropped: %v", got)
+	}
+	if got["new"] != now-5 {
+		t.Fatalf("new missing: %v", got)
+	}
+}
+
+func TestUpsertNodeClientIps_EmptyMergeDeletesRow(t *testing.T) {
+	setupClientIpTestDB(t)
+	now := time.Now().Unix()
+	db := database.GetDB()
+	svc := &InboundService{}
+
+	// Seed an already-stale row, then record another all-stale observation: the
+	// merge yields nothing fresh, so the row must be removed (not left lingering).
+	staleIps, _ := json.Marshal([]model.ClientIpEntry{{IP: "old", Timestamp: now - 999999}})
+	if err := db.Create(&model.NodeClientIp{NodeGuid: "g", Email: "u@x", Ips: string(staleIps)}).Error; err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	if err := svc.RecordLocalClientIps("g", map[string][]model.ClientIpEntry{
+		"u@x": {{IP: "old2", Timestamp: now - 999999}},
+	}); err != nil {
+		t.Fatalf("record: %v", err)
+	}
+
+	var count int64
+	database.GetDB().Model(&model.NodeClientIp{}).
+		Where("node_guid = ? AND email = ?", "g", "u@x").Count(&count)
+	if count != 0 {
+		t.Fatalf("row should be deleted when merge is empty, got %d", count)
+	}
+}
+
+func TestGetClientIpNodeAttribution_NewestGuidWins(t *testing.T) {
+	setupClientIpTestDB(t)
+	now := time.Now().Unix()
+	svc := &InboundService{}
+
+	// Same IP observed on two panels; the most recent observation attributes it.
+	if err := svc.RecordLocalClientIps("gA", map[string][]model.ClientIpEntry{
+		"u@x": {{IP: "9.9.9.9", Timestamp: now - 100}},
+	}); err != nil {
+		t.Fatalf("record gA: %v", err)
+	}
+	if err := svc.MergeClientIpsByGuid(map[string]map[string][]model.ClientIpEntry{
+		"gB": {"u@x": {{IP: "9.9.9.9", Timestamp: now}}},
+	}); err != nil {
+		t.Fatalf("merge gB: %v", err)
+	}
+
+	attr, err := svc.GetClientIpNodeAttribution("u@x")
+	if err != nil {
+		t.Fatalf("attribution: %v", err)
+	}
+	if attr["9.9.9.9"] != "gB" {
+		t.Fatalf("newest guid should win, got %q", attr["9.9.9.9"])
+	}
+}
+
+func TestGetClientIpsWithNodes_LabelsNodes(t *testing.T) {
+	setupClientIpTestDB(t)
+	now := time.Now().Unix()
+	db := database.GetDB()
+	svc := &InboundService{}
+
+	panelGuid, err := (&SettingService{}).GetPanelGuid()
+	if err != nil || panelGuid == "" {
+		t.Fatalf("panel guid: %v", err)
+	}
+
+	if err := db.Create(&model.Node{Name: "edge-1", Guid: "node-guid", Address: "x", Port: 2053, ApiToken: "t"}).Error; err != nil {
+		t.Fatalf("seed node: %v", err)
+	}
+
+	// Flat display set (what the IP-log lists) holds both IPs.
+	flat, _ := json.Marshal([]model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: now}, {IP: "2.2.2.2", Timestamp: now}})
+	if err := db.Create(&model.InboundClientIps{ClientEmail: "u@x", Ips: string(flat)}).Error; err != nil {
+		t.Fatalf("seed flat ips: %v", err)
+	}
+
+	// Attribution: 1.1.1.1 seen locally, 2.2.2.2 seen on the node.
+	if err := svc.RecordLocalClientIps(panelGuid, map[string][]model.ClientIpEntry{
+		"u@x": {{IP: "1.1.1.1", Timestamp: now}},
+	}); err != nil {
+		t.Fatalf("record local: %v", err)
+	}
+	if err := svc.MergeClientIpsByGuid(map[string]map[string][]model.ClientIpEntry{
+		"node-guid": {"u@x": {{IP: "2.2.2.2", Timestamp: now}}},
+	}); err != nil {
+		t.Fatalf("merge node: %v", err)
+	}
+
+	infos, err := svc.GetClientIpsWithNodes("u@x")
+	if err != nil {
+		t.Fatalf("getIpsWithNodes: %v", err)
+	}
+	byIP := map[string]string{}
+	for _, in := range infos {
+		byIP[in.IP] = in.Node
+	}
+	if byIP["1.1.1.1"] != "" {
+		t.Fatalf("local IP should have empty node, got %q", byIP["1.1.1.1"])
+	}
+	if byIP["2.2.2.2"] != "edge-1" {
+		t.Fatalf("node IP should be labelled edge-1, got %q", byIP["2.2.2.2"])
+	}
+}

+ 12 - 0
internal/web/service/node.go

@@ -433,12 +433,24 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
 
 
 func (s *NodeService) Delete(id int) error {
 func (s *NodeService) Delete(id int) error {
 	db := database.GetDB()
 	db := database.GetDB()
+	// Capture the node's guid before deleting the row so we can drop its per-node
+	// IP attribution (NodeClientIp is keyed by guid, not node id).
+	var guid string
+	var n model.Node
+	if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
+		guid = n.Guid
+	}
 	if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
 	if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
 		return err
 		return err
 	}
 	}
 	if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
 	if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
 		return err
 		return err
 	}
 	}
+	if guid != "" {
+		if err := db.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error; err != nil {
+			return err
+		}
+	}
 	if mgr := runtime.GetManager(); mgr != nil {
 	if mgr := runtime.GetManager(); mgr != nil {
 		mgr.InvalidateNode(id)
 		mgr.InvalidateNode(id)
 	}
 	}

+ 9 - 0
internal/web/translation/ar-EG.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For موثوق",
         "trustedXForwardedFor": "X-Forwarded-For موثوق",
+        "trustedXForwardedForHint": "ثِق بترويسة الطلب هذه للحصول على IP الحقيقي للعميل (مثل CF-Connecting-IP خلف CDN الخاص بـ Cloudflare). تعمل فقط على وسائل النقل WebSocket و HTTPUpgrade و XHTTP. اتركها فارغة لتجاهل ترويسات التمرير.",
+        "proxyProtocolHint": "اقبل ترويسة PROXY protocol لمعرفة IP الحقيقي للعميل من نفق/مُرحِّل L4 أعلى (HAProxy و gost و nginx-stream و Xray dokodemo-door) أو Cloudflare Spectrum. يجب على الجهة الأعلى إرسال PROXY protocol. تعمل على TCP و WebSocket و HTTPUpgrade و gRPC؛ ولا تعمل على mKCP.",
+        "realClientIp": "IP الحقيقي للعميل",
+        "realClientIpHint": "احصل على IP الحقيقي للزائر عندما يصل المرور إلى هذا الـ inbound عبر CDN أو مُرحِّل، بدلاً من تسجيل عنوان الوسيط. اختر إعدادًا مسبقًا لملء حقول sockopt المقابلة أدناه. لا تُرسَل هذه الحقول أبدًا إلى العملاء في الاشتراكات.",
+        "realClientIpPresetOff": "إيقاف / مباشر",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "مُرحِّل L4 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For تعمل فقط على WebSocket و HTTPUpgrade و XHTTP. على وسيلة النقل الحالية يتم تجاهل هذه الترويسة.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol غير مدعوم على وسيلة النقل هذه (mKCP). استخدم TCP/RAW أو WebSocket أو HTTPUpgrade أو gRPC أو XHTTP.",
         "addressPortStrategy": "استراتيجية العنوان+المنفذ",
         "addressPortStrategy": "استراتيجية العنوان+المنفذ",
         "tryDelayMs": "تأخير المحاولة (ms)",
         "tryDelayMs": "تأخير المحاولة (ms)",
         "prioritizeIPv6": "أولوية IPv6",
         "prioritizeIPv6": "أولوية IPv6",

+ 9 - 0
internal/web/translation/en-US.json

@@ -558,6 +558,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Trusted X-Forwarded-For",
         "trustedXForwardedFor": "Trusted X-Forwarded-For",
+        "trustedXForwardedForHint": "Trust this request header for the real client IP (e.g. CF-Connecting-IP behind Cloudflare's CDN). Only honored on WebSocket, HTTPUpgrade and XHTTP transports. Leave empty to ignore forwarded headers.",
+        "proxyProtocolHint": "Accept the PROXY-protocol header to learn the real client IP from an upstream L4 tunnel or relay (HAProxy, gost, nginx-stream, Xray dokodemo-door) or Cloudflare Spectrum. The upstream MUST emit PROXY protocol. Works on TCP, WebSocket, HTTPUpgrade and gRPC; not on mKCP.",
+        "realClientIp": "Real client IP",
+        "realClientIpHint": "Capture the visitor's real IP when traffic reaches this inbound through a CDN or relay, instead of recording the intermediary's address. Pick a preset to fill the matching sockopt fields below. These fields are never sent to clients in subscriptions.",
+        "realClientIpPresetOff": "Off / direct",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "L4 relay / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For is only honored on WebSocket, HTTPUpgrade and XHTTP. On the current transport this header is ignored.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol is not supported on this transport (mKCP). Use TCP/RAW, WebSocket, HTTPUpgrade, gRPC or XHTTP.",
         "addressPortStrategy": "Address+port strategy",
         "addressPortStrategy": "Address+port strategy",
         "tryDelayMs": "Try delay (ms)",
         "tryDelayMs": "Try delay (ms)",
         "prioritizeIPv6": "Prioritize IPv6",
         "prioritizeIPv6": "Prioritize IPv6",

+ 9 - 0
internal/web/translation/es-ES.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For de confianza",
         "trustedXForwardedFor": "X-Forwarded-For de confianza",
+        "trustedXForwardedForHint": "Confía en esta cabecera de solicitud para obtener la IP real del cliente (p. ej. CF-Connecting-IP detrás del CDN de Cloudflare). Solo válido en los transportes WebSocket, HTTPUpgrade y XHTTP. Déjalo vacío para ignorar las cabeceras reenviadas.",
+        "proxyProtocolHint": "Acepta la cabecera PROXY protocol para obtener la IP real del cliente desde un túnel/relé L4 superior (HAProxy, gost, nginx-stream, Xray dokodemo-door) o Cloudflare Spectrum. El nodo superior DEBE enviar PROXY protocol. Funciona en TCP, WebSocket, HTTPUpgrade y gRPC; no en mKCP.",
+        "realClientIp": "IP real del cliente",
+        "realClientIpHint": "Captura la IP real del visitante cuando el tráfico llega a este inbound a través de un CDN o relé, en lugar de registrar la dirección del intermediario. Elige un preajuste para rellenar los campos sockopt correspondientes más abajo. Estos campos nunca se envían a los clientes en las suscripciones.",
+        "realClientIpPresetOff": "Desactivado / directo",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "Relé L4 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For solo es válido en WebSocket, HTTPUpgrade y XHTTP. En el transporte actual esta cabecera se ignora.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol no es compatible con este transporte (mKCP). Usa TCP/RAW, WebSocket, HTTPUpgrade, gRPC o XHTTP.",
         "addressPortStrategy": "Estrategia dirección+puerto",
         "addressPortStrategy": "Estrategia dirección+puerto",
         "tryDelayMs": "Retraso de intento (ms)",
         "tryDelayMs": "Retraso de intento (ms)",
         "prioritizeIPv6": "Priorizar IPv6",
         "prioritizeIPv6": "Priorizar IPv6",

+ 9 - 0
internal/web/translation/fa-IR.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "تراکم TCP",
         "tcpCongestion": "تراکم TCP",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For مورد اعتماد",
         "trustedXForwardedFor": "X-Forwarded-For مورد اعتماد",
+        "trustedXForwardedForHint": "این هدر درخواست برای گرفتن IP واقعی کاربر مورد اعتماد قرار می‌گیرد (مثلاً CF-Connecting-IP پشت CDN کلودفلر). فقط روی ترنسپورت‌های WebSocket، HTTPUpgrade و XHTTP اعمال می‌شود. برای نادیده‌گرفتن هدرها خالی بگذارید.",
+        "proxyProtocolHint": "پذیرش هدر PROXY protocol برای گرفتن IP واقعی کاربر از یک تونل/رله L4 بالادست (HAProxy، gost، nginx-stream، Xray dokodemo-door) یا Cloudflare Spectrum. بالادست باید PROXY protocol را ارسال کند. روی TCP، WebSocket، HTTPUpgrade و gRPC کار می‌کند؛ روی mKCP خیر.",
+        "realClientIp": "IP واقعی کاربر",
+        "realClientIpHint": "وقتی ترافیک از طریق CDN یا رله به این ورودی می‌رسد، به‌جای ثبت آدرس واسط، IP واقعی کاربر گرفته می‌شود. یک پریست انتخاب کنید تا فیلدهای sockopt مربوطه پایین تکمیل شوند. این فیلدها هرگز در اشتراک‌ها به کلاینت‌ها ارسال نمی‌شوند.",
+        "realClientIpPresetOff": "خاموش / مستقیم",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "رله L4 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For فقط روی WebSocket، HTTPUpgrade و XHTTP اعمال می‌شود. روی ترنسپورت فعلی این هدر نادیده گرفته می‌شود.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol روی این ترنسپورت (mKCP) پشتیبانی نمی‌شود. از TCP/RAW، WebSocket، HTTPUpgrade، gRPC یا XHTTP استفاده کنید.",
         "addressPortStrategy": "استراتژی آدرس+پورت",
         "addressPortStrategy": "استراتژی آدرس+پورت",
         "tryDelayMs": "تأخیر تلاش (ms)",
         "tryDelayMs": "تأخیر تلاش (ms)",
         "prioritizeIPv6": "اولویت IPv6",
         "prioritizeIPv6": "اولویت IPv6",

+ 9 - 0
internal/web/translation/id-ID.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For tepercaya",
         "trustedXForwardedFor": "X-Forwarded-For tepercaya",
+        "trustedXForwardedForHint": "Percayai header permintaan ini untuk IP klien asli (mis. CF-Connecting-IP di belakang CDN Cloudflare). Hanya berlaku pada transport WebSocket, HTTPUpgrade, dan XHTTP. Kosongkan untuk mengabaikan header yang diteruskan.",
+        "proxyProtocolHint": "Terima header PROXY protocol untuk mengetahui IP klien asli dari tunnel/relay L4 di hulu (HAProxy, gost, nginx-stream, Xray dokodemo-door) atau Cloudflare Spectrum. Hulu HARUS mengirim PROXY protocol. Berfungsi pada TCP, WebSocket, HTTPUpgrade, dan gRPC; tidak pada mKCP.",
+        "realClientIp": "IP klien asli",
+        "realClientIpHint": "Tangkap IP asli pengunjung saat lalu lintas mencapai inbound ini melalui CDN atau relay, alih-alih mencatat alamat perantara. Pilih preset untuk mengisi kolom sockopt terkait di bawah. Kolom ini tidak pernah dikirim ke klien dalam langganan.",
+        "realClientIpPresetOff": "Mati / langsung",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "Relay L4 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For hanya berlaku pada WebSocket, HTTPUpgrade, dan XHTTP. Pada transport saat ini header ini diabaikan.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol tidak didukung pada transport ini (mKCP). Gunakan TCP/RAW, WebSocket, HTTPUpgrade, gRPC, atau XHTTP.",
         "addressPortStrategy": "Strategi alamat+port",
         "addressPortStrategy": "Strategi alamat+port",
         "tryDelayMs": "Penundaan percobaan (ms)",
         "tryDelayMs": "Penundaan percobaan (ms)",
         "prioritizeIPv6": "Prioritaskan IPv6",
         "prioritizeIPv6": "Prioritaskan IPv6",

+ 9 - 0
internal/web/translation/ja-JP.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "信頼できる X-Forwarded-For",
         "trustedXForwardedFor": "信頼できる X-Forwarded-For",
+        "trustedXForwardedForHint": "実際のクライアント IP を取得するためにこのリクエストヘッダーを信頼します(例: Cloudflare CDN の背後の CF-Connecting-IP)。WebSocket、HTTPUpgrade、XHTTP トランスポートでのみ有効です。空欄にすると転送ヘッダーを無視します。",
+        "proxyProtocolHint": "PROXY protocol ヘッダーを受け入れ、上流の L4 トンネル/リレー(HAProxy、gost、nginx-stream、Xray dokodemo-door)または Cloudflare Spectrum から実際のクライアント IP を取得します。上流は必ず PROXY protocol を送信する必要があります。TCP、WebSocket、HTTPUpgrade、gRPC で動作します。mKCP では動作しません。",
+        "realClientIp": "実際のクライアント IP",
+        "realClientIpHint": "トラフィックが CDN やリレーを経由してこのインバウンドに到達したときに、中継ノードのアドレスではなく訪問者の実際の IP を取得します。プリセットを選ぶと、下の対応する sockopt フィールドが自動入力されます。これらのフィールドはサブスクリプションでクライアントに送信されることはありません。",
+        "realClientIpPresetOff": "オフ / 直接",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "L4 リレー / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For は WebSocket、HTTPUpgrade、XHTTP でのみ有効です。現在のトランスポートではこのヘッダーは無視されます。",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol はこのトランスポート(mKCP)ではサポートされていません。TCP/RAW、WebSocket、HTTPUpgrade、gRPC、または XHTTP を使用してください。",
         "addressPortStrategy": "アドレス+ポート戦略",
         "addressPortStrategy": "アドレス+ポート戦略",
         "tryDelayMs": "試行遅延 (ms)",
         "tryDelayMs": "試行遅延 (ms)",
         "prioritizeIPv6": "IPv6 優先",
         "prioritizeIPv6": "IPv6 優先",

+ 9 - 0
internal/web/translation/pt-BR.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For confiável",
         "trustedXForwardedFor": "X-Forwarded-For confiável",
+        "trustedXForwardedForHint": "Confie neste cabeçalho de requisição para obter o IP real do cliente (ex.: CF-Connecting-IP atrás do CDN da Cloudflare). Válido apenas nos transportes WebSocket, HTTPUpgrade e XHTTP. Deixe vazio para ignorar cabeçalhos encaminhados.",
+        "proxyProtocolHint": "Aceite o cabeçalho PROXY protocol para obter o IP real do cliente a partir de um túnel/relay L4 upstream (HAProxy, gost, nginx-stream, Xray dokodemo-door) ou Cloudflare Spectrum. O upstream DEVE enviar PROXY protocol. Funciona em TCP, WebSocket, HTTPUpgrade e gRPC; não em mKCP.",
+        "realClientIp": "IP real do cliente",
+        "realClientIpHint": "Capture o IP real do visitante quando o tráfego chega a este inbound através de um CDN ou relay, em vez de registrar o endereço do intermediário. Escolha uma predefinição para preencher os campos sockopt correspondentes abaixo. Esses campos nunca são enviados aos clientes nas assinaturas.",
+        "realClientIpPresetOff": "Desligado / direto",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "Relay L4 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For é válido apenas em WebSocket, HTTPUpgrade e XHTTP. No transporte atual este cabeçalho é ignorado.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol não é suportado neste transporte (mKCP). Use TCP/RAW, WebSocket, HTTPUpgrade, gRPC ou XHTTP.",
         "addressPortStrategy": "Estratégia endereço+porta",
         "addressPortStrategy": "Estratégia endereço+porta",
         "tryDelayMs": "Atraso de tentativa (ms)",
         "tryDelayMs": "Atraso de tentativa (ms)",
         "prioritizeIPv6": "Priorizar IPv6",
         "prioritizeIPv6": "Priorizar IPv6",

+ 9 - 0
internal/web/translation/ru-RU.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Доверенный X-Forwarded-For",
         "trustedXForwardedFor": "Доверенный X-Forwarded-For",
+        "trustedXForwardedForHint": "Доверять этому заголовку запроса для определения реального IP клиента (например, CF-Connecting-IP за CDN Cloudflare). Работает только на транспортах WebSocket, HTTPUpgrade и XHTTP. Оставьте пустым, чтобы игнорировать заголовки пересылки.",
+        "proxyProtocolHint": "Принимать заголовок PROXY protocol, чтобы получить реальный IP клиента от вышестоящего L4-туннеля или релея (HAProxy, gost, nginx-stream, Xray dokodemo-door) либо Cloudflare Spectrum. Вышестоящий узел ДОЛЖЕН отправлять PROXY protocol. Работает на TCP, WebSocket, HTTPUpgrade и gRPC; не работает на mKCP.",
+        "realClientIp": "Реальный IP клиента",
+        "realClientIpHint": "Получать реальный IP посетителя, когда трафик приходит на этот входящий через CDN или релей, вместо адреса промежуточного узла. Выберите пресет, чтобы заполнить соответствующие поля sockopt ниже. Эти поля никогда не отправляются клиентам в подписках.",
+        "realClientIpPresetOff": "Выкл. / напрямую",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "L4-релей / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For работает только на WebSocket, HTTPUpgrade и XHTTP. На текущем транспорте этот заголовок игнорируется.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol не поддерживается на этом транспорте (mKCP). Используйте TCP/RAW, WebSocket, HTTPUpgrade, gRPC или XHTTP.",
         "addressPortStrategy": "Стратегия адрес+порт",
         "addressPortStrategy": "Стратегия адрес+порт",
         "tryDelayMs": "Задержка попытки (мс)",
         "tryDelayMs": "Задержка попытки (мс)",
         "prioritizeIPv6": "Приоритет IPv6",
         "prioritizeIPv6": "Приоритет IPv6",

+ 9 - 0
internal/web/translation/tr-TR.json

@@ -558,6 +558,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Güvenilir X-Forwarded-For",
         "trustedXForwardedFor": "Güvenilir X-Forwarded-For",
+        "trustedXForwardedForHint": "Gerçek istemci IP'sini almak için bu istek başlığına güven (örn. Cloudflare CDN arkasındaki CF-Connecting-IP). Yalnızca WebSocket, HTTPUpgrade ve XHTTP taşımalarında geçerlidir. İletilen başlıkları yok saymak için boş bırakın.",
+        "proxyProtocolHint": "Gerçek istemci IP'sini bir üst L4 tüneli veya rölesi (HAProxy, gost, nginx-stream, Xray dokodemo-door) ya da Cloudflare Spectrum üzerinden öğrenmek için PROXY protocol başlığını kabul et. Üst sunucu PROXY protocol göndermek ZORUNDADIR. TCP, WebSocket, HTTPUpgrade ve gRPC üzerinde çalışır; mKCP üzerinde çalışmaz.",
+        "realClientIp": "Gerçek istemci IP'si",
+        "realClientIpHint": "Trafik bu gelen bağlantıya bir CDN veya röle üzerinden ulaştığında, aracı adresini kaydetmek yerine ziyaretçinin gerçek IP'sini al. Aşağıdaki ilgili sockopt alanlarını doldurmak için bir hazır ayar seç. Bu alanlar aboneliklerde istemcilere asla gönderilmez.",
+        "realClientIpPresetOff": "Kapalı / doğrudan",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "L4 röle / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For yalnızca WebSocket, HTTPUpgrade ve XHTTP üzerinde geçerlidir. Mevcut taşımada bu başlık yok sayılır.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol bu taşımada (mKCP) desteklenmez. TCP/RAW, WebSocket, HTTPUpgrade, gRPC veya XHTTP kullanın.",
         "addressPortStrategy": "Adres+Port Stratejisi",
         "addressPortStrategy": "Adres+Port Stratejisi",
         "tryDelayMs": "Deneme Gecikmesi (ms)",
         "tryDelayMs": "Deneme Gecikmesi (ms)",
         "prioritizeIPv6": "IPv6 Önceliği",
         "prioritizeIPv6": "IPv6 Önceliği",

+ 9 - 0
internal/web/translation/uk-UA.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Довірений X-Forwarded-For",
         "trustedXForwardedFor": "Довірений X-Forwarded-For",
+        "trustedXForwardedForHint": "Довіряти цьому заголовку запиту для визначення справжнього IP клієнта (наприклад, CF-Connecting-IP за CDN Cloudflare). Працює лише на транспортах WebSocket, HTTPUpgrade та XHTTP. Залиште порожнім, щоб ігнорувати заголовки пересилання.",
+        "proxyProtocolHint": "Приймати заголовок PROXY protocol, щоб отримати справжній IP клієнта від висхідного L4-тунелю чи релея (HAProxy, gost, nginx-stream, Xray dokodemo-door) або Cloudflare Spectrum. Висхідний вузол МУСИТЬ надсилати PROXY protocol. Працює на TCP, WebSocket, HTTPUpgrade та gRPC; не працює на mKCP.",
+        "realClientIp": "Справжній IP клієнта",
+        "realClientIpHint": "Отримувати справжній IP відвідувача, коли трафік надходить на цей вхідний через CDN або релей, замість адреси проміжного вузла. Виберіть пресет, щоб заповнити відповідні поля sockopt нижче. Ці поля ніколи не надсилаються клієнтам у підписках.",
+        "realClientIpPresetOff": "Вимк. / напряму",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "L4-релей / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For працює лише на WebSocket, HTTPUpgrade та XHTTP. На поточному транспорті цей заголовок ігнорується.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol не підтримується на цьому транспорті (mKCP). Використовуйте TCP/RAW, WebSocket, HTTPUpgrade, gRPC або XHTTP.",
         "addressPortStrategy": "Стратегія адрес+порт",
         "addressPortStrategy": "Стратегія адрес+порт",
         "tryDelayMs": "Затримка спроби (мс)",
         "tryDelayMs": "Затримка спроби (мс)",
         "prioritizeIPv6": "Пріоритет IPv6",
         "prioritizeIPv6": "Пріоритет IPv6",

+ 9 - 0
internal/web/translation/vi-VN.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For tin cậy",
         "trustedXForwardedFor": "X-Forwarded-For tin cậy",
+        "trustedXForwardedForHint": "Tin cậy header yêu cầu này để lấy IP thật của client (ví dụ CF-Connecting-IP phía sau CDN của Cloudflare). Chỉ có hiệu lực trên các transport WebSocket, HTTPUpgrade và XHTTP. Để trống để bỏ qua các header chuyển tiếp.",
+        "proxyProtocolHint": "Chấp nhận header PROXY protocol để lấy IP thật của client từ tunnel/relay L4 phía trên (HAProxy, gost, nginx-stream, Xray dokodemo-door) hoặc Cloudflare Spectrum. Phía trên PHẢI gửi PROXY protocol. Hoạt động trên TCP, WebSocket, HTTPUpgrade và gRPC; không hoạt động trên mKCP.",
+        "realClientIp": "IP thật của client",
+        "realClientIpHint": "Lấy IP thật của khách khi lưu lượng đến inbound này qua CDN hoặc relay, thay vì ghi lại địa chỉ của trung gian. Chọn một preset để tự điền các trường sockopt tương ứng bên dưới. Các trường này không bao giờ được gửi đến client trong subscription.",
+        "realClientIpPresetOff": "Tắt / trực tiếp",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "Relay L4 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For chỉ có hiệu lực trên WebSocket, HTTPUpgrade và XHTTP. Trên transport hiện tại header này bị bỏ qua.",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol không được hỗ trợ trên transport này (mKCP). Hãy dùng TCP/RAW, WebSocket, HTTPUpgrade, gRPC hoặc XHTTP.",
         "addressPortStrategy": "Chiến lược địa chỉ+cổng",
         "addressPortStrategy": "Chiến lược địa chỉ+cổng",
         "tryDelayMs": "Độ trễ thử (ms)",
         "tryDelayMs": "Độ trễ thử (ms)",
         "prioritizeIPv6": "Ưu tiên IPv6",
         "prioritizeIPv6": "Ưu tiên IPv6",

+ 9 - 0
internal/web/translation/zh-CN.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "可信 X-Forwarded-For",
         "trustedXForwardedFor": "可信 X-Forwarded-For",
+        "trustedXForwardedForHint": "信任此请求头来获取真实客户端 IP(例如 Cloudflare CDN 后的 CF-Connecting-IP)。仅在 WebSocket、HTTPUpgrade 和 XHTTP 传输上生效。留空则忽略转发头。",
+        "proxyProtocolHint": "接受 PROXY protocol 头,从上游 L4 隧道或中继(HAProxy、gost、nginx-stream、Xray dokodemo-door)或 Cloudflare Spectrum 获取真实客户端 IP。上游必须发送 PROXY protocol。适用于 TCP、WebSocket、HTTPUpgrade 和 gRPC;不适用于 mKCP。",
+        "realClientIp": "真实客户端 IP",
+        "realClientIpHint": "当流量通过 CDN 或中继到达此入站时,获取访客的真实 IP,而不是记录中间节点的地址。选择一个预设以自动填写下方对应的 sockopt 字段。这些字段绝不会在订阅中发送给客户端。",
+        "realClientIpPresetOff": "关闭 / 直连",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "L4 中继 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For 仅在 WebSocket、HTTPUpgrade 和 XHTTP 上生效。在当前传输上此请求头将被忽略。",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol 不支持此传输(mKCP)。请使用 TCP/RAW、WebSocket、HTTPUpgrade、gRPC 或 XHTTP。",
         "addressPortStrategy": "地址+端口策略",
         "addressPortStrategy": "地址+端口策略",
         "tryDelayMs": "尝试延迟 (ms)",
         "tryDelayMs": "尝试延迟 (ms)",
         "prioritizeIPv6": "IPv6 优先",
         "prioritizeIPv6": "IPv6 优先",

+ 9 - 0
internal/web/translation/zh-TW.json

@@ -557,6 +557,15 @@
         "tcpCongestion": "TCP Congestion",
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "信任的 X-Forwarded-For",
         "trustedXForwardedFor": "信任的 X-Forwarded-For",
+        "trustedXForwardedForHint": "信任此請求標頭以取得真實用戶端 IP(例如 Cloudflare CDN 後的 CF-Connecting-IP)。僅在 WebSocket、HTTPUpgrade 和 XHTTP 傳輸上生效。留空則忽略轉發標頭。",
+        "proxyProtocolHint": "接受 PROXY protocol 標頭,從上游 L4 隧道或中繼(HAProxy、gost、nginx-stream、Xray dokodemo-door)或 Cloudflare Spectrum 取得真實用戶端 IP。上游必須傳送 PROXY protocol。適用於 TCP、WebSocket、HTTPUpgrade 和 gRPC;不適用於 mKCP。",
+        "realClientIp": "真實用戶端 IP",
+        "realClientIpHint": "當流量透過 CDN 或中繼到達此入站時,取得訪客的真實 IP,而非記錄中間節點的位址。選擇一個預設以自動填入下方對應的 sockopt 欄位。這些欄位絕不會在訂閱中傳送給用戶端。",
+        "realClientIpPresetOff": "關閉 / 直連",
+        "realClientIpPresetCloudflare": "Cloudflare CDN",
+        "realClientIpPresetProxyProtocol": "L4 中繼 / Spectrum (PROXY)",
+        "realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For 僅在 WebSocket、HTTPUpgrade 和 XHTTP 上生效。在目前的傳輸上此標頭將被忽略。",
+        "realClientIpProxyProtocolTransportWarn": "PROXY protocol 不支援此傳輸(mKCP)。請使用 TCP/RAW、WebSocket、HTTPUpgrade、gRPC 或 XHTTP。",
         "addressPortStrategy": "地址+連接埠策略",
         "addressPortStrategy": "地址+連接埠策略",
         "tryDelayMs": "嘗試延遲 (ms)",
         "tryDelayMs": "嘗試延遲 (ms)",
         "prioritizeIPv6": "IPv6 優先",
         "prioritizeIPv6": "IPv6 優先",