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",