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 ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"strings" "strings"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
@@ -91,11 +92,68 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
return bits return bits
} }
func listenOverlaps(a, b string) bool { // bindAddr is a listen address plus sockopt.v6only. xray listens on "tcp"/"udp",
if isAnyListen(a) || isAnyListen(b) { // 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 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 { 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 // port twice (#5304). Nodes run their own Xray, so this only applies to
// the local panel. // the local panel.
if inbound.NodeID == nil && inbound.Port == reservedAPIPort() && 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{ return &portConflictDetail{
Tag: "api", Tag: "api",
Listen: "127.0.0.1", 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 // Egress SOCKS server holds loopback EgressBasePort when AWG outbounds are
// active; conflict check prevents inbounds from colliding with it. // active; conflict check prevents inbounds from colliding with it.
if inbound.NodeID == nil && inbound.Port == int(amneziawgnet.EgressBasePort) && 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{ return &portConflictDetail{
Tag: "amneziawg-egress", Tag: "amneziawg-egress",
Listen: "127.0.0.1", 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 // see it. Without this check, an unrelated inbound saved onto that exact
// port silently fails at the next Xray start, taking every other // port silently fails at the next Xray start, taking every other
// protocol down with it, not just AmneziaWG. // 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) conflict, err := checkAmneziawgnetSocksConflict(db, inbound, ignoreId, newBits)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -274,7 +332,7 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
if !sameNode(c.NodeID, inbound.NodeID) { if !sameNode(c.NodeID, inbound.NodeID) {
continue continue
} }
if !listenOverlaps(c.Listen, inbound.Listen) { if !listenOverlaps(inboundBindAddr(c), inboundBindAddr(inbound)) {
continue continue
} }
existingBits := inboundTransports(c.Protocol, c.StreamSettings, c.Settings) 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 // 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. // own id derives -- all three checks below exclude that id, so nothing else does.
func amneziawgnetSocksSelfConflict(inbound *model.Inbound, id int) string { 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 "" return ""
} }
relayPort := amneziawgnet.SOCKSPortForInbound(id) relayPort := amneziawgnet.SOCKSPortForInbound(id)
@@ -414,7 +472,7 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe
return nil, err return nil, err
} }
for _, c := range candidates { for _, c := range candidates {
if !listenOverlaps("127.0.0.1", c.Listen) { if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
continue continue
} }
return &portConflictDetail{ return &portConflictDetail{
+27 -1
View File
@@ -114,7 +114,7 @@ func TestListenOverlaps(t *testing.T) {
{"1.2.3.4", "::1", false}, {"1.2.3.4", "::1", false},
} }
for _, c := range cases { 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) 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) 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++ { for j := i + 1; j < len(group); j++ {
left, right := group[i], group[j] left, right := group[i], group[j]
listenLeft, listenRight := configListen(left.Listen), configListen(right.Listen) 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 continue
} }
// One port carrying tcp on one inbound and udp on another is a // 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"}}`, {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b","streamSettings":{"network":"tcp"}}`,
``, 0, ``, 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", "wildcard listen overlaps a loopback one",
`{"listen":"0.0.0.0","port":8443,"protocol":"vless","tag":"a"}, `{"listen":"0.0.0.0","port":8443,"protocol":"vless","tag":"a"},