Explorar o código

fix(inbound): re-derive auto tags on edit and keep node tags consistent

Auto-generated inbound tags (in-<port>-<l4>, n<id>- prefixed for node inbounds) now re-derive when port/listen/transport change on update instead of keeping the stale round-tripped value. The resolved tag is mirrored onto the API response, and NodeID is pinned to the stored row so a node inbound never loses its n<id>- prefix on edit. The edit form recomputes the tag live via a Go-parity helper so the JSON preview matches what gets saved.

Make node/central tag matching prefix-agnostic in all three places (traffic attribution, remote-id resolution, and the orphan sweep) so an n<id>- prefix present on only one side can no longer spawn duplicate inbounds or drop traffic on sync.

Force LF on shell scripts via .gitattributes (CRLF broke the Docker build shebang when the repo is checked out on Windows) and add a .dockerignore to keep node_modules/.git out of the build context.

Adds Go and frontend tests covering tag re-derivation, prefix-agnostic matching, and node-snapshot prefix mismatch.
MHSanaei hai 8 horas
pai
achega
eb78b8666f

+ 8 - 0
.dockerignore

@@ -0,0 +1,8 @@
+.git
+**/node_modules
+web/dist
+build
+db
+cert
+pgdata
+*.db

+ 5 - 0
.gitattributes

@@ -0,0 +1,5 @@
+# Shell scripts must stay LF so the Docker build works when the repo is
+# checked out on Windows (CRLF breaks the script shebang -> exit 127).
+*.sh text eol=lf
+DockerInit.sh text eol=lf
+DockerEntrypoint.sh text eol=lf

+ 91 - 0
frontend/src/lib/xray/inbound-tag.ts

@@ -0,0 +1,91 @@
+// Client-side mirror of the backend inbound-tag derivation
+// (web/service/port_conflict.go). Keep in sync; inbound-tag.test.ts guards parity.
+
+type TransportBits = number;
+const TCP: TransportBits = 1;
+const UDP: TransportBits = 2;
+
+function asString(v: unknown): string {
+  return typeof v === 'string' ? v : '';
+}
+
+function inboundTransports(
+  protocol: string,
+  streamSettings: Record<string, unknown> | undefined,
+  settings: Record<string, unknown> | undefined,
+): TransportBits {
+  if (protocol === 'hysteria' || protocol === 'wireguard') return UDP;
+
+  let bits: TransportBits = 0;
+  const network = asString(streamSettings?.network);
+  if (network === 'kcp' || network === 'quic') bits |= UDP;
+  else bits |= TCP;
+
+  if (settings) {
+    if (protocol === 'shadowsocks' || protocol === 'tunnel') {
+      const key = protocol === 'tunnel' ? 'allowedNetwork' : 'network';
+      const n = asString(settings[key]);
+      if (n !== '') {
+        bits = 0;
+        for (const part of n.split(',')) {
+          const p = part.trim();
+          if (p === 'tcp') bits |= TCP;
+          else if (p === 'udp') bits |= UDP;
+        }
+      }
+    } else if (protocol === 'mixed') {
+      if (settings.udp === true) bits |= UDP;
+    }
+  }
+
+  if (bits === 0) bits = TCP;
+  return bits;
+}
+
+function transportTagSuffix(bits: TransportBits): string {
+  if (bits === TCP) return 'tcp';
+  if (bits === UDP) return 'udp';
+  if (bits === (TCP | UDP)) return 'tcpudp';
+  return 'any';
+}
+
+function isAnyListen(listen: string): boolean {
+  return listen === '' || listen === '0.0.0.0' || listen === '::' || listen === '::0';
+}
+
+function baseInboundTag(listen: string, port: number): string {
+  return isAnyListen(listen) ? `in-${port}` : `in-${listen}:${port}`;
+}
+
+function nodeTagPrefix(nodeId: number | null | undefined): string {
+  return nodeId == null ? '' : `n${nodeId}-`;
+}
+
+export interface InboundTagInput {
+  listen: string;
+  port: number;
+  nodeId: number | null | undefined;
+  protocol: string;
+  streamSettings?: Record<string, unknown>;
+  settings?: Record<string, unknown>;
+}
+
+export function composeInboundTag(input: InboundTagInput): string {
+  const bits = inboundTransports(input.protocol, input.streamSettings, input.settings);
+  return (
+    nodeTagPrefix(input.nodeId)
+    + baseInboundTag(input.listen ?? '', input.port ?? 0)
+    + '-'
+    + transportTagSuffix(bits)
+  );
+}
+
+export function isAutoInboundTag(tag: string, input: InboundTagInput): boolean {
+  if (tag === '') return true;
+  const base = composeInboundTag(input);
+  if (tag === base) return true;
+  const prefix = `${base}-`;
+  if (!tag.startsWith(prefix)) return false;
+  const suffix = tag.slice(prefix.length);
+  return suffix !== '' && /^[0-9]+$/.test(suffix);
+}

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

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import dayjs from 'dayjs';
 import {
@@ -20,6 +20,7 @@ import {
   formValuesToWirePayload,
 } from '@/lib/xray/inbound-form-adapter';
 import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
+import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
 import {
   canEnableReality,
   canEnableStream,
@@ -158,6 +159,23 @@ export default function InboundFormModal({
   const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
   const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
   const streamEnabled = canEnableStream({ protocol });
+
+  const wListen = Form.useWatch('listen', form) ?? '';
+  const wPort = Form.useWatch('port', form);
+  const wNodeId = Form.useWatch('nodeId', form) ?? null;
+  const wTag = Form.useWatch('tag', form) ?? '';
+  const wSsNetwork = Form.useWatch(['settings', 'network'], form);
+  const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
+  const autoTagRef = useRef(true);
+  const lastWrittenTagRef = useRef('');
+  const currentTagInput = (): InboundTagInput => ({
+    listen: typeof wListen === 'string' ? wListen : '',
+    port: typeof wPort === 'number' ? wPort : 0,
+    nodeId: typeof wNodeId === 'number' ? wNodeId : null,
+    protocol,
+    streamSettings: { network },
+    settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
+  });
   const isFallbackHost =
     (protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
     && network === 'tcp'
@@ -273,6 +291,16 @@ export default function InboundFormModal({
       : buildAddModeValues();
     form.resetFields();
     form.setFieldsValue(initial);
+    const initialTag = (initial.tag ?? '') as string;
+    autoTagRef.current = isAutoInboundTag(initialTag, {
+      listen: initial.listen ?? '',
+      port: initial.port ?? 0,
+      nodeId: initial.nodeId ?? null,
+      protocol: initial.protocol,
+      streamSettings: (initial.streamSettings ?? {}) as Record<string, unknown>,
+      settings: (initial.settings ?? {}) as Record<string, unknown>,
+    });
+    lastWrittenTagRef.current = initialTag;
     if (
       mode === 'edit'
       && dbInbound
@@ -286,6 +314,23 @@ export default function InboundFormModal({
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [open, mode, dbInbound, form]);
 
+  useEffect(() => {
+    if (!open) return;
+    if (wTag === lastWrittenTagRef.current) return;
+    autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, wTag]);
+
+  useEffect(() => {
+    if (!open || !autoTagRef.current) return;
+    const next = composeInboundTag(currentTagInput());
+    if (next !== (form.getFieldValue('tag') ?? '')) {
+      lastWrittenTagRef.current = next;
+      form.setFieldValue('tag', next);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, wListen, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
+
   // Why: protocol picker reset cascades through the form — clearing the
   // settings DU branch and dropping a nodeId that no longer applies. The
   // legacy modal did this imperatively in onProtocolChange; here we hook

+ 65 - 0
frontend/src/test/inbound-tag.test.ts

@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest';
+
+import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
+
+// Parity with web/service/port_conflict.go TestInboundTransports: the L4 suffix
+// the tag encodes must match the Go service so the form preview agrees with the
+// tag the backend re-derives on save.
+describe('composeInboundTag transport suffix parity', () => {
+  const base = (over: Partial<InboundTagInput>): InboundTagInput => ({
+    listen: '0.0.0.0',
+    port: 443,
+    nodeId: null,
+    protocol: 'vless',
+    ...over,
+  });
+
+  const cases: Array<[string, InboundTagInput, string]> = [
+    ['vless tcp', base({ streamSettings: { network: 'tcp' } }), 'in-443-tcp'],
+    ['vless ws (still tcp)', base({ streamSettings: { network: 'ws' } }), 'in-443-tcp'],
+    ['vless kcp is udp', base({ streamSettings: { network: 'kcp' } }), 'in-443-udp'],
+    ['vless quic is udp', base({ streamSettings: { network: 'quic' } }), 'in-443-udp'],
+    ['vless empty stream defaults tcp', base({}), 'in-443-tcp'],
+    ['vmess tcp', base({ protocol: 'vmess', streamSettings: { network: 'tcp' } }), 'in-443-tcp'],
+    ['trojan grpc is tcp', base({ protocol: 'trojan', streamSettings: { network: 'grpc' } }), 'in-443-tcp'],
+    ['hysteria forced udp', base({ protocol: 'hysteria', streamSettings: { network: 'tcp' } }), 'in-443-udp'],
+    ['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'],
+    ['shadowsocks tcp,udp', base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }), 'in-443-tcpudp'],
+    ['shadowsocks udp only', base({ protocol: 'shadowsocks', settings: { network: 'udp' } }), 'in-443-udp'],
+    ['shadowsocks tcp only', base({ protocol: 'shadowsocks', settings: { network: 'tcp' } }), 'in-443-tcp'],
+    ['mixed udp on', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: true } }), 'in-443-tcpudp'],
+    ['mixed udp off', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: false } }), 'in-443-tcp'],
+    ['tunnel allowedNetwork udp', base({ protocol: 'tunnel', settings: { allowedNetwork: 'udp' } }), 'in-443-udp'],
+  ];
+
+  it.each(cases)('%s', (_name, input, want) => {
+    expect(composeInboundTag(input)).toBe(want);
+  });
+
+  it('scopes a non-any listen and node prefix', () => {
+    expect(composeInboundTag(base({ listen: '127.0.0.1', port: 8443, streamSettings: { network: 'tcp' } })))
+      .toBe('in-127.0.0.1:8443-tcp');
+    expect(composeInboundTag(base({ nodeId: 1, port: 443, streamSettings: { network: 'tcp' } })))
+      .toBe('n1-in-443-tcp');
+  });
+});
+
+// Parity with TestIsAutoGeneratedTag.
+describe('isAutoInboundTag', () => {
+  const input: InboundTagInput = {
+    listen: '0.0.0.0', port: 443, nodeId: null, protocol: 'vless', streamSettings: { network: 'tcp' },
+  };
+
+  it('recognises canonical, dedup-suffixed and empty as auto', () => {
+    expect(isAutoInboundTag('in-443-tcp', input)).toBe(true);
+    expect(isAutoInboundTag('in-443-tcp-2', input)).toBe(true);
+    expect(isAutoInboundTag('', input)).toBe(true);
+  });
+
+  it('treats custom / stale / malformed-suffix tags as not auto', () => {
+    expect(isAutoInboundTag('my-custom', input)).toBe(false);
+    expect(isAutoInboundTag('in-8443-tcp', input)).toBe(false);
+    expect(isAutoInboundTag('in-443-tcp-x', input)).toBe(false);
+    expect(isAutoInboundTag('in-443-tcp-', input)).toBe(false);
+  });
+});

+ 16 - 2
web/runtime/remote.go

@@ -146,18 +146,32 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 }
 
 func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
-	if id, ok := r.cacheGet(tag); ok {
+	if id, ok := r.cacheGetTag(tag); ok {
 		return id, nil
 	}
 	if err := r.refreshRemoteIDs(ctx); err != nil {
 		return 0, err
 	}
-	if id, ok := r.cacheGet(tag); ok {
+	if id, ok := r.cacheGetTag(tag); ok {
 		return id, nil
 	}
 	return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
 }
 
+// cacheGetTag looks up a remote inbound id by tag, tolerating an n<id>- prefix
+// that lives on only one of the two panels: the node may carry the bare tag
+// while the central panel stores the prefixed form, or vice versa.
+func (r *Remote) cacheGetTag(tag string) (int, bool) {
+	if id, ok := r.cacheGet(tag); ok {
+		return id, true
+	}
+	prefix := fmt.Sprintf("n%d-", r.node.Id)
+	if stripped, found := strings.CutPrefix(tag, prefix); found {
+		return r.cacheGet(stripped)
+	}
+	return r.cacheGet(prefix + tag)
+}
+
 func (r *Remote) cacheGet(tag string) (int, bool) {
 	r.mu.RLock()
 	defer r.mu.RUnlock()

+ 31 - 0
web/runtime/remote_test.go

@@ -3,8 +3,39 @@ package runtime
 import (
 	"encoding/json"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 )
 
+// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
+// sits on only one side: the node may store the bare tag while the central
+// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
+// the push create a duplicate inbound on the node.
+func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
+	cases := []struct {
+		name      string
+		cacheTag  string
+		lookup    string
+		wantID    int
+		wantFound bool
+	}{
+		{"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true},
+		{"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true},
+		{"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true},
+		{"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			r := NewRemote(&model.Node{Id: 1, Name: "n1"})
+			r.cacheSet(c.cacheTag, 7)
+			id, ok := r.cacheGetTag(c.lookup)
+			if ok != c.wantFound || id != c.wantID {
+				t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound)
+			}
+		})
+	}
+}
+
 func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 	tests := []struct {
 		name  string

+ 25 - 4
web/service/inbound.go

@@ -758,8 +758,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err != nil {
 		return inbound, false, err
 	}
+	inbound.NodeID = oldInbound.NodeID
 
 	tag := oldInbound.Tag
+	oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings)
+	oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Listen, oldInbound.Port, oldInbound.NodeID, oldBits)
 
 	db := database.GetDB()
 	tx := db.Begin()
@@ -847,10 +850,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	oldInbound.Settings = inbound.Settings
 	oldInbound.StreamSettings = inbound.StreamSettings
 	oldInbound.Sniffing = inbound.Sniffing
+	if oldTagWasAuto && inbound.Tag == tag {
+		inbound.Tag = ""
+	}
 	oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id)
 	if err != nil {
 		return inbound, false, err
 	}
+	inbound.Tag = oldInbound.Tag
 
 	needRestart := false
 	rt, rterr := s.runtimeFor(oldInbound)
@@ -1267,14 +1274,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		Find(&central).Error; err != nil {
 		return false, err
 	}
-	// Index under both stored tag and the prefix-stripped form so a snap's
-	// bare tag resolves whether or not we rewrote it with n<id>- at create.
+	// Index under the stored tag and its prefix-flipped form so a snap matches
+	// whether the n<id>- prefix lives on the node side, the central side, or
+	// neither — a mismatch must never spawn a duplicate central inbound.
 	tagToCentral := make(map[string]*model.Inbound, len(central)*2)
 	prefix := nodeTagPrefix(&nodeID)
 	for i := range central {
 		tagToCentral[central[i].Tag] = &central[i]
-		if prefix != "" && strings.HasPrefix(central[i].Tag, prefix) {
-			tagToCentral[strings.TrimPrefix(central[i].Tag, prefix)] = &central[i]
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(central[i].Tag, prefix); found {
+				tagToCentral[stripped] = &central[i]
+			} else {
+				tagToCentral[prefix+central[i].Tag] = &central[i]
+			}
 		}
 	}
 
@@ -1329,6 +1341,15 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			continue
 		}
 		snapTags[snapIb.Tag] = struct{}{}
+		// Record the prefix-flipped form too so the orphan sweep below keeps a
+		// central inbound whether its tag carries the n<id>- prefix or not.
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(snapIb.Tag, prefix); found {
+				snapTags[stripped] = struct{}{}
+			} else {
+				snapTags[prefix+snapIb.Tag] = struct{}{}
+			}
+		}
 
 		c, ok := tagToCentral[snapIb.Tag]
 		if !ok {

+ 100 - 0
web/service/inbound_update_tag_test.go

@@ -0,0 +1,100 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+// changing an inbound's port must re-derive an auto-generated tag, both in
+// the persisted row and in the value returned to the caller (the API
+// response the UI renders). The UI round-trips the old tag in a hidden
+// field, so the update arrives carrying the stale tag.
+func TestUpdateInbound_RegeneratesAutoTagOnPortChange(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 33000
+	update.Tag = "in-22435-tcp"
+	got, _, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.Tag != "in-33000-tcp" {
+		t.Fatalf("persisted tag = %q, want in-33000-tcp", reloaded.Tag)
+	}
+	if got.Tag != "in-33000-tcp" {
+		t.Fatalf("returned tag = %q, want in-33000-tcp", got.Tag)
+	}
+}
+
+// a node-scoped inbound (tag carries the "n1-" prefix) must keep that prefix
+// when its port changes, even if the caller omits nodeId in the update body —
+// the node can't be migrated, so the stored NodeID drives the tag. The runtime
+// manager isn't wired in unit tests, so UpdateInbound returns a runtime error
+// for node inbounds before persisting; we assert on the tag it computed (set on
+// the returned object) which is what the save would use.
+func TestUpdateInbound_NodeTagKeepsPrefixWhenNodeIdOmitted(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflictNode(t, "n1-in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, intPtr(1))
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "n1-in-443-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 8443
+	update.Tag = "n1-in-443-tcp"
+	update.NodeID = nil
+	got, _, _ := svc.UpdateInbound(&update)
+	if got.Tag != "n1-in-8443-tcp" {
+		t.Fatalf("node prefix must survive a port change, got %q", got.Tag)
+	}
+}
+
+// a tag the user set by hand (doesn't match the canonical shape) survives a
+// port change untouched.
+func TestUpdateInbound_KeepsCustomTagOnPortChange(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "my-custom-tag", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "my-custom-tag").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 33000
+	update.Tag = "my-custom-tag"
+	got, _, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.Tag != "my-custom-tag" {
+		t.Fatalf("persisted tag = %q, want my-custom-tag", reloaded.Tag)
+	}
+	if got.Tag != "my-custom-tag" {
+		t.Fatalf("returned tag = %q, want my-custom-tag", got.Tag)
+	}
+}

+ 66 - 0
web/service/node_tag_sync_test.go

@@ -0,0 +1,66 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+)
+
+// A node-backed inbound whose central tag carries the n<id>- prefix must
+// survive a snapshot in which the node reports the bare tag (prefix lives on
+// the central side only). Before the fix the orphan sweep matched snapTags
+// exactly, so it deleted and recreated the inbound on every sync — churning
+// its id and dropping traffic for that cycle.
+func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	const nodeID = 1
+	id := nodeID
+	central := &model.Inbound{
+		UserId:   1,
+		NodeID:   &id,
+		Tag:      "n1-in-443-tcp",
+		Enable:   true,
+		Port:     443,
+		Protocol: model.VLESS,
+		Settings: `{"clients":[]}`,
+	}
+	if err := db.Create(central).Error; err != nil {
+		t.Fatalf("create node inbound: %v", err)
+	}
+	centralID := central.Id
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{
+			Tag:      "in-443-tcp",
+			Enable:   true,
+			Port:     443,
+			Protocol: model.VLESS,
+			Settings: `{"clients":[]}`,
+			Up:       1000,
+			Down:     2000,
+		}},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var rows []model.Inbound
+	if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil {
+		t.Fatalf("list node inbounds: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("expected exactly 1 node inbound (no churn), got %d", len(rows))
+	}
+	if rows[0].Id != centralID {
+		t.Fatalf("inbound was deleted+recreated: id %d -> %d", centralID, rows[0].Id)
+	}
+	if rows[0].Up != 1000 || rows[0].Down != 2000 {
+		t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down)
+	}
+}

+ 17 - 0
web/service/port_conflict.go

@@ -204,6 +204,23 @@ func composeInboundTag(listen string, port int, nodeID *int, bits transportBits)
 	return nodeTagPrefix(nodeID) + baseInboundTag(listen, port) + "-" + transportTagSuffix(bits)
 }
 
+func isAutoGeneratedTag(tag, listen string, port int, nodeID *int, bits transportBits) bool {
+	base := composeInboundTag(listen, port, nodeID, bits)
+	if tag == base {
+		return true
+	}
+	suffix, ok := strings.CutPrefix(tag, base+"-")
+	if !ok || suffix == "" {
+		return false
+	}
+	for _, r := range suffix {
+		if r < '0' || r > '9' {
+			return false
+		}
+	}
+	return true
+}
+
 func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
 	bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
 	candidate := composeInboundTag(inbound.Listen, inbound.Port, inbound.NodeID, bits)

+ 34 - 0
web/service/port_conflict_test.go

@@ -635,3 +635,37 @@ func TestCheckPortConflict_DetailMessage(t *testing.T) {
 		t.Fatalf("message should mention the port; got %q", msg)
 	}
 }
+
+// isAutoGeneratedTag must recognise the tags generateInboundTag emits (so an
+// edit that changes port/transport re-derives them) while leaving user-typed
+// or cross-panel tags untouched.
+func TestIsAutoGeneratedTag(t *testing.T) {
+	tcp := transportTCP
+	cases := []struct {
+		name   string
+		tag    string
+		listen string
+		port   int
+		nodeID *int
+		bits   transportBits
+		want   bool
+	}{
+		{"canonical", "in-443-tcp", "0.0.0.0", 443, nil, tcp, true},
+		{"canonical udp", "in-443-udp", "0.0.0.0", 443, nil, transportUDP, true},
+		{"dedup suffix", "in-443-tcp-2", "0.0.0.0", 443, nil, tcp, true},
+		{"listen scoped", "in-127.0.0.1:443-tcp", "127.0.0.1", 443, nil, tcp, true},
+		{"node prefixed", "n1-in-443-tcp", "0.0.0.0", 443, intPtr(1), tcp, true},
+		{"custom tag", "my-cool-tag", "0.0.0.0", 443, nil, tcp, false},
+		{"stale port", "in-443-tcp", "0.0.0.0", 8443, nil, tcp, false},
+		{"stale transport", "in-443-tcp", "0.0.0.0", 443, nil, transportUDP, false},
+		{"non-numeric suffix", "in-443-tcp-x", "0.0.0.0", 443, nil, tcp, false},
+		{"empty suffix", "in-443-tcp-", "0.0.0.0", 443, nil, tcp, false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := isAutoGeneratedTag(c.tag, c.listen, c.port, c.nodeID, c.bits); got != c.want {
+				t.Fatalf("isAutoGeneratedTag(%q) = %v, want %v", c.tag, got, c.want)
+			}
+		})
+	}
+}