Files
3x-ui/internal/amneziawg/instance.go
T
Kuzz007 1d39de4d13 feat(amneziawg): restore per-client public IPv6 identity (Phase 3.5)
Adds internal/amneziawg.FirstIPv6 and a new internal/amneziawgnet/v6alias.go
that aliases each IPv6-enabled peer's own address onto the host NIC
(ip -6 addr add), wired into the Manager's Ensure/Remove/Reconcile/StopAll
lifecycle. internal/web/service/xray.go's new injectAmneziawgV6Egress gives
each such peer a dedicated freedom outbound (sendThrough) plus a routing
rule matching its own email, so its outbound connections carry a distinct
public source address again -- restoring what the embedded-architecture
hard cutover temporarily dropped. Scoped to outbound source identity only
(not unsolicited inbound/port-forwarding, which stays the separate Phase
3.6); no frontend changes needed since IPv6Enabled/IPv6ExternalInterface
were already in the UI and per-peer opt-in is just an IPv6 AllowedIPs entry,
same as today.
2026-08-03 10:41:58 +03:00

167 lines
6.4 KiB
Go

// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
// (Instance, Peer, Obfuscation20, ServerSettings/InboundSettings) and the
// pure functions that derive an Instance from a stored inbound row. It no
// longer manages any OS-level interface itself: that was the kernel-module
// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally,
// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor
// netstack, see internal/amneziawgnet) in a hard cutover. This package's
// remaining code is deliberately protocol-shape-only, with no OS dependency
// at all, so both the (now-removed) kernel-module path and the embedded
// path could read -- and, historically, did read -- it identically.
package amneziawg
import (
"encoding/json"
"fmt"
"net/netip"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound,
// building one peer per active client. Returns false when the inbound is not
// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no
// server block) or has no enabled peer to serve — mirroring
// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than
// run it with nothing to serve.
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.AmneziaWG {
return Instance{}, false
}
var parsed InboundSettings
if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil {
return Instance{}, false
}
server := parsed.Server
peers := make([]Peer, 0, len(parsed.Clients))
for _, c := range parsed.Clients {
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
continue
}
peers = append(peers, Peer{
Email: c.Email,
PublicKey: c.PublicKey,
PresharedKey: c.PreSharedKey,
AllowedIPs: c.AllowedIPs,
ForwardedPorts: c.ForwardedPorts,
})
}
if len(peers) == 0 {
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: addresses,
MTU: server.MTU,
Obfuscation: server.Obfuscation(),
Peers: peers,
ExternalInterface: server.ExternalInterface,
IPv6Enabled: server.IPv6Enabled,
IPv6ExternalInterface: server.IPv6ExternalInterface,
RouteThroughXray: server.RouteThroughXray,
}, true
}
// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
// "awg42". Kept even though the embedded path has no real kernel interface
// of its own: internal/amneziawgnet still uses the same name as a purely
// cosmetic/log-friendly label, so an existing peer's identity/history
// doesn't shift across the cutover.
func interfaceNameForID(id int) string {
return fmt.Sprintf("awg%d", id)
}
// serverAddress returns the server's own tunnel address for a subnet base,
// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always
// holds the first usable host of the network subnetIP/cidr actually
// describes -- derived via netip rather than assuming subnetIP already ends
// in ".0", so a subnetIP that isn't a bare network address (a typo, or a
// manually edited value) can never collide with peer addresses, which are
// allocated starting from the network's second host upward (see
// allocateWireguardAddress). Falls back to the previous literal behavior
// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal
// saves never reach that path since ValidateSubnetIPv4 already rejects it.
func serverAddress(subnetIP string, cidr int) string {
if cidr <= 0 {
cidr = 24
}
// A /32 has no host bits at all -- "first usable host" is meaningless,
// and Next() would step outside the block entirely -- so a single-host
// base is used exactly as given, same as before this fix.
prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
if err != nil || !prefix.Addr().Is4() || cidr >= 32 {
return fmt.Sprintf("%s/%d", subnetIP, cidr)
}
host := prefix.Masked().Addr().Next()
return fmt.Sprintf("%s/%d", host, 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
}
// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
// or "" if none — used by internal/web/service/server.go's
// amneziawgEmailIndex to derive a peer's tunnel IPv4 address for the panel's
// access-log viewer.
func FirstIPv4(allowedIPs []string) string {
for _, a := range allowedIPs {
if prefix, err := netip.ParsePrefix(a); err == nil {
if prefix.Addr().Is4() {
return prefix.Addr().String()
}
continue
}
if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() {
return addr.String()
}
}
return ""
}
// FirstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs,
// or "" if none — the IPv6 counterpart of FirstIPv4, used by
// internal/amneziawgnet's IPv6-address-alias mechanism to find which
// address, if any, a peer wants aliased onto the host, and by
// internal/web/service/xray.go's injectAmneziawgV6Egress to build that
// peer's own freedom outbound (sendThrough). Only the first match is
// returned, exactly like FirstIPv4 — more than one IPv6 AllowedIPs entry
// per peer is not a supported configuration for either feature.
func FirstIPv6(allowedIPs []string) string {
for _, a := range allowedIPs {
if prefix, err := netip.ParsePrefix(a); err == nil {
if prefix.Addr().Is6() && !prefix.Addr().Is4In6() {
return prefix.Addr().String()
}
continue
}
if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() && !addr.Is4In6() {
return addr.String()
}
}
return ""
}