Files
3x-ui/internal/amneziawgnet/client_device.go
T
Rouzbeh† d5ab84e8d5 feat(amneziawg): add AmneziaWG as an outbound protocol (#6320)
* feat(amneziawg): add AmneziaWG as an outbound protocol

- AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI
- Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test
- Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions
- Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test
- Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go
- Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients)
- Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress)
- Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts)
- Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks

* fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys

- Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns
- Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound
- Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection
- Trim comment blocks exceeding 2 lines across modified files
- Fix Storybook test execution on environments with POSIX locale

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-10 14:50:48 +02:00

140 lines
4.5 KiB
Go

package amneziawgnet
import (
"fmt"
"strings"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// buildClientUAPIConfig renders a client-mode UAPI set string: the device
// lines of buildUAPIConfig plus per-peer endpoint/keepalive for dialing.
func buildClientUAPIConfig(inst amneziawg.OutboundInstance, opts DeviceOptions) (string, error) {
var b strings.Builder
privHex, err := wireguard.KeyToHex(inst.PrivateKey)
if err != nil {
return "", fmt.Errorf("invalid private key: %w", err)
}
fmt.Fprintf(&b, "private_key=%s\n", privHex)
if inst.ListenPort > 0 {
fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
}
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)
writeOptionalLine(&b, "h1", o.H1)
writeOptionalLine(&b, "h2", o.H2)
writeOptionalLine(&b, "h3", o.H3)
writeOptionalLine(&b, "h4", o.H4)
writeOptionalLine(&b, "i1", o.I1)
writeOptionalLine(&b, "i2", o.I2)
writeOptionalLine(&b, "i3", o.I3)
writeOptionalLine(&b, "i4", o.I4)
writeOptionalLine(&b, "i5", o.I5)
// An omitted line means "unchanged" to amneziawg-go, so a cleared key can
// only reach a live device as the all-zero one that disables the feature.
hpHex := strings.Repeat("0", 64)
if opts.HeaderProtectionKey != "" {
var err error
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)
}
if opts.RekeyAfterTime != "" {
fmt.Fprintf(&b, "rekey_after_time=%s\n", opts.RekeyAfterTime)
}
if opts.RekeyTimeout != "" {
fmt.Fprintf(&b, "rekey_timeout=%s\n", opts.RekeyTimeout)
}
if opts.RejectAfterTime != "" {
fmt.Fprintf(&b, "reject_after_time=%s\n", opts.RejectAfterTime)
}
if opts.KeepaliveTimeout != "" {
fmt.Fprintf(&b, "keepalive_timeout=%s\n", opts.KeepaliveTimeout)
}
if opts.MaxHandshakeAttempts != "" {
fmt.Fprintf(&b, "max_handshake_attempts=%s\n", opts.MaxHandshakeAttempts)
}
fmt.Fprintf(&b, "random_trailers=%t\n", opts.RandomTrailers)
fmt.Fprintf(&b, "disable_cookies=%t\n", opts.DisableCookies)
for _, p := range inst.Peers {
pubHex, err := wireguard.KeyToHex(p.PublicKey)
if err != nil {
return "", fmt.Errorf("peer %q: invalid public key: %w", p.Endpoint, 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.Endpoint, err)
}
fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
}
fmt.Fprintf(&b, "endpoint=%s\n", p.Endpoint)
if p.KeepAlive > 0 {
fmt.Fprintf(&b, "persistent_keepalive_interval=%d\n", p.KeepAlive)
}
for _, allowedIP := range p.AllowedIPs {
fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
}
}
return b.String(), nil
}
// newUnconfiguredClientDevice builds the tun/netstack/device trio for a
// client-mode instance; same construction rules as newUnconfiguredDevice.
func newUnconfiguredClientDevice(inst amneziawg.OutboundInstance, opts DeviceOptions) (*Device, error) {
addrs, err := hostAddresses(inst.Address)
if err != nil {
return nil, fmt.Errorf("amneziawgnet: %w", err)
}
mtu := amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4)
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, fmt.Sprintf("(awg-out %s) ", inst.Tag))
}
dev := device.NewDevice(tun, newResolvingBind(), logger)
return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil
}
// ConfigureClient applies inst/opts via UAPI and brings the interface up;
// same single-call contract as Configure.
func (d *Device) ConfigureClient(inst amneziawg.OutboundInstance, opts DeviceOptions) error {
conf, err := buildClientUAPIConfig(inst, opts)
if err != nil {
d.Close()
return fmt.Errorf("amneziawgnet: %w", err)
}
if err := d.IpcSet(conf); err != nil {
d.Close()
return fmt.Errorf("amneziawgnet: IpcSet for outbound %q: %w", inst.Tag, err)
}
if err := d.Up(); err != nil {
d.Close()
return fmt.Errorf("amneziawgnet: bring up outbound %q: %w", inst.Tag, err)
}
return nil
}