feat(wireguard): make client allowedIPs editable with validation

The WireGuard peer address was allocated server-side and shown read-only
in the client editor, so changing it required hand-editing the inbound's
raw settings JSON (#5715). The backend add/update paths already honored a
submitted allowedIPs; only the form withheld it.

Make the field editable (comma-separated, empty still auto-assigns) and
validate submissions server-side: entries must parse as an IP or CIDR,
bare addresses normalize to single-host prefixes, and an address already
used by another peer on the inbound is rejected.

Closes #5715
This commit is contained in:
MHSanaei
2026-07-02 09:45:54 +02:00
parent 8dd3b31ee8
commit 64c306037f
17 changed files with 167 additions and 5 deletions
@@ -528,6 +528,26 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
if len(clients[0].AllowedIPs) == 0 {
clients[0].AllowedIPs = old.AllowedIPs
} else {
normalized, nErr := normalizeWireguardAllowedIPs(clients[0].AllowedIPs)
if nErr != nil {
return false, nErr
}
if len(normalized) == 0 {
clients[0].AllowedIPs = old.AllowedIPs
} else {
peers := make([]string, 0, len(oldClients))
for i := range oldClients {
if i == clientIndex {
continue
}
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)
}
clients[0].AllowedIPs = normalized
}
}
if clients[0].PreSharedKey == "" {
clients[0].PreSharedKey = old.PreSharedKey
+53
View File
@@ -73,6 +73,47 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
return "", common.NewError("wireguard: no free address available in", base)
}
// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
// canonicalizes them: bare addresses become single-host prefixes, duplicates drop.
func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
p, err := netip.ParsePrefix(v)
if err != nil {
a, aErr := netip.ParseAddr(v)
if aErr != nil {
return nil, common.NewError("wireguard: invalid allowedIPs entry:", v)
}
p = netip.PrefixFrom(a, a.BitLen())
}
norm := p.String()
if _, dup := seen[norm]; dup {
continue
}
seen[norm] = struct{}{}
out = append(out, norm)
}
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{}{}
}
for _, e := range entries {
if _, ok := taken[e]; ok {
return e
}
}
return ""
}
// defaultWireguardClients fills in blank WireGuard credentials for newly added
// clients: a generated keypair when none was provided, a derived public key when
// only a private key was given, and a unique tunnel address allocated from the
@@ -107,6 +148,18 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
return err
}
c.AllowedIPs = []string{addr}
} else {
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
if err != nil {
return err
}
if len(normalized) == 0 {
return common.NewError("wireguard: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
}
c.AllowedIPs = normalized
}
used = append(used, c.AllowedIPs...)
@@ -140,3 +140,67 @@ func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
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); 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"}})
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"}}); err == nil {
t.Fatal("invalid allowedIPs entry must be rejected")
}
}