mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 03:02:08 +03:00
c8a182b6fb
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
393 lines
16 KiB
Go
393 lines
16 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 TestAllocateWireguardAddressNoWideningFailsWhenPoolExhausted(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))
|
|
}
|
|
// allowWidening=false: AmneziaWG's own call. A full /24 must fail loudly
|
|
// instead of handing out an address from the containing /16 that the
|
|
// kernel interface's own Address never routes (PR #6105 Finding 12).
|
|
if _, err := allocateWireguardAddress(used, "10.0.0.0/24", false); err == nil {
|
|
t.Fatal("a full /24 with widening disabled must fail, not widen")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|