Files
3x-ui/internal/amneziawgnet/device.go
T
Kuzz007 58671533bb feat(amneziawg): add embedded amneziawg-go device package (Phase 1)
New internal/amneziawgnet package: builds a real amneziawg-go Device over a
gVisor netstack from an existing amneziawg.Instance, with a TCP/UDP
forwarder that recovers each tunnel connection's real destination and a
peer-identity index keyed by AllowedIPs. This is the foundation for
migrating AmneziaWG off the kernel-module+TPROXY path (see the AmneziaWG-go
vs kernel-module decision) -- nothing wires into live traffic yet, that's
Phase 2 (relay into Xray's own SOCKS5 inbound).

Covered by three real end-to-end tests: a genuine handshake + TCP forwarder
+ identity resolution, the same for UDP (including a reply routed back
through the tunnel), and the manager's reconfigure-in-place vs. rebuild
lifecycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:48:13 +03:00

186 lines
6.7 KiB
Go

package amneziawgnet
import (
"fmt"
"net/netip"
"strings"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// defaultMTU matches internal/amneziawg's own kernel-module interface
// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel
// encapsulation overhead on a standard 1500-byte-MTU host link.
const defaultMTU = 1420
// DeviceOptions carries the AmneziaWG 3.0 header-protection fields, kept out
// of amneziawg.Instance/Obfuscation20 deliberately: those are the shared,
// DB-backed types the still-live kernel-module path also reads and writes,
// and 3.0 header protection is a device-wide, strictly opt-in setting (see
// the migration plan's "Reference material" section) that isn't wired into
// that shared schema yet. Zero-value DeviceOptions means classic
// (non-3.0) obfuscation only, matching the kernel-module path's own
// defaults today.
type DeviceOptions struct {
// HeaderProtectionKey is a base64 32-byte key. Empty disables AWG 3.0
// header protection entirely. Non-empty requires every one of
// Obfuscation20.S1-S4 to be >= 12 (amneziawg-go's own HeaderCipherNonceSize
// requirement) -- IpcSet will reject the config otherwise.
HeaderProtectionKey string
// ContentPaddingAddition is a "low-high" range (or a bare integer) per
// amneziawg-tools' own u16_range_from_string grammar. Empty disables it.
ContentPaddingAddition string
// Logger is passed to device.NewDevice as-is; nil uses a silent logger
// (device.NewLogger(device.LogLevelSilent, "")).
Logger *device.Logger
}
// Device is one running embedded AmneziaWG interface: an amneziawg-go
// Device over a gVisor netstack, plus the raw *stack.Stack a caller needs to
// attach a TCP/UDP forwarder (see forwarder.go / udp.go). Closing it tears
// down both the WireGuard device and the underlying tun/stack.
type Device struct {
*device.Device
Stack *stack.Stack
}
// NewDevice constructs and brings up an embedded AmneziaWG interface for
// inst: a gVisor-backed tun.Device sized to inst.MTU (or defaultMTU),
// addressed with inst.Address, configured via UAPI with inst.Obfuscation,
// inst.PrivateKey, opts' AWG 3.0 fields, and one UAPI peer per inst.Peers
// entry. It does not attach a forwarder or start relaying traffic --
// that's the caller's job (see AttachTCPForwarder / AttachUDPHandler),
// keeping this constructor usable both for a real relay and for a plain
// mechanical test.
func NewDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
addrs, err := hostAddresses(inst.Address)
if err != nil {
return nil, fmt.Errorf("amneziawgnet: %w", err)
}
mtu := inst.MTU
if mtu <= 0 {
mtu = defaultMTU
}
tun, gstack, err := createNetTUNWithStack(addrs, mtu)
if err != nil {
return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err)
}
logger := opts.Logger
if logger == nil {
logger = device.NewLogger(device.LogLevelSilent, "")
}
dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger)
conf, err := buildUAPIConfig(inst, opts)
if err != nil {
dev.Close()
return nil, fmt.Errorf("amneziawgnet: %w", err)
}
if err := dev.IpcSet(conf); err != nil {
dev.Close()
return nil, fmt.Errorf("amneziawgnet: IpcSet for inbound %d: %w", inst.Id, err)
}
if err := dev.Up(); err != nil {
dev.Close()
return nil, fmt.Errorf("amneziawgnet: bring up inbound %d: %w", inst.Id, err)
}
return &Device{Device: dev, Stack: gstack}, nil
}
// hostAddresses parses each of inst.Address's CIDR strings (e.g.
// "10.8.1.1/24") down to the bare host address the netstack's NIC gets
// configured with -- the interface's own address, not the subnet it routes.
func hostAddresses(addresses []string) ([]netip.Addr, error) {
out := make([]netip.Addr, 0, len(addresses))
for _, a := range addresses {
prefix, err := netip.ParsePrefix(a)
if err != nil {
return nil, fmt.Errorf("invalid interface address %q: %w", a, err)
}
out = append(out, prefix.Addr())
}
return out, nil
}
// buildUAPIConfig renders inst (plus opts' AWG 3.0 fields) as a WireGuard
// UAPI "set" configuration string -- private_key/listen_port/jc.../s1-s4/
// h1-h4/i1 device lines, the AWG 3.0 device lines when opts asks for them,
// then one public_key/preshared_key/allowed_ip block per peer. Field names
// and format match amneziawg-go v3.0.3's device/uapi.go exactly (confirmed
// against its real source during Phase 0 spiking, not just its docs).
func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error) {
var b strings.Builder
privHex, err := wireguard.KeyToHex(inst.PrivateKey)
if err != nil {
return "", fmt.Errorf("invalid server private key: %w", err)
}
fmt.Fprintf(&b, "private_key=%s\n", privHex)
fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
// replace_peers makes every apply a full resync (matches this package's
// own Manager.Ensure semantics): peers no longer in inst.Peers are
// dropped instead of lingering from a previous IpcSet call.
b.WriteString("replace_peers=true\n")
o := inst.Obfuscation
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
writeHLine(&b, "h1", o.H1)
writeHLine(&b, "h2", o.H2)
writeHLine(&b, "h3", o.H3)
writeHLine(&b, "h4", o.H4)
if o.I1 != "" {
fmt.Fprintf(&b, "i1=%s\n", o.I1)
}
if opts.HeaderProtectionKey != "" {
hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
if err != nil {
return "", fmt.Errorf("invalid header protection key: %w", err)
}
fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
}
if opts.ContentPaddingAddition != "" {
fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
}
for _, p := range inst.Peers {
pubHex, err := wireguard.KeyToHex(p.PublicKey)
if err != nil {
return "", fmt.Errorf("peer %q: invalid public key: %w", p.Email, err)
}
fmt.Fprintf(&b, "public_key=%s\n", pubHex)
if p.PresharedKey != "" {
pskHex, err := wireguard.KeyToHex(p.PresharedKey)
if err != nil {
return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Email, err)
}
fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
}
for _, allowedIP := range p.AllowedIPs {
fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
}
}
return b.String(), nil
}
// writeHLine writes an hN UAPI line only when v is set -- an empty H value
// means "let amneziawg-go fall back to its own default," mirroring how
// internal/amneziawg's generateServerConfig treats the same optional field.
func writeHLine(b *strings.Builder, name, v string) {
if v == "" {
return
}
fmt.Fprintf(b, "%s=%s\n", name, v)
}