mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 01:32:08 +03:00
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:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user