From f3b100282afaa29dc16cbcf11752fd90d59d669f Mon Sep 17 00:00:00 2001 From: Xinny Lin <54943417+lin-finegold@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:02:22 +0800 Subject: [PATCH] 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 --- internal/web/service/port_conflict.go | 76 ++++++++++++++++--- internal/web/service/port_conflict_test.go | 28 ++++++- internal/web/service/xray_bind_conflict.go | 4 +- .../web/service/xray_bind_conflict_test.go | 24 ++++++ 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index 21b18b807..772616493 100644 --- a/internal/web/service/port_conflict.go +++ b/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{ diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index bfc1a32f4..31be5e2aa 100644 --- a/internal/web/service/port_conflict_test.go +++ b/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) + } + }) + } +} diff --git a/internal/web/service/xray_bind_conflict.go b/internal/web/service/xray_bind_conflict.go index 66688ce7e..89a035a6e 100644 --- a/internal/web/service/xray_bind_conflict.go +++ b/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 diff --git a/internal/web/service/xray_bind_conflict_test.go b/internal/web/service/xray_bind_conflict_test.go index 791d8a73c..66a21078e 100644 --- a/internal/web/service/xray_bind_conflict_test.go +++ b/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"},