From da9ecf6f4d808516bf2bc9ce024604ae192ede1f Mon Sep 17 00:00:00 2001 From: aleskxyz <39186039+aleskxyz@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:39:55 +0200 Subject: [PATCH] fix(nodes): strip central n- tag prefix when pushing inbounds to remote (#5399) The central panel stores node inbounds with an n- 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 <39186039+aleskxyz@users.noreply.github.com> --- internal/web/runtime/remote.go | 30 ++++++++++++++++++---- internal/web/runtime/remote_test.go | 40 +++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index a42cd8a77..94d78c465 100644 --- a/internal/web/runtime/remote.go +++ b/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- 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- 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 { diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index cc228b4de..09f80ddc5 100644 --- a/internal/web/runtime/remote_test.go +++ b/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