Просмотр исходного кода

fix: allow IPv4 and IPv6 inbounds to share a port (#6603)

* fix: distinguish IPv4 and IPv6 listen conflicts

* fix(ports): let an IPv4 address share a port only with a v6only wildcard

xray listens on tcp/udp, and Go opens every wildcard listen, 0.0.0.0
included, as one dual-stack socket unless sockopt.v6only is set. Treating
:: and 0.0.0.0 as separate families let the panel save pairs the core
then fails to bind, and it broke main's own TestListenOverlaps.

listenOverlaps now takes the inbound's sockopt.v6only: a wildcard claims
both families, or only IPv6 with v6only, so :: with v6only may share its
port with an IPv4 address while a plain :: or 0.0.0.0 still may not.

---------

Co-authored-by: MHSanaei <[email protected]>
Xinny Lin 7 часов назад
Родитель
Сommit
f3b100282a

+ 67 - 9
internal/web/service/port_conflict.go

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/json"
 	"fmt"
+	"net"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
@@ -91,11 +92,68 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 	return bits
 }
 
-func listenOverlaps(a, b string) bool {
-	if isAnyListen(a) || isAnyListen(b) {
+// bindAddr is a listen address plus sockopt.v6only. xray listens on "tcp"/"udp",
+// so Go opens every wildcard, 0.0.0.0 included, dual-stack unless v6only is set.
+type bindAddr struct {
+	listen string
+	v6only bool
+}
+
+var loopbackBind = bindAddr{listen: "127.0.0.1"}
+
+func inboundBindAddr(ib *model.Inbound) bindAddr {
+	return bindAddr{listen: ib.Listen, v6only: streamV6Only(ib.StreamSettings)}
+}
+
+func streamV6Only(streamSettings string) bool {
+	if !strings.Contains(streamSettings, "v6only") {
+		return false
+	}
+	var stream struct {
+		Sockopt struct {
+			V6Only bool `json:"v6only"`
+		} `json:"sockopt"`
+	}
+	_ = json.Unmarshal([]byte(streamSettings), &stream)
+	return stream.Sockopt.V6Only
+}
+
+func listenOverlaps(a, b bindAddr) bool {
+	if a.listen == b.listen {
 		return true
 	}
-	return a == b
+	familiesA, wildcardA, okA := bindFamilies(a)
+	familiesB, wildcardB, okB := bindFamilies(b)
+	if !okA || !okB {
+		return wildcardA || wildcardB
+	}
+	return (wildcardA || wildcardB) && familiesA&familiesB != 0
+}
+
+type addrFamily uint8
+
+const (
+	familyIPv4 addrFamily = 1 << iota
+	familyIPv6
+)
+
+// bindFamilies reports the address families a listen claims; ok is false for a
+// listen that is not an IP, such as a unix socket path.
+func bindFamilies(a bindAddr) (families addrFamily, wildcard, ok bool) {
+	if isAnyListen(a.listen) {
+		if a.v6only {
+			return familyIPv6, true, true
+		}
+		return familyIPv4 | familyIPv6, true, true
+	}
+	ip := net.ParseIP(a.listen)
+	if ip == nil {
+		return 0, false, false
+	}
+	if ip.To4() != nil {
+		return familyIPv4, false, true
+	}
+	return familyIPv6, false, true
 }
 
 func isAnyListen(s string) bool {
@@ -189,7 +247,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 	// 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) {
+		newBits&transportTCP != 0 && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
 		return &portConflictDetail{
 			Tag:        "api",
 			Listen:     "127.0.0.1",
@@ -201,7 +259,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 	// Egress SOCKS server holds loopback EgressBasePort when AWG outbounds are
 	// active; conflict check prevents inbounds from colliding with it.
 	if inbound.NodeID == nil && inbound.Port == int(amneziawgnet.EgressBasePort) &&
-		newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) {
+		newBits&transportTCP != 0 && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
 		return &portConflictDetail{
 			Tag:        "amneziawg-egress",
 			Listen:     "127.0.0.1",
@@ -218,7 +276,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 	// see it. Without this check, an unrelated inbound saved onto that exact
 	// port silently fails at the next Xray start, taking every other
 	// protocol down with it, not just AmneziaWG.
-	if inbound.NodeID == nil && listenOverlaps("127.0.0.1", inbound.Listen) {
+	if inbound.NodeID == nil && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
 		conflict, err := checkAmneziawgnetSocksConflict(db, inbound, ignoreId, newBits)
 		if err != nil {
 			return nil, err
@@ -274,7 +332,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 		if !sameNode(c.NodeID, inbound.NodeID) {
 			continue
 		}
-		if !listenOverlaps(c.Listen, inbound.Listen) {
+		if !listenOverlaps(inboundBindAddr(c), inboundBindAddr(inbound)) {
 			continue
 		}
 		existingBits := inboundTransports(c.Protocol, c.StreamSettings, c.Settings)
@@ -392,7 +450,7 @@ func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDet
 // amneziawgnetSocksSelfConflict: a row's own WireGuard port vs the relay port its
 // own id derives -- all three checks below exclude that id, so nothing else does.
 func amneziawgnetSocksSelfConflict(inbound *model.Inbound, id int) string {
-	if id <= 0 || inbound.NodeID != nil || !listenOverlaps("127.0.0.1", inbound.Listen) {
+	if id <= 0 || inbound.NodeID != nil || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
 		return ""
 	}
 	relayPort := amneziawgnet.SOCKSPortForInbound(id)
@@ -414,7 +472,7 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe
 		return nil, err
 	}
 	for _, c := range candidates {
-		if !listenOverlaps("127.0.0.1", c.Listen) {
+		if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
 			continue
 		}
 		return &portConflictDetail{

+ 27 - 1
internal/web/service/port_conflict_test.go

@@ -114,7 +114,7 @@ func TestListenOverlaps(t *testing.T) {
 		{"1.2.3.4", "::1", false},
 	}
 	for _, c := range cases {
-		if got := listenOverlaps(c.a, c.b); got != c.want {
+		if got := listenOverlaps(bindAddr{listen: c.a}, bindAddr{listen: c.b}); got != c.want {
 			t.Errorf("listenOverlaps(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
 		}
 	}
@@ -927,3 +927,29 @@ func TestCheckPortConflict_AmneziawgnetSocksRelayReverseDirectionBlockedOnUpdate
 		t.Fatalf("awg-1's own derived relay port %d collides with vless-1's real port; must be rejected", relayPort)
 	}
 }
+
+// xray binds "::" dual-stack unless sockopt.v6only is set, so only then may an
+// IPv4 address share its port; the flag is read from the saved streamSettings.
+func TestCheckPortConflict_V6OnlyWildcardLeavesIPv4AddressFree(t *testing.T) {
+	for _, tc := range []struct {
+		name   string
+		stream string
+		want   bool
+	}{
+		{"dual-stack", `{"network":"tcp"}`, true},
+		{"v6only", `{"network":"tcp","sockopt":{"v6only":true}}`, false},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			setupConflictDB(t)
+			seedInboundConflict(t, "vless-v6", "::", 443, model.VLESS, tc.stream, `{}`)
+			v4 := &model.Inbound{Tag: "vless-v4", Listen: "10.5.0.200", Port: 443, Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`}
+			exist, err := (&InboundService{}).checkPortConflict(v4, 0)
+			if err != nil {
+				t.Fatalf("checkPortConflict: %v", err)
+			}
+			if got := exist != nil; got != tc.want {
+				t.Fatalf("conflict = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}

+ 3 - 1
internal/web/service/xray_bind_conflict.go

@@ -66,7 +66,9 @@ func rawBindConflicts(cfg *xray.Config) []bindConflict {
 			for j := i + 1; j < len(group); j++ {
 				left, right := group[i], group[j]
 				listenLeft, listenRight := configListen(left.Listen), configListen(right.Listen)
-				if !listenOverlaps(listenLeft, listenRight) {
+				bindLeft := bindAddr{listen: listenLeft, v6only: streamV6Only(string(left.StreamSettings))}
+				bindRight := bindAddr{listen: listenRight, v6only: streamV6Only(string(right.StreamSettings))}
+				if !listenOverlaps(bindLeft, bindRight) {
 					continue
 				}
 				// One port carrying tcp on one inbound and udp on another is a

+ 24 - 0
internal/web/service/xray_bind_conflict_test.go

@@ -53,6 +53,30 @@ func TestBindConflicts(t *testing.T) {
 			 {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b","streamSettings":{"network":"tcp"}}`,
 			``, 0,
 		},
+		{
+			"ipv4 and ipv6 wildcards bind one dual-stack socket",
+			`{"listen":"::","port":443,"protocol":"vless","tag":"ipv6"},
+			 {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"ipv4"}`,
+			``, 1,
+		},
+		{
+			"dual-stack ipv6 wildcard takes an ipv4 address",
+			`{"listen":"::","port":443,"protocol":"vless","tag":"ipv6"},
+			 {"listen":"10.5.0.200","port":443,"protocol":"vless","tag":"ipv4"}`,
+			``, 1,
+		},
+		{
+			"v6only ipv6 wildcard leaves an ipv4 address free",
+			`{"listen":"::","port":443,"protocol":"vless","tag":"ipv6","streamSettings":{"network":"tcp","sockopt":{"v6only":true}}},
+			 {"listen":"10.5.0.200","port":443,"protocol":"vless","tag":"ipv4"}`,
+			``, 0,
+		},
+		{
+			"v6only ipv6 wildcard still collides with the dual-stack 0.0.0.0",
+			`{"listen":"::","port":443,"protocol":"vless","tag":"ipv6","streamSettings":{"network":"tcp","sockopt":{"v6only":true}}},
+			 {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"ipv4"}`,
+			``, 1,
+		},
 		{
 			"wildcard listen overlaps a loopback one",
 			`{"listen":"0.0.0.0","port":8443,"protocol":"vless","tag":"a"},