From 64c306037ff404ecf9d655a90f76e938e92158fb Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 2 Jul 2026 09:45:54 +0200 Subject: [PATCH] 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 --- .../src/pages/clients/ClientFormModal.tsx | 22 +++++-- internal/web/service/client_inbound_apply.go | 20 ++++++ internal/web/service/client_wireguard.go | 53 +++++++++++++++ internal/web/service/client_wireguard_test.go | 64 +++++++++++++++++++ internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 17 files changed, 167 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index bd6cadd0c..3fa543ef3 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -492,6 +492,13 @@ export default function ClientFormModal({ if (form.wgPreSharedKey) { clientPayload.preSharedKey = form.wgPreSharedKey; } + const allowedIPs = form.wgAllowedIPs + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''); + if (allowedIPs.length > 0) { + clientPayload.allowedIPs = allowedIPs; + } } const externalLinks: ExternalLinkInput[] = form.externalLinks @@ -802,11 +809,16 @@ export default function ClientFormModal({ onChange={(e) => update('wgPreSharedKey', e.target.value)} /> - {isEdit && form.wgAllowedIPs && ( - - - - )} + + update('wgAllowedIPs', e.target.value)} + /> + )} diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index 6170dff2a..f1c0856be 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -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 diff --git a/internal/web/service/client_wireguard.go b/internal/web/service/client_wireguard.go index 261a8e363..d4736a25b 100644 --- a/internal/web/service/client_wireguard.go +++ b/internal/web/service/client_wireguard.go @@ -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...) diff --git a/internal/web/service/client_wireguard_test.go b/internal/web/service/client_wireguard_test.go index eb35d975c..76c77a4ad 100644 --- a/internal/web/service/client_wireguard_test.go +++ b/internal/web/service/client_wireguard_test.go @@ -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") + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 1bc15cdb2..170e88af4 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "مفتاح وايرغارد العام", "wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا", "wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد", + "wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل", "reverseTag": "وسم عكسي", "reverseTagPlaceholder": "Reverse tag اختياري", "telegramId": "معرّف مستخدم تلغرام", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 6e8694f7f..d0c49d600 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "WireGuard Public Key", "wireguardPreSharedKey": "WireGuard Pre-Shared Key", "wireguardAllowedIPs": "WireGuard Allowed IPs", + "wireguardAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas", "reverseTag": "Reverse tag", "reverseTagPlaceholder": "Optional reverse tag", "telegramId": "Telegram user ID", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 834441195..ad7916a53 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "Clave pública de WireGuard", "wireguardPreSharedKey": "Clave precompartida de WireGuard", "wireguardAllowedIPs": "IP permitidas de WireGuard", + "wireguardAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas", "reverseTag": "Etiqueta inversa", "reverseTagPlaceholder": "Reverse tag opcional", "telegramId": "ID de usuario de Telegram", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 7e7d1472b..211ee8eaf 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "کلید عمومی وایرگارد", "wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد", "wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد", + "wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید", "reverseTag": "تگ معکوس", "reverseTagPlaceholder": "Reverse tag اختیاری", "telegramId": "شناسه کاربر تلگرام", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 2140c0a6f..a07bf2016 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "Kunci Publik WireGuard", "wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard", "wireguardAllowedIPs": "IP yang Diizinkan WireGuard", + "wireguardAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma", "reverseTag": "Reverse tag", "reverseTagPlaceholder": "Reverse tag opsional", "telegramId": "ID pengguna Telegram", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index dfe8876f0..8bab29a0a 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "WireGuard 公開鍵", "wireguardPreSharedKey": "WireGuard 事前共有鍵", "wireguardAllowedIPs": "WireGuard 許可IP", + "wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り", "reverseTag": "Reverse tag", "reverseTagPlaceholder": "任意の Reverse tag", "telegramId": "Telegram ユーザー ID", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 4c3f5fcfd..c845e22c9 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "Chave pública do WireGuard", "wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard", "wireguardAllowedIPs": "IPs permitidos do WireGuard", + "wireguardAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas", "reverseTag": "Tag reversa", "reverseTagPlaceholder": "Reverse tag opcional", "telegramId": "ID de usuário do Telegram", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 21e087650..6ce1279d0 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "Публичный ключ WireGuard", "wireguardPreSharedKey": "Общий ключ WireGuard", "wireguardAllowedIPs": "Разрешённые IP WireGuard", + "wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми", "reverseTag": "Обратный тег", "reverseTagPlaceholder": "Необязательный Reverse tag", "telegramId": "ID пользователя Telegram", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 9ee216198..56b8be2f7 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "WireGuard Genel Anahtarı", "wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar", "wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler", + "wireguardAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın", "reverseTag": "Reverse Tag", "reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag", "telegramId": "Telegram Kullanıcı ID'si", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 3c71f7f9f..f0be8eea0 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "Публічний ключ WireGuard", "wireguardPreSharedKey": "Спільний ключ WireGuard", "wireguardAllowedIPs": "Дозволені IP WireGuard", + "wireguardAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами", "reverseTag": "Зворотний тег", "reverseTagPlaceholder": "Необов'язковий Reverse tag", "telegramId": "ID користувача Telegram", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 6d4befbcf..e324cce13 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "Khóa công khai WireGuard", "wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard", "wireguardAllowedIPs": "IP được phép WireGuard", + "wireguardAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy", "reverseTag": "Reverse tag", "reverseTagPlaceholder": "Reverse tag tùy chọn", "telegramId": "ID người dùng Telegram", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index df1083dc7..13f7d78c8 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "WireGuard 公钥", "wireguardPreSharedKey": "WireGuard 预共享密钥", "wireguardAllowedIPs": "WireGuard 允许的 IP", + "wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔", "reverseTag": "反向标签", "reverseTagPlaceholder": "可选 Reverse tag", "telegramId": "Telegram 用户 ID", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index b3c8f2d44..561683a96 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -900,6 +900,7 @@ "wireguardPublicKey": "WireGuard 公鑰", "wireguardPreSharedKey": "WireGuard 預共用金鑰", "wireguardAllowedIPs": "WireGuard 允許的 IP", + "wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔", "reverseTag": "反向標籤", "reverseTagPlaceholder": "選用 Reverse tag", "telegramId": "Telegram 使用者 ID",