Browse Source

fix(nodes): strip central n<id>- tag prefix when pushing inbounds to remote (#5399)

The central panel stores node inbounds with an n<id>- prefix so tags stay
unique in its database, but pushes were sending that prefixed tag to the
remote node. A no-op save or reconcile could rename the remote inbound and
break Xray routing rules that still referenced the original tag.

Strip only this node's prefix in wireInbound before add/update so the remote
keeps its bare tag while central retains the aliased form locally.

Signed-off-by: aleskxyz <[email protected]>
aleskxyz 1 day ago
parent
commit
da9ecf6f4d
2 changed files with 60 additions and 10 deletions
  1. 25 5
      internal/web/runtime/remote.go
  2. 35 5
      internal/web/runtime/remote_test.go

+ 25 - 5
internal/web/runtime/remote.go

@@ -286,6 +286,22 @@ func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
 	return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
 }
 
+// nodeInboundTagPrefix is the central-panel alias for an inbound on nodeID.
+// Kept in sync with service.nodeTagPrefix (port_conflict.go); duplicated here
+// so runtime does not import service.
+func nodeInboundTagPrefix(nodeID int) string {
+	return fmt.Sprintf("n%d-", nodeID)
+}
+
+// stripNodeInboundTagPrefix removes the central-only n<id>- prefix before
+// pushing an inbound to the node so Xray keeps its original tag and routing.
+func stripNodeInboundTagPrefix(nodeID int, tag string) string {
+	if stripped, ok := strings.CutPrefix(tag, nodeInboundTagPrefix(nodeID)); ok {
+		return stripped
+	}
+	return tag
+}
+
 // 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.
@@ -293,7 +309,7 @@ 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)
+	prefix := nodeInboundTagPrefix(r.node.Id)
 	if stripped, found := strings.CutPrefix(tag, prefix); found {
 		return r.cacheGet(stripped)
 	}
@@ -370,7 +386,7 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
 }
 
 func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error {
-	payload := wireInbound(ib)
+	payload := wireInbound(ib, r.node.Id)
 	env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload)
 	if err != nil {
 		return err
@@ -405,7 +421,7 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
 	if err != nil {
 		return r.AddInbound(ctx, newIb)
 	}
-	payload := wireInbound(newIb)
+	payload := wireInbound(newIb, r.node.Id)
 	if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil {
 		return err
 	}
@@ -609,7 +625,7 @@ func (r *Remote) PushGlobalClientTraffics(ctx context.Context, masterGuid string
 	return err
 }
 
-func wireInbound(ib *model.Inbound) url.Values {
+func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
 	v := url.Values{}
 	v.Set("total", strconv.FormatInt(ib.Total, 10))
 	v.Set("remark", ib.Remark)
@@ -621,7 +637,11 @@ func wireInbound(ib *model.Inbound) url.Values {
 	v.Set("protocol", string(ib.Protocol))
 	v.Set("settings", ib.Settings)
 	v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
-	v.Set("tag", ib.Tag)
+	tag := ib.Tag
+	if remoteNodeID > 0 {
+		tag = stripNodeInboundTagPrefix(remoteNodeID, tag)
+	}
+	v.Set("tag", tag)
 	v.Set("sniffing", ib.Sniffing)
 	shareAddrStrategy := strings.TrimSpace(ib.ShareAddrStrategy)
 	switch shareAddrStrategy {

+ 35 - 5
internal/web/runtime/remote_test.go

@@ -144,7 +144,7 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) {
 	values := wireInbound(&model.Inbound{
 		ShareAddrStrategy: "custom",
 		ShareAddr:         "edge.example.com",
-	})
+	}, 0)
 
 	if got := values.Get("shareAddrStrategy"); got != "custom" {
 		t.Fatalf("shareAddrStrategy = %q, want custom", got)
@@ -252,30 +252,60 @@ func TestIsNonEmptySlice(t *testing.T) {
 }
 
 func TestWireInboundTrafficReset(t *testing.T) {
-	with := wireInbound(&model.Inbound{TrafficReset: "daily"})
+	with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
 	if got := with.Get("trafficReset"); got != "daily" {
 		t.Fatalf("trafficReset = %q, want daily", got)
 	}
 	// Empty TrafficReset must be omitted entirely, not sent as an empty field.
-	without := wireInbound(&model.Inbound{})
+	without := wireInbound(&model.Inbound{}, 0)
 	if without.Has("trafficReset") {
 		t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
 	}
 }
 
 func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
-	values := wireInbound(&model.Inbound{})
+	values := wireInbound(&model.Inbound{}, 0)
 
 	if got := values.Get("shareAddrStrategy"); got != "node" {
 		t.Fatalf("shareAddrStrategy = %q, want node", got)
 	}
 
-	values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"})
+	values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"}, 0)
 	if got := values.Get("shareAddrStrategy"); got != "node" {
 		t.Fatalf("invalid shareAddrStrategy = %q, want node", got)
 	}
 }
 
+func TestStripNodeInboundTagPrefix(t *testing.T) {
+	cases := []struct {
+		nodeID int
+		tag    string
+		want   string
+	}{
+		{2, "n2-in-443-tcp", "in-443-tcp"},
+		{2, "in-443-tcp", "in-443-tcp"},
+		{2, "my-custom", "my-custom"},
+		{2, "n3-in-443-tcp", "n3-in-443-tcp"},
+		{0, "n2-in-443-tcp", "n2-in-443-tcp"},
+	}
+	for _, c := range cases {
+		if got := stripNodeInboundTagPrefix(c.nodeID, c.tag); got != c.want {
+			t.Fatalf("stripNodeInboundTagPrefix(%d, %q) = %q, want %q", c.nodeID, c.tag, got, c.want)
+		}
+	}
+}
+
+func TestWireInboundStripsNodeTagOnPush(t *testing.T) {
+	values := wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 2)
+	if got := values.Get("tag"); got != "in-443-tcp" {
+		t.Fatalf("tag = %q, want in-443-tcp", got)
+	}
+	values = wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 0)
+	if got := values.Get("tag"); got != "n2-in-443-tcp" {
+		t.Fatalf("nodeID 0 must not strip, got %q", got)
+	}
+}
+
 func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 	tests := []struct {
 		name  string