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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
Xinny Lin
2026-09-27 04:02:22 +08:00
committed by GitHub
parent bd01f923fb
commit f3b100282a
4 changed files with 121 additions and 11 deletions
+67 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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"},