Files
3x-ui/internal/amneziawgnet/manager.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

181 lines
6.0 KiB
Go

package amneziawgnet
import (
"fmt"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// Desired pairs an amneziawg.Instance (the shared, DB-backed shape
// internal/amneziawg's own kernel-module Manager also reconciles toward)
// with this package's own embedded-only DeviceOptions -- the AWG 3.0 fields
// that shared type doesn't carry, see DeviceOptions' doc comment.
type Desired struct {
Instance amneziawg.Instance
Options DeviceOptions
}
// managed is one running embedded interface: the live Device, the peer
// lookup index built from its current peer list, and enough of its own
// configuration to decide whether a later Ensure call can reconfigure it in
// place or needs to rebuild it from scratch.
type managed struct {
dev *Device
peers *PeerIndex
inst amneziawg.Instance
structFP string
}
// Manager owns the set of running embedded AmneziaWG interfaces, keyed by
// inbound id -- the same shape as internal/amneziawg.Manager (GetManager()
// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
// caller already familiar with that Manager needs to learn nothing new here.
// Unlike that Manager, this one doesn't attach any traffic handling by
// itself: Ensure/Reconcile only bring each Instance's Device up to date.
// Attaching a forwarder/UDP handler (see forwarder.go / udp.go) using the
// Device and PeerIndex returned by Lookup is left to the caller -- today a
// test harness, later the Phase 2 SOCKS5 relay wiring -- since this package
// doesn't yet know what that handler should do with a recovered connection.
type Manager struct {
mu sync.Mutex
ifaces map[int]*managed
}
var (
managerOnce sync.Once
manager *Manager
)
// GetManager returns the process-wide embedded-AmneziaWG manager singleton.
func GetManager() *Manager {
managerOnce.Do(func() {
manager = &Manager{ifaces: map[int]*managed{}}
})
return manager
}
// Ensure brings inbound d.Instance.Id's embedded interface to the state
// d describes, creating it if it doesn't exist yet. A no-op only when
// nothing has changed since the last successful Ensure/Reconcile.
func (m *Manager) Ensure(d Desired) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.ensureLocked(d)
}
// ensureLocked decides between three actions: nothing changed since the
// last apply (skip entirely); only peers/obfuscation/keys/listen_port
// changed (reconfigure the existing Device in place via IpcSet, which
// already sends replace_peers=true -- see buildUAPIConfig -- so removed
// peers are dropped correctly without a full rebuild); or the interface's
// own address(es)/MTU changed (these are fixed at netstack-construction
// time, so the only option is closing the old Device and building a fresh
// one). This is a coarser split than internal/amneziawg's own three-tier
// noop/reload/restart fingerprinting (that one also tracks host-side
// TPROXY/NDP rules this embedded path has no equivalent of) -- correct and
// sufficient for Phase 1; revisit only if reconcile frequency at real scale
// makes the address/MTU rebuild path worth avoiding too.
func (m *Manager) ensureLocked(d Desired) error {
inst, opts := d.Instance, d.Options
structFP := addressFingerprint(inst)
cur, exists := m.ifaces[inst.Id]
if exists && cur.structFP == structFP {
conf, err := buildUAPIConfig(inst, opts)
if err != nil {
return fmt.Errorf("amneziawgnet: %w", err)
}
if err := cur.dev.IpcSet(conf); err != nil {
return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
}
cur.peers = NewPeerIndex(inst.Peers)
cur.inst = inst
return nil
}
if exists {
cur.dev.Close()
delete(m.ifaces, inst.Id)
}
dev, err := NewDevice(inst, opts)
if err != nil {
return err
}
m.ifaces[inst.Id] = &managed{
dev: dev,
peers: NewPeerIndex(inst.Peers),
inst: inst,
structFP: structFP,
}
logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
return nil
}
// addressFingerprint captures the two Instance fields that can't be changed
// on a running Device via IpcSet alone (they're fixed when the gVisor
// netstack is built) -- everything else (keys, listen port, obfuscation,
// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure.
func addressFingerprint(inst amneziawg.Instance) string {
return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ","))
}
// Reconcile brings every desired instance's embedded interface up to date
// and stops any managed interface whose inbound is no longer desired --
// mirroring internal/amneziawg.Manager.Reconcile's per-tick contract.
func (m *Manager) Reconcile(desired []Desired) {
m.mu.Lock()
defer m.mu.Unlock()
want := make(map[int]struct{}, len(desired))
for _, d := range desired {
want[d.Instance.Id] = struct{}{}
}
for id, cur := range m.ifaces {
if _, ok := want[id]; ok {
continue
}
cur.dev.Close()
delete(m.ifaces, id)
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
}
for _, d := range desired {
if err := m.ensureLocked(d); err != nil {
logger.Warningf("amneziawgnet: reconcile failed for inbound %d: %v", d.Instance.Id, err)
}
}
}
// StopAll tears down every managed interface. Called on panel shutdown.
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, cur := range m.ifaces {
cur.dev.Close()
delete(m.ifaces, id)
}
}
// HasRunning reports whether any embedded interface is currently managed.
func (m *Manager) HasRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.ifaces) > 0
}
// Lookup returns the running Device and PeerIndex for inbound id, if any --
// for a caller that wants to attach its own forwarder/handler (a test
// harness today, the Phase 2 SOCKS5 relay wiring later) once the interface
// is up.
func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
m.mu.Lock()
defer m.mu.Unlock()
cur, exists := m.ifaces[id]
if !exists {
return nil, nil, false
}
return cur.dev, cur.peers, true
}