Browse Source

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
MHSanaei 2 ngày trước cách đây
mục cha
commit
64c306037f

+ 17 - 5
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)}
                           />
                         </Form.Item>
-                        {isEdit && form.wgAllowedIPs && (
-                          <Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
-                            <Input value={form.wgAllowedIPs} disabled />
-                          </Form.Item>
-                        )}
+                        <Form.Item
+                          label={t('pages.clients.wireguardAllowedIPs')}
+                          extra={t('pages.clients.wireguardAllowedIPsHint')}
+                        >
+                          <Input
+                            value={form.wgAllowedIPs}
+                            placeholder="10.0.0.2/32"
+                            onChange={(e) => update('wgAllowedIPs', e.target.value)}
+                          />
+                        </Form.Item>
                       </>
                     )}
                   </>

+ 20 - 0
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

+ 53 - 0
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...)
 

+ 64 - 0
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")
+	}
+}

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -900,6 +900,7 @@
       "wireguardPublicKey": "مفتاح وايرغارد العام",
       "wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
       "wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
+      "wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
       "reverseTag": "وسم عكسي",
       "reverseTagPlaceholder": "Reverse tag اختياري",
       "telegramId": "معرّف مستخدم تلغرام",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -900,6 +900,7 @@
       "wireguardPublicKey": "کلید عمومی وایرگارد",
       "wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
       "wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
+      "wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
       "reverseTag": "تگ معکوس",
       "reverseTagPlaceholder": "Reverse tag اختیاری",
       "telegramId": "شناسه کاربر تلگرام",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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",