feat(amneziawg): Phase 2a — IPv6 support + NDP proxy

Adds native dual-stack IPv6 to AmneziaWG inbounds, ported from
coinman-dev/3ax-ui's approach:

- ServerSettings gets ipv6Enabled/ipv6Subnet/ipv6ExternalInterface;
  Instance carries the server's own IPv6 address (first host of the
  subnet) alongside its IPv4 one.
- defaultAmneziaWGClients allocates an IPv6 host address per client
  (second AllowedIPs entry) when the server has IPv6 enabled, reusing
  allocateWireguardAddress — which needed a real fix along the way: it
  always suffixed "/32" regardless of address family, which is wrong
  for an IPv6 host address (needs /128). Now family-aware.
- generateServerConfig's PostUp/PostDown gains IPv6 forward-accept
  rules, proxy_ndp sysctl, and one `ip -6 neigh add/del proxy` entry per
  enabled peer with an IPv6 address — the lightweight per-client
  method, not the ndppd-daemon whole-subnet method (not worth the
  config-file-management complexity at this scale; ndppd itself is
  still installed by install.sh in case that changes later).
- ValidateIPv6Subnet rejects a malformed subnet before save.
- Frontend: ipv6Enabled/ipv6Subnet/ipv6ExternalInterface fields on the
  AmneziaWG inbound form, EN+RU translations, openapi.json/generated/*
  regenerated (the latter via `go run ./tools/openapigen`, pure Go).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 17:25:58 +03:00
parent a76f0ab233
commit ef13e8567e
15 changed files with 227 additions and 33 deletions
+23 -10
View File
@@ -10,22 +10,27 @@ import (
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// defaultAmneziaWGSubnetBase resolves the /CIDR base new peer addresses are
// allocated from, out of the inbound's own configured server subnet — unlike
// WireGuard, which always falls back to a fixed 10.0.0.0/24.
func defaultAmneziaWGSubnetBase(settingsJSON string) (string, error) {
// defaultAmneziaWGSubnetBases resolves the /CIDR bases new peer addresses are
// allocated from, out of the inbound's own configured server subnet(s) —
// unlike WireGuard, which always falls back to a fixed 10.0.0.0/24. v6Base is
// "" when the server doesn't have IPv6 enabled.
func defaultAmneziaWGSubnetBases(settingsJSON string) (v4Base, v6Base string, err error) {
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(settingsJSON), &parsed); err != nil {
return "", fmt.Errorf("amneziawg: invalid settings: %w", err)
return "", "", fmt.Errorf("amneziawg: invalid settings: %w", err)
}
if parsed.Server == nil {
return "", fmt.Errorf("amneziawg: settings missing server block")
return "", "", fmt.Errorf("amneziawg: settings missing server block")
}
cidr := parsed.Server.SubnetCIDR
if cidr <= 0 {
cidr = 24
}
return fmt.Sprintf("%s/%d", parsed.Server.SubnetIP, cidr), nil
v4Base = fmt.Sprintf("%s/%d", parsed.Server.SubnetIP, cidr)
if parsed.Server.IPv6Enabled && parsed.Server.IPv6Subnet != "" {
v6Base = parsed.Server.IPv6Subnet
}
return v4Base, v6Base, nil
}
// defaultAmneziaWGClients fills in blank AmneziaWG credentials for newly
@@ -38,7 +43,7 @@ func defaultAmneziaWGSubnetBase(settingsJSON string) (string, error) {
// its IP allocation and validation helpers — the only real difference is
// where the allocation base comes from.
func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any) error {
base, err := defaultAmneziaWGSubnetBase(settingsJSON)
v4Base, v6Base, err := defaultAmneziaWGSubnetBases(settingsJSON)
if err != nil {
return err
}
@@ -64,11 +69,19 @@ func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Clie
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
addr, err := allocateWireguardAddress(used, base)
addr, err := allocateWireguardAddress(used, v4Base)
if err != nil {
return err
}
c.AllowedIPs = []string{addr}
allowed := []string{addr}
if v6Base != "" {
addr6, err := allocateWireguardAddress(used, v6Base)
if err != nil {
return err
}
allowed = append(allowed, addr6)
}
c.AllowedIPs = allowed
} else {
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
if err != nil {
+9 -4
View File
@@ -46,9 +46,10 @@ func wireguardAllocationBase(used []string, fallback string) string {
return fallback
}
// allocateWireguardAddress returns the first free /32 host address in base that
// is not already present in used. The server holds the first host (.1), so
// allocation starts at the second host (.2).
// allocateWireguardAddress returns the first free single-host address in base
// (suffixed /32 for an IPv4 base, /128 for IPv6) that is not already present
// in used. The server holds the first host (.1 / ::1), so allocation starts
// at the second host (.2 / ::2).
func allocateWireguardAddress(used []string, base string) (string, error) {
if base == "" {
base = defaultWireguardBase
@@ -57,6 +58,10 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
if err != nil {
return "", err
}
hostBits := "32"
if prefix.Addr().Is6() {
hostBits = "128"
}
taken := make(map[netip.Addr]struct{}, len(used))
for _, u := range used {
if a := wireguardHostAddr(u); a.IsValid() {
@@ -66,7 +71,7 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
addr := prefix.Masked().Addr().Next().Next()
for prefix.Contains(addr) {
if _, ok := taken[addr]; !ok {
return addr.String() + "/32", nil
return addr.String() + "/" + hostBits, nil
}
addr = addr.Next()
}
@@ -188,6 +188,9 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
bs, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
+5
View File
@@ -1933,6 +1933,11 @@
"secondaryDns": "Secondary DNS",
"externalInterface": "External Interface",
"externalInterfaceHint": "Host NIC for NAT (PostUp/PostDown). Leave empty to auto-detect.",
"ipv6Enabled": "Enable IPv6",
"ipv6Subnet": "IPv6 Subnet",
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
"ipv6ExternalInterface": "IPv6 External Interface",
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
"obfuscation": "Obfuscation parameters",
"regenerateObfuscation": "Regenerate",
"jc": "Jc (junk packet count)",
+5
View File
@@ -1816,6 +1816,11 @@
"secondaryDns": "Резервный DNS",
"externalInterface": "Внешний интерфейс",
"externalInterfaceHint": "Сетевой интерфейс хоста для NAT (PostUp/PostDown). Оставьте пустым для автоопределения.",
"ipv6Enabled": "Включить IPv6",
"ipv6Subnet": "Подсеть IPv6",
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
"obfuscation": "Параметры обфускации",
"regenerateObfuscation": "Сгенерировать заново",
"jc": "Jc (кол-во мусорных пакетов)",