Files
3x-ui/internal/web/service/client_wireguard_test.go
T
Kuzz007 569b7fbc6b Give WireGuard an explicit, admin-configurable subnet field
WireGuard previously had no configurable subnet at all -- only an
implicit one, either inferred from existing clients' own addresses
(wireguardAllocationBase) or a hardcoded 10.0.0.0/24 fallback when
none exist yet. AmneziaWG, by contrast, has always had a real
server.subnetIp/subnetCidr field in its settings, editable in the
UI. User request: give WireGuard the same treatment.

Backend: explicitWireguardSubnetBase reads an optional subnetIp/
subnetCidr pair from the inbound's own settings JSON (mirroring
AmneziaWG's defaultAmneziaWGSubnetBases). defaultWireguardClients
checks it first; only when unset does it fall back to today's
inference-from-existing-clients behavior, so an inbound saved before
this field existed keeps working exactly as it always has.

Frontend: subnetIp/subnetCidr added to WireguardInboundSettingsSchema
and the inbound form (mirroring AmneziaWG's own field layout/labels),
with a real default (10.0.0.0/24, the same value the backend already
fell back to) seeded for newly created inbounds so the field starts
populated and editable rather than blank. Translated across all 13
locales.

This also structurally closes the class of bug fixed in
82cc69f5/291c47b3: with wg and awg subnets explicit and
independently controllable, an admin who wants matching addresses
across both protocols can configure them to actually agree, instead
of one silently inheriting the other's incompatible range.
2026-08-04 11:27:51 +03:00

366 lines
15 KiB
Go

package service
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestAllocateWireguardAddress(t *testing.T) {
tests := []struct {
name string
used []string
base string
want string
err bool
}{
{name: "empty starts at .2", used: nil, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "skips used", used: []string{"10.0.0.2/32"}, base: "10.0.0.0/24", want: "10.0.0.3/32"},
{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: "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) {
got, err := allocateWireguardAddress(tt.used, tt.base, true)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestDefaultWireguardClientsGeneratesKeypair(t *testing.T) {
clients := []model.Client{{Email: "a@wg"}}
ifaces := []any{map[string]any{"email": "a@wg"}}
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
c := clients[0]
if c.PrivateKey == "" || c.PublicKey == "" {
t.Fatalf("keypair not generated: priv=%q pub=%q", c.PrivateKey, c.PublicKey)
}
if len(c.AllowedIPs) != 1 || c.AllowedIPs[0] != "10.0.0.2/32" {
t.Fatalf("allowedIPs not allocated: %v", c.AllowedIPs)
}
m := ifaces[0].(map[string]any)
if m["privateKey"] != c.PrivateKey || m["publicKey"] != c.PublicKey {
t.Fatalf("interface map not updated: %v", m)
}
}
func TestDefaultWireguardClientsDerivesPublicKey(t *testing.T) {
priv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatal(err)
}
wantPub, err := wgutil.PublicKeyFromPrivate(priv)
if err != nil {
t.Fatal(err)
}
clients := []model.Client{{Email: "b@wg", PrivateKey: priv}}
ifaces := []any{map[string]any{"email": "b@wg"}}
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PublicKey != wantPub {
t.Fatalf("derived public key = %q, want %q", clients[0].PublicKey, wantPub)
}
}
func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
clients := []model.Client{{
Email: "c@wg",
PrivateKey: "keep-priv",
PublicKey: "keep-pub",
AllowedIPs: []string{"10.0.0.50/32"},
}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
t.Fatalf("provided keys were rotated: %+v", clients[0])
}
if clients[0].AllowedIPs[0] != "10.0.0.50/32" {
t.Fatalf("provided allowedIPs changed: %v", clients[0].AllowedIPs)
}
}
func TestWireguardAllocationBase(t *testing.T) {
tests := []struct {
name string
used []string
fallback string
want string
}{
{name: "no peers uses fallback", used: nil, fallback: "10.0.0.0/24", want: "10.0.0.0/24"},
{name: "derives subnet from existing peer", used: []string{"172.16.0.2/32"}, fallback: "10.0.0.0/24", want: "172.16.0.0/24"},
{name: "skips catch-all and ipv6", used: []string{"0.0.0.0/0", "::/0", "fd00::2/128", "192.168.5.7/32"}, fallback: "10.0.0.0/24", want: "192.168.5.0/24"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := wireguardAllocationBase(tt.used, tt.fallback); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
clients := []model.Client{{Email: "new@wg"}}
ifaces := []any{map[string]any{"email": "new@wg"}}
if err := defaultWireguardClients("", existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "172.16.0.3/32" {
t.Fatalf("new client address = %q, want 172.16.0.3/32 in existing subnet", got)
}
}
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", true)
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", true)
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", true)
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 TestAllocateWireguardAddressWithoutWideningFailsClosed(t *testing.T) {
// AmneziaWG's caller passes allowWidening=false: an address outside the
// kernel interface's own configured subnet would never actually route,
// so exhausting the pool must error instead of silently handing out an
// address from a wider scope (unlike plain WireGuard's allowWidening=true).
used := make([]string, 0, 254)
for i := 2; i <= 255; i++ {
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
}
if _, err := allocateWireguardAddress(used, "10.0.0.0/24", false); err == nil {
t.Fatal("expected an error on a full /24 with widening disabled, got nil")
}
}
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"}}
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] == clients[1].AllowedIPs[0] {
t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
}
}
func TestNormalizeWireguardAllowedIPs(t *testing.T) {
tests := []struct {
name string
in []string
want []string
err bool
}{
{name: "cidr passes through", in: []string{"10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
{name: "bare ipv4 becomes /32", in: []string{"10.0.0.5"}, want: []string{"10.0.0.5/32"}},
{name: "bare ipv6 becomes /128", in: []string{"fd00::5"}, want: []string{"fd00::5/128"}},
{name: "trims and drops empties", in: []string{" 10.0.0.5/32 ", "", " "}, want: []string{"10.0.0.5/32"}},
{name: "dedupes", in: []string{"10.0.0.5/32", "10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
{name: "routed subnet allowed", in: []string{"10.0.0.5/32", "192.168.1.0/24"}, want: []string{"10.0.0.5/32", "192.168.1.0/24"}},
{name: "garbage rejected", in: []string{"not-an-ip"}, err: true},
{name: "bad prefix rejected", in: []string{"10.0.0.5/99"}, err: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeWireguardAllowedIPs(tt.in)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %v", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
clients := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.0.0.9"}}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients("", existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if len(clients[0].AllowedIPs) != 1 || clients[0].AllowedIPs[0] != "10.0.0.9/32" {
t.Fatalf("supplied allowedIPs not normalized: %v", clients[0].AllowedIPs)
}
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
err := defaultWireguardClients("", existing, dup, []any{map[string]any{"email": "d@wg"}}, nil)
if err == nil {
t.Fatal("duplicate allowedIPs across clients must be rejected")
}
bad := []model.Client{{Email: "e@wg", AllowedIPs: []string{"not-an-ip"}}}
if err := defaultWireguardClients("", existing, bad, []any{map[string]any{"email": "e@wg"}}, nil); err == nil {
t.Fatal("invalid allowedIPs entry must be rejected")
}
}
// A duplicate manually-typed address is rejected even when the OTHER holder
// lives on a completely different inbound (e.g. a WireGuard client and an
// AmneziaWG peer given the same address by habit) -- this is the exact
// real-world scenario that motivated crossInboundUsed: two inbounds sharing
// a subnet must not be able to silently hand out or accept the same address.
func TestDefaultWireguardClientsRejectsCrossInboundDuplicate(t *testing.T) {
crossUsed := map[string]string{"10.8.1.21/32": "inbound 'awg' (#10)"}
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.8.1.21/32"}}}
err := defaultWireguardClients("", nil, dup, []any{map[string]any{"email": "d@wg"}}, crossUsed)
if err == nil {
t.Fatal("allowedIPs already used on another inbound must be rejected")
}
if !strings.Contains(err.Error(), "inbound 'awg' (#10)") {
t.Fatalf("error should name the other inbound holding the address, got: %v", err)
}
}
// Auto-allocation (no AllowedIPs supplied) must also skip addresses already
// claimed on another inbound, not just ones used on this one.
func TestDefaultWireguardClientsAutoAllocateSkipsCrossInboundUsed(t *testing.T) {
crossUsed := map[string]string{"10.0.0.2/32": "inbound 'other-wg' (#7)"}
clients := []model.Client{{Email: "f@wg"}}
ifaces := []any{map[string]any{"email": "f@wg"}}
if err := defaultWireguardClients("", nil, clients, ifaces, crossUsed); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] != "10.0.0.3/32" {
t.Fatalf("auto-allocation should skip the cross-inbound-used .2 and pick .3, got %v", clients[0].AllowedIPs)
}
}
// crossInboundUsed must never influence which subnet THIS inbound's own new
// clients get allocated from -- only existing (this inbound's own clients)
// may do that. Otherwise a brand-new WireGuard inbound on a panel that
// already has an unrelated AmneziaWG inbound would infer the wrong base
// subnet purely from the other inbound's addresses.
func TestDefaultWireguardClientsCrossInboundUsedDoesNotSkewSubnetInference(t *testing.T) {
crossUsed := map[string]string{"10.8.1.21/32": "inbound 'awg' (#10)"}
clients := []model.Client{{Email: "g@wg"}}
ifaces := []any{map[string]any{"email": "g@wg"}}
if err := defaultWireguardClients("", nil, clients, ifaces, crossUsed); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "10.0.0.2/32" {
t.Fatalf("base subnet must stay the default 10.0.0.0/24, not be skewed by a cross-inbound address; got %v", got)
}
}
func TestExplicitWireguardSubnetBase(t *testing.T) {
tests := []struct {
name string
settingsJSON string
want string
}{
{name: "unset settings", settingsJSON: `{"secretKey":"x"}`, want: ""},
{name: "empty subnetIp", settingsJSON: `{"subnetIp":"","subnetCidr":24}`, want: ""},
{name: "zero cidr", settingsJSON: `{"subnetIp":"10.8.1.0","subnetCidr":0}`, want: ""},
{name: "invalid ip", settingsJSON: `{"subnetIp":"not-an-ip","subnetCidr":24}`, want: ""},
{name: "invalid json", settingsJSON: `not json`, want: ""},
{name: "configured subnet", settingsJSON: `{"subnetIp":"10.8.1.0","subnetCidr":24}`, want: "10.8.1.0/24"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := explicitWireguardSubnetBase(tt.settingsJSON); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
// TestDefaultWireguardClientsPrefersExplicitSubnetOverInference is the
// backend half of a user-requested feature: WireGuard previously had no
// admin-configurable subnet at all, only an implicit one (inferred from
// existing clients' own addresses, or a hardcoded 10.0.0.0/24 fallback when
// none exist yet) -- unlike AmneziaWG, which has always had a real
// server.subnetIp/subnetCidr field. An explicit subnetIp/subnetCidr in the
// inbound's own settings must now win outright, even when existing clients
// would otherwise suggest a different base via wireguardAllocationBase.
func TestDefaultWireguardClientsPrefersExplicitSubnetOverInference(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
clients := []model.Client{{Email: "new@wg"}}
ifaces := []any{map[string]any{"email": "new@wg"}}
settingsJSON := `{"subnetIp":"10.8.1.0","subnetCidr":24}`
if err := defaultWireguardClients(settingsJSON, existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "10.8.1.2/32" {
t.Fatalf("explicit subnet must win over inference from existing clients (172.16.0.0/24); got %v", got)
}
}
// TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet locks in the
// backward-compat half of the same feature: an inbound saved before this
// field existed (settingsJSON carries no subnetIp/subnetCidr at all) must
// keep allocating exactly as it always has.
func TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
clients := []model.Client{{Email: "new@wg"}}
ifaces := []any{map[string]any{"email": "new@wg"}}
settingsJSON := `{"secretKey":"x","peers":[],"clients":[]}`
if err := defaultWireguardClients(settingsJSON, existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "172.16.0.3/32" {
t.Fatalf("with no explicit subnet, inference from existing clients must still apply; got %v", got)
}
}