From aa60d54ea5669b743bdeceac82ac507fc9611479 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Fri, 24 Jul 2026 22:21:18 +0200 Subject: [PATCH] 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. --- internal/web/service/client_wireguard.go | 25 +++++++----- internal/web/service/client_wireguard_test.go | 38 ++++++++++++++++++- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/internal/web/service/client_wireguard.go b/internal/web/service/client_wireguard.go index d4736a25b..afa4b8688 100644 --- a/internal/web/service/client_wireguard.go +++ b/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) } - addr = addr.Next() } - return "", common.NewError("wireguard: no free address available in", base) + 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() + } + } + return "", common.NewError("wireguard: no free address available in", scopes[len(scopes)-1].String()) } // normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and diff --git a/internal/web/service/client_wireguard_test.go b/internal/web/service/client_wireguard_test.go index 76c77a4ad..336688d18 100644 --- a/internal/web/service/client_wireguard_test.go +++ b/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"}}