mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 15:47:14 +00:00
d5ab84e8d5
* 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>
195 lines
5.4 KiB
Go
195 lines
5.4 KiB
Go
package amneziawgnet
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
// OutboundDesired pairs an instance with inbound-path DeviceOptions; AWG
|
|
// parameters must be identical on both ends of a tunnel.
|
|
type OutboundDesired struct {
|
|
Instance amneziawg.OutboundInstance
|
|
Options DeviceOptions
|
|
}
|
|
|
|
// managedOutbound is one running interface plus its rendered UAPI config
|
|
// (no-op/reconfigure decision) and an address/MTU fingerprint.
|
|
type managedOutbound struct {
|
|
dev *Device
|
|
uapiConfig string
|
|
structFP string
|
|
}
|
|
|
|
// OutboundManager owns the running AmneziaWG client interfaces keyed by tag,
|
|
// keeping the egress registry and listener current; callers just Reconcile.
|
|
type OutboundManager struct {
|
|
mu sync.Mutex
|
|
iface map[string]*managedOutbound
|
|
}
|
|
|
|
var (
|
|
outboundManagerOnce sync.Once
|
|
outboundManager *OutboundManager
|
|
)
|
|
|
|
// GetOutboundManager returns the process-wide outbound manager singleton.
|
|
func GetOutboundManager() *OutboundManager {
|
|
outboundManagerOnce.Do(func() {
|
|
outboundManager = &OutboundManager{iface: map[string]*managedOutbound{}}
|
|
})
|
|
return outboundManager
|
|
}
|
|
|
|
// outboundFingerprint captures what IpcSet can't change on a running Device,
|
|
// fixed when the netstack is built: address, and the S4-derived effective MTU.
|
|
func outboundFingerprint(inst amneziawg.OutboundInstance) string {
|
|
return fmt.Sprintf("%d|%s",
|
|
amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4),
|
|
strings.Join(inst.Address, ","))
|
|
}
|
|
|
|
// normalizeDNSServer normalizes a configured DNS server to 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
|
|
}
|
|
|
|
// Reconcile converges devices to desired and stops removed tags; per-tick
|
|
// contract of Manager.Reconcile -- errors log, never abort the batch.
|
|
func (m *OutboundManager) Reconcile(desired []OutboundDesired) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Empty desired converges to "no tunnels": close egress listener so
|
|
// 127.0.0.1:64900 stays free on installs without AWG outbounds.
|
|
if len(desired) == 0 {
|
|
for tag, cur := range m.iface {
|
|
cur.dev.Close()
|
|
GetEgressServer().DeleteStack(tag)
|
|
delete(m.iface, tag)
|
|
logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
|
|
}
|
|
GetEgressServer().Close()
|
|
return
|
|
}
|
|
|
|
if err := GetEgressServer().Listen(); err != nil {
|
|
logger.Warningf("amneziawgnet: egress listener unavailable: %v", err)
|
|
}
|
|
|
|
want := make(map[string]struct{}, len(desired))
|
|
for _, d := range desired {
|
|
want[d.Instance.Tag] = struct{}{}
|
|
}
|
|
for tag, cur := range m.iface {
|
|
if _, ok := want[tag]; ok {
|
|
continue
|
|
}
|
|
cur.dev.Close()
|
|
GetEgressServer().DeleteStack(tag)
|
|
delete(m.iface, tag)
|
|
logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
|
|
}
|
|
|
|
for _, d := range desired {
|
|
if err := m.ensureLocked(d); err != nil {
|
|
logger.Warningf("amneziawgnet: reconcile failed for outbound %q: %v", d.Instance.Tag, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensureLocked picks no-op / reconfigure-in-place / rebuild for one desired
|
|
// outbound (address/MTU are fixed at netstack build time).
|
|
func (m *OutboundManager) ensureLocked(d OutboundDesired) error {
|
|
inst, opts := d.Instance, d.Options
|
|
if opts.Logger == nil {
|
|
opts.Logger = verboseLoggerIfEnabled(0)
|
|
}
|
|
|
|
fp := outboundFingerprint(inst)
|
|
conf, err := buildClientUAPIConfig(inst, opts)
|
|
if err != nil {
|
|
return fmt.Errorf("render UAPI config: %w", err)
|
|
}
|
|
|
|
cur, exists := m.iface[inst.Tag]
|
|
if exists && cur.structFP == fp {
|
|
if conf == cur.uapiConfig {
|
|
GetEgressServer().SetStack(inst.Tag, cur.dev, inst.DNS)
|
|
return nil
|
|
}
|
|
if err := cur.dev.IpcSet(conf); err != nil {
|
|
return fmt.Errorf("reconfigure outbound %q: %w", inst.Tag, err)
|
|
}
|
|
cur.uapiConfig = conf
|
|
GetEgressServer().SetStack(inst.Tag, cur.dev, inst.DNS)
|
|
return nil
|
|
}
|
|
|
|
if exists {
|
|
cur.dev.Close()
|
|
// A failed rebuild must not leave stackFor handing out a closed device.
|
|
GetEgressServer().DeleteStack(inst.Tag)
|
|
delete(m.iface, inst.Tag)
|
|
}
|
|
dev, err := newUnconfiguredClientDevice(inst, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := dev.ConfigureClient(inst, opts); err != nil {
|
|
return err
|
|
}
|
|
m.iface[inst.Tag] = &managedOutbound{dev: dev, uapiConfig: conf, structFP: fp}
|
|
GetEgressServer().SetStack(inst.Tag, dev, inst.DNS)
|
|
logger.Infof("amneziawgnet: started embedded outbound %s (%d peers)", inst.Tag, len(inst.Peers))
|
|
return nil
|
|
}
|
|
|
|
// Remove tears down one outbound's device by tag.
|
|
func (m *OutboundManager) Remove(tag string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
cur, exists := m.iface[tag]
|
|
if !exists {
|
|
return
|
|
}
|
|
cur.dev.Close()
|
|
GetEgressServer().DeleteStack(tag)
|
|
delete(m.iface, tag)
|
|
logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
|
|
}
|
|
|
|
// StopAll tears down every managed outbound device and the egress listener;
|
|
// m.mu stays held across Close so a cron tick cannot re-bind mid-teardown.
|
|
func (m *OutboundManager) StopAll() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for tag, cur := range m.iface {
|
|
cur.dev.Close()
|
|
GetEgressServer().DeleteStack(tag)
|
|
delete(m.iface, tag)
|
|
}
|
|
GetEgressServer().Close()
|
|
}
|
|
|
|
// HasRunning reports whether any outbound device is currently managed.
|
|
func (m *OutboundManager) HasRunning() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return len(m.iface) > 0
|
|
}
|