6 Revize 0706b0b3a8 ... 6ed6f57b5c

Autor SHA1 Zpráva Datum
  nima1024m 6ed6f57b5c fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target (#4988) před 20 hodinami
  MHSanaei e409bc305d fix(iplimit): skip stale access-log emails after client rename/delete před 20 hodinami
  MHSanaei 2b4e199a97 fix(sub): don't project public inbounds through a fallback master před 20 hodinami
  MHSanaei 75bc6e8076 fix(inbound-form): wrap long labels and shorten RU pinned-cert label před 21 hodinami
  MHSanaei eeb19b7240 fix(node-sync): merge client enable with boolean AND for PostgreSQL před 21 hodinami
  MHSanaei 5b9db13e55 fix(finalmask): treat sudoku customTables as array of tables před 21 hodinami

+ 4 - 2
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -29,7 +29,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
       return { packets: '1-3', length: '', delay: '', maxSplit: '' };
     case 'sudoku':
       return {
-        password: '', ascii: '', customTable: '', customTables: '',
+        password: '', ascii: '', customTable: '', customTables: [''],
         paddingMin: 0, paddingMax: 0,
       };
     case 'header-custom':
@@ -228,7 +228,9 @@ function TcpMaskItem({
                 <Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
                 <Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
                 <Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
-                <Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
+                <Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
+                  <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
+                </Form.Item>
                 <Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
                   <InputNumber min={0} />
                 </Form.Item>

+ 6 - 2
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -10,6 +10,7 @@ import {
 import type { StreamSettings } from '@/schemas/api/inbound';
 import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
+import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 
 // Plain-data adapter between the panel's stored inbound row shape and
 // the typed InboundFormValues that Form.useForm<T> carries inside
@@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
   if (Array.isArray(settingsPruned.clients)) {
     settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
   }
-  const streamPruned = values.streamSettings
+  let streamPruned = values.streamSettings
     ? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
     : undefined;
-  if (streamPruned) stripTlsCertUseFile(streamPruned);
+  if (streamPruned) {
+    streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
+    stripTlsCertUseFile(streamPruned);
+  }
   dropLegacyOptionalEmpties(settingsPruned, streamPruned);
   const payload: WireInboundPayload = {
     up: values.up,

+ 4 - 3
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -1,4 +1,5 @@
 import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { Wireguard } from '@/utils';
 
 import type {
@@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
     userLevel: s.userLevel || undefined,
     proxyProtocol: s.proxyProtocol || undefined,
     fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
-    noises: s.noises.length > 0 ? s.noises : undefined,
-    finalRules: s.finalRules.length > 0
+    noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
+    finalRules: s.finalRules && s.finalRules.length > 0
       ? s.finalRules.map((r) => ({
           action: r.action,
           network: r.network || undefined,
@@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
     if (!xmuxEnabled) delete cleaned.xmux;
     next.xhttpSettings = dropEmptyStrings(cleaned);
   }
-  return next;
+  return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
 }
 
 function muxAllowed(values: OutboundFormValues): boolean {

+ 225 - 0
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -0,0 +1,225 @@
+// Shapes the streamSettings subtree that 3x-ui persists to match what
+// xray-core actually consumes. The panel's Zod defaults mirror the full
+// SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
+// (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
+// them anyway bloats configs and — for sockopt — can inject doc-example
+// values like tcpWindowClamp: 600 that throttle throughput.
+
+export type StreamWireSide = 'inbound' | 'outbound';
+
+const PACKET_UP_FIELDS = [
+  'scMaxEachPostBytes',
+  'scMinPostsIntervalMs',
+  'scMaxBufferedPosts',
+] as const;
+
+const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
+
+const PLACEMENT_STRING_FIELDS = [
+  'sessionPlacement',
+  'sessionKey',
+  'seqPlacement',
+  'seqKey',
+  'uplinkDataPlacement',
+  'uplinkDataKey',
+  'uplinkHTTPMethod',
+  'xPaddingKey',
+  'xPaddingHeader',
+  'xPaddingPlacement',
+  'xPaddingMethod',
+] as const;
+
+function isRecord(v: unknown): v is Record<string, unknown> {
+  return v != null && typeof v === 'object' && !Array.isArray(v);
+}
+
+function nonEmptyString(v: unknown): v is string {
+  return typeof v === 'string' && v.trim() !== '';
+}
+
+function hasMeaningfulHeaders(headers: unknown): boolean {
+  return isRecord(headers) && Object.keys(headers).length > 0;
+}
+
+/** Validates REALITY inbound `target` / `dest` (must include a port). */
+export function validateRealityTarget(target: string): string | undefined {
+  const trimmed = target.trim();
+  if (!trimmed) {
+    return 'pages.inbounds.form.realityTargetRequired';
+  }
+
+  // Unix socket destinations (rare, but valid in xray-core).
+  if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
+    return undefined;
+  }
+
+  // Pure port → localhost:port in xray-core.
+  if (/^\d+$/.test(trimmed)) {
+    const port = Number(trimmed);
+    if (port >= 1 && port <= 65535) return undefined;
+    return 'pages.inbounds.form.realityTargetInvalidPort';
+  }
+
+  const lastColon = trimmed.lastIndexOf(':');
+  if (lastColon <= 0 || lastColon === trimmed.length - 1) {
+    return 'pages.inbounds.form.realityTargetNeedsPort';
+  }
+
+  const portPart = trimmed.slice(lastColon + 1);
+  if (!/^\d+$/.test(portPart)) {
+    return 'pages.inbounds.form.realityTargetInvalidPort';
+  }
+  const port = Number(portPart);
+  if (port < 1 || port > 65535) {
+    return 'pages.inbounds.form.realityTargetInvalidPort';
+  }
+  return undefined;
+}
+
+function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
+  for (const key of keys) {
+    const v = obj[key];
+    if (v === '' || v == null) delete obj[key];
+  }
+}
+
+function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
+  for (const key of keys) {
+    if (obj[key] === false) delete obj[key];
+  }
+}
+
+function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
+  for (const key of keys) {
+    if (obj[key] === 0) delete obj[key];
+  }
+}
+
+export function normalizeXhttpForWire(
+  raw: Record<string, unknown>,
+  side: StreamWireSide,
+): Record<string, unknown> {
+  const out: Record<string, unknown> = { ...raw };
+  const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
+
+  delete out.enableXmux;
+
+  if (side === 'inbound') {
+    delete out.xmux;
+    delete out.scMinPostsIntervalMs;
+    delete out.uplinkChunkSize;
+  }
+
+  dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
+
+  if (!hasMeaningfulHeaders(out.headers)) {
+    delete out.headers;
+  }
+
+  if (out.xPaddingObfsMode !== true) {
+    delete out.xPaddingObfsMode;
+    dropEmptyStrings(out, [
+      'xPaddingKey',
+      'xPaddingHeader',
+      'xPaddingPlacement',
+      'xPaddingMethod',
+    ]);
+  }
+
+  if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
+  if (out.noSSEHeader !== true) delete out.noSSEHeader;
+  if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
+  if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
+
+  if (mode === 'stream-one') {
+    for (const key of PACKET_UP_FIELDS) delete out[key];
+    for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
+  } else if (mode === 'stream-up') {
+    for (const key of PACKET_UP_FIELDS) delete out[key];
+    if (side === 'outbound') {
+      delete out.scStreamUpServerSecs;
+    }
+  } else if (mode === 'packet-up') {
+    delete out.scStreamUpServerSecs;
+  }
+
+  return out;
+}
+
+export function normalizeSockoptForWire(
+  raw: Record<string, unknown>,
+): Record<string, unknown> | undefined {
+  const out: Record<string, unknown> = { ...raw };
+
+  dropZeroNumbers(out, [
+    'tcpWindowClamp',
+    'tcpMaxSeg',
+    'tcpUserTimeout',
+    'tcpKeepAliveIdle',
+    'tcpKeepAliveInterval',
+    'mark',
+  ]);
+
+  dropFalseFlags(out, [
+    'acceptProxyProtocol',
+    'tcpFastOpen',
+    'tcpMptcp',
+    'penetrate',
+    'V6Only',
+  ]);
+
+  if (out.tproxy === 'off') delete out.tproxy;
+  if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
+  if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
+  if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
+  if (nonEmptyString(out.interface) === false) delete out.interface;
+  if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
+    delete out.trustedXForwardedFor;
+  }
+  if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
+    delete out.customSockopt;
+  }
+
+  const he = out.happyEyeballs;
+  if (isRecord(he)) {
+    const heOut: Record<string, unknown> = { ...he };
+    if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs;
+    if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
+    if (heOut.interleave === 1) delete heOut.interleave;
+    if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
+    if (Object.keys(heOut).length === 0) {
+      delete out.happyEyeballs;
+    } else {
+      out.happyEyeballs = heOut;
+    }
+  }
+
+  if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
+
+  if (Object.keys(out).length === 0) return undefined;
+  return out;
+}
+
+export function normalizeStreamSettingsForWire(
+  stream: Record<string, unknown>,
+  opts: { side: StreamWireSide },
+): Record<string, unknown> {
+  const out: Record<string, unknown> = { ...stream };
+
+  const xhttp = out.xhttpSettings;
+  if (isRecord(xhttp)) {
+    out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
+  }
+
+  const sockopt = out.sockopt;
+  if (isRecord(sockopt)) {
+    const normalized = normalizeSockoptForWire(sockopt);
+    if (normalized) {
+      out.sockopt = normalized;
+    } else {
+      delete out.sockopt;
+    }
+  }
+
+  return out;
+}

+ 1 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -875,6 +875,7 @@ export default function InboundFormModal({
           colon={false}
           labelCol={{ sm: { span: 8 } }}
           wrapperCol={{ sm: { span: 14 } }}
+          labelWrap
           onValuesChange={onValuesChange}
         >
           <Tabs items={[

+ 18 - 3
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
 
 import { UTLS_FINGERPRINT } from '@/schemas/primitives';
+import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
 
 interface RealityFormProps {
   saving: boolean;
@@ -44,10 +45,24 @@ export default function RealityForm({
           options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
         />
       </Form.Item>
-      <Form.Item label={t('pages.inbounds.form.target')}>
+      <Form.Item
+        label={t('pages.inbounds.form.target')}
+        extra={t('pages.inbounds.form.realityTargetHint')}
+      >
         <Space.Compact block>
-          <Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
-            <Input style={{ width: 'calc(100% - 32px)' }} />
+          <Form.Item
+            name={['streamSettings', 'realitySettings', 'target']}
+            noStyle
+            rules={[
+              {
+                validator: async (_, value) => {
+                  const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
+                  if (errKey) throw new Error(t(errKey));
+                },
+              },
+            ]}
+          >
+            <Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
           </Form.Item>
           <Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
         </Space.Compact>

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

@@ -60,6 +60,7 @@ export default function SockoptForm({
                 <Form.Item
                   name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
                   label={t('pages.inbounds.form.tcpWindowClamp')}
+                  tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
                 >
                   <InputNumber min={0} />
                 </Form.Item>

+ 91 - 0
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -10,6 +10,7 @@ import {
 } from '@ant-design/icons';
 
 import { OutboundDomainStrategies } from '@/schemas/primitives';
+import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -84,6 +85,49 @@ export default function BasicsTab({
       | { domainStrategy?: string }
       | undefined)?.domainStrategy ?? 'AsIs';
 
+  const directFreedomOutbound = templateSettings?.outbounds?.find(
+    (o) => o?.protocol === 'freedom' && o?.tag === 'direct',
+  );
+  const directHappyEyeballs = (() => {
+    const sockopt = (directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined)
+      ?.sockopt;
+    const raw = sockopt?.happyEyeballs;
+    if (raw == null || typeof raw !== 'object') return null;
+    return HappyEyeballsSchema.parse(raw);
+  })();
+
+  const setDirectHappyEyeballs = useCallback(
+    (next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
+      mutate((tt) => {
+        if (!tt.outbounds) tt.outbounds = [];
+        let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
+        if (idx < 0) {
+          tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
+          idx = tt.outbounds.length - 1;
+        }
+        const ob = tt.outbounds[idx];
+        const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
+        const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
+        if (next == null) {
+          delete sockopt.happyEyeballs;
+        } else {
+          sockopt.happyEyeballs = next;
+        }
+        if (Object.keys(sockopt).length === 0) {
+          delete stream.sockopt;
+        } else {
+          stream.sockopt = sockopt;
+        }
+        if (Object.keys(stream).length === 0) {
+          delete ob.streamSettings;
+        } else {
+          ob.streamSettings = stream;
+        }
+      });
+    },
+    [mutate],
+  );
+
   const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
   const log = (templateSettings?.log || {}) as Record<string, unknown>;
   const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
@@ -124,6 +168,53 @@ export default function BasicsTab({
               />
             }
           />
+          <SettingListItem
+            title={t('pages.xray.FreedomHappyEyeballs')}
+            description={t('pages.xray.FreedomHappyEyeballsDesc')}
+            paddings="small"
+            control={
+              <Switch
+                checked={directHappyEyeballs != null}
+                onChange={(checked) => {
+                  setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
+                }}
+              />
+            }
+          />
+          {directHappyEyeballs != null && (
+            <>
+              <SettingListItem
+                title={t('pages.inbounds.form.tryDelayMs')}
+                description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
+                paddings="small"
+                control={
+                  <InputNumber
+                    min={0}
+                    style={{ width: '100%' }}
+                    value={directHappyEyeballs.tryDelayMs}
+                    placeholder="150"
+                    onChange={(v) => setDirectHappyEyeballs({
+                      ...directHappyEyeballs,
+                      tryDelayMs: typeof v === 'number' ? v : 0,
+                    })}
+                  />
+                }
+              />
+              <SettingListItem
+                title={t('pages.inbounds.form.prioritizeIPv6')}
+                paddings="small"
+                control={
+                  <Switch
+                    checked={directHappyEyeballs.prioritizeIPv6}
+                    onChange={(checked) => setDirectHappyEyeballs({
+                      ...directHappyEyeballs,
+                      prioritizeIPv6: checked,
+                    })}
+                  />
+                }
+              />
+            </>
+          )}
           <SettingListItem
             title={t('pages.xray.RoutingStrategy')}
             description={t('pages.xray.RoutingStrategyDesc')}

+ 1 - 0
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -350,6 +350,7 @@ export default function OutboundFormModal({
           colon={false}
           labelCol={{ md: { span: 8 } }}
           wrapperCol={{ md: { span: 14 } }}
+          labelWrap
           onValuesChange={onValuesChange}
         >
           <Tabs

+ 1 - 0
frontend/src/pages/xray/outbounds/transport/sockopt.tsx

@@ -171,6 +171,7 @@ export default function SockoptForm({
                 <Form.Item
                   label={t('pages.inbounds.form.tcpWindowClamp')}
                   name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
+                  tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
                 >
                   <InputNumber min={0} style={{ width: '100%' }} />
                 </Form.Item>

+ 9 - 5
frontend/src/schemas/protocols/stream/sockopt.ts

@@ -57,14 +57,18 @@ export const SockoptStreamSettingsSchema = z.object({
   tcpMptcp: z.boolean().default(false),
   penetrate: z.boolean().default(false),
   domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
-  tcpMaxSeg: z.number().int().min(0).default(1440),
+  // 0 = omit on the wire; xray-core skips sockopt fields <= 0 and uses OS defaults.
+  // Non-zero defaults here previously came from the xray docs *example* (clamp 600,
+  // maxSeg 1440, userTimeout 10000) and were written into every config when the
+  // panel sockopt switch was enabled, throttling long-haul links.
+  tcpMaxSeg: z.number().int().min(0).default(0),
   dialerProxy: z.string().default(''),
-  tcpKeepAliveInterval: z.number().int().min(0).default(45),
-  tcpKeepAliveIdle: z.number().int().min(0).default(45),
-  tcpUserTimeout: z.number().int().min(0).default(10000),
+  tcpKeepAliveInterval: z.number().int().min(0).default(0),
+  tcpKeepAliveIdle: z.number().int().min(0).default(0),
+  tcpUserTimeout: z.number().int().min(0).default(0),
   tcpcongestion: TcpCongestionSchema.default('bbr'),
   V6Only: z.boolean().default(false),
-  tcpWindowClamp: z.number().int().min(0).default(600),
+  tcpWindowClamp: z.number().int().min(0).default(0),
   interface: z.string().default(''),
   trustedXForwardedFor: z.array(z.string()).default([]),
   addressPortStrategy: AddressPortStrategySchema.default('none'),

+ 10 - 10
frontend/src/test/__snapshots__/sockopt.test.ts.snap

@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
   "mark": 0,
   "penetrate": false,
   "tcpFastOpen": false,
-  "tcpKeepAliveIdle": 45,
-  "tcpKeepAliveInterval": 45,
-  "tcpMaxSeg": 1440,
+  "tcpKeepAliveIdle": 0,
+  "tcpKeepAliveInterval": 0,
+  "tcpMaxSeg": 0,
   "tcpMptcp": false,
-  "tcpUserTimeout": 10000,
-  "tcpWindowClamp": 600,
+  "tcpUserTimeout": 0,
+  "tcpWindowClamp": 0,
   "tcpcongestion": "bbr",
   "tproxy": "off",
   "trustedXForwardedFor": [],
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
   "mark": 255,
   "penetrate": true,
   "tcpFastOpen": false,
-  "tcpKeepAliveIdle": 45,
-  "tcpKeepAliveInterval": 45,
-  "tcpMaxSeg": 1440,
+  "tcpKeepAliveIdle": 0,
+  "tcpKeepAliveInterval": 0,
+  "tcpMaxSeg": 0,
   "tcpMptcp": false,
-  "tcpUserTimeout": 10000,
-  "tcpWindowClamp": 600,
+  "tcpUserTimeout": 0,
+  "tcpWindowClamp": 0,
   "tcpcongestion": "bbr",
   "tproxy": "tproxy",
   "trustedXForwardedFor": [],

+ 243 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -0,0 +1,243 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
+import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
+import {
+  normalizeSockoptForWire,
+  normalizeStreamSettingsForWire,
+  normalizeXhttpForWire,
+  validateRealityTarget,
+} from '@/lib/xray/stream-wire-normalize';
+import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+
+describe('validateRealityTarget', () => {
+  it('accepts host:port and bare port', () => {
+    expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
+    expect(validateRealityTarget('443')).toBeUndefined();
+  });
+
+  it('rejects host without port', () => {
+    expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
+    expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
+  });
+});
+
+describe('normalizeXhttpForWire stream-one', () => {
+  it('drops packet-up and stream-up-only fields on inbound', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      host: 'play.google.com',
+      mode: 'stream-one',
+      xPaddingBytes: '100-1000',
+      scMaxEachPostBytes: '1000000',
+      scMinPostsIntervalMs: '30',
+      scMaxBufferedPosts: 30,
+      scStreamUpServerSecs: '20-80',
+      enableXmux: false,
+      headers: {},
+    }, 'inbound');
+
+    expect(out).toMatchObject({
+      path: '/app',
+      host: 'play.google.com',
+      mode: 'stream-one',
+      xPaddingBytes: '100-1000',
+    });
+    expect(out).not.toHaveProperty('scMaxEachPostBytes');
+    expect(out).not.toHaveProperty('scMinPostsIntervalMs');
+    expect(out).not.toHaveProperty('scMaxBufferedPosts');
+    expect(out).not.toHaveProperty('scStreamUpServerSecs');
+    expect(out).not.toHaveProperty('enableXmux');
+    expect(out).not.toHaveProperty('headers');
+  });
+
+  it('keeps xmux on outbound stream-one', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'stream-one',
+      xPaddingBytes: '100-1000',
+      xmux: { maxConcurrency: '16-32' },
+      scMaxEachPostBytes: '1000000',
+    }, 'outbound');
+
+    expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
+    expect(out).not.toHaveProperty('scMaxEachPostBytes');
+  });
+});
+
+describe('normalizeSockoptForWire', () => {
+  it('omits doc-example defaults that throttle throughput', () => {
+    const out = normalizeSockoptForWire({
+      tcpWindowClamp: 0,
+      tcpMaxSeg: 0,
+      tcpUserTimeout: 0,
+      tcpFastOpen: true,
+      tcpcongestion: 'bbr',
+      domainStrategy: 'AsIs',
+      tproxy: 'off',
+      mark: 0,
+    });
+
+    expect(out).toEqual({
+      tcpFastOpen: true,
+      tcpcongestion: 'bbr',
+    });
+  });
+
+  it('preserves happyEyeballs on freedom-style outbound', () => {
+    const out = normalizeSockoptForWire({
+      domainStrategy: 'UseIP',
+      happyEyeballs: {
+        tryDelayMs: 150,
+        prioritizeIPv6: true,
+        interleave: 1,
+        maxConcurrentTry: 4,
+      },
+    });
+
+    expect(out?.happyEyeballs).toMatchObject({
+      tryDelayMs: 150,
+      prioritizeIPv6: true,
+    });
+    expect(out?.domainStrategy).toBe('UseIP');
+  });
+});
+
+describe('normalizeStreamSettingsForWire reality', () => {
+  it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
+    const out = normalizeStreamSettingsForWire({
+      network: 'xhttp',
+      security: 'reality',
+      realitySettings: {
+        target: 'play.google.com:443',
+        privateKey: 'priv',
+        serverNames: ['play.google.com'],
+        shortIds: ['abcd'],
+        settings: {
+          publicKey: 'pub',
+          fingerprint: 'chrome',
+          spiderX: '/',
+        },
+      },
+    }, { side: 'inbound' });
+
+    const reality = out.realitySettings as Record<string, unknown>;
+    expect(reality.target).toBe('play.google.com:443');
+    expect(reality.privateKey).toBe('priv');
+    const settings = reality.settings as Record<string, unknown>;
+    expect(settings.publicKey).toBe('pub');
+    expect(settings.spiderX).toBe('/');
+  });
+
+  it('passes client realitySettings through unchanged on outbound', () => {
+    const out = normalizeStreamSettingsForWire({
+      network: 'xhttp',
+      security: 'reality',
+      realitySettings: {
+        publicKey: 'pub',
+        fingerprint: 'chrome',
+        serverName: 'play.google.com',
+        shortId: 'abcd',
+        spiderX: '/x',
+      },
+    }, { side: 'outbound' });
+
+    const reality = out.realitySettings as Record<string, unknown>;
+    expect(reality.publicKey).toBe('pub');
+    expect(reality.serverName).toBe('play.google.com');
+    expect(reality.spiderX).toBe('/x');
+  });
+});
+
+describe('inbound formValuesToWirePayload integration', () => {
+  it('emits lean stream-one xhttp + sockopt on save', () => {
+    const values = {
+      remark: 't',
+      enable: true,
+      port: 443,
+      listen: '0.0.0.0',
+      tag: 'in-443',
+      expiryTime: 0,
+      sniffing: { enabled: false },
+      up: 0,
+      down: 0,
+      total: 0,
+      trafficReset: 'never',
+      lastTrafficResetTime: 0,
+      nodeId: null,
+      protocol: 'vless',
+      settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
+      streamSettings: {
+        network: 'xhttp',
+        security: 'reality',
+        realitySettings: {
+          target: 'play.google.com:443',
+          privateKey: 'priv',
+          serverNames: ['play.google.com'],
+          shortIds: ['44003d86dc1e'],
+          settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
+        },
+        xhttpSettings: {
+          path: '/app',
+          host: 'play.google.com',
+          mode: 'stream-one',
+          xPaddingBytes: '100-1000',
+          scMaxEachPostBytes: '1000000',
+          scMinPostsIntervalMs: '30',
+          enableXmux: false,
+        },
+        sockopt: {
+          tcpWindowClamp: 0,
+          tcpMaxSeg: 0,
+          tcpUserTimeout: 0,
+          tcpFastOpen: true,
+          tcpcongestion: 'bbr',
+        },
+      },
+    } as InboundFormValues;
+
+    const payload = formValuesToWirePayload(values);
+    const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
+    const xhttp = stream.xhttpSettings as Record<string, unknown>;
+    const sockopt = stream.sockopt as Record<string, unknown>;
+    const reality = stream.realitySettings as Record<string, unknown>;
+
+    expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
+    expect(sockopt).not.toHaveProperty('tcpWindowClamp');
+    expect(sockopt.tcpFastOpen).toBe(true);
+    const realitySettings = reality.settings as Record<string, unknown>;
+    expect(realitySettings.publicKey).toBe('pub');
+  });
+});
+
+describe('freedom outbound sockopt wire payload', () => {
+  it('preserves happyEyeballs on direct freedom outbound', () => {
+    const wire = outboundToWire({
+      protocol: 'freedom',
+      tag: 'direct',
+      settings: { domainStrategy: 'UseIP' },
+      streamSettings: {
+        sockopt: {
+          domainStrategy: 'UseIP',
+          happyEyeballs: {
+            tryDelayMs: 150,
+            prioritizeIPv6: true,
+            interleave: 1,
+            maxConcurrentTry: 4,
+          },
+        },
+      },
+    } as Parameters<typeof outboundToWire>[0]);
+
+    expect(wire.streamSettings).toMatchObject({
+      sockopt: {
+        domainStrategy: 'UseIP',
+        happyEyeballs: {
+          tryDelayMs: 150,
+          prioritizeIPv6: true,
+        },
+      },
+    });
+  });
+});

+ 26 - 0
sub/subService.go

@@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool {
 	return ip != nil && ip.IsLoopback()
 }
 
+// listenIsInternalOnly reports whether a bind address is reachable only from
+// the same host — a loopback IP or a unix-domain socket. Such an inbound can't
+// be dialed directly by a remote client, so when it is the child side of a
+// fallback its share link must be projected through the master. A public or
+// wildcard listen (""/0.0.0.0/::) is reachable on its own port and advertises
+// itself.
+func listenIsInternalOnly(listen string) bool {
+	if listen == "" {
+		return false
+	}
+	if listen[0] == '@' || listen[0] == '/' {
+		return true
+	}
+	return isLoopbackHost(listen)
+}
+
 // GetSubs retrieves subscription links for a given subscription ID and host.
 func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	s.PrepareForRequest(host)
@@ -260,10 +276,20 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 // Returns true when a projection happened; sub services call this before
 // generating links so a child VLESS-WS bound to 127.0.0.1 emits the
 // master's :443 + TLS state instead of its own loopback endpoint.
+//
+// Projection only applies to a child that is not directly reachable on its
+// own listen (loopback or a unix-domain socket). An inbound on a public or
+// wildcard listen is reachable on its own port, so it advertises its own
+// port + security even when a stale fallback rule still names it as a child —
+// otherwise its share link would leak the master's port and Reality/TLS
+// settings (#4987).
 func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
 	if inbound == nil {
 		return false
 	}
+	if !listenIsInternalOnly(inbound.Listen) {
+		return false
+	}
 	db := database.GetDB()
 	var master *model.Inbound
 

+ 19 - 0
sub/subService_test.go

@@ -61,6 +61,25 @@ func TestIsRoutableHost(t *testing.T) {
 	}
 }
 
+func TestListenIsInternalOnly(t *testing.T) {
+	// Reachable only from the same host -> a fallback child here must be
+	// projected through its master.
+	internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
+	for _, v := range internalOnly {
+		if !listenIsInternalOnly(v) {
+			t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
+		}
+	}
+	// Directly reachable on its own port -> never projected, even if a stale
+	// fallback rule names it as a child (#4987).
+	reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
+	for _, v := range reachable {
+		if listenIsInternalOnly(v) {
+			t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
+		}
+	}
+}
+
 func TestResolveInboundAddress(t *testing.T) {
 	const reqHost = "sub.example.com"
 

+ 29 - 8
web/job/check_client_ip_job.go

@@ -17,6 +17,8 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/xray"
+
+	"gorm.io/gorm"
 )
 
 // IPWithTimestamp tracks an IP address with its last seen timestamp
@@ -184,6 +186,22 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 	shouldCleanLog := false
 	for email, ipTimestamps := range inboundClientIps {
 
+		// The access log can still reference a client that was just renamed
+		// or deleted; its email no longer matches any inbound. Skip it (and
+		// drop any orphaned tracking row) instead of recreating a row and
+		// logging an ERROR every run until the log rotates out the old email
+		// (#4963).
+		inbound, err := j.getInboundByEmail(email)
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
+				j.delInboundClientIps(email)
+			} else {
+				j.checkError(err)
+			}
+			continue
+		}
+
 		// Convert to IPWithTimestamp slice
 		ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
 		for ip, timestamp := range ipTimestamps {
@@ -196,7 +214,7 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 			continue
 		}
 
-		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog
+		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
 	}
 
 	return shouldCleanLog
@@ -311,14 +329,17 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
 	return nil
 }
 
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
-	// Get the inbound configuration
-	inbound, err := j.getInboundByEmail(clientEmail)
-	if err != nil {
-		logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
-		return false
+// delInboundClientIps drops the inbound_client_ips tracking row for an email
+// that no longer maps to any inbound (a renamed or deleted client), so stale
+// access-log entries don't keep a ghost row alive (#4963).
+func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
+	db := database.GetDB()
+	if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
+		j.checkError(err)
 	}
+}
 
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
 	if inbound.Settings == "" {
 		logger.Debug("wrong data:", inbound)
 		return false
@@ -411,7 +432,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	inboundClientIps.Ips = string(jsonIps)
 
 	db := database.GetDB()
-	err = db.Save(inboundClientIps).Error
+	err := db.Save(inboundClientIps).Error
 	if err != nil {
 		logger.Error("failed to save inboundClientIps:", err)
 		return false

+ 36 - 2
web/job/check_client_ip_job_integration_test.go

@@ -195,7 +195,11 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
 		{IP: "128.71.1.1", Timestamp: now},
 	}
 
-	shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
+	inbound, err := j.getInboundByEmail(email)
+	if err != nil {
+		t.Fatalf("getInboundByEmail: %v", err)
+	}
+	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
 
 	if shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
@@ -244,7 +248,11 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 		{IP: "192.0.2.9", Timestamp: now},
 	}
 
-	shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
+	inbound, err := j.getInboundByEmail(email)
+	if err != nil {
+		t.Fatalf("getInboundByEmail: %v", err)
+	}
+	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
 
 	if !shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
@@ -321,6 +329,32 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
 	}
 }
 
+// #4963: a stale access-log entry for a renamed/deleted client (its email no
+// longer maps to any inbound) must not create or resurrect an
+// inbound_client_ips row, and must drop any orphan left behind — instead of
+// spamming "failed to fetch inbound settings" every run.
+func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
+	setupIntegrationDB(t)
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
+	fakeFail2BanClient(t)
+
+	const staleEmail = "renamed-away"
+	// No inbound references staleEmail. Pre-seed an orphan tracking row to
+	// confirm the job removes it rather than leaving it to error forever.
+	seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
+	writeXrayAccessLog(t, staleEmail, "203.0.113.5")
+
+	NewCheckClientIpJob().Run()
+
+	var count int64
+	if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
+		t.Fatalf("count InboundClientIps: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
+	}
+}
+
 // readIpLimitLogPath reads the 3xipl.log path the same way the job
 // does via xray.GetIPLimitLogPath but without importing xray here
 // just for the path helper (which would pull a lot more deps into the

+ 6 - 1
web/service/inbound.go

@@ -1193,6 +1193,11 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb
 		if err := s.DelClientStat(tx, email); err != nil {
 			return err
 		}
+		// Keep inbound_client_ips in sync when the inbound edit drops an
+		// email, so the IP-limit job doesn't keep a ghost tracking row (#4963).
+		if err := s.DelClientIPs(tx, email); err != nil {
+			return err
+		}
 	}
 	for i := range newClients {
 		email := newClients[i].Email
@@ -1760,7 +1765,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			// from the node arriving after a central disable would otherwise
 			// overwrite enable=false back to true, letting the client accumulate
 			// far more traffic than their limit before being disabled again.
-			enableExpr := "CASE WHEN ? = 0 THEN 0 ELSE enable END"
+			enableExpr := "enable AND ?"
 			if err := tx.Exec(
 				fmt.Sprintf(
 					`UPDATE client_traffics

+ 8 - 0
web/translation/en-US.json

@@ -560,6 +560,7 @@
         "tcpMaxSeg": "TCP Max Seg",
         "tcpUserTimeout": "TCP User Timeout",
         "tcpWindowClamp": "TCP Window Clamp",
+        "tcpWindowClampHint": "Leave 0 to use the OS default. Non-zero values cap the advertised TCP receive window; values like 600 (from the Xray docs example) can collapse throughput on high-latency links.",
         "tcpFastOpen": "TCP Fast Open",
         "multipathTcp": "Multipath TCP",
         "penetrate": "Penetrate",
@@ -598,6 +599,10 @@
         "minClientVer": "Min Client Ver",
         "maxClientVer": "Max Client Ver",
         "shortIds": "Short IDs",
+        "realityTargetHint": "Required. Must include a port (e.g. example.com:443). Without a port Xray-core refuses to start.",
+        "realityTargetRequired": "REALITY target is required",
+        "realityTargetNeedsPort": "REALITY target must include a port (e.g. example.com:443)",
+        "realityTargetInvalidPort": "REALITY target has an invalid port",
         "spiderX": "SpiderX",
         "getNewCert": "Get New Cert",
         "mldsa65Seed": "mldsa65 Seed",
@@ -1178,6 +1183,9 @@
       "TemplateDesc": "The final Xray config file will be generated based on this template.",
       "FreedomStrategy": "Freedom Protocol Strategy",
       "FreedomStrategyDesc": "Set the output strategy for the network in the Freedom Protocol.",
+      "FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
+      "FreedomHappyEyeballsDesc": "Dual-stack dialing for the direct (freedom) outbound — useful on exit servers with both IPv4 and IPv6.",
+      "FreedomHappyEyeballsTryDelayDesc": "Milliseconds before trying the alternate address family. 150–250 ms is a good starting point.",
       "RoutingStrategy": "Overall Routing Strategy",
       "RoutingStrategyDesc": "Set the overall traffic routing strategy for resolving all requests.",
       "outboundTestUrl": "Outbound Test URL",

+ 1 - 1
web/translation/ru-RU.json

@@ -585,7 +585,7 @@
         "buildChain": "Build Chain",
         "echKey": "ECH key",
         "echConfig": "ECH config",
-        "pinnedPeerCertSha256": "Закреплённый SHA-256 сертификата пира",
+        "pinnedPeerCertSha256": "SHA-256 сертификата пира",
         "pinnedPeerCertSha256Tip": "SHA-256-хеши сертификата пира в виде шестнадцатеричной строки (напр. e8e2d3…), через запятую. Только для панели — не записывается в конфиг xray сервера, но включается в ссылки-приглашения, чтобы клиенты могли закрепить сертификат.",
         "pinnedPeerCertSha256Placeholder": "шестнадцатеричный хеш(и), через запятую",
         "generateRandomPin": "Сгенерировать случайный хеш",