14 Commits 16b2bcf9aa ... 2b1308ca29

Author SHA1 Message Date
  Yuri Khachaturyan 2b1308ca29 feat(notifications): add a consecutive-failure threshold for outbound.down alerts (#5968) 14 hours ago
  Blazethan 9e117bbdd3 fix(clients): keep VLESS xtls-rprx-vision flow when inbound options reload (#5971) 14 hours ago
  Mr. Nickson 8cd71e07ea fix: refresh stale client_traffics row when an inbound-deleted client's email is reused (#6003) 14 hours ago
  Seyed Ali Shahrokhi 79e65f63df fix(xray): validate generated egress targets (#5989) 14 hours ago
  Alexey c87649d9f2 fix(sub) Happ profileEnableRouting button (#6008) 14 hours ago
  w3struk 123fac222b feat(sub): add raw subscription download actions (#6017) 14 hours ago
  Matt Van Horn d38c912dc1 fix: gate embedded unencrypted-outbound rejection on running Xray core version (#6028) 14 hours ago
  H-TTTTT fde66ba820 fix(sub): omit non-standard fm param from Hysteria2 URI (#6048) 14 hours ago
  H-TTTTT a0dec000b2 fix(clients): allow case-only email updates without duplicates (#6050) 14 hours ago
  H-TTTTT 11f602fe04 fix(sub): preserve external link names in Clash/JSON (#6049) 14 hours ago
  H-TTTTT c80e5e276b fix(wireguard): preserve all Allowed IPs in share link, .conf, and subscription (#6051) 14 hours ago
  H-TTTTT 22ff07b24b fix(warp): preserve outbound customization when rotating IP (#6052) 14 hours ago
  H-TTTTT d623410cf4 fix(clients): persist all editable fields for clients with no inbound (#6053) 14 hours ago
  H-TTTTT a9d5d9afdb fix(balancer): pin loopback routing rules ahead of general rules (#6054) 14 hours ago
44 changed files with 1853 additions and 151 deletions
  1. 12 0
      frontend/public/openapi.json
  2. 8 1
      frontend/src/components/ui/notifications/EmailNotifications.tsx
  3. 8 1
      frontend/src/components/ui/notifications/TelegramNotifications.tsx
  4. 2 0
      frontend/src/generated/examples.ts
  5. 12 0
      frontend/src/generated/schemas.ts
  6. 2 0
      frontend/src/generated/types.ts
  7. 2 0
      frontend/src/generated/zod.ts
  8. 3 3
      frontend/src/lib/xray/inbound-link.ts
  9. 3 0
      frontend/src/models/setting.ts
  10. 18 4
      frontend/src/pages/clients/ClientFormModal.tsx
  11. 33 2
      frontend/src/pages/clients/ClientInfoModal.tsx
  12. 21 0
      frontend/src/pages/sub/SubPage.tsx
  13. 22 4
      frontend/src/pages/xray/balancers/balancer-loopback.ts
  14. 43 6
      frontend/src/pages/xray/overrides/WarpModal.tsx
  15. 1 0
      frontend/src/schemas/setting.ts
  16. 100 0
      frontend/src/test/balancer-loopback.test.ts
  17. 70 0
      frontend/src/test/client-form-flow.test.tsx
  18. 37 0
      frontend/src/test/inbound-link.test.ts
  19. 99 0
      frontend/src/test/warp-change-ip.test.ts
  20. 29 10
      internal/sub/controller.go
  21. 62 0
      internal/sub/controller_test.go
  22. 4 4
      internal/sub/dist.go
  23. 45 4
      internal/sub/external_config.go
  24. 29 0
      internal/sub/external_config_test.go
  25. 5 6
      internal/sub/service.go
  26. 29 0
      internal/sub/service_test.go
  27. 30 0
      internal/sub/service_wireguard_test.go
  28. 2 0
      internal/web/entity/entity.go
  29. 29 0
      internal/web/service/client_crud.go
  30. 3 1
      internal/web/service/client_inbound_apply.go
  31. 60 47
      internal/web/service/client_link.go
  32. 177 0
      internal/web/service/client_stat_reuse_test.go
  33. 205 0
      internal/web/service/client_update_no_inbound_test.go
  34. 30 0
      internal/web/service/client_update_rename_test.go
  35. 10 0
      internal/web/service/inbound.go
  36. 34 0
      internal/web/service/inbound_import_shared_clients_test.go
  37. 29 7
      internal/web/service/inbound_traffic.go
  38. 14 0
      internal/web/service/setting.go
  39. 58 29
      internal/web/service/xray.go
  40. 185 1
      internal/web/service/xray_config_inject_test.go
  41. 59 21
      internal/web/service/xray_metrics.go
  42. 122 0
      internal/web/service/xray_metrics_test.go
  43. 59 0
      internal/web/service/xray_setting.go
  44. 48 0
      internal/web/service/xray_setting_test.go

+ 12 - 0
frontend/public/openapi.json

@@ -109,6 +109,11 @@
           "ldapVlessField": {
             "type": "string"
           },
+          "outboundDownThreshold": {
+            "maximum": 100,
+            "minimum": 1,
+            "type": "integer"
+          },
           "pageSize": {
             "maximum": 1000,
             "minimum": 0,
@@ -387,6 +392,7 @@
           "ldapUserAttr",
           "ldapUserFilter",
           "ldapVlessField",
+          "outboundDownThreshold",
           "pageSize",
           "panelOutbound",
           "remarkTemplate",
@@ -570,6 +576,11 @@
           "ldapVlessField": {
             "type": "string"
           },
+          "outboundDownThreshold": {
+            "maximum": 100,
+            "minimum": 1,
+            "type": "integer"
+          },
           "pageSize": {
             "maximum": 1000,
             "minimum": 0,
@@ -855,6 +866,7 @@
           "ldapUserAttr",
           "ldapUserFilter",
           "ldapVlessField",
+          "outboundDownThreshold",
           "pageSize",
           "panelOutbound",
           "remarkTemplate",

+ 8 - 1
frontend/src/components/ui/notifications/EmailNotifications.tsx

@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
     icon: <CloudServerOutlined />,
     title: 'eventGroupOutbound',
     events: [
-      { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
+      {
+        key: 'outbound.down',
+        label: 'eventOutboundDown',
+        settingKey: 'outboundDownThreshold',
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+        ),
+      },
       { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
     ],
   },

+ 8 - 1
frontend/src/components/ui/notifications/TelegramNotifications.tsx

@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
     icon: <CloudServerOutlined />,
     title: 'eventGroupOutbound',
     events: [
-      { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
+      {
+        key: 'outbound.down',
+        label: 'eventOutboundDown',
+        settingKey: 'outboundDownThreshold',
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+        ),
+      },
       { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
     ],
   },

+ 2 - 0
frontend/src/generated/examples.ts

@@ -26,6 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapUserAttr": "",
     "ldapUserFilter": "",
     "ldapVlessField": "",
+    "outboundDownThreshold": 1,
     "pageSize": 0,
     "panelOutbound": "",
     "remarkTemplate": "",
@@ -136,6 +137,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapUserAttr": "",
     "ldapUserFilter": "",
     "ldapVlessField": "",
+    "outboundDownThreshold": 1,
     "pageSize": 0,
     "panelOutbound": "",
     "remarkTemplate": "",

+ 12 - 0
frontend/src/generated/schemas.ts

@@ -83,6 +83,11 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapVlessField": {
         "type": "string"
       },
+      "outboundDownThreshold": {
+        "maximum": 100,
+        "minimum": 1,
+        "type": "integer"
+      },
       "pageSize": {
         "maximum": 1000,
         "minimum": 0,
@@ -361,6 +366,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapUserAttr",
       "ldapUserFilter",
       "ldapVlessField",
+      "outboundDownThreshold",
       "pageSize",
       "panelOutbound",
       "remarkTemplate",
@@ -544,6 +550,11 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapVlessField": {
         "type": "string"
       },
+      "outboundDownThreshold": {
+        "maximum": 100,
+        "minimum": 1,
+        "type": "integer"
+      },
       "pageSize": {
         "maximum": 1000,
         "minimum": 0,
@@ -829,6 +840,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapUserAttr",
       "ldapUserFilter",
       "ldapVlessField",
+      "outboundDownThreshold",
       "pageSize",
       "panelOutbound",
       "remarkTemplate",

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

@@ -32,6 +32,7 @@ export interface AllSetting {
   ldapUserAttr: string;
   ldapUserFilter: string;
   ldapVlessField: string;
+  outboundDownThreshold: number;
   pageSize: number;
   panelOutbound: string;
   remarkTemplate: string;
@@ -143,6 +144,7 @@ export interface AllSettingView {
   ldapUserAttr: string;
   ldapUserFilter: string;
   ldapVlessField: string;
+  outboundDownThreshold: number;
   pageSize: number;
   panelOutbound: string;
   remarkTemplate: string;

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

@@ -44,6 +44,7 @@ export const AllSettingSchema = z.object({
   ldapUserAttr: z.string(),
   ldapUserFilter: z.string(),
   ldapVlessField: z.string(),
+  outboundDownThreshold: z.number().int().min(1).max(100),
   pageSize: z.number().int().min(0).max(1000),
   panelOutbound: z.string(),
   remarkTemplate: z.string(),
@@ -156,6 +157,7 @@ export const AllSettingViewSchema = z.object({
   ldapUserAttr: z.string(),
   ldapUserFilter: z.string(),
   ldapVlessField: z.string(),
+  outboundDownThreshold: z.number().int().min(1).max(100),
   pageSize: z.number().int().min(0).max(1000),
   panelOutbound: z.string(),
   remarkTemplate: z.string(),

+ 3 - 3
frontend/src/lib/xray/inbound-link.ts

@@ -822,8 +822,8 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
     ? Wireguard.generateKeypair(settings.secretKey).publicKey
     : '';
   if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
-  if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
-    url.searchParams.set('address', peer.allowedIPs[0]);
+  if (peer.allowedIPs.length > 0) {
+    url.searchParams.set('address', peer.allowedIPs.join(','));
   }
   if (typeof settings.mtu === 'number' && settings.mtu > 0) {
     url.searchParams.set('mtu', String(settings.mtu));
@@ -850,7 +850,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
 
   let txt = `[Interface]\n`;
   txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
-  txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
+  txt += `Address = ${peer.allowedIPs.join(', ')}\n`;
   txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
   if (typeof settings.mtu === 'number' && settings.mtu > 0) {
     txt += `MTU = ${settings.mtu}\n`;

+ 3 - 0
frontend/src/models/setting.ts

@@ -103,6 +103,7 @@ export class AllSetting {
   smtpEnabledEvents = '';
   smtpCpu = 80;
   smtpMemory = 80;
+  outboundDownThreshold = 3;
   hasTgBotToken = false;
   hasTwoFactorToken = false;
   hasLdapPassword = false;
@@ -120,6 +121,8 @@ export class AllSetting {
     }
     const cpu = Math.round(Number(this.tgCpu));
     this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
+    const threshold = Math.round(Number(this.outboundDownThreshold));
+    this.outboundDownThreshold = Number.isFinite(threshold) ? Math.min(100, Math.max(1, threshold)) : 3;
   }
 
   equals(other: AllSetting): boolean {

+ 18 - 4
frontend/src/pages/clients/ClientFormModal.tsx

@@ -48,6 +48,7 @@ const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
 interface ExternalLinkRow {
   kind: 'link' | 'subscription';
   value: string;
+  remark: string;
 }
 
 interface ApiMsg<T = unknown> {
@@ -138,6 +139,7 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
   return (links || []).map((l) => ({
     kind: l.kind === 'subscription' ? 'subscription' : 'link',
     value: l.value || '',
+    remark: l.remark || '',
   }));
 }
 
@@ -207,7 +209,7 @@ export default function ClientFormModal({
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
   function addExternalLinkRow(kind: 'link' | 'subscription') {
-    appendExternalLink({ kind, value: '' });
+    appendExternalLink({ kind, value: '', remark: '' });
   }
 
   useEffect(() => {
@@ -371,10 +373,15 @@ export default function ClientFormModal({
   }
 
   useEffect(() => {
-    if (!showFlow && flow) {
+    // Only clear the flow once we actually have inbound options to judge
+    // capability from. While the options list is momentarily empty (e.g. the
+    // options query is (re)loading and `inbounds` falls back to `[]`), showFlow
+    // is a false negative, so clearing here would silently drop a valid
+    // xtls-rprx-vision flow the user picked for a Reality/TLS inbound.
+    if (inbounds.length > 0 && !showFlow && flow) {
       methods.setValue('flow', '');
     }
-  }, [showFlow, flow, methods]);
+  }, [inbounds, showFlow, flow, methods]);
 
   useEffect(() => {
     if (!showReverseTag && reverseTag) {
@@ -547,7 +554,7 @@ export default function ClientFormModal({
     }
 
     const externalLinks: ExternalLinkInput[] = values.externalLinks
-      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
+      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
       .filter((r) => r.value !== '');
 
     setSubmitting(true);
@@ -917,6 +924,13 @@ export default function ClientFormModal({
                                 placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
                               />
                             </FormField>
+                            <FormField name={`externalLinks.${index}.remark`} noStyle>
+                              <Input
+                                style={{ width: 140 }}
+                                aria-label={t('remark')}
+                                placeholder={t('remark')}
+                              />
+                            </FormField>
                             <Tooltip title={t('delete')}>
                               <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
                             </Tooltip>

+ 33 - 2
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -1,9 +1,9 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
-import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
+import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
 
-import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
+import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { useDatepicker } from '@/hooks/useDatepicker';
@@ -64,6 +64,12 @@ const DEFAULT_SUB: SubSettings = {
   publicHost: '',
 };
 
+const SUBSCRIPTION_DOWNLOAD_NAMES = {
+  standard: 'subscription-standard.txt',
+  json: 'subscription-json.json',
+  clash: 'subscription-clash.yaml',
+} as const;
+
 export default function ClientInfoModal({
   open,
   client,
@@ -89,6 +95,7 @@ export default function ClientInfoModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
+  const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
 
   useEffect(() => {
     if (!open) {
@@ -148,6 +155,21 @@ export default function ClientInfoModal({
     if (ok) messageApi.success(t('copied'));
   }
 
+  async function downloadSubscription(url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES) {
+    if (!url || downloadingFormat) return;
+    setDownloadingFormat(format);
+    try {
+      const response = await fetch(url);
+      if (!response.ok) throw new Error('Subscription download failed');
+      const content = await response.text();
+      FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
+    } catch (_) {
+      messageApi.error(t('somethingWentWrong'));
+    } finally {
+      setDownloadingFormat(null);
+    }
+  }
+
   async function loadIps() {
     if (!client?.email) return;
     setIpsLoading(true);
@@ -383,6 +405,9 @@ export default function ClientInfoModal({
                     <Tooltip title={t('copy')}>
                       <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
                     </Tooltip>
+                    <Tooltip title={t('download')}>
+                      <Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'standard'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subLink, 'standard')} />
+                    </Tooltip>
                     <Popover
                       trigger="click"
                       placement="left"
@@ -411,6 +436,9 @@ export default function ClientInfoModal({
                       <Tooltip title={t('copy')}>
                         <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
                       </Tooltip>
+                      <Tooltip title={t('download')}>
+                        <Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'json'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subJsonLink, 'json')} />
+                      </Tooltip>
                       <Popover
                         trigger="click"
                         placement="left"
@@ -442,6 +470,9 @@ export default function ClientInfoModal({
                       <Tooltip title={t('copy')}>
                         <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
                       </Tooltip>
+                      <Tooltip title={t('download')}>
+                        <Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'clash'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subClashLink, 'clash')} />
+                      </Tooltip>
                       <Popover
                         trigger="click"
                         placement="left"

+ 21 - 0
frontend/src/pages/sub/SubPage.tsx

@@ -24,6 +24,7 @@ import {
   AppleOutlined,
   CopyOutlined,
   DownOutlined,
+  DownloadOutlined,
   MoonFilled,
   MoonOutlined,
   QrcodeOutlined,
@@ -64,6 +65,8 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
 const datepicker = subData.datepicker || 'gregorian';
 const announce = subData.announce || '';
 
+const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
+
 const isUnlimited = totalByte <= 0 && expireMs === 0;
 const isActive = (() => {
   if (!enabled) return false;
@@ -354,6 +357,15 @@ export default function SubPage() {
                             {sId}
                           </a>
                           <div className="sub-link-actions">
+                            <Button
+                              size="small"
+                              href={appendRawView(subJsonUrl)}
+                              target="_blank"
+                              rel="noopener noreferrer"
+                              icon={<DownloadOutlined />}
+                              aria-label={t('download')}
+                              title={t('download')}
+                            />
                             <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
                             <Popover
                               trigger="click"
@@ -386,6 +398,15 @@ export default function SubPage() {
                             {sId}
                           </a>
                           <div className="sub-link-actions">
+                            <Button
+                              size="small"
+                              href={appendRawView(subClashUrl)}
+                              target="_blank"
+                              rel="noopener noreferrer"
+                              icon={<DownloadOutlined />}
+                              aria-label={t('download')}
+                              title={t('download')}
+                            />
                             <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
                             <Popover
                               trigger="click"

+ 22 - 4
frontend/src/pages/xray/balancers/balancer-loopback.ts

@@ -52,6 +52,18 @@ function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): numb
   return count;
 }
 
+function isGeneralRoutingRule(rule: Record<string, unknown>): boolean {
+  const inboundTag = rule.inboundTag;
+  return !Array.isArray(inboundTag) || inboundTag.length === 0;
+}
+
+function firstGeneralRoutingRuleIndex(rules: Array<Record<string, unknown>>): number {
+  for (let i = 0; i < rules.length; i += 1) {
+    if (isGeneralRoutingRule(rules[i])) return i;
+  }
+  return rules.length;
+}
+
 export function ensureBalancerLoopback(
   settings: XraySettingsValue,
   targetBalancerTag: string,
@@ -72,13 +84,19 @@ export function ensureBalancerLoopback(
   if (!settings.routing) settings.routing = { rules: [], balancers: [] };
   if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
 
-  const existingRuleIdx = (settings.routing.rules as Array<{ inboundTag?: string[] }>).findIndex(
-    (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
+  const rules = settings.routing.rules as Array<Record<string, unknown>>;
+
+  const existingRuleIdx = rules.findIndex(
+    (r) => Array.isArray(r.inboundTag) && (r.inboundTag as string[]).includes(lbTag),
   );
+
   if (existingRuleIdx >= 0) {
-    (settings.routing.rules as Record<string, unknown>[])[existingRuleIdx].balancerTag = targetBalancerTag;
+    const existing = rules[existingRuleIdx];
+    existing.balancerTag = targetBalancerTag;
+    rules.splice(existingRuleIdx, 1);
+    rules.splice(firstGeneralRoutingRuleIndex(rules), 0, existing);
   } else {
-    (settings.routing.rules as Record<string, unknown>[]).push({
+    rules.splice(firstGeneralRoutingRuleIndex(rules), 0, {
       type: 'field',
       inboundTag: [lbTag],
       balancerTag: targetBalancerTag,

+ 43 - 6
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -75,6 +75,40 @@ function reservedFor(clientId?: string): number[] {
   return out;
 }
 
+export function mergeWarpRotation(
+  existing: Record<string, unknown> | undefined,
+  data: WarpData | null,
+  config: WarpConfig | null,
+): Record<string, unknown> | null {
+  const cfg = config?.config;
+  const peer = cfg?.peers?.[0];
+  if (!cfg || !peer) return null;
+  const base: Record<string, unknown> =
+    existing && typeof existing === 'object' ? { ...existing } : { tag: 'warp', protocol: 'wireguard' };
+  const prevSettings =
+    base.settings && typeof base.settings === 'object'
+      ? { ...(base.settings as Record<string, unknown>) }
+      : {};
+  const prevPeers = Array.isArray(prevSettings.peers)
+    ? [...(prevSettings.peers as Record<string, unknown>[])]
+    : [];
+  const prevFirstPeer =
+    prevPeers[0] && typeof prevPeers[0] === 'object'
+      ? { ...(prevPeers[0] as Record<string, unknown>) }
+      : {};
+  prevFirstPeer.publicKey = peer.public_key;
+  prevFirstPeer.endpoint = peer.endpoint?.host;
+  prevPeers[0] = prevFirstPeer;
+  prevSettings.secretKey = data?.private_key;
+  prevSettings.address = addressesFor(cfg.interface?.addresses || {});
+  prevSettings.reserved = reservedFor(cfg.client_id ?? data?.client_id);
+  prevSettings.peers = prevPeers;
+  base.settings = prevSettings;
+  base.tag = 'warp';
+  base.protocol = 'wireguard';
+  return base;
+}
+
 export default function WarpModal({
   open,
   templateSettings,
@@ -194,12 +228,15 @@ export default function WarpModal({
         const parsed = JSON.parse(msg.obj);
         setWarpData(parsed.data);
         setWarpConfig(parsed.config);
-        const built = collectConfig(parsed.data, parsed.config);
-        // The backend already persisted the new keys into the saved Xray
-        // template; keep the in-memory editor in sync so a later template
-        // save doesn't revert them to the old keys.
-        if (built && warpOutboundIndex >= 0) {
-          onResetOutbound({ index: warpOutboundIndex, outbound: built });
+        collectConfig(parsed.data, parsed.config);
+        if (warpOutboundIndex >= 0) {
+          const existing = templateSettings?.outbounds?.[warpOutboundIndex] as
+            | Record<string, unknown>
+            | undefined;
+          const merged = mergeWarpRotation(existing, parsed.data, parsed.config);
+          if (merged) {
+            onResetOutbound({ index: warpOutboundIndex, outbound: merged });
+          }
         }
         messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
       }

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -27,6 +27,7 @@ export const AllSettingSchema = z.object({
   tgRunTime: z.string().optional(),
   tgBotBackup: z.boolean().optional(),
   tgCpu: z.number().int().min(0).max(100).optional(),
+  outboundDownThreshold: z.number().int().min(1).max(100).optional(),
   tgLang: z.string().optional(),
   twoFactorEnable: z.boolean().optional(),
   twoFactorToken: z.string().optional(),

+ 100 - 0
frontend/src/test/balancer-loopback.test.ts

@@ -24,6 +24,7 @@ interface RuleEntry {
   type?: string;
   inboundTag?: string[];
   balancerTag?: string;
+  domain?: string[];
 }
 interface BalancerEntry {
   tag?: string;
@@ -140,6 +141,105 @@ describe('ensureBalancerLoopback dedup', () => {
   });
 });
 
+describe('ensureBalancerLoopback rule ordering', () => {
+  function loopbackRuleIndex(settings: XraySettingsValue, lbTag: string): number {
+    return ruleEntries(settings).findIndex(
+      (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
+    );
+  }
+  function generalRuleIndex(settings: XraySettingsValue): number {
+    return ruleEntries(settings).findIndex(
+      (r) => !Array.isArray(r.inboundTag) || r.inboundTag.length === 0,
+    );
+  }
+
+  it('inserts a new loopback rule ahead of a general (no inboundTag) rule', () => {
+    const settings = makeSettings({
+      rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
+      balancers: [{ tag: 'parent', selector: [] }],
+    });
+
+    ensureBalancerLoopback(settings, 'target');
+
+    expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(
+      generalRuleIndex(settings),
+    );
+  });
+
+  it('repositions an existing loopback rule that landed after a general rule', () => {
+    const settings = makeSettings({
+      rules: [
+        { type: 'field', domain: ['example.com'], balancerTag: 'parent' },
+        { type: 'field', inboundTag: ['_bl_target'], balancerTag: 'stale' },
+      ],
+      balancers: [{ tag: 'parent', selector: [] }],
+    });
+
+    ensureBalancerLoopback(settings, 'target');
+
+    const lbIdx = loopbackRuleIndex(settings, '_bl_target');
+    expect(lbIdx).toBeLessThan(generalRuleIndex(settings));
+    expect(ruleEntries(settings)[lbIdx].balancerTag).toBe('target');
+  });
+
+  it('leaves inboundTag-restricted rules in place and slots loopback ahead of general rules only', () => {
+    const settings = makeSettings({
+      rules: [
+        { type: 'field', inboundTag: ['api'], balancerTag: 'stats' },
+        { type: 'field', domain: ['example.com'], balancerTag: 'parent' },
+      ],
+      balancers: [{ tag: 'parent', selector: [] }],
+    });
+
+    ensureBalancerLoopback(settings, 'target');
+
+    const entries = ruleEntries(settings);
+    expect(entries[0].inboundTag).toEqual(['api']);
+    const lbIdx = loopbackRuleIndex(settings, '_bl_target');
+    const generalIdx = generalRuleIndex(settings);
+    expect(lbIdx).toBeLessThan(generalIdx);
+    expect(lbIdx).toBeGreaterThan(0);
+  });
+
+  it('ensureMissingBalancerLoopbacks repositions every mis-ordered loopback rule', () => {
+    const settings = makeSettings({
+      rules: [
+        { type: 'field', domain: ['example.com'], balancerTag: 'B1' },
+        { type: 'field', inboundTag: ['_bl_B2'], balancerTag: 'B2' },
+      ],
+      balancers: [
+        { tag: 'B1', selector: [], fallbackTag: '_bl_B2' },
+        { tag: 'B2', selector: [] },
+      ],
+    });
+
+    ensureMissingBalancerLoopbacks(settings);
+
+    expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(
+      generalRuleIndex(settings),
+    );
+  });
+
+  it('keeps the loopback rule ahead of the general rule after a second ensureBalancerLoopback call', () => {
+    const settings = makeSettings({
+      rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
+      balancers: [{ tag: 'parent', selector: [] }],
+    });
+
+    ensureBalancerLoopback(settings, 'target');
+    ensureBalancerLoopback(settings, 'target');
+
+    expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(
+      generalRuleIndex(settings),
+    );
+    expect(
+      ruleEntries(settings).filter(
+        (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_target'),
+      ),
+    ).toHaveLength(1);
+  });
+});
+
 describe('detectBalancerCycles', () => {
   const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [
     {

+ 70 - 0
frontend/src/test/client-form-flow.test.tsx

@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, fireEvent, waitFor, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import { ThemeProvider } from '@/hooks/useTheme';
+import ClientFormModal from '@/pages/clients/ClientFormModal';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+function makeQC() {
+  return new QueryClient({ defaultOptions: { queries: { retry: false } } });
+}
+
+const REALITY_INBOUND = {
+  id: 4,
+  port: 10443,
+  protocol: 'vless',
+  tag: 'in-10443-tcp',
+  tlsFlowCapable: true,
+  enable: true,
+} as unknown as InboundOption;
+
+const CLIENT = {
+  email: 'testuser',
+  flow: 'xtls-rprx-vision',
+  uuid: '11111111-1111-1111-1111-111111111111',
+  subId: 'subid123',
+  enable: true,
+} as unknown as ClientRecord;
+
+function savedFlow(save: ReturnType<typeof vi.fn>): unknown {
+  return (save.mock.calls[0][0] as Record<string, unknown>).flow;
+}
+
+describe('ClientFormModal — Vision flow preservation', () => {
+  it('keeps xtls-rprx-vision with a stable Reality inbound', async () => {
+    const qc = makeQC();
+    const save = vi.fn().mockResolvedValue({ success: true });
+    render(
+      <ThemeProvider>
+        <QueryClientProvider client={qc}>
+          <ClientFormModal open mode="edit" client={CLIENT} inbounds={[REALITY_INBOUND]} attachedIds={[4]} save={save} onOpenChange={() => {}} />
+        </QueryClientProvider>
+      </ThemeProvider>,
+    );
+    fireEvent.click(await screen.findByRole('button', { name: /save/i }));
+    await waitFor(() => expect(save).toHaveBeenCalled());
+    expect(savedFlow(save)).toBe('xtls-rprx-vision');
+  });
+
+  it('does not drop a selected Vision flow while the inbound options momentarily reload', async () => {
+    const qc = makeQC();
+    const save = vi.fn().mockResolvedValue({ success: true });
+    const tree = (inbounds: InboundOption[]) => (
+      <ThemeProvider>
+        <QueryClientProvider client={qc}>
+          <ClientFormModal open mode="edit" client={CLIENT} inbounds={inbounds} attachedIds={[4]} save={save} onOpenChange={() => {}} />
+        </QueryClientProvider>
+      </ThemeProvider>
+    );
+    // Options loaded -> reloading (inboundOptionsQuery.data ?? [] === []) -> loaded again.
+    const { rerender } = render(tree([REALITY_INBOUND]));
+    await screen.findByRole('button', { name: /save/i });
+    rerender(tree([]));
+    rerender(tree([REALITY_INBOUND]));
+
+    fireEvent.click(await screen.findByRole('button', { name: /save/i }));
+    await waitFor(() => expect(save).toHaveBeenCalled());
+    expect(savedFlow(save)).toBe('xtls-rprx-vision');
+  });
+});

+ 37 - 0
frontend/src/test/inbound-link.test.ts

@@ -308,6 +308,43 @@ describe('genWireguardLink + genWireguardConfig', () => {
   }
 });
 
+describe('genWireguardLink + genWireguardConfig multi allowedIPs', () => {
+  const settings = {
+    secretKey: '',
+    mtu: 1280,
+    dns: '',
+    peers: [
+      {
+        privateKey: 'cLI',
+        allowedIPs: ['10.0.0.2/32', 'fd00::2/128'],
+      },
+    ],
+  } as unknown as WireguardInboundSettings;
+
+  it('joins every allowed IP into the share-link address param', () => {
+    const link = genWireguardLink({
+      settings,
+      address: 'wg.example.test',
+      port: 51820,
+      remark: 'dual-stack',
+      peerIndex: 0,
+    });
+    const u = new URL(link);
+    expect(u.searchParams.get('address')).toBe('10.0.0.2/32,fd00::2/128');
+  });
+
+  it('joins every allowed IP into the .conf Address line', () => {
+    const config = genWireguardConfig({
+      settings,
+      address: 'wg.example.test',
+      port: 51820,
+      remark: 'dual-stack',
+      peerIndex: 0,
+    });
+    expect(config).toContain('Address = 10.0.0.2/32, fd00::2/128\n');
+  });
+});
+
 describe('resolveAddr precedence', () => {
   const baseInbound = {
     listen: '',

+ 99 - 0
frontend/src/test/warp-change-ip.test.ts

@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+
+import { mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
+
+const clientId = btoa(String.fromCharCode(1, 2, 3));
+
+function rotatedConfig(overrides: { public_key?: string; host?: string; v4?: string; v6?: string } = {}) {
+  return {
+    config: {
+      client_id: clientId,
+      interface: { addresses: { v4: overrides.v4 ?? '172.16.0.2', v6: overrides.v6 ?? '2606:4700::2' } },
+      peers: [{ public_key: overrides.public_key ?? 'newPub', endpoint: { host: overrides.host ?? 'engage.cloudflareclient.com:2408' } }],
+    },
+  };
+}
+
+describe('mergeWarpRotation', () => {
+  it('patches only rotated fields and preserves user customizations', () => {
+    const existing = {
+      tag: 'warp',
+      protocol: 'wireguard',
+      settings: {
+        mtu: 1280,
+        secretKey: 'oldSecret',
+        address: ['172.16.0.2/32'],
+        reserved: [9, 9, 9],
+        domainStrategy: 'ForceIPv4',
+        noKernelTun: false,
+        peers: [
+          {
+            publicKey: 'oldPub',
+            endpoint: 'engage.cloudflareclient.com:2408',
+            keepAlive: 25,
+            allowedIPs: ['10.0.0.0/24'],
+            preSharedKey: 'psk',
+          },
+          { publicKey: 'extraPeer', endpoint: 'extra.test:51820' },
+        ],
+      },
+    };
+
+    const merged = mergeWarpRotation(
+      existing,
+      { private_key: 'newSecret' },
+      rotatedConfig({ public_key: 'newPub', host: 'engage.cloudflareclient.com:2408', v4: '172.16.0.9' }),
+    );
+
+    expect(merged).not.toBeNull();
+    const settings = (merged as { settings: Record<string, unknown> }).settings;
+    expect(settings.secretKey).toBe('newSecret');
+    expect(settings.address).toEqual(['172.16.0.9/32', '2606:4700::2/128']);
+    expect(settings.reserved).toEqual([1, 2, 3]);
+    const peers = settings.peers as Array<Record<string, unknown>>;
+    expect(peers[0].publicKey).toBe('newPub');
+    expect(peers[0].endpoint).toBe('engage.cloudflareclient.com:2408');
+    expect(peers[0].keepAlive).toBe(25);
+    expect(peers[0].allowedIPs).toEqual(['10.0.0.0/24']);
+    expect(peers[0].preSharedKey).toBe('psk');
+    expect(peers[1]).toEqual({ publicKey: 'extraPeer', endpoint: 'extra.test:51820' });
+    expect(settings.mtu).toBe(1280);
+    expect(settings.domainStrategy).toBe('ForceIPv4');
+    expect(settings.noKernelTun).toBe(false);
+  });
+
+  it('does not mutate the existing outbound object', () => {
+    const existing = {
+      tag: 'warp',
+      protocol: 'wireguard',
+      settings: {
+        secretKey: 'oldSecret',
+        address: ['172.16.0.2/32'],
+        reserved: [9, 9, 9],
+        peers: [{ publicKey: 'oldPub', endpoint: 'old:1', keepAlive: 25 }],
+      },
+    };
+    const snapshot = JSON.parse(JSON.stringify(existing));
+
+    mergeWarpRotation(existing, { private_key: 'newSecret' }, rotatedConfig());
+
+    expect(existing).toEqual(snapshot);
+  });
+
+  it('returns null when the rotation response has no peer', () => {
+    expect(mergeWarpRotation(undefined, null, { config: { peers: [] } })).toBeNull();
+    expect(mergeWarpRotation(undefined, null, null)).toBeNull();
+  });
+
+  it('seeds a default warp outbound when none existed yet', () => {
+    const merged = mergeWarpRotation(undefined, { private_key: 'newSecret' }, rotatedConfig());
+    expect(merged).not.toBeNull();
+    expect((merged as Record<string, unknown>).tag).toBe('warp');
+    expect((merged as Record<string, unknown>).protocol).toBe('wireguard');
+    const settings = (merged as { settings: Record<string, unknown> }).settings;
+    expect(settings.secretKey).toBe('newSecret');
+    expect(settings.peers).toEqual([
+      { publicKey: 'newPub', endpoint: 'engage.cloudflareclient.com:2408' },
+    ]);
+  });
+});

+ 29 - 10
internal/sub/controller.go

@@ -6,12 +6,12 @@ import (
 	"encoding/json"
 	"fmt"
 	"html/template"
+	"io/fs"
 	"net/http"
 	"net/url"
 	"os"
 	"path/filepath"
 	"regexp"
-	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -340,11 +340,11 @@ func (a *SUBController) subs(c *gin.Context) {
 		logSubscriptionRoute(userAgent, "html")
 		return
 	}
-	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c) {
+	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
 		logSubscriptionRoute(userAgent, "clash")
 		return
 	}
-	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8") {
+	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
 		logSubscriptionRoute(userAgent, "json")
 		return
 	}
@@ -446,7 +446,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
 	if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
 		body = diskBody
 	} else {
-		readBody, err := distFS.ReadFile("dist/subpage.html")
+		readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
 		if err != nil {
 			c.String(http.StatusInternalServerError, "missing embedded subpage")
 			return
@@ -596,6 +596,12 @@ func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, er
 
 // subJsons handles HTTP requests for JSON subscription configurations.
 func (a *SUBController) subJsons(c *gin.Context) {
+	if strings.EqualFold(c.Query("view"), "raw") {
+		if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
+			writeSubError(c, nil)
+		}
+		return
+	}
 	if a.maybeServeSubPage(c) {
 		return
 	}
@@ -603,12 +609,12 @@ func (a *SUBController) subJsons(c *gin.Context) {
 }
 
 func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
-	if !a.serveJsonBody(c, alwaysReturnArray, contentType) {
+	if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
 		writeSubError(c, nil)
 	}
 }
 
-func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string) bool {
+func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
@@ -624,21 +630,30 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
 		profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 	}
 	a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
+	if rawDownload {
+		c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
+	}
 
 	c.Data(200, contentType, []byte(jsonSub))
 	return true
 }
 
 func (a *SUBController) subClashs(c *gin.Context) {
+	if strings.EqualFold(c.Query("view"), "raw") {
+		if !a.serveClashBody(c, true) {
+			writeSubError(c, nil)
+		}
+		return
+	}
 	if a.maybeServeSubPage(c) {
 		return
 	}
-	if !a.serveClashBody(c) {
+	if !a.serveClashBody(c, false) {
 		writeSubError(c, nil)
 	}
 }
 
-func (a *SUBController) serveClashBody(c *gin.Context) bool {
+func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	clashSub, header, err := a.subClashService.GetClash(subId, host)
@@ -654,7 +669,9 @@ func (a *SUBController) serveClashBody(c *gin.Context) bool {
 		profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 	}
 	a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
-	if a.subTitle != "" {
+	if rawDownload {
+		c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
+	} else if a.subTitle != "" {
 		// Clash clients commonly use Content-Disposition to choose the imported profile name.
 		c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
 	}
@@ -693,7 +710,9 @@ func (a *SUBController) ApplyCommonHeaders(
 	}
 
 	// Advanced (Happ)
-	c.Writer.Header().Set("Routing-Enable", strconv.FormatBool(profileEnableRouting))
+	if profileEnableRouting {
+		c.Writer.Header().Set("Routing-Enable", "true")
+	}
 	if profileRoutingRules != "" {
 		c.Writer.Header().Set("Routing", profileRoutingRules)
 	}

+ 62 - 0
internal/sub/controller_test.go

@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+	"testing/fstest"
 	"time"
 
 	"github.com/gin-gonic/gin"
@@ -19,6 +20,10 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
+var testDistFS = fstest.MapFS{
+	"dist/subpage.html": {Data: []byte(`<!doctype html><html><head></head><body><div id="root"></div></body></html>`)},
+}
+
 // newTestSUBController builds a controller with just the bits loadSubTemplate
 // needs, so the template tests don't require a database.
 func newTestSUBController() *SUBController {
@@ -391,6 +396,63 @@ func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
 	})
 }
 
+func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
+	gin.SetMode(gin.TestMode)
+	oldDistFS := distFS
+	distFS = testDistFS
+	t.Cleanup(func() { distFS = oldDistFS })
+	router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+
+	tests := []struct {
+		name         string
+		path         string
+		contentType  string
+		disposition  string
+		bodyContains string
+	}{
+		{name: "JSON", path: "/json/s1?view=raw", contentType: "application/json; charset=utf-8", disposition: `attachment; filename="subscription.json"`, bodyContains: "outbounds"},
+		{name: "Clash", path: "/clash/s1?view=raw", contentType: "application/yaml; charset=utf-8", disposition: `attachment; filename="subscription.yaml"`, bodyContains: "proxies:"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tt.path, nil)
+			req.Header.Set("Accept", "text/html")
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, req)
+
+			if resp.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+			}
+			if got := resp.Header().Get("Content-Type"); got != tt.contentType {
+				t.Fatalf("Content-Type = %q, want %q", got, tt.contentType)
+			}
+			if got := resp.Header().Get("Content-Disposition"); got != tt.disposition {
+				t.Fatalf("Content-Disposition = %q, want %q", got, tt.disposition)
+			}
+			if !strings.Contains(resp.Body.String(), tt.bodyContains) {
+				t.Fatalf("raw body does not contain %q: %s", tt.bodyContains, resp.Body.String())
+			}
+		})
+	}
+
+	for _, path := range []string{"/json/s1", "/clash/s1"} {
+		t.Run(path+" browser page", func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
+			req.Header.Set("Accept", "text/html")
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, req)
+			if resp.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+			}
+			if got := resp.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
+				t.Fatalf("Content-Type = %q, want HTML", got)
+			}
+		})
+	}
+}
+
 func writeFile(t *testing.T, path, content string) {
 	t.Helper()
 	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {

+ 4 - 4
internal/sub/dist.go

@@ -1,16 +1,16 @@
 package sub
 
-import "embed"
+import "io/fs"
 
 // distFS holds the Vite-built frontend filesystem, injected from main at
 // startup. The `web` package owns the //go:embed directive (because dist/
 // is at internal/web/dist/), and hands the FS over via SetDistFS so the sub package
 // doesn't import web — that would create an import cycle once any
 // internal/web/controller handler reuses sub's link-building service.
-var distFS embed.FS
+var distFS fs.FS
 
 // SetDistFS installs the embedded frontend filesystem the sub server uses
 // for its info page assets. Must be called before NewServer().Start().
-func SetDistFS(fs embed.FS) {
-	distFS = fs
+func SetDistFS(frontendFS fs.FS) {
+	distFS = frontendFS
 }

+ 45 - 4
internal/sub/external_config.go

@@ -74,18 +74,59 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
 }
 
 // expandEntry turns one entry into the concrete share links it contributes. A
-// "subscription" entry is fetched (cached) and its links are kept with their own
-// names; a "link" entry yields the single link with the row's remark.
+// "subscription" entry is fetched (cached) and its links keep their own names
+// (URL #fragment / vmess ps). A "link" entry uses the row remark when set,
+// otherwise the link's original name — never blank, so Clash/JSON do not fall
+// back to the client email.
 func expandEntry(e externalLinkEntry) []expandedLink {
 	if e.Kind == model.ExternalLinkKindSubscription {
 		links := fetchSubscriptionLinks(e.Value)
 		out := make([]expandedLink, 0, len(links))
 		for _, l := range links {
-			out = append(out, expandedLink{Link: l, Name: ""})
+			out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)})
 		}
 		return out
 	}
-	return []expandedLink{{Link: e.Value, Name: e.Remark}}
+	name := strings.TrimSpace(e.Remark)
+	if name == "" {
+		name = linkDisplayName(e.Value)
+	}
+	return []expandedLink{{Link: e.Value, Name: name}}
+}
+
+// linkDisplayName extracts the human-readable name already carried by a share
+// link: vmess JSON `ps`, or the URL #fragment for every other scheme.
+func linkDisplayName(rawLink string) string {
+	rawLink = strings.TrimSpace(rawLink)
+	if rawLink == "" {
+		return ""
+	}
+	if strings.HasPrefix(rawLink, "vmess://") {
+		b64 := strings.TrimPrefix(rawLink, "vmess://")
+		raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
+		if err != nil {
+			raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
+		}
+		if err != nil {
+			return ""
+		}
+		var j map[string]any
+		if err := json.Unmarshal(raw, &j); err != nil {
+			return ""
+		}
+		if ps, ok := j["ps"].(string); ok {
+			return strings.TrimSpace(ps)
+		}
+		return ""
+	}
+	if i := strings.IndexByte(rawLink, '#'); i >= 0 && i+1 < len(rawLink) {
+		frag := rawLink[i+1:]
+		if decoded, err := url.PathUnescape(frag); err == nil {
+			return strings.TrimSpace(decoded)
+		}
+		return strings.TrimSpace(frag)
+	}
+	return ""
 }
 
 // applyRemarkToLink rewrites a share link's display name to remark (when set),

+ 29 - 0
internal/sub/external_config_test.go

@@ -98,6 +98,35 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
 	}
 }
 
+func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) {
+	got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:8443#orig", Remark: ""})
+	if len(got) != 1 || got[0].Name != "orig" {
+		t.Fatalf("expandEntry empty remark = %#v, want Name=orig", got)
+	}
+}
+
+func TestLinkDisplayName(t *testing.T) {
+	payload := map[string]any{"v": "2", "ps": "NL-Node", "add": "1.2.3.4", "port": "443", "id": "uuid"}
+	b, _ := json.Marshal(payload)
+	vmess := "vmess://" + base64.StdEncoding.EncodeToString(b)
+
+	cases := []struct {
+		link string
+		want string
+	}{
+		{"vless://[email protected]:443#one", "one"},
+		{"trojan://[email protected]:8443#" + url.PathEscape("DE Node"), "DE Node"},
+		{vmess, "NL-Node"},
+		{"ss://def", ""},
+		{"", ""},
+	}
+	for _, c := range cases {
+		if got := linkDisplayName(c.link); got != c.want {
+			t.Errorf("linkDisplayName(%q) = %q, want %q", c.link, got, c.want)
+		}
+	}
+}
+
 func TestClashProxyFromExternalTrojanReality(t *testing.T) {
 	link := "trojan://[email protected]:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
 	svc := NewSubClashService(false, "", NewSubService(""))

+ 5 - 6
internal/sub/service.go

@@ -644,8 +644,8 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
 			params["publickey"] = pub
 		}
 	}
-	if len(client.AllowedIPs) > 0 && client.AllowedIPs[0] != "" {
-		params["address"] = client.AllowedIPs[0]
+	if joined := strings.Join(client.AllowedIPs, ","); joined != "" {
+		params["address"] = joined
 	}
 	if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
 		params["mtu"] = strconv.Itoa(int(mtu))
@@ -1025,11 +1025,10 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		}
 	}
 
-	// salamander obfs (Hysteria2). The panel-side link generator already
-	// emits these; keep the subscription output in sync so a client has
-	// the obfs password to match the server.
+	// salamander obfs (Hysteria2). Emit only the standard URI fields;
+	// the non-standard fm=<json> finalmask dump breaks mihomo and other
+	// Hysteria2 clients that reject unknown query params.
 	if finalmask, ok := stream["finalmask"].(map[string]any); ok {
-		applyFinalMaskParams(finalmask, params)
 		if udpMasks, ok := finalmask["udp"].([]any); ok {
 			for _, m := range udpMasks {
 				mask, _ := m.(map[string]any)

+ 29 - 0
internal/sub/service_test.go

@@ -1110,3 +1110,32 @@ func TestHysteriaHopPorts(t *testing.T) {
 		})
 	}
 }
+
+func TestGenHysteriaLinkOmitsFinalMaskQueryParam(t *testing.T) {
+	stream := `{
+		"security":"tls",
+		"tlsSettings":{"serverName":"hy.sni","alpn":["h3"],"settings":{"fingerprint":"chrome"}},
+		"finalmask":{"udp":[{"type":"salamander","settings":{"password":"obfs-secret"}}]}
+	}`
+	in := &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.Hysteria,
+		Remark:         "hy2",
+		Settings:       `{"version":2,"clients":[{"auth":"hyauth","email":"user"}]}`,
+		StreamSettings: stream,
+	}
+	got := (&SubService{}).genHysteriaLink(in, "user")
+	if got == "" {
+		t.Fatal("expected hysteria2 link")
+	}
+	if strings.Contains(got, "fm=") {
+		t.Fatalf("hysteria2 subscription URI must not include non-standard fm param: %s", got)
+	}
+	if !strings.Contains(got, "obfs=salamander") {
+		t.Fatalf("missing standard obfs=salamander: %s", got)
+	}
+	if !strings.Contains(got, "obfs-password=obfs-secret") {
+		t.Fatalf("missing standard obfs-password: %s", got)
+	}
+}

+ 30 - 0
internal/sub/service_wireguard_test.go

@@ -55,6 +55,36 @@ func TestGenWireguardLinkFields(t *testing.T) {
 	}
 }
 
+func TestGenWireguardLinkMultiAllowedIPs(t *testing.T) {
+	serverPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("keypair: %v", err)
+	}
+	clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.7",
+		Port:     51820,
+		Protocol: model.WireGuard,
+		Remark:   "wg-sub",
+		Settings: `{"secretKey":"` + serverPriv + `","clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.0.0.2/32","fd00::2/128"]}]}`,
+	}
+
+	s := &SubService{}
+	link := s.genWireguardLink(inbound, "user")
+
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("link does not parse: %v\n got: %s", err, link)
+	}
+	if got, want := u.Query().Get("address"), "10.0.0.2/32,fd00::2/128"; got != want {
+		t.Fatalf("address = %q, want %q (all allowed IPs joined, not just the first)", got, want)
+	}
+}
+
 func TestGenWireguardLinkWrongProtocol(t *testing.T) {
 	s := &SubService{}
 	vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}

+ 2 - 0
internal/web/entity/entity.go

@@ -59,6 +59,8 @@ type AllSetting struct {
 	SmtpCpu            int    `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
 	SmtpMemory         int    `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
 
+	OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
+
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`
 	TwoFactorEnable bool   `json:"twoFactorEnable" form:"twoFactorEnable"`
 	TwoFactorToken  string `json:"twoFactorToken" form:"twoFactorToken"`

+ 29 - 0
internal/web/service/client_crud.go

@@ -433,6 +433,35 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		}
 	}
 
+	if len(inboundIds) == 0 {
+		merged := *existing
+		applyClientRecordMerge(&merged, updated.ToRecord())
+		if err := database.GetDB().Model(&model.ClientRecord{}).
+			Where("id = ?", id).
+			Updates(map[string]any{
+				"sub_id":            merged.SubID,
+				"uuid":              merged.UUID,
+				"password":          merged.Password,
+				"auth":              merged.Auth,
+				"secret":            merged.Secret,
+				"flow":              merged.Flow,
+				"security":          merged.Security,
+				"wg_private_key":    merged.PrivateKey,
+				"wg_public_key":     merged.PublicKey,
+				"wg_allowed_ips":    merged.AllowedIPs,
+				"wg_pre_shared_key": merged.PreSharedKey,
+				"wg_keep_alive":     merged.KeepAlive,
+				"limit_ip":          merged.LimitIP,
+				"total_gb":          merged.TotalGB,
+				"expiry_time":       merged.ExpiryTime,
+				"tg_id":             merged.TgID,
+				"comment":           merged.Comment,
+				"reset":             merged.Reset,
+			}).Error; err != nil {
+			return needRestart, err
+		}
+	}
+
 	reverseStr := ""
 	if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
 		if b, mErr := json.Marshal(updated.Reverse); mErr == nil {

+ 3 - 1
internal/web/service/client_inbound_apply.go

@@ -807,7 +807,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		}
 		// Rename the client record in the same transaction as the settings JSON
 		// so no concurrent SyncInbound can see one renamed without the other.
-		if len(oldEmail) > 0 && !strings.EqualFold(oldEmail, clients[0].Email) {
+		// Byte-level compare (not EqualFold): case-only edits must rename too,
+		// otherwise SyncInbound's case-sensitive lookup creates a duplicate row.
+		if len(oldEmail) > 0 && oldEmail != clients[0].Email {
 			var renameTaken int64
 			if e := tx.Model(&model.ClientRecord{}).Where("email = ?", clients[0].Email).Count(&renameTaken).Error; e != nil {
 				return e

+ 60 - 47
internal/web/service/client_link.go

@@ -9,6 +9,65 @@ import (
 	"gorm.io/gorm"
 )
 
+// applyClientRecordMerge merges incoming client-record fields onto row using the
+// same rules everywhere a client record is persisted: scalar quota / lifecycle /
+// subscription fields are applied unconditionally (so clearing them takes
+// effect), while credentials and identifiers are only overwritten when the
+// incoming value is non-empty (so a partial update preserves the stored UUID /
+// password / keys). CreatedAt keeps the earliest known value. Email, UpdatedAt,
+// and the Id primary key are intentionally not touched here — callers handle
+// those separately. Shared by SyncInbound (per-inbound persistence) and Update
+// (the no-attached-inbound fallback) so the two paths cannot diverge.
+func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecord) {
+	if incoming.UUID != "" {
+		row.UUID = incoming.UUID
+	}
+	if incoming.Password != "" {
+		row.Password = incoming.Password
+	}
+	if incoming.Auth != "" {
+		row.Auth = incoming.Auth
+	}
+	if incoming.Secret != "" {
+		row.Secret = incoming.Secret
+	}
+	if incoming.AdTag != "" {
+		row.AdTag = incoming.AdTag
+	}
+	row.Flow = incoming.Flow
+	if incoming.Security != "" {
+		row.Security = incoming.Security
+	}
+	if incoming.Reverse != "" {
+		row.Reverse = incoming.Reverse
+	}
+	if incoming.PrivateKey != "" {
+		row.PrivateKey = incoming.PrivateKey
+	}
+	if incoming.PublicKey != "" {
+		row.PublicKey = incoming.PublicKey
+	}
+	if incoming.AllowedIPs != "" {
+		row.AllowedIPs = incoming.AllowedIPs
+	}
+	row.PreSharedKey = incoming.PreSharedKey
+	row.KeepAlive = incoming.KeepAlive
+	row.SubID = incoming.SubID
+	row.LimitIP = incoming.LimitIP
+	row.TotalGB = incoming.TotalGB
+	row.ExpiryTime = incoming.ExpiryTime
+	row.Enable = incoming.Enable
+	row.TgID = incoming.TgID
+	if incoming.Group != "" {
+		row.Group = incoming.Group
+	}
+	row.Comment = incoming.Comment
+	row.Reset = incoming.Reset
+	if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
+		row.CreatedAt = incoming.CreatedAt
+	}
+}
+
 func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
 	if tx == nil {
 		tx = database.GetDB()
@@ -66,53 +125,7 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 		}
 
 		before := *row
-		if incoming.UUID != "" {
-			row.UUID = incoming.UUID
-		}
-		if incoming.Password != "" {
-			row.Password = incoming.Password
-		}
-		if incoming.Auth != "" {
-			row.Auth = incoming.Auth
-		}
-		if incoming.Secret != "" {
-			row.Secret = incoming.Secret
-		}
-		if incoming.AdTag != "" {
-			row.AdTag = incoming.AdTag
-		}
-		row.Flow = incoming.Flow
-		if incoming.Security != "" {
-			row.Security = incoming.Security
-		}
-		if incoming.Reverse != "" {
-			row.Reverse = incoming.Reverse
-		}
-		if incoming.PrivateKey != "" {
-			row.PrivateKey = incoming.PrivateKey
-		}
-		if incoming.PublicKey != "" {
-			row.PublicKey = incoming.PublicKey
-		}
-		if incoming.AllowedIPs != "" {
-			row.AllowedIPs = incoming.AllowedIPs
-		}
-		row.PreSharedKey = incoming.PreSharedKey
-		row.KeepAlive = incoming.KeepAlive
-		row.SubID = incoming.SubID
-		row.LimitIP = incoming.LimitIP
-		row.TotalGB = incoming.TotalGB
-		row.ExpiryTime = incoming.ExpiryTime
-		row.Enable = incoming.Enable
-		row.TgID = incoming.TgID
-		if incoming.Group != "" {
-			row.Group = incoming.Group
-		}
-		row.Comment = incoming.Comment
-		row.Reset = incoming.Reset
-		if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
-			row.CreatedAt = incoming.CreatedAt
-		}
+		applyClientRecordMerge(row, incoming)
 		preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
 		row.UpdatedAt = preservedUpdatedAt
 

+ 177 - 0
internal/web/service/client_stat_reuse_test.go

@@ -0,0 +1,177 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse covers #5958:
+// deleting a client's only inbound leaves its clients/client_traffics rows in
+// place (matching ClientService.Detach, which does the same on purpose so a
+// later Attach can resume a client with its accumulated traffic intact). If
+// that same email is instead reused for a freshly (re)created client via
+// ClientService.Create, the new enable/expiry/reset/total must win over
+// whatever the orphaned row still holds instead of being silently ignored by
+// AddClientStat's OnConflict.
+func TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const email = "[email protected]"
+	const subID = "sub-reused"
+
+	ibA := mkInbound(t, 22001, model.VLESS, `{"clients":[]}`)
+
+	// Create starts every client enabled (it forces Enable=true), so the
+	// depleted/disabled shape a client has by the time it's naturally deleted
+	// is reached the same way production reaches it: a follow-up Update, not
+	// the initial Create.
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client: model.Client{
+			Email: email, SubID: subID, Enable: true,
+			TotalGB: 0, ExpiryTime: 1000, Reset: 0,
+		},
+		InboundIds: []int{ibA.Id},
+	}); err != nil {
+		t.Fatalf("initial Create: %v", err)
+	}
+	rec0 := lookupClientRecord(t, email)
+	if _, err := svc.Update(inboundSvc, rec0.Id, model.Client{
+		Email: email, SubID: subID, Enable: false,
+		TotalGB: 0, ExpiryTime: 1000, Reset: 0,
+	}); err != nil {
+		t.Fatalf("Update to disabled: %v", err)
+	}
+
+	db := database.GetDB()
+	var before xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&before).Error; err != nil {
+		t.Fatalf("lookup client_traffics before delete: %v", err)
+	}
+	if before.Enable || before.Reset != 0 || before.Total != 0 {
+		t.Fatalf("unexpected initial client_traffics row: %+v", before)
+	}
+
+	// Delete the client's only inbound. This must NOT delete the client or its
+	// traffic row (that's the documented, intentional Detach-parity behavior) —
+	// it only orphans them.
+	if _, err := inboundSvc.DelInbound(ibA.Id); err != nil {
+		t.Fatalf("DelInbound: %v", err)
+	}
+
+	rec := lookupClientRecord(t, email)
+	ids, err := svc.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("GetInboundIdsForRecord: %v", err)
+	}
+	if len(ids) != 0 {
+		t.Fatalf("client should be fully detached after its only inbound was deleted, still attached to: %v", ids)
+	}
+	var stillThere xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&stillThere).Error; err != nil {
+		t.Fatalf("client_traffics row should survive inbound deletion (Detach parity), lookup failed: %v", err)
+	}
+
+	// Reuse the same email + subId (the only way ClientService.Create allows
+	// re-adding under an already-used email) on a freshly created inbound, with
+	// deliberately different, "fresh" settings.
+	ibB := mkInbound(t, 22002, model.VLESS, `{"clients":[]}`)
+	const wantExpiry = int64(9999999999000)
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client: model.Client{
+			Email: email, SubID: subID, Enable: true,
+			TotalGB: 10 << 30, ExpiryTime: wantExpiry, Reset: 5,
+		},
+		InboundIds: []int{ibB.Id},
+	}); err != nil {
+		t.Fatalf("reuse Create: %v", err)
+	}
+
+	var after xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&after).Error; err != nil {
+		t.Fatalf("lookup client_traffics after reuse: %v", err)
+	}
+	if !after.Enable {
+		t.Errorf("client_traffics.enable still stale (false) after reuse, want true")
+	}
+	if after.Reset != 5 {
+		t.Errorf("client_traffics.reset = %d, want 5 (stale value from before delete was 0)", after.Reset)
+	}
+	if after.Total != 10<<30 {
+		t.Errorf("client_traffics.total = %d, want %d", after.Total, int64(10<<30))
+	}
+	if after.ExpiryTime != wantExpiry {
+		t.Errorf("client_traffics.expiry_time = %d, want %d", after.ExpiryTime, wantExpiry)
+	}
+	if after.InboundId != ibB.Id {
+		t.Errorf("client_traffics.inbound_id = %d, want refreshed to new inbound %d (was %d)", after.InboundId, ibB.Id, ibA.Id)
+	}
+
+	// up/down are deliberately NOT refreshed by AddClientStat — confirm that
+	// stays true (would matter if the original client had real usage).
+	if after.Up != before.Up || after.Down != before.Down {
+		t.Errorf("up/down should be left untouched by the conflict refresh: before up=%d down=%d, after up=%d down=%d",
+			before.Up, before.Down, after.Up, after.Down)
+	}
+}
+
+// TestAddClientStat_MultiInboundReattachStaysIdempotent guards the legitimate
+// case AddClientStat's OnConflict is also responsible for: a client attached
+// to two inbounds at once shares one client_traffics row, and re-asserting
+// its own current settings for the second inbound must be a no-op in effect,
+// not a data loss. In particular it must not zero out real accumulated
+// traffic just because the client gained a second attachment.
+func TestAddClientStat_MultiInboundReattachStaysIdempotent(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const email = "[email protected]"
+	const subID = "sub-multi"
+
+	ibA := mkInbound(t, 22003, model.VLESS, `{"clients":[]}`)
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client: model.Client{
+			Email: email, SubID: subID, Enable: true,
+			TotalGB: 5 << 30, ExpiryTime: 42, Reset: 3,
+		},
+		InboundIds: []int{ibA.Id},
+	}); err != nil {
+		t.Fatalf("first Create: %v", err)
+	}
+
+	db := database.GetDB()
+	// Simulate real accumulated usage before the second attachment.
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
+		Updates(map[string]any{"up": int64(123), "down": int64(456)}).Error; err != nil {
+		t.Fatalf("seed usage: %v", err)
+	}
+
+	ibB := mkInbound(t, 22004, model.VLESS, `{"clients":[]}`)
+	// Re-adding the same identity to a second inbound: same email/subId/settings,
+	// exactly what the panel does when attaching an existing client elsewhere.
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client: model.Client{
+			Email: email, SubID: subID, Enable: true,
+			TotalGB: 5 << 30, ExpiryTime: 42, Reset: 3,
+		},
+		InboundIds: []int{ibB.Id},
+	}); err != nil {
+		t.Fatalf("second Create (attach to ibB): %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("lookup after second attach: %v", err)
+	}
+	if row.Up != 123 || row.Down != 456 {
+		t.Errorf("accumulated traffic was reset by re-attach: up=%d down=%d, want 123/456", row.Up, row.Down)
+	}
+	if !row.Enable || row.Reset != 3 || row.Total != 5<<30 || row.ExpiryTime != 42 {
+		t.Errorf("config columns changed unexpectedly on idempotent re-assert: %+v", row)
+	}
+}

+ 205 - 0
internal/web/service/client_update_no_inbound_test.go

@@ -0,0 +1,205 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestUpdate_PersistsFields_NoInbound(t *testing.T) {
+	cases := []struct {
+		name     string
+		mutate   func(c *model.Client)
+		readBack func(rec *model.ClientRecord) any
+		want     any
+	}{
+		{
+			name:     "subId",
+			mutate:   func(c *model.Client) { c.SubID = "new-sub-id" },
+			readBack: func(rec *model.ClientRecord) any { return rec.SubID },
+			want:     "new-sub-id",
+		},
+		{
+			name:     "totalGB cleared to zero",
+			mutate:   func(c *model.Client) { c.TotalGB = 0 },
+			readBack: func(rec *model.ClientRecord) any { return rec.TotalGB },
+			want:     int64(0),
+		},
+		{
+			name:     "expiryTime",
+			mutate:   func(c *model.Client) { c.ExpiryTime = 1700000000 },
+			readBack: func(rec *model.ClientRecord) any { return rec.ExpiryTime },
+			want:     int64(1700000000),
+		},
+		{
+			name:     "limitIp",
+			mutate:   func(c *model.Client) { c.LimitIP = 7 },
+			readBack: func(rec *model.ClientRecord) any { return rec.LimitIP },
+			want:     7,
+		},
+		{
+			name:     "tgId",
+			mutate:   func(c *model.Client) { c.TgID = 9876543210 },
+			readBack: func(rec *model.ClientRecord) any { return rec.TgID },
+			want:     int64(9876543210),
+		},
+		{
+			name:     "comment cleared to empty",
+			mutate:   func(c *model.Client) { c.Comment = "" },
+			readBack: func(rec *model.ClientRecord) any { return rec.Comment },
+			want:     "",
+		},
+		{
+			name:     "reset",
+			mutate:   func(c *model.Client) { c.Reset = 30 },
+			readBack: func(rec *model.ClientRecord) any { return rec.Reset },
+			want:     30,
+		},
+		{
+			name:     "flow",
+			mutate:   func(c *model.Client) { c.Flow = "xtls-rprx-vision" },
+			readBack: func(rec *model.ClientRecord) any { return rec.Flow },
+			want:     "xtls-rprx-vision",
+		},
+		{
+			name:     "security",
+			mutate:   func(c *model.Client) { c.Security = "aes-128-gcm" },
+			readBack: func(rec *model.ClientRecord) any { return rec.Security },
+			want:     "aes-128-gcm",
+		},
+		{
+			name:     "uuid rotated",
+			mutate:   func(c *model.Client) { c.ID = "22222222-2222-2222-2222-222222222222" },
+			readBack: func(rec *model.ClientRecord) any { return rec.UUID },
+			want:     "22222222-2222-2222-2222-222222222222",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupBulkDB(t)
+			svc := &ClientService{}
+			inboundSvc := &InboundService{}
+
+			email := "noib-" + strings.ReplaceAll(strings.ToLower(tc.name), " ", "-") + "@x"
+			rec := &model.ClientRecord{
+				Email:      email,
+				UUID:       "11111111-1111-1111-1111-111111111111",
+				SubID:      email,
+				TotalGB:    5,
+				ExpiryTime: 1000,
+				LimitIP:    1,
+				TgID:       1,
+				Comment:    "seeded",
+				Reset:      1,
+				Flow:       "seeded-flow",
+				Security:   "seeded-sec",
+			}
+			if err := database.GetDB().Create(rec).Error; err != nil {
+				t.Fatalf("create record: %v", err)
+			}
+
+			updated := rec.ToClient()
+			tc.mutate(updated)
+			if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+				t.Fatalf("Update: %v", err)
+			}
+
+			got, err := svc.GetByID(rec.Id)
+			if err != nil {
+				t.Fatalf("GetByID: %v", err)
+			}
+			if tc.readBack(got) != tc.want {
+				t.Fatalf("%s: not persisted for no-inbound client, got %v, want %v", tc.name, tc.readBack(got), tc.want)
+			}
+		})
+	}
+}
+
+func TestUpdate_NoInbound_PreservesCredentialsWhenOmitted(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	email := "noib-preserve@x"
+	rec := &model.ClientRecord{
+		Email:    email,
+		UUID:     "11111111-1111-1111-1111-111111111111",
+		SubID:    email,
+		Password: "seeded-pw",
+		Auth:     "seeded-auth",
+		Secret:   "seeded-secret",
+	}
+	if err := database.GetDB().Create(rec).Error; err != nil {
+		t.Fatalf("create record: %v", err)
+	}
+
+	updated := rec.ToClient()
+	updated.ID = ""
+	updated.Password = ""
+	updated.Auth = ""
+	updated.Secret = ""
+	updated.Comment = "only comment changed"
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	got, err := svc.GetByID(rec.Id)
+	if err != nil {
+		t.Fatalf("GetByID: %v", err)
+	}
+	if got.UUID != "11111111-1111-1111-1111-111111111111" {
+		t.Fatalf("uuid wiped on partial update, got %q", got.UUID)
+	}
+	if got.Password != "seeded-pw" {
+		t.Fatalf("password wiped on partial update, got %q", got.Password)
+	}
+	if got.Auth != "seeded-auth" {
+		t.Fatalf("auth wiped on partial update, got %q", got.Auth)
+	}
+	if got.Secret != "seeded-secret" {
+		t.Fatalf("secret wiped on partial update, got %q", got.Secret)
+	}
+	if got.Comment != "only comment changed" {
+		t.Fatalf("comment not persisted, got %q", got.Comment)
+	}
+}
+
+func TestApplyClientRecordMerge_MirrorsSyncInboundRules(t *testing.T) {
+	row := &model.ClientRecord{
+		UUID:     "kept-uuid",
+		Password: "kept-pw",
+		Flow:     "kept-flow",
+		TotalGB:  9,
+		Group:    "kept-group",
+		Comment:  "kept-comment",
+	}
+	incoming := &model.ClientRecord{
+		Password: "new-pw",
+		TotalGB:  0,
+		Comment:  "new-comment",
+	}
+
+	applyClientRecordMerge(row, incoming)
+
+	if row.UUID != "kept-uuid" {
+		t.Fatalf("empty incoming UUID should preserve stored UUID, got %q", row.UUID)
+	}
+	if row.Password != "new-pw" {
+		t.Fatalf("non-empty incoming Password should overwrite, got %q", row.Password)
+	}
+	if row.Flow != "" {
+		t.Fatalf("incoming Flow is unconditional and should overwrite with empty, got %q", row.Flow)
+	}
+	if row.TotalGB != 0 {
+		t.Fatalf("incoming TotalGB is unconditional and should overwrite with zero, got %v", row.TotalGB)
+	}
+	if row.Group != "kept-group" {
+		t.Fatalf("empty incoming Group should preserve stored group, got %q", row.Group)
+	}
+	if row.Comment != "new-comment" {
+		t.Fatalf("incoming Comment is unconditional and should overwrite, got %q", row.Comment)
+	}
+}

+ 30 - 0
internal/web/service/client_update_rename_test.go

@@ -46,6 +46,36 @@ func TestUpdateInboundClientRenameDoesNotDuplicateRecord(t *testing.T) {
 	}
 }
 
+func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{{Email: "test", ID: "aaaaaaaa-0000-0000-0000-000000000002", SubID: "sub-case", Enable: true}}
+	ib := mkInbound(t, 22002, model.VLESS, clientsSettings(t, source))
+	if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	origId := lookupClientRecord(t, "test").Id
+
+	updated := source[0]
+	updated.Email = "Test"
+	if _, err := svc.Update(inboundSvc, origId, updated); err != nil {
+		t.Fatalf("Update case-only email: %v", err)
+	}
+
+	if n := countClientRecords(t); n != 1 {
+		t.Fatalf("client records after case-only rename = %d, want 1", n)
+	}
+	rec := lookupClientRecord(t, "Test")
+	if rec.Id != origId {
+		t.Fatalf("record id after case-only rename = %d, want %d", rec.Id, origId)
+	}
+	if rec.Email != "Test" {
+		t.Fatalf("email after case-only rename = %q, want %q", rec.Email, "Test")
+	}
+}
+
 func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
 	setupBulkDB(t)
 	svc := &ClientService{}

+ 10 - 0
internal/web/service/inbound.go

@@ -830,10 +830,17 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
 			return err
 		}
+		// Emails seeded here (import's ClientStats, e.g. the controller's forced
+		// Enable=true on every imported stat row) are authoritative for this call
+		// and must not be clobbered by the AddClientStat loop below, which derives
+		// its enable/total/expiry/reset from Settings.clients[] instead — a second,
+		// possibly-stale source for the same columns on a plain (non-import) create.
+		statEmails := make(map[string]bool, len(inbound.ClientStats))
 		for i := range inbound.ClientStats {
 			if inbound.ClientStats[i].Email == "" {
 				continue
 			}
+			statEmails[inbound.ClientStats[i].Email] = true
 			inbound.ClientStats[i].Id = 0
 			inbound.ClientStats[i].InboundId = inbound.Id
 			if err := tx.Clauses(clause.OnConflict{
@@ -844,6 +851,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 			}
 		}
 		for _, client := range clients {
+			if statEmails[client.Email] {
+				continue
+			}
 			if err := s.AddClientStat(tx, inbound.Id, &client); err != nil {
 				return err
 			}

+ 34 - 0
internal/web/service/inbound_import_shared_clients_test.go

@@ -125,3 +125,37 @@ func TestAddInbound_ImportStatsMissingClientStillGetsTrafficRow(t *testing.T) {
 		t.Fatalf("erin Total = %d, want 2000 (quota taken from client settings)", erin.Total)
 	}
 }
+
+// TestAddInbound_ImportForcedEnableSurvivesDisabledSettingsClient covers a
+// regression the AddClientStat OnConflict fix (#5958) could otherwise
+// introduce: controller.importInbound forces every ClientStats row to
+// Enable=true (imports are meant to bring every client back enabled), but
+// Settings.clients[].enable is untouched and can still say false for a client
+// that was disabled at export time. AddInbound runs the ClientStats loop
+// first (plain insert, Enable=true) and then calls AddClientStat once per
+// Settings-derived client on the same email — that second call must not let
+// the stale, disabled Settings value win over the forced-enabled row.
+func TestAddInbound_ImportForcedEnableSurvivesDisabledSettingsClient(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	settings := `{"clients":[` +
+		`{"id":"66666666-6666-6666-6666-666666666666","email":"frank","subId":"s-frank","enable":false,"totalGB":5000}` +
+		`],"decryption":"none","encryption":"none"}`
+	// makeImportInbound forces Enable=true on every stats row, matching
+	// controller.importInbound's behavior regardless of what's passed here.
+	in := makeImportInbound("in-9104-tcp", 9104, settings, []xray.ClientTraffic{
+		{Email: "frank", Up: 10, Down: 20, Total: 5000},
+	})
+	if _, _, err := svc.AddInbound(in); err != nil {
+		t.Fatalf("import inbound: %v", err)
+	}
+
+	var frank xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "frank").First(&frank).Error; err != nil {
+		t.Fatalf("frank row: %v", err)
+	}
+	if !frank.Enable {
+		t.Fatalf("frank.Enable = false, want true (import must force-enable even though Settings still says disabled)")
+	}
+}

+ 29 - 7
internal/web/service/inbound_traffic.go

@@ -116,8 +116,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 	// attached to a local inbound. The old `inbound_id NOT IN (node inbounds)`
 	// filter dropped the local traffic of a client attached to both a node and the
 	// mother inbound whenever the node inbound happened to be attached first — its
-	// shared row then carried the node inbound's id (AddClientStat uses OnConflict
-	// DoNothing and never refreshes it), so the local poll skipped it entirely.
+	// shared row then carried the node inbound's id (AddClientStat used to use
+	// OnConflict DoNothing and never refreshed it; it now refreshes inbound_id on
+	// conflict, but this filter was removed rather than relying on that ordering).
 	err = tx.Model(xray.ClientTraffic{}).
 		Where("email IN (?)", emails).
 		Find(&dbClientTraffics).Error
@@ -446,9 +447,28 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	return needRestart, int64(len(traffics)), nil
 }
 
-// AddClientStat inserts a per-client accounting row, no-op on email
-// conflict. Xray reports traffic per email, so the surviving row acts as
-// the shared accumulator for inbounds that re-use the same identity.
+// AddClientStat inserts a per-client accounting row, or refreshes the
+// config-derived columns on an email conflict. Xray reports traffic per
+// email, so the surviving row also acts as the shared accumulator for
+// inbounds that re-use the same identity — every call for that identity
+// (one per attached inbound) carries the same enable/expiry/reset/total,
+// so re-asserting them here is idempotent for that legitimate case.
+//
+// The conflict path matters on its own for a second reason: an inbound
+// delete detaches its clients (InboundService.DelInbound) without deleting
+// their client_traffics row, by design — mirroring ClientService.Detach,
+// which intentionally leaves a fully-detached client's row in place so a
+// later Attach can resume it with its accumulated traffic intact. If that
+// same email is instead reused for a freshly (re)created client, the new
+// config's enable/expiry/reset/total must win over whatever the orphaned
+// row still holds; DoNothing left them stale indefinitely (#5958).
+//
+// up/down are deliberately excluded from the refresh: they are the
+// accumulated traffic totals, and zeroing them here would erase real usage
+// every time an existing, actively-used client is attached to one more
+// inbound. One tradeoff this does not resolve: a genuinely new client that
+// happens to reuse an orphaned email still inherits that row's leftover
+// up/down, since nothing at this call site can tell the two cases apart.
 func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
 	clientTraffic := xray.ClientTraffic{
 		InboundId:  inboundId,
@@ -458,8 +478,10 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
 		Enable:     client.Enable,
 		Reset:      client.Reset,
 	}
-	return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).
-		Create(&clientTraffic).Error
+	return tx.Clauses(clause.OnConflict{
+		Columns:   []clause.Column{{Name: "email"}},
+		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset"}),
+	}).Create(&clientTraffic).Error
 }
 
 func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *model.Client) error {

+ 14 - 0
internal/web/service/setting.go

@@ -155,6 +155,9 @@ var defaultValueMap = map[string]string{
 	"smtpCpu":           "80",
 	"smtpMemory":        "80",
 
+	// Consecutive failed observatory probes before an outbound.down event fires
+	"outboundDownThreshold": "3",
+
 	// Email (SMTP) notifications
 	"smtpEnable":         "false",
 	"smtpHost":           "",
@@ -1136,6 +1139,17 @@ func (s *SettingService) SetSmtpMemory(value int) error {
 	return s.setInt("smtpMemory", value)
 }
 
+// GetOutboundDownThreshold returns how many consecutive failed observatory
+// probes an outbound must accumulate before an outbound.down notification is
+// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
+func (s *SettingService) GetOutboundDownThreshold() (int, error) {
+	return s.getInt("outboundDownThreshold")
+}
+
+func (s *SettingService) SetOutboundDownThreshold(value int) error {
+	return s.setInt("outboundDownThreshold", value)
+}
+
 // SecretClears marks redacted secrets the user explicitly emptied. Without a
 // flag, a blank submitted secret means "unchanged" (the field is always served
 // blank to the browser) and the stored value is preserved.

+ 58 - 29
internal/web/service/xray.go

@@ -354,10 +354,10 @@ const PanelEgressInboundTag = "panel-egress"
 // already taken by other inbounds in the generated config are skipped.
 const panelEgressBasePort = 62790
 
-// injectPanelEgress appends a loopback SOCKS inbound to the generated config
-// and prepends a routing rule sending it to outboundTag. Both live only in the
-// generated config — the stored template is never modified — and both are
-// hot-appliable, so changing the panel outbound never restarts the core.
+// injectPanelEgress appends a loopback SOCKS inbound and routing rule only when
+// outboundTag resolves in the final outbound or balancer set. Otherwise the
+// entire injection is skipped. Generated state is hot-appliable and never
+// modifies the stored template or restarts the core.
 func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	for i := range cfg.InboundConfigs {
 		if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
@@ -375,6 +375,10 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 			return
 		}
 	}
+	if !routingTargetExists(routing, cfg.OutboundConfigs, outboundTag) {
+		logger.Warning("panel egress: target tag [", outboundTag, "] not found, skipping injection")
+		return
+	}
 	rules, _ := routing["rules"].([]any)
 	rule := map[string]any{
 		"type":       "field",
@@ -418,6 +422,25 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	})
 }
 
+func outboundTagExists(outbounds json_util.RawMessage, tag string) bool {
+	var parsed []struct {
+		Tag string `json:"tag"`
+	}
+	if tag == "" || json.Unmarshal(outbounds, &parsed) != nil {
+		return false
+	}
+	for _, outbound := range parsed {
+		if outbound.Tag == tag {
+			return true
+		}
+	}
+	return false
+}
+
+func routingTargetExists(routing map[string]any, outbounds json_util.RawMessage, tag string) bool {
+	return routingTagIsBalancer(routing, tag) || outboundTagExists(outbounds, tag)
+}
+
 // NodeEgressInboundTag returns the loopback SOCKS inbound tag for a given node.
 func NodeEgressInboundTag(nodeID int) string {
 	return fmt.Sprintf("node-egress-%d", nodeID)
@@ -452,6 +475,10 @@ func injectNodeEgresses(cfg *xray.Config, nodes []*model.Node) {
 		if !n.Enable || n.OutboundTag == "" {
 			continue
 		}
+		if !routingTargetExists(routing, cfg.OutboundConfigs, n.OutboundTag) {
+			logger.Warning("node egress: target tag [", n.OutboundTag, "] not found, skipping node [", n.Id, "]")
+			continue
+		}
 		tag := NodeEgressInboundTag(n.Id)
 		if _, exists := usedTags[tag]; exists {
 			logger.Warning("node egress: inbound tag [", tag, "] already exists, skipping")
@@ -529,11 +556,11 @@ func routingTagIsBalancer(routing map[string]any, tag string) bool {
 const mtprotoEgressSocksSettings = `{"auth":"noauth","udp":false}`
 
 // injectMtprotoEgress wires one routed mtproto inbound into the generated
-// config: it appends a loopback SOCKS inbound (tagged with the inbound's own tag,
-// on the egress port persisted in settings) and, when an outbound is selected,
-// prepends a routing rule sending that tag to it. Both live only in the generated
-// config — the stored template is untouched — and both are hot-appliable, so
-// toggling routing never forces a full Xray restart. Mirrors injectPanelEgress.
+// config after any selected outbound resolves in the final target set. Invalid
+// selected targets or routing data skip the entire injection; without a selected
+// outbound, the bridge retains default-route behavior. Generated state remains
+// hot-appliable, leaves the stored template untouched, and never forces a full
+// Xray restart. Mirrors injectPanelEgress.
 func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
 	var parsed struct {
 		RouteThroughXray bool   `json:"routeThroughXray"`
@@ -556,31 +583,33 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
 
 	if parsed.OutboundTag != "" {
 		routing := map[string]any{}
-		parseOK := true
 		if len(cfg.RouterConfig) > 0 {
 			if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
-				logger.Warning("mtproto egress: routing section is unparsable, skipping rule:", err)
-				parseOK = false
+				logger.Warning("mtproto egress: routing section is unparsable, skipping injection:", err)
+				return
 			}
 		}
-		if parseOK {
-			rules, _ := routing["rules"].([]any)
-			rule := map[string]any{
-				"type":       "field",
-				"inboundTag": []any{tag},
-			}
-			if routingTagIsBalancer(routing, parsed.OutboundTag) {
-				rule["balancerTag"] = parsed.OutboundTag
-			} else {
-				rule["outboundTag"] = parsed.OutboundTag
-			}
-			routing["rules"] = append([]any{rule}, rules...)
-			if newRouting, err := json.Marshal(routing); err == nil {
-				cfg.RouterConfig = json_util.RawMessage(newRouting)
-			} else {
-				logger.Warning("mtproto egress: failed to rebuild routing section, skipping rule:", err)
-			}
+		if !routingTargetExists(routing, cfg.OutboundConfigs, parsed.OutboundTag) {
+			logger.Warning("mtproto egress: target tag [", parsed.OutboundTag, "] not found, skipping injection")
+			return
+		}
+		rules, _ := routing["rules"].([]any)
+		rule := map[string]any{
+			"type":       "field",
+			"inboundTag": []any{tag},
+		}
+		if routingTagIsBalancer(routing, parsed.OutboundTag) {
+			rule["balancerTag"] = parsed.OutboundTag
+		} else {
+			rule["outboundTag"] = parsed.OutboundTag
+		}
+		routing["rules"] = append([]any{rule}, rules...)
+		newRouting, err := json.Marshal(routing)
+		if err != nil {
+			logger.Warning("mtproto egress: failed to rebuild routing section, skipping injection:", err)
+			return
 		}
+		cfg.RouterConfig = json_util.RawMessage(newRouting)
 	}
 
 	cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{

+ 185 - 1
internal/web/service/xray_config_inject_test.go

@@ -127,7 +127,8 @@ func TestEnsureStatsPolicy(t *testing.T) {
 
 func egressTestConfig() *xray.Config {
 	return &xray.Config{
-		RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
+		RouterConfig:    json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
+		OutboundConfigs: json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"socks","tag":"warp"}]`),
 		InboundConfigs: []xray.InboundConfig{
 			{Port: 62789, Protocol: "tunnel", Tag: "api", Listen: json_util.RawMessage(`"127.0.0.1"`)},
 		},
@@ -278,6 +279,146 @@ func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
 	}
 }
 
+func TestInjectPanelEgress_MissingTargetSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	before := string(cfg.RouterConfig)
+	injectPanelEgress(cfg, "removed-subscription-outbound")
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("a missing target must not expose the panel bridge, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != before {
+		t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
+	}
+}
+
+func TestInjectPanelEgress_BadOutboundsSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
+	before := string(cfg.RouterConfig)
+	injectPanelEgress(cfg, "direct")
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("unparsable outbounds must not expose the panel bridge, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != before {
+		t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
+	}
+}
+
+func TestInjectNodeEgresses_MissingTargetSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	injectNodeEgresses(cfg, []*model.Node{
+		{Id: 1, Enable: true, OutboundTag: "removed-subscription-outbound"},
+		{Id: 2, Enable: true, OutboundTag: "warp"},
+	})
+
+	if len(cfg.InboundConfigs) != 2 {
+		t.Fatalf("only the node with a valid target should get a bridge, got %+v", cfg.InboundConfigs)
+	}
+	bridge := cfg.InboundConfigs[1]
+	if bridge.Tag != NodeEgressInboundTag(2) || bridge.Port != nodeEgressBasePort+2 {
+		t.Fatalf("unexpected node egress bridge: %+v", bridge)
+	}
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "warp" ||
+		len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(2) {
+		t.Fatalf("only the valid node egress rule should be prepended, got %+v", routing.Rules)
+	}
+}
+
+func TestInjectNodeEgresses_BadOutboundsSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
+	before := string(cfg.RouterConfig)
+	injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
+
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("unparsable outbounds must not expose a node bridge, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != before {
+		t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
+	}
+}
+
+func TestInjectNodeEgresses_BalancerTarget(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
+	injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "lb"}})
+
+	var routing struct {
+		Rules []struct {
+			OutboundTag string `json:"outboundTag"`
+			BalancerTag string `json:"balancerTag"`
+		} `json:"rules"`
+	}
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
+		routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
+		t.Fatalf("a valid balancer target must create the node bridge and rule, got %+v", routing.Rules)
+	}
+}
+
+func TestInjectNodeEgresses_TagCollisionSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: NodeEgressInboundTag(1)},
+	)
+	before := string(cfg.RouterConfig)
+	injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
+
+	if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
+		t.Fatal("an existing node egress tag must make that node injection a no-op")
+	}
+}
+
+func TestInjectNodeEgresses_PortCollision(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: nodeEgressBasePort + 1, Protocol: "vless", Tag: "in-1"},
+		xray.InboundConfig{Port: nodeEgressBasePort + 2, Protocol: "vless", Tag: "in-2"},
+	)
+	injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
+
+	bridge := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
+	if bridge.Tag != NodeEgressInboundTag(1) || bridge.Port != nodeEgressBasePort+3 {
+		t.Fatalf("node egress must skip taken ports, got %+v", bridge)
+	}
+}
+
+func TestInjectNodeEgresses_NoRoutingSection(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = nil
+	injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
+		routing.Rules[0].OutboundTag != "direct" ||
+		len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(1) {
+		t.Fatalf("a routing section must be created with the node egress rule, got %+v", routing.Rules)
+	}
+}
+
+func TestInjectNodeEgresses_BadRoutingSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{not json`)
+	injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
+
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("unparsable routing must not expose a node bridge, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != `{not json` {
+		t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
+	}
+}
+
 func mtprotoInbound(tag string, settings string) *model.Inbound {
 	return &model.Inbound{Tag: tag, Protocol: model.MTProto, Enable: true, Settings: settings}
 }
@@ -373,3 +514,46 @@ func TestInjectMtprotoEgress_TagCollisionSkips(t *testing.T) {
 		t.Fatal("a real inbound already owning the tag must make the bridge a no-op")
 	}
 }
+
+func TestInjectMtprotoEgress_MissingTargetSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	before := string(cfg.RouterConfig)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50004,"outboundTag":"removed-subscription-outbound"}`))
+
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("a missing target must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != before {
+		t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
+	}
+}
+
+func TestInjectMtprotoEgress_BadOutboundsSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
+	before := string(cfg.RouterConfig)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50005,"outboundTag":"direct"}`))
+
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("unparsable outbounds must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != before {
+		t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
+	}
+}
+
+func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{not json`)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50006,"outboundTag":"direct"}`))
+
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatalf("unparsable routing must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != `{not json` {
+		t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
+	}
+}

+ 59 - 21
internal/web/service/xray_metrics.go

@@ -46,6 +46,19 @@ type XrayMetricsService struct {
 	state    xrayMetricsState
 	client   *http.Client
 	obsByTag map[string]ObsTagSnapshot
+	health   map[string]outboundHealth
+}
+
+// outboundHealth debounces observatory flapping. Xray flips an outbound's
+// alive flag on a single failed probe, so raw transitions produce a storm of
+// down/up notifications on a flaky link. We instead require failStreak to reach
+// the configured threshold (consecutive FAILED probes, tracked per new probe
+// via lastTry) before publishing outbound.down, and only publish outbound.up
+// once a down has actually been notified.
+type outboundHealth struct {
+	lastTry    int64
+	failStreak int
+	notified   bool
 }
 
 var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
@@ -214,32 +227,57 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra
 		xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay))
 	}
 
+	threshold := 3
+	if v, err := s.settingService.GetOutboundDownThreshold(); err == nil && v > 0 {
+		threshold = v
+	}
+
 	s.mu.Lock()
-	// Detect transitions and publish events
+	// Debounce observatory flapping into stable down/up notifications.
 	if eventBus != nil {
-		// Check existing tags for state changes
-		for tag, old := range s.obsByTag {
-			cur, exists := next[tag]
-			if !exists {
-				// Tag disappeared from observatory — skip, not a real failure
+		if s.health == nil {
+			s.health = make(map[string]outboundHealth, len(next))
+		}
+		for tag, cur := range next {
+			// React only to a genuinely new probe attempt (lastTry advanced).
+			// The sampler polls far more often than xray probes, so counting
+			// samples instead of probes would trip the threshold instantly.
+			h := s.health[tag]
+			if cur.LastTryTime == 0 || cur.LastTryTime == h.lastTry {
 				continue
 			}
-			if old.Alive && !cur.Alive {
-				errMsg := ""
-				if cur.Delay < 0 {
-					errMsg = "probe failed"
+			h.lastTry = cur.LastTryTime
+			if cur.Alive {
+				if h.notified {
+					eventBus.Publish(eventbus.Event{
+						Type:   eventbus.EventOutboundUp,
+						Source: tag,
+						Data:   &eventbus.OutboundHealthData{Delay: cur.Delay},
+					})
+				}
+				h.failStreak = 0
+				h.notified = false
+			} else {
+				h.failStreak++
+				if h.failStreak >= threshold && !h.notified {
+					errMsg := ""
+					if cur.Delay < 0 {
+						errMsg = "probe failed"
+					}
+					eventBus.Publish(eventbus.Event{
+						Type:   eventbus.EventOutboundDown,
+						Source: tag,
+						Data:   &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
+					})
+					h.notified = true
 				}
-				eventBus.Publish(eventbus.Event{
-					Type:   eventbus.EventOutboundDown,
-					Source: tag,
-					Data:   &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
-				})
-			} else if !old.Alive && cur.Alive {
-				eventBus.Publish(eventbus.Event{
-					Type:   eventbus.EventOutboundUp,
-					Source: tag,
-					Data:   &eventbus.OutboundHealthData{Delay: cur.Delay},
-				})
+			}
+			s.health[tag] = h
+		}
+		// Forget tags that vanished from the observatory.
+		for tag := range s.health {
+			if _, ok := next[tag]; !ok {
+				delete(s.health, tag)
 			}
 		}
 	}

+ 122 - 0
internal/web/service/xray_metrics_test.go

@@ -0,0 +1,122 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
+)
+
+// probe is one observatory sample: whether the outbound is alive and the
+// last_try_time xray reports for it (a new probe advances lastTry).
+type probe struct {
+	alive   bool
+	lastTry int64
+}
+
+const testSentinel eventbus.EventType = "test.sentinel"
+
+// runObservatory feeds a probe sequence through applyObservatory with the given
+// threshold and returns the outbound.* events it published, in order.
+func runObservatory(t *testing.T, threshold int, seq []probe) []eventbus.EventType {
+	t.Helper()
+
+	ss := SettingService{}
+	if err := ss.SetOutboundDownThreshold(threshold); err != nil {
+		t.Fatalf("set threshold: %v", err)
+	}
+
+	bus := eventbus.New(256)
+	events := make(chan eventbus.Event, 256)
+	bus.Subscribe("test", func(e eventbus.Event) { events <- e })
+	SetEventBus(bus)
+	t.Cleanup(func() {
+		SetEventBus(nil)
+		bus.Stop()
+	})
+
+	s := &XrayMetricsService{settingService: ss}
+	for _, p := range seq {
+		s.applyObservatory(time.Unix(p.lastTry, 0), map[string]rawObsEntry{
+			"proxy": {Alive: p.alive, Delay: 10, LastTryTime: p.lastTry, OutboundTag: "proxy"},
+		})
+	}
+
+	bus.Publish(eventbus.Event{Type: testSentinel, Source: "x"})
+	var got []eventbus.EventType
+	for {
+		select {
+		case e := <-events:
+			if e.Type == testSentinel {
+				return got
+			}
+			got = append(got, e.Type)
+		case <-time.After(2 * time.Second):
+			t.Fatal("timed out waiting for events to drain")
+		}
+	}
+}
+
+func TestApplyObservatoryDebounce(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	tests := []struct {
+		name      string
+		threshold int
+		seq       []probe
+		want      []eventbus.EventType
+	}{
+		{
+			name:      "notifies only after threshold consecutive failed probes",
+			threshold: 3,
+			seq: []probe{
+				{true, 1},
+				{false, 2},
+				{false, 3},
+				{false, 4},
+				{false, 5},
+				{true, 6},
+				{false, 7},
+				{true, 8},
+			},
+			want: []eventbus.EventType{eventbus.EventOutboundDown, eventbus.EventOutboundUp},
+		},
+		{
+			name:      "repeated samples of the same probe do not advance the streak",
+			threshold: 3,
+			seq:       []probe{{false, 2}, {false, 2}, {false, 2}, {false, 2}, {false, 2}},
+			want:      nil,
+		},
+		{
+			name:      "single-probe blip never notifies",
+			threshold: 3,
+			seq:       []probe{{true, 1}, {false, 2}, {true, 3}},
+			want:      nil,
+		},
+		{
+			name:      "threshold 1 keeps the legacy notify-on-first-failure behaviour",
+			threshold: 1,
+			seq:       []probe{{true, 1}, {false, 2}},
+			want:      []eventbus.EventType{eventbus.EventOutboundDown},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := runObservatory(t, tt.threshold, tt.seq)
+			if len(got) != len(tt.want) {
+				t.Fatalf("events = %v, want %v", got, tt.want)
+			}
+			for i := range got {
+				if got[i] != tt.want[i] {
+					t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], tt.want[i], got)
+				}
+			}
+		})
+	}
+}

+ 59 - 0
internal/web/service/xray_setting.go

@@ -5,6 +5,8 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"slices"
+	"strconv"
+	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -16,6 +18,11 @@ type XraySettingService struct {
 	SettingService
 }
 
+const (
+	unencryptedOutboundProhibitedError = "without TLS or other encryption is prohibited unless the server address is a private IP or domain"
+	unencryptedOutboundMinimumVersion  = "26.7.11"
+)
+
 func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
 	// The frontend round-trips the whole getXraySetting response back
 	// through the textarea, so if it has ever received a wrapped
@@ -46,8 +53,15 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 		if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
 			return common.NewError("xray template config invalid: outbounds is not an array:", err)
 		}
+		coreVersion := "Unknown"
+		if p != nil {
+			coreVersion = p.GetXrayVersion()
+		}
 		for _, outbound := range outbounds {
 			if err := xray.ValidateOutboundConfig(outbound); err != nil {
+				if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
+					continue
+				}
 				tagged := struct {
 					Tag string `json:"tag"`
 				}{}
@@ -59,6 +73,51 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 	return nil
 }
 
+// shouldSkipLegacyUnencryptedOutboundRejection lets an older running Xray
+// core accept an outbound that the newer embedded validator rejects solely
+// because it is unencrypted and targets a public address. Unknown or malformed
+// versions preserve the embedded validator's strict behavior.
+func shouldSkipLegacyUnencryptedOutboundRejection(coreVersion string, err error) bool {
+	if err == nil || !strings.Contains(err.Error(), unencryptedOutboundProhibitedError) {
+		return false
+	}
+	comparison, ok := compareXrayCoreVersions(coreVersion, unencryptedOutboundMinimumVersion)
+	return ok && comparison < 0
+}
+
+func compareXrayCoreVersions(a, b string) (int, bool) {
+	aParts, okA := parseXrayCoreVersionParts(a)
+	bParts, okB := parseXrayCoreVersionParts(b)
+	if !okA || !okB {
+		return 0, false
+	}
+	for i := range len(aParts) {
+		if aParts[i] > bParts[i] {
+			return 1, true
+		}
+		if aParts[i] < bParts[i] {
+			return -1, true
+		}
+	}
+	return 0, true
+}
+
+func parseXrayCoreVersionParts(version string) ([3]int, bool) {
+	var result [3]int
+	parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(version), "v"), ".")
+	if len(parts) != len(result) {
+		return result, false
+	}
+	for i, part := range parts {
+		n, err := strconv.Atoi(part)
+		if err != nil {
+			return result, false
+		}
+		result[i] = n
+	}
+	return result, true
+}
+
 func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]any) error {
 	template, err := s.GetXrayConfigTemplate()
 	if err != nil {

+ 48 - 0
internal/web/service/xray_setting_test.go

@@ -2,10 +2,58 @@ package service
 
 import (
 	"encoding/json"
+	"errors"
 	"strings"
 	"testing"
 )
 
+func TestShouldSkipLegacyUnencryptedOutboundRejection(t *testing.T) {
+	prohibited := errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain")
+
+	tests := []struct {
+		name    string
+		version string
+		err     error
+		want    bool
+	}{
+		{name: "older core", version: "26.4.25", err: prohibited, want: true},
+		{name: "boundary version", version: "26.7.11", err: prohibited, want: false},
+		{name: "newer core", version: "26.10.0", err: prohibited, want: false},
+		{name: "unknown version", version: "Unknown", err: prohibited, want: false},
+		{name: "unparseable version", version: "26.7", err: prohibited, want: false},
+		{name: "empty version", version: "", err: prohibited, want: false},
+		{name: "unrelated validation error", version: "26.4.25", err: errors.New("invalid outbound"), want: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := shouldSkipLegacyUnencryptedOutboundRejection(tt.version, tt.err); got != tt.want {
+				t.Fatalf("shouldSkipLegacyUnencryptedOutboundRejection(%q, %v) = %v, want %v", tt.version, tt.err, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestCompareXrayCoreVersions(t *testing.T) {
+	tests := []struct {
+		a, b string
+		want int
+		ok   bool
+	}{
+		{a: "26.7.11", b: "26.7.11", want: 0, ok: true},
+		{a: "26.10.0", b: "26.7.11", want: 1, ok: true},
+		{a: "v26.4.25", b: "26.7.11", want: -1, ok: true},
+		{a: "Unknown", b: "26.7.11", want: 0, ok: false},
+	}
+
+	for _, tt := range tests {
+		got, ok := compareXrayCoreVersions(tt.a, tt.b)
+		if got != tt.want || ok != tt.ok {
+			t.Errorf("compareXrayCoreVersions(%q, %q) = (%d, %v), want (%d, %v)", tt.a, tt.b, got, ok, tt.want, tt.ok)
+		}
+	}
+}
+
 func TestUnwrapXrayTemplateConfig(t *testing.T) {
 	real := `{"log":{},"inbounds":[],"outbounds":[],"routing":{}}`