mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 07:44:59 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 (кол-во мусорных пакетов)",
|
||||
|
||||
Reference in New Issue
Block a user