Give WireGuard an explicit, admin-configurable subnet field

WireGuard previously had no configurable subnet at all -- only an
implicit one, either inferred from existing clients' own addresses
(wireguardAllocationBase) or a hardcoded 10.0.0.0/24 fallback when
none exist yet. AmneziaWG, by contrast, has always had a real
server.subnetIp/subnetCidr field in its settings, editable in the
UI. User request: give WireGuard the same treatment.

Backend: explicitWireguardSubnetBase reads an optional subnetIp/
subnetCidr pair from the inbound's own settings JSON (mirroring
AmneziaWG's defaultAmneziaWGSubnetBases). defaultWireguardClients
checks it first; only when unset does it fall back to today's
inference-from-existing-clients behavior, so an inbound saved before
this field existed keeps working exactly as it always has.

Frontend: subnetIp/subnetCidr added to WireguardInboundSettingsSchema
and the inbound form (mirroring AmneziaWG's own field layout/labels),
with a real default (10.0.0.0/24, the same value the backend already
fell back to) seeded for newly created inbounds so the field starts
populated and editable rather than blank. Translated across all 13
locales.

This also structurally closes the class of bug fixed in
82cc69f5/291c47b3: with wg and awg subnets explicit and
independently controllable, an admin who wants matching addresses
across both protocols can configure them to actually agree, instead
of one silently inheriting the other's incompatible range.
This commit is contained in:
Kuzz007
2026-08-04 11:27:51 +03:00
parent 291c47b3fb
commit 569b7fbc6b
22 changed files with 175 additions and 16 deletions
+10
View File
@@ -257,12 +257,20 @@ export interface WireguardInboundSeed {
mtu?: number;
secretKey?: string;
noKernelTun?: boolean;
subnetIp?: string;
subnetCidr?: number;
}
// WireGuard is multi-client now: a new inbound holds only the server identity
// (secretKey/mtu) and starts with no clients. Clients (peers) are added later
// through the client modal, which generates each one's keypair and a unique
// tunnel address. peers stays empty for backward-compatible parsing.
//
// subnetIp/subnetCidr default to 10.0.0.0/24 here — the same value the Go
// backend has always fallen back to for an inbound with no clients yet — so
// a freshly created inbound shows an explicit, editable value from the
// start (matching AmneziaWG's own subnet field), rather than an empty one
// that silently relies on server-side inference until an admin fills it in.
export function createDefaultWireguardInboundSettings(
seed: WireguardInboundSeed = {},
): WireguardInboundSettings {
@@ -272,6 +280,8 @@ export function createDefaultWireguardInboundSettings(
peers: [],
clients: [],
noKernelTun: seed.noKernelTun ?? false,
subnetIp: seed.subnetIp ?? '10.0.0.0',
subnetCidr: seed.subnetCidr ?? 24,
};
}
@@ -24,6 +24,12 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
<Input value={wgPubKey} disabled />
</Form.Item>
<FormField name={['settings', 'subnetIp']} label={t('pages.xray.wireguard.subnetIp')}>
<Input placeholder="10.0.0.0" />
</FormField>
<FormField name={['settings', 'subnetCidr']} label={t('pages.xray.wireguard.subnetCidr')}>
<InputNumber min={1} max={32} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'mtu']} label="MTU">
<InputNumber />
</FormField>
@@ -66,5 +66,12 @@ export const WireguardInboundSettingsSchema = z.object({
clients: z.array(WireguardClientSchema).default([]),
noKernelTun: z.boolean().default(false),
domainStrategy: WireguardDomainStrategySchema.optional(),
// Admin-configurable base subnet new clients are auto-allocated from —
// mirrors AmneziaWG's settings.server.subnetIp/subnetCidr. Optional and
// left blank by default: an inbound that never sets this keeps the
// pre-existing behavior (infer from existing clients' own addresses, else
// fall back to 10.0.0.0/24 server-side).
subnetIp: z.string().default(''),
subnetCidr: optionalClearedInt(z.number().int().min(1).max(32)),
});
export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;
@@ -54,6 +54,8 @@ exports[`createDefault*InboundSettings factories > wireguard 1`] = `
"noKernelTun": false,
"peers": [],
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
"subnetCidr": 24,
"subnetIp": "10.0.0.0",
}
`;
@@ -622,6 +622,7 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
},
],
"secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
"subnetIp": "",
},
"shareAddr": "",
"shareAddrStrategy": "node",
@@ -248,6 +248,7 @@ exports[`InboundSettingsSchema fixtures > parses wireguard-basic byte-stably 1`]
},
],
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
"subnetIp": "",
},
}
`;
+1 -1
View File
@@ -422,7 +422,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
return false, cErr
}
if oldInbound.Protocol == model.WireGuard {
if dErr := defaultWireguardClients(existingClients, clients, interfaceClients, crossUsed); dErr != nil {
if dErr := defaultWireguardClients(oldInbound.Settings, existingClients, clients, interfaceClients, crossUsed); dErr != nil {
return false, dErr
}
}
+50 -4
View File
@@ -1,6 +1,8 @@
package service
import (
"encoding/json"
"fmt"
"net/netip"
"strconv"
"strings"
@@ -12,6 +14,41 @@ import (
const defaultWireguardBase = "10.0.0.0/24"
// wireguardSubnetSettings is the subset of a WireGuard inbound's top-level
// settings JSON this package cares about for subnet resolution. Unlike
// AmneziaWG (whose whole settings shape is a typed struct in
// internal/amneziawg), plain WireGuard has no dedicated Go struct on this
// fork's side at all -- everything else is handled as untyped
// map[string]any -- so this stays a narrow, local decode rather than
// introducing a full struct just for two fields.
type wireguardSubnetSettings struct {
SubnetIP string `json:"subnetIp"`
SubnetCIDR int `json:"subnetCidr"`
}
// explicitWireguardSubnetBase resolves an admin-configured subnet base out
// of settingsJSON's own subnetIp/subnetCidr fields, mirroring AmneziaWG's
// defaultAmneziaWGSubnetBases. Returns "" when either field is unset/empty
// or doesn't parse as a valid prefix -- callers fall back to
// wireguardAllocationBase's existing infer-from-clients behavior in that
// case, so an inbound saved before this field existed (or one that simply
// never set it) keeps behaving exactly as it always has.
func explicitWireguardSubnetBase(settingsJSON string) string {
var parsed wireguardSubnetSettings
if err := json.Unmarshal([]byte(settingsJSON), &parsed); err != nil {
return ""
}
ip := strings.TrimSpace(parsed.SubnetIP)
if ip == "" || parsed.SubnetCIDR <= 0 {
return ""
}
base := fmt.Sprintf("%s/%d", ip, parsed.SubnetCIDR)
if _, err := netip.ParsePrefix(base); err != nil {
return ""
}
return base
}
func keepAliveStr(seconds int) string {
if seconds <= 0 {
return ""
@@ -149,16 +186,25 @@ func wireguardAllowedIPsCollision(entries, used []string) string {
// crossInboundUsed maps AllowedIPs already claimed by clients on every OTHER
// WireGuard/AmneziaWG inbound on this panel to a human-readable description
// of which inbound holds it (see otherTunnelAllowedIPs). It is folded into
// used only AFTER wireguardAllocationBase runs, so an unrelated inbound's
// subnet can never skew this inbound's own base-subnet inference — it only
// used only AFTER the base subnet is resolved, so an unrelated inbound's
// subnet can never skew this inbound's own base-subnet resolution — it only
// ever narrows which addresses are free to hand out or accept, and lets a
// manual-entry collision name the other inbound instead of just the address.
func defaultWireguardClients(existing, clients []model.Client, interfaceClients []any, crossInboundUsed map[string]string) error {
//
// settingsJSON is checked first for an admin-configured subnetIp/subnetCidr
// (see explicitWireguardSubnetBase) — set explicitly, that always wins.
// Only when it's unset does base fall back to inferring from existing
// clients' own addresses, and finally to defaultWireguardBase, exactly as
// before this field existed.
func defaultWireguardClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any, crossInboundUsed map[string]string) error {
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
base := wireguardAllocationBase(used, defaultWireguardBase)
base := explicitWireguardSubnetBase(settingsJSON)
if base == "" {
base = wireguardAllocationBase(used, defaultWireguardBase)
}
for addr := range crossInboundUsed {
used = append(used, addr)
}
+71 -11
View File
@@ -47,7 +47,7 @@ func TestAllocateWireguardAddress(t *testing.T) {
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 {
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
c := clients[0]
@@ -74,7 +74,7 @@ func TestDefaultWireguardClientsDerivesPublicKey(t *testing.T) {
}
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 {
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PublicKey != wantPub {
@@ -90,7 +90,7 @@ func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
AllowedIPs: []string{"10.0.0.50/32"},
}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces, nil); err != nil {
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" {
@@ -125,7 +125,7 @@ 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 {
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" {
@@ -184,7 +184,7 @@ func TestAllocateWireguardAddressWithoutWideningFailsClosed(t *testing.T) {
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 {
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] == clients[1].AllowedIPs[0] {
@@ -237,7 +237,7 @@ func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.
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 {
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" {
@@ -245,13 +245,13 @@ func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.
}
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)
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 {
if err := defaultWireguardClients("", existing, bad, []any{map[string]any{"email": "e@wg"}}, nil); err == nil {
t.Fatal("invalid allowedIPs entry must be rejected")
}
}
@@ -264,7 +264,7 @@ func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.
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)
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")
}
@@ -279,7 +279,7 @@ 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 {
if err := defaultWireguardClients("", nil, clients, ifaces, crossUsed); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] != "10.0.0.3/32" {
@@ -296,10 +296,70 @@ func TestDefaultWireguardClientsCrossInboundUsedDoesNotSkewSubnetInference(t *te
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 {
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)
}
}
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "المفتاح السري",
"publicKey": "المفتاح العام",
"subnetIp": "الشبكة الفرعية",
"subnetCidr": "بادئة الشبكة الفرعية (CIDR)",
"allowedIPs": "عناوين IP المسموح بها",
"endpoint": "النهاية",
"domainStrategy": "استراتيجية الدومين"
+2
View File
@@ -1752,6 +1752,8 @@
"wireguard": {
"secretKey": "Secret Key",
"publicKey": "Public Key",
"subnetIp": "Subnet",
"subnetCidr": "Subnet CIDR",
"allowedIPs": "Allowed IPs",
"endpoint": "Endpoint",
"domainStrategy": "Domain Strategy"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "Llave secreta",
"publicKey": "Llave pública",
"subnetIp": "Subred",
"subnetCidr": "CIDR de la subred",
"allowedIPs": "IP permitidas",
"endpoint": "Punto final",
"domainStrategy": "Estrategia de dominio"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "کلید شخصی",
"publicKey": "کلید عمومی",
"subnetIp": "زیرشبکه",
"subnetCidr": "پیشوند زیرشبکه (CIDR)",
"allowedIPs": "آی‌پی‌های مجاز",
"endpoint": "نقطه پایانی",
"domainStrategy": "استراتژی حل دامنه"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "Kunci Rahasia",
"publicKey": "Kunci Publik",
"subnetIp": "Subnet",
"subnetCidr": "CIDR Subnet",
"allowedIPs": "IP yang Diizinkan",
"endpoint": "Titik Akhir",
"domainStrategy": "Strategi Domain"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "シークレットキー",
"publicKey": "公開鍵",
"subnetIp": "サブネット",
"subnetCidr": "サブネットCIDR",
"allowedIPs": "許可されたIP",
"endpoint": "エンドポイント",
"domainStrategy": "ドメイン戦略"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "Chave Secreta",
"publicKey": "Chave Pública",
"subnetIp": "Sub-rede",
"subnetCidr": "CIDR da Sub-rede",
"allowedIPs": "IPs Permitidos",
"endpoint": "Ponto Final",
"domainStrategy": "Estratégia de Domínio"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "Секретный ключ",
"publicKey": "Публичный ключ",
"subnetIp": "Подсеть",
"subnetCidr": "Маска подсети (CIDR)",
"allowedIPs": "Разрешенные IP-адреса",
"endpoint": "Конечная точка",
"domainStrategy": "Стратегия домена"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "Gizli Anahtar",
"publicKey": "Genel Anahtar",
"subnetIp": "Alt Ağ",
"subnetCidr": "Alt Ağ CIDR",
"allowedIPs": "İzin Verilen IP'ler",
"endpoint": "Uç Nokta",
"domainStrategy": "Alan Adı Stratejisi"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "Приватний ключ",
"publicKey": "Публічний ключ",
"subnetIp": "Підмережа",
"subnetCidr": "CIDR підмережі",
"allowedIPs": "Дозволені IP-адреси",
"endpoint": "Кінцева точка",
"domainStrategy": "Стратегія домену"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "Khoá bí mật",
"publicKey": "Khóa công khai",
"subnetIp": "Mạng con",
"subnetCidr": "CIDR mạng con",
"allowedIPs": "IP được phép",
"endpoint": "Điểm cuối",
"domainStrategy": "Chiến lược tên miền"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "密钥",
"publicKey": "公钥",
"subnetIp": "子网",
"subnetCidr": "子网 CIDR",
"allowedIPs": "允许的 IP",
"endpoint": "端点",
"domainStrategy": "域策略"
+2
View File
@@ -1635,6 +1635,8 @@
"wireguard": {
"secretKey": "金鑰",
"publicKey": "公鑰",
"subnetIp": "子網路",
"subnetCidr": "子網路 CIDR",
"allowedIPs": "允許的 IP",
"endpoint": "端點",
"domainStrategy": "域策略"