Browse Source

fix(wireguard): widen the client address pool past a full /24 (#6089)

allocateWireguardAddress scanned exactly one /24, so a WireGuard inbound
was hard-capped at 254 clients with no way out -- the pool is not
configurable anywhere in the UI or API.

Fill the inbound's own /24 first, then widen to the enclosing /16 instead
of failing. A wireguard inbound carries no interface subnet and xray
routes purely by each peer's allowedIPs, so nothing constrains the wider
address. Capped at /16 to keep the worst-case scan bounded; IPv4 only.
Sanaei 16 hours ago
parent
commit
aa60d54ea5

+ 16 - 9
internal/web/service/client_wireguard.go

@@ -46,9 +46,8 @@ func wireguardAllocationBase(used []string, fallback string) string {
 	return fallback
 }
 
-// allocateWireguardAddress returns the first free /32 host address in base that
-// is not already present in used. The server holds the first host (.1), so
-// allocation starts at the second host (.2).
+const wireguardPoolFloorBits = 16
+
 func allocateWireguardAddress(used []string, base string) (string, error) {
 	if base == "" {
 		base = defaultWireguardBase
@@ -63,14 +62,22 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
 			taken[a] = struct{}{}
 		}
 	}
-	addr := prefix.Masked().Addr().Next().Next()
-	for prefix.Contains(addr) {
-		if _, ok := taken[addr]; !ok {
-			return addr.String() + "/32", nil
+	scopes := []netip.Prefix{prefix}
+	if prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
+		if wider, wErr := prefix.Addr().Prefix(wireguardPoolFloorBits); wErr == nil {
+			scopes = append(scopes, wider)
+		}
+	}
+	for _, scope := range scopes {
+		addr := scope.Masked().Addr().Next().Next()
+		for scope.Contains(addr) {
+			if _, ok := taken[addr]; !ok {
+				return addr.String() + "/32", nil
+			}
+			addr = addr.Next()
 		}
-		addr = addr.Next()
 	}
-	return "", common.NewError("wireguard: no free address available in", base)
+	return "", common.NewError("wireguard: no free address available in", scopes[len(scopes)-1].String())
 }
 
 // normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and

+ 37 - 1
internal/web/service/client_wireguard_test.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -20,7 +21,8 @@ func TestAllocateWireguardAddress(t *testing.T) {
 		{name: "fills gap", used: []string{"10.0.0.3/32", "10.0.0.4/32"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
 		{name: "ignores catch-all", used: []string{"0.0.0.0/0", "::/0"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
 		{name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"},
-		{name: "exhausted /30", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", err: true},
+		{name: "full ipv4 scope widens instead of failing", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", want: "10.9.0.4/32"},
+		{name: "exhausted ipv6 scope errors", used: []string{"fd00::2/128", "fd00::3/128"}, base: "fd00::/126", err: true},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -130,6 +132,40 @@ func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
 	}
 }
 
+func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
+	used := make([]string, 0, 254)
+	for i := 2; i <= 255; i++ {
+		used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
+	}
+
+	got, err := allocateWireguardAddress(used, "10.0.0.0/24")
+	if err != nil {
+		t.Fatalf("allocate with a full /24: %v", err)
+	}
+	if got != "10.0.1.0/32" {
+		t.Fatalf("address after a full /24 = %q, want 10.0.1.0/32", got)
+	}
+
+	used = append(used, got)
+	next, err := allocateWireguardAddress(used, "10.0.0.0/24")
+	if err != nil {
+		t.Fatalf("allocate after widening: %v", err)
+	}
+	if next != "10.0.1.1/32" {
+		t.Fatalf("second widened address = %q, want 10.0.1.1/32", next)
+	}
+}
+
+func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
+	got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24")
+	if err != nil {
+		t.Fatalf("allocateWireguardAddress: %v", err)
+	}
+	if got != "172.16.0.3/32" {
+		t.Fatalf("address = %q, want 172.16.0.3/32 — the inbound's own /24 comes first", got)
+	}
+}
+
 func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
 	clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
 	ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}