فهرست منبع

fix(inbounds): flag conflicts with the reserved Xray API port (#5304)

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.
MHSanaei 13 ساعت پیش
والد
کامیت
0d87bb8b4b
2فایلهای تغییر یافته به همراه111 افزوده شده و 2 حذف شده
  1. 49 2
      internal/web/service/port_conflict.go
  2. 62 0
      internal/web/service/port_conflict_test.go

+ 49 - 2
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

+ 62 - 0
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)
+	}
+}