fix(wireguard): reject allowedIPs that overlap another client's range

xray's WireGuard inbound credits a packet to the first peer whose allowedIPs
contain its source address, and routes replies by the same table. The panel
only rejected an allowedIPs entry that was string-equal to another client's,
so a pre-assigned address typed in interface notation (10.10.2.9/24, as other
WireGuard tools export it) was accepted and claimed the whole /24: other
clients' traffic and online IPs were credited to that one email, and replies
went to a peer with no endpoint ("no known endpoint for peer").

The collision check now compares masked ranges on every path that uses it:
add, edit, the cross-inbound recheck inside the write transaction, and
AmneziaWG. Auto-allocation skips any address inside a prefix another client
holds. A /0 default route still claims nothing, as before, because legacy
migrated peers carry one. Clients already saved with overlapping ranges keep
working as they do today until edited. The API docs describing the error are
updated, including the stale claim that cross-inbound duplicates are accepted.

Closes #6623
This commit is contained in:
MHSanaei
2026-09-27 18:18:50 +02:00
parent 4df570b3b0
commit c8a182b6fb
8 changed files with 115 additions and 38 deletions
+4 -4
View File
@@ -93,11 +93,11 @@ func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Clie
if len(normalized) == 0 {
return common.NewError("amneziawg: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
if where := crossInboundUsed[hit]; where != "" {
return common.NewError("amneziawg: allowedIPs entry", hit, "is already used by a client on", where)
if entry, taken := wireguardAllowedIPsOverlap(normalized, used); taken != "" {
if where := crossInboundUsed[taken]; where != "" {
return common.NewError("amneziawg: allowedIPs entry", entry, "overlaps", taken, "used by a client on", where)
}
return common.NewError("amneziawg: allowedIPs entry already used by another client:", hit)
return common.NewError("amneziawg: allowedIPs entry", entry, "overlaps", taken, "used by another client")
}
c.AllowedIPs = normalized
}
+4 -4
View File
@@ -539,8 +539,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
crossAddrs = append(crossAddrs, addr)
}
for i := range clients {
if hit := wireguardAllowedIPsCollision(clients[i].AllowedIPs, crossAddrs); hit != "" {
return common.NewError("allowedIPs entry", hit, "is already used by a client on", crossUsed[hit])
if entry, taken := wireguardAllowedIPsOverlap(clients[i].AllowedIPs, crossAddrs); taken != "" {
return common.NewError("allowedIPs entry", entry, "overlaps", taken, "used by a client on", crossUsed[taken])
}
}
}
@@ -760,8 +760,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
peers = append(peers, oldClients[i].AllowedIPs...)
}
if hit := wireguardAllowedIPsCollision(normalized, peers); hit != "" {
return false, common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
if entry, taken := wireguardAllowedIPsOverlap(normalized, peers); taken != "" {
return false, common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by another client")
}
clients[0].AllowedIPs = normalized
}
+59 -14
View File
@@ -105,10 +105,28 @@ func allocateWireguardAddress(used []string, base string, allowWidening bool) (s
hostBits = "128"
}
taken := make(map[netip.Addr]struct{}, len(used))
var wide []netip.Prefix
for _, u := range used {
if a := wireguardHostAddr(u); a.IsValid() {
taken[a] = struct{}{}
p, ok := wireguardClaimedPrefix(u)
if !ok {
continue
}
if p.IsSingleIP() {
taken[p.Addr()] = struct{}{}
} else {
wide = append(wide, p)
}
}
isTaken := func(a netip.Addr) bool {
if _, ok := taken[a]; ok {
return true
}
for _, p := range wide {
if p.Contains(a) {
return true
}
}
return false
}
scopes := []netip.Prefix{prefix}
if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
@@ -119,7 +137,7 @@ func allocateWireguardAddress(used []string, base string, allowWidening bool) (s
for _, scope := range scopes {
addr := scope.Masked().Addr().Next().Next()
for scope.Contains(addr) {
if _, ok := taken[addr]; !ok {
if !isTaken(addr) {
return addr.String() + "/" + hostBits, nil
}
addr = addr.Next()
@@ -156,17 +174,44 @@ func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
return out, nil
}
func wireguardAllowedIPsCollision(entries, used []string) string {
taken := make(map[string]struct{}, len(used))
for _, u := range used {
taken[strings.TrimSpace(u)] = struct{}{}
// wireguardClaimedPrefix is the masked range an allowedIPs entry claims, as xray
// reads it. A /0 default route claims no tunnel address, as legacy peers carry it.
func wireguardClaimedPrefix(s string) (netip.Prefix, bool) {
s = strings.TrimSpace(s)
p, err := netip.ParsePrefix(s)
if err != nil {
a, aErr := netip.ParseAddr(s)
if aErr != nil {
return netip.Prefix{}, false
}
p = netip.PrefixFrom(a, a.BitLen())
}
if p.Bits() == 0 {
return netip.Prefix{}, false
}
return p.Masked(), true
}
// wireguardAllowedIPsOverlap returns the first entry whose range overlaps a used
// one, and that used entry; xray routes and attributes by containment, not equality.
func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) {
usedPrefixes := make([]netip.Prefix, len(used))
usedOK := make([]bool, len(used))
for i, u := range used {
usedPrefixes[i], usedOK[i] = wireguardClaimedPrefix(u)
}
for _, e := range entries {
if _, ok := taken[e]; ok {
return e
ep, ok := wireguardClaimedPrefix(e)
if !ok {
continue
}
for i, up := range usedPrefixes {
if usedOK[i] && ep.Overlaps(up) {
return e, used[i]
}
}
}
return ""
return "", ""
}
// defaultWireguardClients fills in blank WireGuard credentials for newly added
@@ -231,11 +276,11 @@ func defaultWireguardClients(settingsJSON string, existing, clients []model.Clie
if len(normalized) == 0 {
return common.NewError("wireguard: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
if where := crossInboundUsed[hit]; where != "" {
return common.NewError("wireguard: allowedIPs entry", hit, "is already used by a client on", where)
if entry, taken := wireguardAllowedIPsOverlap(normalized, used); taken != "" {
if where := crossInboundUsed[taken]; where != "" {
return common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by a client on", where)
}
return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
return common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by another client")
}
c.AllowedIPs = normalized
}
@@ -362,3 +362,31 @@ func TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet(t *testing.T) {
t.Fatalf("with no explicit subnet, inference from existing clients must still apply; got %v", got)
}
}
// xray credits a packet to the first peer whose allowedIPs contain its source,
// so a prefix covering another client's address steals that client's traffic.
func TestDefaultWireguardClientsRejectsOverlappingAllowedIPs(t *testing.T) {
existing := []model.Client{{Email: "a@wg", AllowedIPs: []string{"10.10.2.9/24"}}}
clients := []model.Client{{Email: "b@wg", AllowedIPs: []string{"10.10.2.52/24"}}}
err := defaultWireguardClients("", existing, clients, []any{map[string]any{"email": "b@wg"}}, nil)
if err == nil || !strings.Contains(err.Error(), "10.10.2.52/24") || !strings.Contains(err.Error(), "10.10.2.9/24") {
t.Fatalf("overlapping allowedIPs must be rejected naming both entries, got: %v", err)
}
crossUsed := map[string]string{"10.8.1.0/24": "inbound 'awg' (#10)"}
inside := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.8.1.21/32"}}}
err = defaultWireguardClients("", nil, inside, []any{map[string]any{"email": "c@wg"}}, crossUsed)
if err == nil || !strings.Contains(err.Error(), "inbound 'awg' (#10)") {
t.Fatalf("an address inside another inbound's prefix must be rejected naming that inbound, got: %v", err)
}
}
func TestAllocateWireguardAddressSkipsAddressesInsideUsedPrefixes(t *testing.T) {
got, err := allocateWireguardAddress([]string{"10.0.0.2/31"}, "10.0.0.0/24", true)
if err != nil {
t.Fatalf("allocateWireguardAddress: %v", err)
}
if got != "10.0.0.4/32" {
t.Fatalf("got %s, want 10.0.0.4/32: .2 and .3 are both inside the used 10.0.0.2/31", got)
}
}