Parcourir la source

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 il y a 14 heures
Parent
commit
0d87bb8b4b

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