6 コミット d0ad773edf ... cfd4f64a79

作者 SHA1 メッセージ 日付
  Sanaei cfd4f64a79 fix(amneziawg): bound the SOCKS5 UDP associate exchange 23 時間 前
  Sanaei 22346eef78 fix(node): import a newly selected node inbound instead of sweeping it 1 日 前
  mrchatam 5ad9df69b9 fix(link): restore mKCP seed and headerType on share-link import (#6480) 1 日 前
  mrchatam 939c470698 feat(inbounds): show linked host remarks in inbound list (#6468) 1 日 前
  Mapioe 4760ccaba0 fix(logs): standardize logs (#6484) 1 日 前
  mrchatam 435ed976c0 fix(web): restart panel after ImportDB so subPath routes match (#6446) (#6456) 1 日 前

+ 74 - 8
frontend/src/lib/xray/outbound-link-parser.ts

@@ -219,6 +219,15 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void {
       applyXhttpStringFromParams(xhttp, params);
       applyXhttpStringFromParams(xhttp, params);
       break;
       break;
     }
     }
+    case 'kcp': {
+      // mtu/tti on kcpSettings; header/seed via applyMkcpLegacyFromShare.
+      const kcp = stream.kcpSettings as Raw;
+      const mtu = kcpParamInRange(params.get('mtu'), KCP_MIN_MTU, KCP_MAX_MTU);
+      if (mtu !== null) kcp.mtu = mtu;
+      const tti = kcpParamInRange(params.get('tti'), KCP_MIN_TTI, KCP_MAX_TTI);
+      if (tti !== null) kcp.tti = tti;
+      break;
+    }
     case 'tcp':
     case 'tcp':
       // vless/trojan TCP HTTP camouflage rides on header=http+host+path
       // vless/trojan TCP HTTP camouflage rides on header=http+host+path
       if (params.get('headerType') === 'http' || params.get('type') === 'http') {
       if (params.get('headerType') === 'http' || params.get('type') === 'http') {
@@ -236,21 +245,78 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void {
   }
   }
 }
 }
 
 
+// mKCP bounds mirror xray-core's KCPConfig.Build checks (a value outside them fails
+// the whole config load); mtu's ceiling is the int32 that fits its uint32 field.
+const KCP_MIN_MTU = 21;
+const KCP_MAX_MTU = 0x7fffffff;
+const KCP_MIN_TTI = 10;
+const KCP_MAX_TTI = 1000;
+
+// Decimal digits only, like the Go importer's strconv.Atoi; anything else keeps
+// buildStream's default.
+function kcpParamInRange(raw: string | null, min: number, max: number): number | null {
+  if (raw === null || !/^\d+$/.test(raw)) return null;
+  const n = Number(raw);
+  return Number.isSafeInteger(n) && n >= min && n <= max ? n : null;
+}
+
+const kcpHeaderTypeToMask: Record<string, string> = {
+  dns: 'dns',
+  dtls: 'dtls',
+  srtp: 'srtp',
+  utp: 'utp',
+  'wechat-video': 'wechat',
+  wireguard: 'wireguard',
+};
+
 // The inbound link emits the entire finalmask object as a JSON-encoded
 // The inbound link emits the entire finalmask object as a JSON-encoded
 // `fm` query param. Decode and attach to streamSettings so udpHop /
 // `fm` query param. Decode and attach to streamSettings so udpHop /
 // quicParams / tcp+udp masks round-trip on outbound import.
 // quicParams / tcp+udp masks round-trip on outbound import.
 function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
 function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
   const fm = params.get('fm');
   const fm = params.get('fm');
-  if (!fm) return;
-  try {
-    const parsed = JSON.parse(fm) as Record<string, unknown>;
-    if (parsed && typeof parsed === 'object') {
-      sanitizeFinalMaskQuicParams(parsed);
-      stream.finalmask = parsed;
+  if (fm) {
+    try {
+      const parsed = JSON.parse(fm) as Record<string, unknown>;
+      if (parsed && typeof parsed === 'object') {
+        sanitizeFinalMaskQuicParams(parsed);
+        stream.finalmask = parsed;
+      }
+    } catch {
+      // malformed fm — leave streamSettings.finalmask absent
     }
     }
-  } catch {
-    // malformed fm — leave streamSettings.finalmask absent
   }
   }
+  applyMkcpLegacyFromShare(stream, params);
+}
+
+/** Restore headerType/seed into finalmask.udp mkcp-legacy; fm= mkcp-legacy wins. */
+function applyMkcpLegacyFromShare(stream: Raw, params: URLSearchParams): void {
+  let headerType = (params.get('headerType') ?? '').trim();
+  const seed = params.get('seed') ?? '';
+  if (headerType === 'none') headerType = '';
+  if (!headerType && !seed) return;
+  const network = stream.network;
+  if (typeof network === 'string' && network && network !== 'kcp') return;
+
+  let maskHeader = '';
+  if (headerType) {
+    if (!Object.hasOwn(kcpHeaderTypeToMask, headerType)) return;
+    maskHeader = kcpHeaderTypeToMask[headerType];
+  }
+
+  const finalmask = (stream.finalmask as Raw) ?? {};
+  const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : [];
+  if (udp.some((m) => (m as Raw)?.type === 'mkcp-legacy')) return;
+
+  // One mask per field, seed first: MkcpLegacy.Build ignores value once header is
+  // set, and the chain puts the last mask outermost on the wire (header around cipher).
+  if (seed) udp.push(mkcpLegacyMask('', seed));
+  if (maskHeader) udp.push(mkcpLegacyMask(maskHeader, ''));
+  finalmask.udp = udp;
+  stream.finalmask = finalmask;
+}
+
+function mkcpLegacyMask(header: string, value: string): Raw {
+  return { type: 'mkcp-legacy', settings: { header, value } };
 }
 }
 
 
 function ensureFinalMask(stream: Raw): Raw {
 function ensureFinalMask(stream: Raw): Raw {

+ 1 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -803,6 +803,7 @@ export default function InboundsPage() {
                       subEnable={subSettings.enable}
                       subEnable={subSettings.enable}
                       nodesById={nodesById}
                       nodesById={nodesById}
                       hasActiveNode={showNodeInfo}
                       hasActiveNode={showNodeInfo}
+                      hosts={hosts}
                       onAddInbound={onAddInbound}
                       onAddInbound={onAddInbound}
                       onGeneralAction={onGeneralAction}
                       onGeneralAction={onGeneralAction}
                       onRowAction={({ key, dbInbound }) =>
                       onRowAction={({ key, dbInbound }) =>

+ 31 - 0
frontend/src/pages/inbounds/list/InboundList.css

@@ -180,3 +180,34 @@
     padding: 4px;
     padding: 4px;
   }
   }
 }
 }
+
+.inbound-remark-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 2px;
+  min-width: 0;
+}
+
+.inbound-remark {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.inbound-host-remarks {
+  font-size: 11px;
+  font-weight: 400;
+  opacity: 0.65;
+  line-height: 1.2;
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  cursor: default;
+}
+
+.tag-name .inbound-host-remarks {
+  font-weight: 400;
+}

+ 31 - 10
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -32,10 +32,21 @@ import { activateOnKey } from '@/utils/a11y';
 
 
 import { buildRowActionsMenu } from './RowActions';
 import { buildRowActionsMenu } from './RowActions';
 import { useInboundColumns } from './useInboundColumns';
 import { useInboundColumns } from './useInboundColumns';
+import { buildHostRemarksByInboundId, formatHostRemarksLabel } from './helpers';
 import InboundStatsModal from './InboundStatsModal';
 import InboundStatsModal from './InboundStatsModal';
 import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction } from './types';
 import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction } from './types';
 import './InboundList.css';
 import './InboundList.css';
 
 
+function HostRemarksSuffix({ remarks }: { remarks: string[] }) {
+  if (remarks.length === 0) return null;
+  const { display, full } = formatHostRemarksLabel(remarks);
+  return (
+    <Tooltip title={full}>
+      <span className="inbound-host-remarks"> ({display})</span>
+    </Tooltip>
+  );
+}
+
 export default function InboundList({
 export default function InboundList({
   dbInbounds,
   dbInbounds,
   clientCount,
   clientCount,
@@ -48,6 +59,7 @@ export default function InboundList({
   subEnable,
   subEnable,
   nodesById,
   nodesById,
   hasActiveNode,
   hasActiveNode,
+  hosts,
   onAddInbound,
   onAddInbound,
   onGeneralAction,
   onGeneralAction,
   onRowAction,
   onRowAction,
@@ -86,19 +98,22 @@ export default function InboundList({
     [nodesById, t],
     [nodesById, t],
   );
   );
 
 
+  const hostRemarksByInboundId = useMemo(() => buildHostRemarksByInboundId(hosts), [hosts]);
+
   const visibleInbounds = useMemo(() => {
   const visibleInbounds = useMemo(() => {
     let list = dbInbounds;
     let list = dbInbounds;
     if (nodeFilter === 0) list = list.filter((ib) => ib.nodeId == null);
     if (nodeFilter === 0) list = list.filter((ib) => ib.nodeId == null);
     else if (nodeFilter !== 'all') list = list.filter((ib) => ib.nodeId === nodeFilter);
     else if (nodeFilter !== 'all') list = list.filter((ib) => ib.nodeId === nodeFilter);
     const q = searchKey.trim().toLowerCase();
     const q = searchKey.trim().toLowerCase();
     if (!q) return list;
     if (!q) return list;
-    return list.filter(
-      (ib) =>
-        (ib.remark || '').toLowerCase().includes(q) ||
-        String(ib.port).includes(q) ||
-        (ib.protocol || '').toLowerCase().includes(q),
-    );
-  }, [dbInbounds, nodeFilter, searchKey]);
+    return list.filter((ib) => {
+      if ((ib.remark || '').toLowerCase().includes(q)) return true;
+      if (String(ib.port).includes(q)) return true;
+      if ((ib.protocol || '').toLowerCase().includes(q)) return true;
+      const hostRemarks = hostRemarksByInboundId.get(ib.id) ?? [];
+      return hostRemarks.some((remark) => remark.toLowerCase().includes(q));
+    });
+  }, [dbInbounds, nodeFilter, searchKey, hostRemarksByInboundId]);
 
 
   const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
   const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
     const previous = dbInbound.enable;
     const previous = dbInbound.enable;
@@ -114,8 +129,10 @@ export default function InboundList({
   }, []);
   }, []);
 
 
   const hasAnyRemark = useMemo(
   const hasAnyRemark = useMemo(
-    () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
-    [dbInbounds],
+    () =>
+      dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== '') ||
+      dbInbounds.some((i) => (hostRemarksByInboundId.get(i.id)?.length ?? 0) > 0),
+    [dbInbounds, hostRemarksByInboundId],
   );
   );
 
 
   const hasAnySubSortIndex = useMemo(
   const hasAnySubSortIndex = useMemo(
@@ -154,6 +171,7 @@ export default function InboundList({
     hasAnySubSortIndex,
     hasAnySubSortIndex,
     hasActiveNode,
     hasActiveNode,
     nodesById,
     nodesById,
+    hostRemarksByInboundId,
     clientCount,
     clientCount,
     inboundSpeed,
     inboundSpeed,
     subEnable,
     subEnable,
@@ -293,7 +311,10 @@ export default function InboundList({
                         onChange={(e) => toggleSelect(record.id, e.target.checked)}
                         onChange={(e) => toggleSelect(record.id, e.target.checked)}
                       />
                       />
                       <span className="card-id">#{record.id}</span>
                       <span className="card-id">#{record.id}</span>
-                      <span className="tag-name">{record.remark}</span>
+                      <span className="tag-name">
+                        <span className="inbound-remark">{record.remark}</span>
+                        <HostRemarksSuffix remarks={hostRemarksByInboundId.get(record.id) ?? []} />
+                      </span>
                       <div className="card-actions">
                       <div className="card-actions">
                         <Tooltip title={t('pages.inbounds.inboundInfo')}>
                         <Tooltip title={t('pages.inbounds.inboundInfo')}>
                           <InfoCircleOutlined
                           <InfoCircleOutlined

+ 39 - 0
frontend/src/pages/inbounds/list/helpers.ts

@@ -1,5 +1,6 @@
 import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
 import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
 import { coerceInboundJsonField } from '@/models/dbinbound';
 import { coerceInboundJsonField } from '@/models/dbinbound';
+import type { HostRecord } from '@/schemas/api/host';
 
 
 import type { DBInboundRecord, StreamHints } from './types';
 import type { DBInboundRecord, StreamHints } from './types';
 
 
@@ -105,3 +106,41 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
   }
   }
   return false;
   return false;
 }
 }
+
+/** Max host remarks shown inline before truncating with "+N". */
+export const HOST_REMARK_VISIBLE_LIMIT = 2;
+
+/** Join Host Group remarks onto inbound ids using existing /hosts/list fields. */
+export function buildHostRemarksByInboundId(
+  hosts: Pick<HostRecord, 'remark' | 'inboundIds' | 'hosts' | 'isDisabled'>[],
+): Map<number, string[]> {
+  const map = new Map<number, string[]>();
+  for (const host of hosts) {
+    if (host.isDisabled) continue;
+    const addressFallback = Array.isArray(host.hosts)
+      ? host.hosts.map((h) => (h || '').trim()).find(Boolean) || ''
+      : '';
+    const label = (host.remark || '').trim() || addressFallback;
+    if (!label) continue;
+    for (const inboundId of host.inboundIds || []) {
+      if (!Number.isFinite(inboundId)) continue;
+      const list = map.get(inboundId) ?? [];
+      if (!list.includes(label)) list.push(label);
+      map.set(inboundId, list);
+    }
+  }
+  return map;
+}
+
+export function formatHostRemarksLabel(
+  remarks: string[],
+  visibleLimit = HOST_REMARK_VISIBLE_LIMIT,
+): { display: string; full: string } {
+  const full = remarks.join(', ');
+  if (remarks.length <= visibleLimit) {
+    return { display: full, full };
+  }
+  const visible = remarks.slice(0, visibleLimit).join(', ');
+  const more = remarks.length - visibleLimit;
+  return { display: `${visible}, +${more}`, full };
+}

+ 2 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -1,4 +1,5 @@
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import type { HostRecord } from '@/schemas/api/host';
 
 
 export interface StreamHints {
 export interface StreamHints {
   network: string;
   network: string;
@@ -78,6 +79,7 @@ export interface InboundListProps {
   subEnable: boolean;
   subEnable: boolean;
   nodesById: Map<number, NodeRecord>;
   nodesById: Map<number, NodeRecord>;
   hasActiveNode: boolean;
   hasActiveNode: boolean;
+  hosts: HostRecord[];
   onAddInbound: () => void;
   onAddInbound: () => void;
   onGeneralAction: (key: GeneralAction) => void;
   onGeneralAction: (key: GeneralAction) => void;
   onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
   onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;

+ 20 - 1
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -23,6 +23,7 @@ import {
   shadowsocksNetworkLabel,
   shadowsocksNetworkLabel,
   tunnelNetworkLabel,
   tunnelNetworkLabel,
   mixedNetworkLabel,
   mixedNetworkLabel,
+  formatHostRemarksLabel,
 } from './helpers';
 } from './helpers';
 import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
 import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
 
 
@@ -31,6 +32,7 @@ interface UseInboundColumnsParams {
   hasAnySubSortIndex: boolean;
   hasAnySubSortIndex: boolean;
   hasActiveNode: boolean;
   hasActiveNode: boolean;
   nodesById: Map<number, NodeRecord>;
   nodesById: Map<number, NodeRecord>;
+  hostRemarksByInboundId: Map<number, string[]>;
   clientCount: Record<number, ClientCountEntry>;
   clientCount: Record<number, ClientCountEntry>;
   inboundSpeed: Record<number, InboundSpeedEntry>;
   inboundSpeed: Record<number, InboundSpeedEntry>;
   subEnable: boolean;
   subEnable: boolean;
@@ -45,6 +47,7 @@ export function useInboundColumns({
   hasAnySubSortIndex,
   hasAnySubSortIndex,
   hasActiveNode,
   hasActiveNode,
   nodesById,
   nodesById,
+  hostRemarksByInboundId,
   clientCount,
   clientCount,
   inboundSpeed,
   inboundSpeed,
   subEnable,
   subEnable,
@@ -138,8 +141,23 @@ export function useInboundColumns({
         dataIndex: 'remark',
         dataIndex: 'remark',
         key: 'remark',
         key: 'remark',
         align: 'center',
         align: 'center',
-        width: 90,
+        width: 140,
         sorter: (a, b) => compareText(a.remark, b.remark),
         sorter: (a, b) => compareText(a.remark, b.remark),
+        render: (_, record) => {
+          const hostRemarks = hostRemarksByInboundId.get(record.id) ?? [];
+          if (hostRemarks.length === 0) {
+            return record.remark || null;
+          }
+          const { display, full } = formatHostRemarksLabel(hostRemarks);
+          return (
+            <div className="inbound-remark-cell">
+              <div className="inbound-remark">{record.remark}</div>
+              <Tooltip title={full}>
+                <div className="inbound-host-remarks">({display})</div>
+              </Tooltip>
+            </div>
+          );
+        },
       });
       });
     }
     }
 
 
@@ -453,6 +471,7 @@ export function useInboundColumns({
     hasAnySubSortIndex,
     hasAnySubSortIndex,
     hasActiveNode,
     hasActiveNode,
     nodesById,
     nodesById,
+    hostRemarksByInboundId,
     clientCount,
     clientCount,
     inboundSpeed,
     inboundSpeed,
     subEnable,
     subEnable,

+ 3 - 7
frontend/src/pages/index/BackupModal.tsx

@@ -59,14 +59,10 @@ export default function BackupModal({
         return;
         return;
       }
       }
 
 
+      // importDB schedules the panel restart server-side; wait it out, then reload.
       onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
       onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
-      const restart = await HttpUtil.post('/panel/api/setting/restartPanel');
-      if (restart?.success) {
-        await PromiseUtil.sleep(5000);
-        window.location.reload();
-      } else {
-        onBusy({ busy: false });
-      }
+      await PromiseUtil.sleep(5000);
+      window.location.reload();
     });
     });
     fileInput.click();
     fileInput.click();
   }
   }

+ 17 - 0
frontend/src/test/inbound-host-remarks.test.ts

@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildHostRemarksByInboundId } from '@/pages/inbounds/list/helpers';
+
+describe('buildHostRemarksByInboundId', () => {
+  it('joins only host groups a client can reach (#6026)', () => {
+    const map = buildHostRemarksByInboundId([
+      { remark: 'USA IPv4', inboundIds: [1, 2], hosts: ['1.2.3.4:443'], isDisabled: false },
+      { remark: 'USA IPv6', inboundIds: [1], hosts: ['[2001:db8::1]:443'], isDisabled: true },
+      { remark: '', inboundIds: [2], hosts: ['', 'cdn.example.com:443'], isDisabled: false },
+      { remark: 'USA IPv4', inboundIds: [2], hosts: ['5.6.7.8'] },
+    ]);
+    expect(map.get(1)).toEqual(['USA IPv4']);
+    expect(map.get(2)).toEqual(['USA IPv4', 'cdn.example.com:443']);
+    expect(map.has(3)).toBe(false);
+  });
+});

+ 61 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -266,6 +266,67 @@ describe('parseVlessLink', () => {
   });
   });
 });
 });
 
 
+describe('mKCP share params', () => {
+  // The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
+  // mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
+  type Mask = { type: string; settings: { header: string; value: string } };
+  const parse = (link: string) => {
+    const out = link.startsWith('trojan://') ? parseTrojanLink(link) : parseVlessLink(link);
+    expect(out).not.toBeNull();
+    const stream = out!.streamSettings as Record<string, unknown>;
+    const kcp = stream.kcpSettings as { mtu: number; tti: number };
+    const udp = (stream.finalmask as { udp?: Mask[] } | undefined)?.udp ?? [];
+    return {
+      kcp: { mtu: kcp.mtu, tti: kcp.tti },
+      masks: udp.map((m) => [m.type, m.settings.header, m.settings.value]),
+    };
+  };
+
+  it.each([
+    [
+      'vless header and seed become two masks, seed first',
+      'vless://[email protected]:443?type=kcp&headerType=wechat-video&seed=secret-seed&mtu=1400&tti=50&security=none#kcp1',
+      { mtu: 1400, tti: 50 },
+      [
+        ['mkcp-legacy', '', 'secret-seed'],
+        ['mkcp-legacy', 'wechat', ''],
+      ],
+    ],
+    [
+      'trojan header only adds no seed mask',
+      'trojan://[email protected]:443?type=kcp&headerType=srtp&security=none#kcp-tj',
+      { mtu: 1350, tti: 20 },
+      [['mkcp-legacy', 'srtp', '']],
+    ],
+    [
+      'seed only adds no header mask',
+      'vless://[email protected]:443?type=kcp&headerType=none&seed=abc123&security=none',
+      { mtu: 1350, tti: 20 },
+      [['mkcp-legacy', '', 'abc123']],
+    ],
+    [
+      'mtu/tti outside KCPConfig.Build bounds keep the defaults',
+      'vless://[email protected]:443?type=kcp&mtu=10&tti=5000&security=none',
+      { mtu: 1350, tti: 20 },
+      [],
+    ],
+    [
+      'non-decimal mtu keeps the default like the Go importer',
+      'vless://[email protected]:443?type=kcp&mtu=1.5&tti=1e2&security=none',
+      { mtu: 1350, tti: 20 },
+      [],
+    ],
+    [
+      'a prototype key is not a header type',
+      'vless://[email protected]:443?type=kcp&headerType=constructor&seed=abc&security=none',
+      { mtu: 1350, tti: 20 },
+      [],
+    ],
+  ])('%s', (_name, link, kcp, masks) => {
+    expect(parse(link)).toEqual({ kcp, masks });
+  });
+});
+
 describe('parseTrojanLink', () => {
 describe('parseTrojanLink', () => {
   it('parses a trojan:// link with ws + tls', () => {
   it('parses a trojan:// link with ws + tls', () => {
     const link =
     const link =

+ 8 - 1
internal/amneziawgnet/relay.go

@@ -143,6 +143,10 @@ type socks5UDPSession struct {
 	udpConn *net.UDPConn
 	udpConn *net.UDPConn
 }
 }
 
 
+// Bounds dial plus the greeting/auth/associate reads: an accepted-but-silent
+// server otherwise parks Handle, and with it the tunnel's delivery path.
+var socks5AssociateTimeout = 5 * time.Second
+
 // newSocks5UDPSession performs the SOCKS5 greeting, username/password auth,
 // newSocks5UDPSession performs the SOCKS5 greeting, username/password auth,
 // and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5
 // and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5
 // client (used by RelayTCP above) only implements CONNECT, and xray-core's
 // client (used by RelayTCP above) only implements CONNECT, and xray-core's
@@ -150,11 +154,13 @@ type socks5UDPSession struct {
 // types, not reusable as a standalone dialer -- so this is a small, direct,
 // types, not reusable as a standalone dialer -- so this is a small, direct,
 // from-the-RFC implementation rather than an existing library call.
 // from-the-RFC implementation rather than an existing library call.
 func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) {
 func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) {
-	dialer := net.Dialer{Timeout: 5 * time.Second}
+	deadline := time.Now().Add(socks5AssociateTimeout)
+	dialer := net.Dialer{Deadline: deadline}
 	ctrl, err := dialer.DialContext(context.Background(), "tcp", addr)
 	ctrl, err := dialer.DialContext(context.Background(), "tcp", addr)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err)
 		return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err)
 	}
 	}
+	_ = ctrl.SetDeadline(deadline)
 	if err := socks5Handshake(ctrl, user, password); err != nil {
 	if err := socks5Handshake(ctrl, user, password); err != nil {
 		ctrl.Close()
 		ctrl.Close()
 		return nil, err
 		return nil, err
@@ -171,6 +177,7 @@ func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error)
 		ctrl.Close()
 		ctrl.Close()
 		return nil, err
 		return nil, err
 	}
 	}
+	_ = ctrl.SetDeadline(time.Time{})
 
 
 	udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
 	udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
 	if err != nil {
 	if err != nil {

+ 42 - 0
internal/amneziawgnet/relay_test.go

@@ -1,9 +1,11 @@
 package amneziawgnet
 package amneziawgnet
 
 
 import (
 import (
+	"errors"
 	"io"
 	"io"
 	"net"
 	"net"
 	"net/netip"
 	"net/netip"
+	"os"
 	"testing"
 	"testing"
 	"time"
 	"time"
 )
 )
@@ -255,3 +257,43 @@ func TestSocks5ReceiveRejectsTruncatedReplies(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+// TestNewSocks5UDPSessionGivesUpOnSilentServer pins that a control connection
+// the kernel accepts but nobody answers returns within the associate deadline
+// instead of parking Handle -- and with it the tunnel's delivery path -- forever.
+func TestNewSocks5UDPSessionGivesUpOnSilentServer(t *testing.T) {
+	// Never accepted: the backlog completes the TCP handshake, the greeting
+	// lands in the socket buffer, and no reply ever comes -- a hung Xray.
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("listen: %v", err)
+	}
+	t.Cleanup(func() { _ = ln.Close() })
+
+	saved := socks5AssociateTimeout
+	socks5AssociateTimeout = 150 * time.Millisecond
+	t.Cleanup(func() { socks5AssociateTimeout = saved })
+
+	type result struct {
+		sess *socks5UDPSession
+		err  error
+	}
+	done := make(chan result, 1)
+	go func() {
+		sess, err := newSocks5UDPSession(ln.Addr().String(), "peer@example", "x")
+		done <- result{sess, err}
+	}()
+
+	select {
+	case got := <-done:
+		if got.err == nil {
+			got.sess.Close()
+			t.Fatal("associate succeeded against a server that never answered")
+		}
+		if !errors.Is(got.err, os.ErrDeadlineExceeded) {
+			t.Fatalf("associate error = %v, want one wrapping os.ErrDeadlineExceeded", got.err)
+		}
+	case <-time.After(2 * time.Second):
+		t.Fatal("newSocks5UDPSession still blocked 2s past the associate deadline: a silent SOCKS5 server parks the tunnel's delivery path")
+	}
+}

+ 2 - 2
internal/database/model/model.go

@@ -823,8 +823,8 @@ type Node struct {
 	ConfigDirty   bool  `json:"configDirty" gorm:"default:false"`
 	ConfigDirty   bool  `json:"configDirty" gorm:"default:false"`
 	ConfigDirtyAt int64 `json:"configDirtyAt"`
 	ConfigDirtyAt int64 `json:"configDirtyAt"`
 
 
-	// InboundsAdoptedAt records the first clean traffic sync that imported the
-	// node's pre-existing inbounds; reconcile must not sweep remote tags before it.
+	// InboundsAdoptedAt is the clean sync that imported the node's inbounds; a
+	// save that grows the selection zeroes it so reconcile waits before sweeping.
 	InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"`
 	InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"`
 
 
 	InboundCount  int `json:"inboundCount" gorm:"-" example:"5"`
 	InboundCount  int `json:"inboundCount" gorm:"-" example:"5"`

+ 90 - 0
internal/util/link/outbound.go

@@ -704,6 +704,15 @@ func applyTransport(stream map[string]any, p url.Values) {
 				xh[k] = v
 				xh[k] = v
 			}
 			}
 		}
 		}
+	case "kcp":
+		// mtu/tti live on kcpSettings; header/seed are finalmask mkcp-legacy (see applyMkcpLegacyFromShare).
+		kcp := stream["kcpSettings"].(map[string]any)
+		if n, ok := kcpParamInRange(p.Get("mtu"), kcpMinMTU, kcpMaxMTU); ok {
+			kcp["mtu"] = n
+		}
+		if n, ok := kcpParamInRange(p.Get("tti"), kcpMinTTI, kcpMaxTTI); ok {
+			kcp["tti"] = n
+		}
 	case "tcp":
 	case "tcp":
 		if p.Get("headerType") == "http" || p.Get("type") == "http" {
 		if p.Get("headerType") == "http" || p.Get("type") == "http" {
 			stream["tcpSettings"] = map[string]any{
 			stream["tcpSettings"] = map[string]any{
@@ -753,6 +762,87 @@ func applyFinalMask(stream map[string]any, p url.Values) {
 			stream["finalmask"] = parsed
 			stream["finalmask"] = parsed
 		}
 		}
 	}
 	}
+	applyMkcpLegacyFromShare(stream, p)
+}
+
+// mKCP bounds mirror xray-core's KCPConfig.Build checks (a value outside them fails
+// the whole config load); mtu's ceiling is the int32 that fits its uint32 field everywhere.
+const (
+	kcpMinMTU = 21
+	kcpMaxMTU = math.MaxInt32
+	kcpMinTTI = 10
+	kcpMaxTTI = 1000
+)
+
+// kcpParamInRange rejects an out-of-range or malformed link value so buildStream's default stays.
+func kcpParamInRange(s string, minVal, maxVal int) (int, bool) {
+	n, err := strconv.Atoi(s)
+	return n, err == nil && n >= minVal && n <= maxVal
+}
+
+// kcpHeaderTypeToMask maps share-link headerType to mkcp-legacy settings.header
+// (inverse of sub.kcpMaskToHeaderType).
+var kcpHeaderTypeToMask = map[string]string{
+	"dns":          "dns",
+	"dtls":         "dtls",
+	"srtp":         "srtp",
+	"utp":          "utp",
+	"wechat-video": "wechat",
+	"wireguard":    "wireguard",
+}
+
+// applyMkcpLegacyFromShare restores headerType/seed into finalmask.udp mkcp-legacy,
+// matching the shape InboundFormModal / FinalMaskForm emit. fm= mkcp-legacy wins.
+func applyMkcpLegacyFromShare(stream map[string]any, p url.Values) {
+	headerType := strings.TrimSpace(p.Get("headerType"))
+	seed := p.Get("seed")
+	if headerType == "" || headerType == "none" {
+		headerType = ""
+	}
+	if headerType == "" && seed == "" {
+		return
+	}
+	if network, _ := stream["network"].(string); network != "" && network != "kcp" {
+		return
+	}
+	maskHeader := ""
+	if headerType != "" {
+		mapped, ok := kcpHeaderTypeToMask[headerType]
+		if !ok {
+			return
+		}
+		maskHeader = mapped
+	}
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	if finalmask == nil {
+		finalmask = map[string]any{}
+	}
+	udp, _ := finalmask["udp"].([]any)
+	for _, raw := range udp {
+		m, _ := raw.(map[string]any)
+		if m != nil {
+			if t, _ := m["type"].(string); t == "mkcp-legacy" {
+				return // fm= (or prior) already carries the live mask
+			}
+		}
+	}
+	// One mask per field, seed first: MkcpLegacy.Build ignores value once header is set,
+	// and the chain puts the last mask outermost on the wire (header around the cipher).
+	if seed != "" {
+		udp = append(udp, mkcpLegacyMask("", seed))
+	}
+	if maskHeader != "" {
+		udp = append(udp, mkcpLegacyMask(maskHeader, ""))
+	}
+	finalmask["udp"] = udp
+	stream["finalmask"] = finalmask
+}
+
+func mkcpLegacyMask(header, value string) map[string]any {
+	return map[string]any{
+		"type":     "mkcp-legacy",
+		"settings": map[string]any{"header": header, "value": value},
+	}
 }
 }
 
 
 // gecko packetSize bounds mirror xray-core's salamander buffer cap.
 // gecko packetSize bounds mirror xray-core's salamander buffer cap.

+ 74 - 0
internal/util/link/outbound_helpers_test.go

@@ -4,6 +4,7 @@ import (
 	"encoding/base64"
 	"encoding/base64"
 	"net/url"
 	"net/url"
 	"reflect"
 	"reflect"
+	"slices"
 	"testing"
 	"testing"
 )
 )
 
 
@@ -244,3 +245,76 @@ func TestParseTrojanAndSS_CoreFields(t *testing.T) {
 		t.Errorf("ss server = %#v", ssrv)
 		t.Errorf("ss server = %#v", ssrv)
 	}
 	}
 }
 }
+
+type mkcpMask struct{ header, value string }
+
+func mkcpLegacyMasks(t *testing.T, res *ParseResult) []mkcpMask {
+	t.Helper()
+	var out []mkcpMask
+	for _, raw := range finalmaskUDP(t, res) {
+		mask, _ := raw.(map[string]any)
+		if mask["type"] != "mkcp-legacy" {
+			t.Fatalf("unexpected udp mask %#v", mask)
+		}
+		settings, _ := mask["settings"].(map[string]any)
+		header, _ := settings["header"].(string)
+		value, _ := settings["value"].(string)
+		out = append(out, mkcpMask{header, value})
+	}
+	return out
+}
+
+func TestParse_KcpShareParams(t *testing.T) {
+	// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
+	// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
+	cases := []struct {
+		name      string
+		link      string
+		wantMTU   int
+		wantTTI   int
+		wantMasks []mkcpMask
+	}{
+		{
+			name:      "vless header and seed become two masks, seed first",
+			link:      "vless://[email protected]:443?type=kcp&headerType=wechat-video&seed=secret-seed&mtu=1400&tti=50&security=none#kcp1",
+			wantMTU:   1400,
+			wantTTI:   50,
+			wantMasks: []mkcpMask{{"", "secret-seed"}, {"wechat", ""}},
+		},
+		{
+			name:      "trojan header only adds no seed mask",
+			link:      "trojan://[email protected]:443?type=kcp&headerType=srtp&security=none#kcp-tj",
+			wantMTU:   1350,
+			wantTTI:   20,
+			wantMasks: []mkcpMask{{"srtp", ""}},
+		},
+		{
+			name:      "seed only adds no header mask",
+			link:      "vless://[email protected]:443?type=kcp&headerType=none&seed=abc123&security=none",
+			wantMTU:   1350,
+			wantTTI:   20,
+			wantMasks: []mkcpMask{{"", "abc123"}},
+		},
+		{
+			name:    "mtu/tti outside KCPConfig.Build bounds keep the defaults",
+			link:    "vless://[email protected]:443?type=kcp&mtu=10&tti=5000&security=none",
+			wantMTU: 1350,
+			wantTTI: 20,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			res, err := ParseLink(c.link)
+			if err != nil {
+				t.Fatalf("parse: %v", err)
+			}
+			kcp := streamSub(t, res, "kcpSettings")
+			if kcp["mtu"] != c.wantMTU || kcp["tti"] != c.wantTTI {
+				t.Fatalf("kcpSettings mtu/tti = %v/%v, want %d/%d", kcp["mtu"], kcp["tti"], c.wantMTU, c.wantTTI)
+			}
+			if got := mkcpLegacyMasks(t, res); !slices.Equal(got, c.wantMasks) {
+				t.Fatalf("mkcp-legacy masks = %v, want %v", got, c.wantMasks)
+			}
+		})
+	}
+}

+ 91 - 0
internal/web/controller/import_db_restart_test.go

@@ -0,0 +1,91 @@
+package controller
+
+import (
+	"bytes"
+	"encoding/json"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/global"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"github.com/gin-gonic/gin"
+)
+
+// A successful import must schedule the panel restart itself: the browser's
+// restartPanel follow-up can 401 once the imported users table lands (#6446).
+func TestImportDBSchedulesPanelRestart(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("the stub xray binary is a shell script")
+	}
+	uploadPath := filepath.Join(t.TempDir(), "x-ui.db")
+	if err := database.InitDB(uploadPath); err != nil {
+		t.Fatalf("InitDB(upload): %v", err)
+	}
+	if err := database.CloseDB(); err != nil {
+		t.Fatalf("CloseDB(upload): %v", err)
+	}
+	upload, err := os.ReadFile(uploadPath)
+	if err != nil {
+		t.Fatalf("read upload: %v", err)
+	}
+
+	newHostTestDB(t)
+	binDir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", binDir)
+	t.Setenv("XUI_LOG_FOLDER", t.TempDir())
+	if err := os.WriteFile(filepath.Join(binDir, xray.GetBinaryName()), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+		t.Fatalf("write stub xray: %v", err)
+	}
+
+	restarts := make(chan struct{}, 1)
+	global.SetRestartHook(func() {
+		select {
+		case restarts <- struct{}{}:
+		default:
+		}
+	})
+	t.Cleanup(func() { global.SetRestartHook(func() {}) })
+
+	var body bytes.Buffer
+	mw := multipart.NewWriter(&body)
+	part, err := mw.CreateFormFile("db", "x-ui.db")
+	if err != nil {
+		t.Fatalf("CreateFormFile: %v", err)
+	}
+	if _, err := part.Write(upload); err != nil {
+		t.Fatalf("write part: %v", err)
+	}
+	if err := mw.Close(); err != nil {
+		t.Fatalf("close multipart: %v", err)
+	}
+
+	a := &ServerController{}
+	engine := gin.New()
+	engine.POST("/panel/api/server/importDB", a.importDB)
+	req := httptest.NewRequest(http.MethodPost, "/panel/api/server/importDB", &body)
+	req.Header.Set("Content-Type", mw.FormDataContentType())
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+
+	var env hostEnvelope
+	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+		t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
+	}
+	if !env.Success {
+		t.Fatalf("importDB failed: %s", env.Msg)
+	}
+
+	select {
+	case <-restarts:
+	case <-time.After(6 * time.Second):
+		t.Fatal("importDB succeeded but no panel restart was scheduled within 6s")
+	}
+}

+ 5 - 5
internal/web/controller/index.go

@@ -80,7 +80,7 @@ func (a *IndexController) login(c *gin.Context) {
 	timeStr := time.Now().Format("2006-01-02 15:04:05")
 	timeStr := time.Now().Format("2006-01-02 15:04:05")
 	if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
 	if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
 		reason := "too many failed attempts"
 		reason := "too many failed attempts"
-		logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
+		logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", form.Username, remoteIP, reason, blockedUntil.Format(time.RFC3339))
 		a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 		a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 			Username: safeUser,
 			Username: safeUser,
 			IP:       remoteIP,
 			IP:       remoteIP,
@@ -97,9 +97,9 @@ func (a *IndexController) login(c *gin.Context) {
 	if user == nil {
 	if user == nil {
 		reason := loginFailureReason(checkErr)
 		reason := loginFailureReason(checkErr)
 		if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked {
 		if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked {
-			logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
+			logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", form.Username, remoteIP, reason, blockedUntil.Format(time.RFC3339))
 		} else {
 		} else {
-			logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
+			logger.Warningf("failed login: username=%q, IP=%q, reason=%q", form.Username, remoteIP, reason)
 		}
 		}
 		a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 		a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 			Username: safeUser,
 			Username: safeUser,
@@ -113,7 +113,7 @@ func (a *IndexController) login(c *gin.Context) {
 	}
 	}
 
 
 	defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
 	defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
-	logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
+	logger.Infof("logged in successfully: username=%q, IP=%q", form.Username, remoteIP)
 	a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 	a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 		Username: safeUser,
 		Username: safeUser,
 		IP:       remoteIP,
 		IP:       remoteIP,
@@ -139,7 +139,7 @@ func loginFailureReason(err error) string {
 func (a *IndexController) logout(c *gin.Context) {
 func (a *IndexController) logout(c *gin.Context) {
 	user := session.GetLoginUser(c)
 	user := session.GetLoginUser(c)
 	if user != nil {
 	if user != nil {
-		logger.Infof("%s logged out successfully", user.Username)
+		logger.Infof("logged out successfully: username=%q", user.Username)
 	}
 	}
 	if err := session.ClearSession(c); err != nil {
 	if err := session.ClearSession(c); err != nil {
 		logger.Warning("Unable to clear session on logout:", err)
 		logger.Warning("Unable to clear session on logout:", err)

+ 3 - 0
internal/web/controller/server.go

@@ -391,6 +391,9 @@ func (a *ServerController) importDB(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
 		return
 		return
 	}
 	}
+	// Startup-registered routes (subPath) must match the restored DB, and the
+	// browser's restartPanel follow-up can 401 once the imported users land (#6446).
+	_ = a.panelService.RestartPanel(3 * time.Second)
 	jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
 	jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
 }
 }
 
 

+ 1 - 1
internal/web/service/inbound_node.go

@@ -172,7 +172,7 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 		}
 		}
 	}
 	}
-	// Before the first clean sync adopts the node's inbounds, "absent locally"
+	// Before the next clean sync adopts the node's inbounds, "absent locally"
 	// means "not imported yet" — sweeping now would wipe the node at onboarding.
 	// means "not imported yet" — sweeping now would wipe the node at onboarding.
 	if n.InboundsAdoptedAt == 0 {
 	if n.InboundsAdoptedAt == 0 {
 		return errors.Join(errs...)
 		return errors.Join(errs...)

+ 69 - 0
internal/web/service/inbound_node_reconcile_test.go

@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
 	"net/url"
 	"net/url"
+	"slices"
 	"sort"
 	"sort"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
@@ -431,3 +432,71 @@ func TestReconcileNode_SelectedModeSweepsPrefixedSelectedTag(t *testing.T) {
 		t.Fatalf("deleted remote ids = %v, want [2] (prefixed selected tag must be swept, unmanaged 3 must survive)", got)
 		t.Fatalf("deleted remote ids = %v, want [2] (prefixed selected tag must be swept, unmanaged 3 must survive)", got)
 	}
 	}
 }
 }
+
+// Saving the node form marks the node dirty in the same transaction that grows
+// its managed set, so reconcile would sweep a tag the panel has not imported yet.
+func TestReconcileNode_SaveGrowingSelectionRearmsSweepGuard(t *testing.T) {
+	cases := []struct {
+		name        string
+		storedTags  []string
+		mode        string
+		tags        []string
+		wantDeleted []int
+	}{
+		{
+			name:        "newly selected tag is imported, not swept",
+			storedTags:  []string{"keep"},
+			mode:        "selected",
+			tags:        []string{"keep", "fresh"},
+			wantDeleted: nil,
+		},
+		{
+			name:        "switch to all mode imports before sweeping",
+			storedTags:  []string{"keep"},
+			mode:        "all",
+			wantDeleted: nil,
+		},
+		{
+			name:        "unchanged selection still sweeps a deleted tag",
+			storedTags:  []string{"keep", "gone"},
+			mode:        "selected",
+			tags:        []string{"keep", "gone"},
+			wantDeleted: []int{3},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupConflictDB(t)
+			ts, deletedIDs := fakeNodePanel(t, map[string]int{"keep": 1, "fresh": 2, "gone": 3})
+			node := reconcileTestNode(t, ts, "grow-node", "selected", tc.storedTags)
+			seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+
+			err := (&NodeService{}).UpdateFromRequest(node.Id, &NodeMutationRequest{
+				Name:                node.Name,
+				Scheme:              node.Scheme,
+				Address:             node.Address,
+				Port:                node.Port,
+				BasePath:            node.BasePath,
+				Enable:              true,
+				AllowPrivateAddress: true,
+				InboundSyncMode:     tc.mode,
+				InboundTags:         tc.tags,
+			})
+			if err != nil {
+				t.Fatalf("UpdateFromRequest: %v", err)
+			}
+			saved := &model.Node{}
+			if err := database.GetDB().First(saved, node.Id).Error; err != nil {
+				t.Fatalf("reload node: %v", err)
+			}
+
+			svc := InboundService{}
+			if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(saved, nil), saved); err != nil {
+				t.Fatalf("ReconcileNode: %v", err)
+			}
+			if got := deletedIDs(); !slices.Equal(got, tc.wantDeleted) {
+				t.Fatalf("deleted remote ids = %v, want %v", got, tc.wantDeleted)
+			}
+		})
+	}
+}

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

@@ -488,6 +488,24 @@ func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, er
 	return toNodeView(n), nil
 	return toNodeView(n), nil
 }
 }
 
 
+// nodeSelectionGrew reports a save that starts managing inbounds the panel has
+// not imported yet; the sweep must wait for the next clean sync to adopt them.
+func nodeSelectionGrew(existing, in *model.Node) bool {
+	if in.InboundSyncMode != "selected" {
+		return existing.InboundSyncMode == "selected"
+	}
+	old := make(map[string]struct{}, len(existing.InboundTags))
+	for _, tag := range existing.InboundTags {
+		old[tag] = struct{}{}
+	}
+	for _, tag := range in.InboundTags {
+		if _, ok := old[tag]; !ok {
+			return true
+		}
+	}
+	return false
+}
+
 func (s *NodeService) Update(id int, in *model.Node) error {
 func (s *NodeService) Update(id int, in *model.Node) error {
 	if err := s.normalize(in); err != nil {
 	if err := s.normalize(in); err != nil {
 		return err
 		return err
@@ -526,6 +544,9 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"inbound_tags":          string(inboundTagsJSON),
 		"inbound_tags":          string(inboundTagsJSON),
 		"outbound_tag":          in.OutboundTag,
 		"outbound_tag":          in.OutboundTag,
 	}
 	}
+	if nodeSelectionGrew(existing, in) {
+		updates["inbounds_adopted_at"] = 0
+	}
 	if err := db.Transaction(func(tx *gorm.DB) error {
 	if err := db.Transaction(func(tx *gorm.DB) error {
 		if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 			return err
 			return err
@@ -586,6 +607,9 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
 		"inbound_tags":          string(inboundTagsJSON),
 		"inbound_tags":          string(inboundTagsJSON),
 		"outbound_tag":          in.OutboundTag,
 		"outbound_tag":          in.OutboundTag,
 	}
 	}
+	if nodeSelectionGrew(existing, in) {
+		updates["inbounds_adopted_at"] = 0
+	}
 	if err := db.Transaction(func(tx *gorm.DB) error {
 	if err := db.Transaction(func(tx *gorm.DB) error {
 		if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 			return err
 			return err