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
+90 -18
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"maps"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
@@ -62,18 +63,27 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
return Instance{}, false
}
addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)}
if server.IPv6Enabled {
if v6, ok := serverAddressV6(server.IPv6Subnet); ok {
addresses = append(addresses, v6)
}
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
InterfaceName: interfaceNameForID(ib.Id),
ListenPort: ib.Port,
PrivateKey: server.PrivateKey,
PublicKey: server.PublicKey,
Address: []string{serverAddress(server.SubnetIP, server.SubnetCIDR)},
MTU: server.MTU,
Obfuscation: server.Obfuscation(),
Peers: peers,
ExternalInterface: server.ExternalInterface,
Id: ib.Id,
Tag: ib.Tag,
InterfaceName: interfaceNameForID(ib.Id),
ListenPort: ib.Port,
PrivateKey: server.PrivateKey,
PublicKey: server.PublicKey,
Address: addresses,
MTU: server.MTU,
Obfuscation: server.Obfuscation(),
Peers: peers,
ExternalInterface: server.ExternalInterface,
IPv6Enabled: server.IPv6Enabled,
IPv6ExternalInterface: server.IPv6ExternalInterface,
}, true
}
@@ -96,6 +106,19 @@ func serverAddress(subnetIP string, cidr int) string {
return fmt.Sprintf("%s/%d", subnetIP, cidr)
}
// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet
// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first
// usable host in the prefix. ok is false when subnetCIDR is empty or not a
// valid IPv6 prefix.
func serverAddressV6(subnetCIDR string) (addr string, ok bool) {
prefix, err := netip.ParsePrefix(subnetCIDR)
if err != nil || !prefix.Addr().Is6() {
return "", false
}
host := prefix.Masked().Addr().Next()
return fmt.Sprintf("%s/%d", host, prefix.Bits()), true
}
// structuralFingerprint changes whenever a value that requires a full
// interface bounce (awg-quick down + up) changes.
func (inst Instance) structuralFingerprint() string {
@@ -397,7 +420,7 @@ func generateServerConfig(inst Instance) string {
if ext == "" {
ext = detectDefaultInterface()
}
postUp, postDown := defaultPostUpDown(inst.InterfaceName, ext, inst.Address)
postUp, postDown := defaultPostUpDown(inst, ext)
fmt.Fprintf(&b, "PostUp = %s\n", postUp)
fmt.Fprintf(&b, "PostDown = %s\n", postDown)
@@ -451,11 +474,15 @@ func hOrDefault(v, def string) string {
return v
}
// defaultPostUpDown returns basic NAT + forwarding rules: MASQUERADE the
// tunnel subnet out the external interface and accept forwarded traffic in
// both directions. Per-peer port-forwarding, IPv6/NDP and RouteViaXray are a
// later phase (see project TODO).
func defaultPostUpDown(iface, ext string, addresses []string) (postUp, postDown string) {
// defaultPostUpDown returns NAT + forwarding rules: MASQUERADE the tunnel
// subnet out the external interface, accept forwarded traffic in both
// directions, and — when the instance has IPv6 enabled — the IPv6-forward
// rules, proxy_ndp sysctl, and one `ip -6 neigh add proxy` entry per enabled
// peer with an IPv6 address, so upstream routers see each client's IPv6 as
// directly reachable on the LAN without NAT66. Per-client port-forwarding and
// RouteViaXray are a later phase (see project TODO).
func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) {
iface := inst.InterfaceName
up := []string{
fmt.Sprintf("iptables -A FORWARD -i %s -j ACCEPT", iface),
fmt.Sprintf("iptables -A FORWARD -o %s -j ACCEPT", iface),
@@ -464,10 +491,38 @@ func defaultPostUpDown(iface, ext string, addresses []string) (postUp, postDown
fmt.Sprintf("iptables -D FORWARD -i %s -j ACCEPT", iface),
fmt.Sprintf("iptables -D FORWARD -o %s -j ACCEPT", iface),
}
if subnet := firstAddress(addresses); subnet != "" && ext != "" {
if subnet := firstAddress(inst.Address); subnet != "" && ext != "" {
up = append([]string{fmt.Sprintf("iptables -t nat -A POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, up...)
down = append([]string{fmt.Sprintf("iptables -t nat -D POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, down...)
}
if inst.IPv6Enabled {
ext6 := inst.IPv6ExternalInterface
if ext6 == "" {
ext6 = ext
}
up = append(up,
fmt.Sprintf("ip6tables -A FORWARD -i %s -j ACCEPT", iface),
fmt.Sprintf("ip6tables -A FORWARD -o %s -j ACCEPT", iface),
fmt.Sprintf("ip6tables -A FORWARD -i %s -o %s -j ACCEPT", ext6, iface),
"sysctl -w net.ipv6.conf.all.forwarding=1",
fmt.Sprintf("sysctl -w net.ipv6.conf.%s.proxy_ndp=1", ext6),
)
down = append(down,
fmt.Sprintf("ip6tables -D FORWARD -i %s -j ACCEPT", iface),
fmt.Sprintf("ip6tables -D FORWARD -o %s -j ACCEPT", iface),
fmt.Sprintf("ip6tables -D FORWARD -i %s -o %s -j ACCEPT", ext6, iface),
)
for _, p := range inst.Peers {
ip6 := firstIPv6(p.AllowedIPs)
if ip6 == "" {
continue
}
up = append(up, fmt.Sprintf("ip -6 neigh add proxy %s dev %s", ip6, ext6))
down = append(down, fmt.Sprintf("ip -6 neigh del proxy %s dev %s", ip6, ext6))
}
}
up = append(up, "sysctl -w net.ipv4.ip_forward=1")
return strings.Join(up, "; "), strings.Join(down, "; ")
}
@@ -481,6 +536,23 @@ func firstAddress(addresses []string) string {
return addresses[0]
}
// firstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs,
// or "" if none — used to build one NDP proxy PostUp/PostDown entry per peer.
func firstIPv6(allowedIPs []string) string {
for _, a := range allowedIPs {
if prefix, err := netip.ParsePrefix(a); err == nil {
if prefix.Addr().Is6() {
return prefix.Addr().String()
}
continue
}
if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() {
return addr.String()
}
}
return ""
}
// detectDefaultInterface returns the first non-loopback, non-tunnel, UP
// interface that has a routable IPv4 address. Falls back to "eth0" only if
// nothing is found.
+21
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"fmt"
"math/big"
"net/netip"
"strconv"
"strings"
)
@@ -119,6 +120,26 @@ func ValidateObfuscation(o Obfuscation20) error {
return nil
}
// ValidateIPv6Subnet rejects a malformed IPv6 subnet before it's saved, so a
// bad manual entry can't bring the interface down on `awg-quick up`. A blank
// subnet is only valid when IPv6 itself is disabled.
func ValidateIPv6Subnet(enabled bool, subnet string) error {
if !enabled {
return nil
}
if strings.TrimSpace(subnet) == "" {
return fmt.Errorf("ipv6Subnet is required when IPv6 is enabled")
}
prefix, err := netip.ParsePrefix(subnet)
if err != nil {
return fmt.Errorf("invalid ipv6Subnet %q: %w", subnet, err)
}
if !prefix.Addr().Is6() {
return fmt.Errorf("invalid ipv6Subnet %q: not an IPv6 prefix", subnet)
}
return nil
}
// validateHValue checks one H parameter: empty, a single uint32, or
// "low-high" with 0 <= low <= high <= uint32 max.
func validateHValue(v string) error {
+18
View File
@@ -47,6 +47,7 @@ type Instance struct {
PrivateKey string
PublicKey string
// Address holds the interface's own tunnel address(es), e.g. "10.8.1.1/24".
// Carries both the IPv4 and (when enabled) IPv6 server address.
Address []string
MTU int
@@ -56,6 +57,13 @@ type Instance struct {
// ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.
// Empty means auto-detect at config-generation time.
ExternalInterface string
// IPv6Enabled turns on the per-peer NDP proxy PostUp/PostDown entries
// (ip -6 neigh add/del proxy) for peers that have an IPv6 AllowedIPs
// entry. IPv6ExternalInterface overrides ExternalInterface for those
// entries specifically; empty means reuse ExternalInterface.
IPv6Enabled bool
IPv6ExternalInterface string
}
// ServerSettings is the "server" block of an AmneziaWG inbound's Settings
@@ -79,6 +87,16 @@ type ServerSettings struct {
// Empty means auto-detect.
ExternalInterface string `json:"externalInterface,omitempty"`
// IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is
// allocated from IPv6Subnet alongside each client's IPv4 one, and the
// server proxies NDP for each enabled client's address so upstream
// routers see it as directly reachable (no NAT66). IPv6ExternalInterface
// overrides ExternalInterface for the NDP-proxy PostUp/PostDown entries
// specifically; empty reuses ExternalInterface.
IPv6Enabled bool `json:"ipv6Enabled,omitempty"`
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
// Obfuscation20's fields, repeated flat (not embedded) rather than
// nested under their own key: encoding/json would happily inline an
// embedded Obfuscation20 the same way, but the frontend's Go->Zod/TS
+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 (кол-во мусорных пакетов)",