From 0d87bb8b4b2a18bedb6940f7afcf0a04fa235509 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 15 Jun 2026 17:21:06 +0200 Subject: [PATCH] fix(inbounds): flag conflicts with the reserved Xray API port (#5304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal API inbound (tag "api", default port 62789 on 127.0.0.1) lives in the Xray config template, not the inbounds table, so checkPortConflict never caught a local user inbound reusing it — Xray then bound the port twice and served requests unpredictably. Now reject a local TCP inbound whose listen overlaps loopback on the reserved API port, read from the template (fallback 62789). Nodes are unaffected since they run their own Xray. --- internal/web/service/port_conflict.go | 51 +++++++++++++++++- internal/web/service/port_conflict_test.go | 62 ++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index fec96fd1a..f36a2f9b7 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -115,8 +115,11 @@ func (d *portConflictDetail) String() string { } if name == "" { name = fmt.Sprintf("#%d", d.InboundID) - } else { + } else if d.InboundID > 0 { name = fmt.Sprintf("'%s' (#%d)", name, d.InboundID) + } else { + // reserved/system inbounds (e.g. the Xray API) have no DB id. + name = fmt.Sprintf("'%s'", name) } listen := d.Listen if isAnyListen(listen) { @@ -126,7 +129,52 @@ func (d *portConflictDetail) String() string { d.Port, transportTagSuffix(d.Transports), name, listen) } +// defaultXrayAPIPort is the loopback port of the internal Xray API inbound +// (tag "api") seeded into the config template. Used as a fallback when the +// template can't be parsed. +const defaultXrayAPIPort = 62789 + +// reservedAPIPort returns the port of the internal Xray API inbound declared +// in the config template, falling back to defaultXrayAPIPort. +func reservedAPIPort() int { + tmpl, err := (&SettingService{}).GetXrayConfigTemplate() + if err != nil || tmpl == "" { + return defaultXrayAPIPort + } + var parsed struct { + Inbounds []struct { + Port int `json:"port"` + Tag string `json:"tag"` + } `json:"inbounds"` + } + if json.Unmarshal([]byte(tmpl), &parsed) != nil { + return defaultXrayAPIPort + } + for _, in := range parsed.Inbounds { + if in.Tag == "api" && in.Port > 0 { + return in.Port + } + } + return defaultXrayAPIPort +} + func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) { + newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings) + + // The internal Xray API inbound (tag "api", loopback TCP) isn't a DB row, + // so a local user inbound reusing its port would leave Xray binding the + // port twice (#5304). Nodes run their own Xray, so this only applies to + // the local panel. + if inbound.NodeID == nil && inbound.Port == reservedAPIPort() && + newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) { + return &portConflictDetail{ + Tag: "api", + Listen: "127.0.0.1", + Port: inbound.Port, + Transports: transportTCP, + }, nil + } + db := database.GetDB() var candidates []*model.Inbound @@ -138,7 +186,6 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) return nil, err } - newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings) for _, c := range candidates { if !sameNode(c.NodeID, inbound.NodeID) { continue diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index 42a40a6a1..b8d91080a 100644 --- a/internal/web/service/port_conflict_test.go +++ b/internal/web/service/port_conflict_test.go @@ -669,3 +669,65 @@ func TestIsAutoGeneratedTag(t *testing.T) { }) } } + +// the internal Xray API inbound (tag "api", loopback TCP) isn't a DB row, so +// checkPortConflict must still reject a local user inbound that reuses its +// reserved port — otherwise Xray binds the port twice (#5304). +func TestCheckPortConflict_ReservedAPIPortBlockedLocal(t *testing.T) { + setupConflictDB(t) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "user-62789", + Listen: "0.0.0.0", + Port: defaultXrayAPIPort, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + } + got, err := svc.checkPortConflict(candidate, 0) + if err != nil { + t.Fatalf("checkPortConflict: %v", err) + } + if got == nil { + t.Fatalf("local inbound on the reserved API port %d must conflict", defaultXrayAPIPort) + } + if msg := got.String(); !strings.Contains(msg, "api") { + t.Fatalf("conflict message should name the api inbound; got %q", msg) + } +} + +// nodes run their own Xray with their own API port, so a node inbound on the +// central panel's reserved API port must be allowed. +func TestCheckPortConflict_ReservedAPIPortAllowedOnNode(t *testing.T) { + setupConflictDB(t) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "node-62789", + Listen: "0.0.0.0", + Port: defaultXrayAPIPort, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + NodeID: intPtr(1), + } + if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { + t.Fatalf("node inbound on the reserved API port must be allowed; got=%v err=%v", got, err) + } +} + +// the API inbound is TCP-only, so a UDP-only inbound (e.g. hysteria) may share +// its port — same tcp/udp coexistence the rest of the checks allow. +func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) { + setupConflictDB(t) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "hyst-62789", + Listen: "0.0.0.0", + Port: defaultXrayAPIPort, + Protocol: model.Hysteria, + } + if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { + t.Fatalf("udp-only inbound must coexist with the tcp API inbound; got=%v err=%v", got, err) + } +}