Files
3x-ui/internal/amneziawg/outbound.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

324 lines
11 KiB
Go

package amneziawg
import (
"encoding/json"
"fmt"
"net"
"net/netip"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// OutboundPeer is one remote AmneziaWG server: its public key, the routes
// AllowedIPs steers into the tunnel, and its "host:port" Endpoint.
type OutboundPeer struct {
PublicKey string
PresharedKey string
AllowedIPs []string
Endpoint string
KeepAlive int
}
// OutboundInstance is the desired runtime config of one client-mode
// AmneziaWG outbound -- the mirror of Instance, consumed by amneziawgnet.
type OutboundInstance struct {
Tag string
Address []string
MTU int
PrivateKey string
Obfuscation Obfuscation31
Peers []OutboundPeer
ListenPort int
DNS string
}
// OutboundSettings is the Settings JSON stored on an "amneziawg" outbound
// row; flat obfuscation keys mirror ServerSettings so values paste 1:1.
type OutboundSettings struct {
MTU int `json:"mtu,omitempty"`
SecretKey string `json:"secretKey"`
Address []string `json:"address"`
ListenPort int `json:"listenPort,omitempty"`
DNS string `json:"dns,omitempty"`
// Flat Obfuscation31 mirror -- see OutboundSettings' doc comment.
Jc int `json:"jc"`
Jmin int `json:"jmin"`
Jmax int `json:"jmax"`
S1 int `json:"s1"`
S2 int `json:"s2"`
S3 int `json:"s3"`
S4 int `json:"s4"`
H1 string `json:"h1"`
H2 string `json:"h2"`
H3 string `json:"h3"`
H4 string `json:"h4"`
I1 string `json:"i1,omitempty"`
I2 string `json:"i2,omitempty"`
I3 string `json:"i3,omitempty"`
I4 string `json:"i4,omitempty"`
I5 string `json:"i5,omitempty"`
HeaderProtectionKey string `json:"headerProtectionKey,omitempty"`
ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
RekeyAfterTime string `json:"rekeyAfterTime,omitempty"`
RekeyTimeout string `json:"rekeyTimeout,omitempty"`
RejectAfterTime string `json:"rejectAfterTime,omitempty"`
KeepaliveTimeout string `json:"keepaliveTimeout,omitempty"`
MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
RandomTrailers bool `json:"randomTrailers"`
DisableCookies bool `json:"disableCookies"`
Peers []OutboundSettingsPeer `json:"peers"`
}
// OutboundSettingsPeer is one entry of OutboundSettings.Peers.
type OutboundSettingsPeer struct {
PublicKey string `json:"publicKey"`
PresharedKey string `json:"presharedKey,omitempty"`
AllowedIPs []string `json:"allowedIPs"`
Endpoint string `json:"endpoint"`
KeepAlive int `json:"keepAlive,omitempty"`
}
// Obfuscation folds the flat wire fields back into the grouped type, matching
// ServerSettings.Obfuscation.
func (s OutboundSettings) Obfuscation() Obfuscation31 {
return Obfuscation31{
Jc: s.Jc, Jmin: s.Jmin, Jmax: s.Jmax,
S1: s.S1, S2: s.S2, S3: s.S3, S4: s.S4,
H1: s.H1, H2: s.H2, H3: s.H3, H4: s.H4,
I1: s.I1, I2: s.I2, I3: s.I3, I4: s.I4, I5: s.I5,
HeaderProtectionKey: s.HeaderProtectionKey,
ContentPaddingAddition: s.ContentPaddingAddition,
RekeyAfterTime: s.RekeyAfterTime,
RekeyTimeout: s.RekeyTimeout,
RejectAfterTime: s.RejectAfterTime,
KeepaliveTimeout: s.KeepaliveTimeout,
MaxHandshakeAttempts: s.MaxHandshakeAttempts,
RandomTrailers: s.RandomTrailers,
DisableCookies: s.DisableCookies,
}
}
// IsAmneziaWGOutbound reports whether a raw outbound JSON object from the
// Xray template carries the panel's amneziawg pseudo-protocol.
func IsAmneziaWGOutbound(raw []byte) bool {
var probe struct {
Protocol string `json:"protocol"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return false
}
return probe.Protocol == "amneziawg"
}
// outboundSettingsOf extracts the nested "settings" block from a raw
// amneziawg template outbound.
func outboundSettingsOf(raw []byte) (json.RawMessage, bool) {
var wrapper struct {
Settings json.RawMessage `json:"settings"`
}
if err := json.Unmarshal(raw, &wrapper); err != nil || len(wrapper.Settings) == 0 {
return nil, false
}
return wrapper.Settings, true
}
// InstanceFromOutbound derives a client-mode instance from one raw template
// outbound; false when unusable or a peer lacks key/endpoint/allowedIPs.
func InstanceFromOutbound(tag string, raw []byte) (OutboundInstance, bool) {
settingsRaw, ok := outboundSettingsOf(raw)
if !ok {
return OutboundInstance{}, false
}
var parsed OutboundSettings
if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
return OutboundInstance{}, false
}
inst := OutboundInstance{
Tag: tag,
Address: parsed.Address,
MTU: parsed.MTU,
PrivateKey: parsed.SecretKey,
ListenPort: parsed.ListenPort,
DNS: NormalizeDNSServer(parsed.DNS),
Obfuscation: Obfuscation31{
Jc: parsed.Jc, Jmin: parsed.Jmin, Jmax: parsed.Jmax,
S1: parsed.S1, S2: parsed.S2, S3: parsed.S3, S4: parsed.S4,
H1: parsed.H1, H2: parsed.H2, H3: parsed.H3, H4: parsed.H4,
I1: parsed.I1, I2: parsed.I2, I3: parsed.I3, I4: parsed.I4, I5: parsed.I5,
HeaderProtectionKey: parsed.HeaderProtectionKey,
ContentPaddingAddition: parsed.ContentPaddingAddition,
RekeyAfterTime: parsed.RekeyAfterTime,
RekeyTimeout: parsed.RekeyTimeout,
RejectAfterTime: parsed.RejectAfterTime,
KeepaliveTimeout: parsed.KeepaliveTimeout,
MaxHandshakeAttempts: parsed.MaxHandshakeAttempts,
RandomTrailers: parsed.RandomTrailers,
DisableCookies: parsed.DisableCookies,
},
}
for _, p := range parsed.Peers {
if p.PublicKey == "" || len(p.AllowedIPs) == 0 || p.Endpoint == "" {
continue
}
peer := OutboundPeer(p)
peer.AllowedIPs = peer.AllowedIPs[:0:0]
for _, a := range p.AllowedIPs {
prefix, err := netip.ParsePrefix(strings.TrimSpace(a))
if err != nil {
return OutboundInstance{}, false
}
peer.AllowedIPs = append(peer.AllowedIPs, prefix.String())
}
inst.Peers = append(inst.Peers, peer)
}
if len(inst.Address) == 0 || len(inst.Peers) == 0 {
return OutboundInstance{}, false
}
return inst, true
}
// validateEndpoint accepts "host:port" with a numeric port and no control
// characters; hostnames resolve at IpcSet time via resolvingBind.
func validateEndpoint(ep string) error {
if ep == "" {
return fmt.Errorf("endpoint is required")
}
if err := ValidateConfigValue("endpoint", ep); err != nil {
return err
}
host, portS, err := net.SplitHostPort(ep)
if err != nil {
return fmt.Errorf("invalid endpoint %q: must be host:port", ep)
}
port, err := strconv.Atoi(portS)
if err != nil || port <= 0 || port > 65535 {
return fmt.Errorf("invalid endpoint %q: bad port", ep)
}
if strings.TrimSpace(host) == "" {
return fmt.Errorf("invalid endpoint %q: empty host", ep)
}
return nil
}
// validateTunnelAddresses requires every entry to be a parseable IP prefix
// (the outbound's own tunnel address(es), e.g. "10.8.1.2/32").
func validateTunnelAddresses(addrs []string) error {
if len(addrs) == 0 {
return fmt.Errorf("at least one tunnel address is required")
}
for _, a := range addrs {
prefix, err := netip.ParsePrefix(a)
if err != nil {
return fmt.Errorf("invalid tunnel address %q: %w", a, err)
}
_ = prefix
}
return nil
}
// NormalizeDNSServer converts a bare IP or IP:port into a standard host:port.
func NormalizeDNSServer(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if addr, err := netip.ParseAddr(s); err == nil {
return netip.AddrPortFrom(addr, 53).String()
}
if ap, err := netip.ParseAddrPort(s); err == nil {
return ap.String()
}
return s
}
// ValidateDNSServer checks that dns is empty or a valid IP or IP:port.
func ValidateDNSServer(s string) error {
if s == "" {
return nil
}
if err := ValidateConfigValue("dns", s); err != nil {
return err
}
if _, err := netip.ParseAddr(s); err == nil {
return nil
}
if _, err := netip.ParseAddrPort(s); err == nil {
return nil
}
return fmt.Errorf("must be an IP address or IP:port")
}
// ValidateAmneziaWGOutbound rejects settings that could break the embedded
// device's UAPI apply or smuggle control characters downstream.
func ValidateAmneziaWGOutbound(tag string, raw []byte) error {
if strings.TrimSpace(tag) == "" {
return fmt.Errorf("amneziawg outbound: tag must be a non-empty string")
}
settingsRaw, ok := outboundSettingsOf(raw)
if !ok {
return fmt.Errorf("amneziawg outbound %q: missing settings block", tag)
}
var parsed OutboundSettings
if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
return fmt.Errorf("amneziawg outbound %q: invalid settings: %w", tag, err)
}
if err := validateTunnelAddresses(parsed.Address); err != nil {
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
}
if err := ValidateDNSServer(parsed.DNS); err != nil {
return fmt.Errorf("amneziawg outbound %q: invalid dns: %w", tag, err)
}
if strings.TrimSpace(parsed.SecretKey) == "" {
return fmt.Errorf("amneziawg outbound %q: privateKey is required", tag)
}
if _, err := wireguard.KeyToHex(parsed.SecretKey); err != nil {
return fmt.Errorf("amneziawg outbound %q: invalid privateKey: %w", tag, err)
}
if err := ValidateObfuscation(parsed.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
}
for n, iv := range map[string]string{
"i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5,
} {
if err := ValidateConfigValue(n, iv); err != nil {
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
}
}
if err := validateHeaderProtectionKey(parsed.HeaderProtectionKey); err != nil {
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
}
if len(parsed.Peers) == 0 {
return fmt.Errorf("amneziawg outbound %q: at least one peer is required", tag)
}
for i, p := range parsed.Peers {
if strings.TrimSpace(p.PublicKey) == "" {
return fmt.Errorf("amneziawg outbound %q: peer %d: publicKey is required", tag, i)
}
if _, err := wireguard.KeyToHex(p.PublicKey); err != nil {
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid publicKey: %w", tag, i, err)
}
if p.PresharedKey != "" {
if _, err := wireguard.KeyToHex(p.PresharedKey); err != nil {
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid presharedKey: %w", tag, i, err)
}
}
if err := validateEndpoint(p.Endpoint); err != nil {
return fmt.Errorf("amneziawg outbound %q: peer %d: %w", tag, i, err)
}
if len(p.AllowedIPs) == 0 {
return fmt.Errorf("amneziawg outbound %q: peer %d: at least one allowedIPs entry is required", tag, i)
}
for _, a := range p.AllowedIPs {
if _, err := netip.ParsePrefix(a); err != nil {
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid allowedIP %q: %w", tag, i, a, err)
}
}
}
return nil
}