Files
3x-ui/internal/web/service/client_wireguard.go
T
Kuzz007 ef13e8567e 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>
2026-07-25 17:25:58 +03:00

188 lines
5.0 KiB
Go

package service
import (
"net/netip"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
const defaultWireguardBase = "10.0.0.0/24"
func keepAliveStr(seconds int) string {
if seconds <= 0 {
return ""
}
return strconv.Itoa(seconds)
}
func wireguardHostAddr(s string) netip.Addr {
s = strings.TrimSpace(s)
if s == "" {
return netip.Addr{}
}
if p, err := netip.ParsePrefix(s); err == nil {
return p.Addr()
}
if a, err := netip.ParseAddr(s); err == nil {
return a
}
return netip.Addr{}
}
func wireguardAllocationBase(used []string, fallback string) string {
for _, u := range used {
a := wireguardHostAddr(u)
if !a.IsValid() || !a.Is4() || a.IsUnspecified() {
continue
}
if p, err := a.Prefix(24); err == nil {
return p.String()
}
}
return fallback
}
// 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
}
prefix, err := netip.ParsePrefix(base)
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() {
taken[a] = struct{}{}
}
}
addr := prefix.Masked().Addr().Next().Next()
for prefix.Contains(addr) {
if _, ok := taken[addr]; !ok {
return addr.String() + "/" + hostBits, nil
}
addr = addr.Next()
}
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
// inbound's subnet. It mutates both the typed clients and the parallel raw client
// maps that get persisted into the inbound settings. Existing values are never
// overwritten, so editing a client never rotates its keys.
func defaultWireguardClients(existing, clients []model.Client, interfaceClients []any) error {
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
base := wireguardAllocationBase(used, defaultWireguardBase)
for i := range clients {
c := &clients[i]
if c.PrivateKey == "" && c.PublicKey == "" {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
return err
}
c.PrivateKey = priv
c.PublicKey = pub
} else if c.PublicKey == "" && c.PrivateKey != "" {
pub, err := wgutil.PublicKeyFromPrivate(c.PrivateKey)
if err != nil {
return err
}
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
addr, err := allocateWireguardAddress(used, base)
if err != nil {
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...)
if i < len(interfaceClients) {
if m, ok := interfaceClients[i].(map[string]any); ok {
m["privateKey"] = c.PrivateKey
m["publicKey"] = c.PublicKey
m["allowedIPs"] = c.AllowedIPs
if c.PreSharedKey != "" {
m["preSharedKey"] = c.PreSharedKey
}
if c.KeepAlive > 0 {
m["keepAlive"] = c.KeepAlive
}
interfaceClients[i] = m
}
}
}
return nil
}