fix(amneziawgnet): stop resetting live peer sessions on every reconcile tick

Real production bug, found via a live test connection that reset every
~10 seconds: ensureLocked's reconfigure-in-place branch called IpcSet
unconditionally on every Ensure, including AmneziaWGJob's routine 10s
reconcile tick even when nothing in the DB had changed. amneziawg-go's
IpcSet always includes replace_peers=true (buildUAPIConfig), and its own
handling of that op is device.RemoveAllPeers() -- unconditional, even
when the new peer list is byte-identical to the old one. So every tick
tore down and recreated every peer's live handshake/session state, and
no connection could ever survive past one reconcile cycle.

Root-caused with AMNEZIAWGNET_DEBUG (previous commit) showing "UAPI:
Removing all peers" + peer Stopping/Starting exactly ~10s after a real
handshake completed, matching AmneziaWGJob's own cadence precisely.

Fixed by comparing the freshly rendered UAPI config string against what
was last actually applied and skipping IpcSet entirely when identical --
reusing buildUAPIConfig's own exhaustive field coverage instead of a
hand-maintained fingerprint that could drift out of sync with it.

TestEnsureUnchangedInstanceDoesNotResetLivePeers verifies via
device.LookupPeer pointer identity (confirmed to fail without this fix,
not just pass trivially with it).
This commit is contained in:
Kuzz007
2026-08-04 01:46:32 +03:00
parent b0c29b7caa
commit 738163699e
2 changed files with 136 additions and 21 deletions
+50 -21
View File
@@ -45,11 +45,12 @@ type Desired struct {
// enough of its own configuration to decide whether a later Ensure call can // enough of its own configuration to decide whether a later Ensure call can
// reconfigure it in place or needs to rebuild it from scratch. // reconfigure it in place or needs to rebuild it from scratch.
type managed struct { type managed struct {
dev *Device dev *Device
udpRelay *UDPRelay udpRelay *UDPRelay
peers *PeerIndex peers *PeerIndex
inst amneziawg.Instance inst amneziawg.Instance
structFP string structFP string
uapiConfig string
} }
// Manager owns the set of running embedded AmneziaWG interfaces, keyed by // Manager owns the set of running embedded AmneziaWG interfaces, keyed by
@@ -89,17 +90,23 @@ func (m *Manager) Ensure(d Desired) error {
} }
// ensureLocked decides between three actions: nothing changed since the // ensureLocked decides between three actions: nothing changed since the
// last apply (skip entirely); only peers/obfuscation/keys/listen_port // last apply (skip entirely -- this is the common case on every 10s
// changed (reconfigure the existing Device in place via IpcSet, which // reconcile tick when no admin edit happened, and it MUST actually skip the
// already sends replace_peers=true -- see buildUAPIConfig -- so removed // IpcSet call, not just look like it should: amneziawg-go's IpcSet always
// peers are dropped correctly without a full rebuild); or the interface's // includes replace_peers=true -- see buildUAPIConfig -- and its own
// own address(es)/MTU changed (these are fixed at netstack-construction // implementation of that op is device.RemoveAllPeers(), unconditionally,
// time, so the only option is closing the old Device and building a fresh // even when the new peer list is byte-identical to the old one. A real
// one). This is a coarser split than internal/amneziawg's own three-tier // production bug, found via a live test connection that reset every ~10s:
// noop/reload/restart fingerprinting (that one also tracks host-side // calling IpcSet on every tick regardless of whether anything changed was
// TPROXY/NDP rules this embedded path has no equivalent of) -- correct and // tearing down every peer's live handshake/session state on every single
// sufficient for Phase 1; revisit only if reconcile frequency at real scale // reconcile, so no connection could ever survive past one tick); only
// makes the address/MTU rebuild path worth avoiding too. // peers/obfuscation/keys/listen_port changed (reconfigure the existing
// Device in place via IpcSet); 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 here.
func (m *Manager) ensureLocked(d Desired) error { func (m *Manager) ensureLocked(d Desired) error {
inst, opts := d.Instance, d.Options inst, opts := d.Instance, d.Options
if opts.Logger == nil { if opts.Logger == nil {
@@ -123,11 +130,25 @@ func (m *Manager) ensureLocked(d Desired) error {
if err != nil { if err != nil {
return fmt.Errorf("amneziawgnet: %w", err) return fmt.Errorf("amneziawgnet: %w", err)
} }
// True no-op: the rendered UAPI config -- which already covers every
// field IpcSet can act on (keys, listen port, obfuscation, AWG 3.0
// options, the full peer list) -- is byte-identical to what's
// already live. Comparing the rendered string instead of inst
// directly means this can never drift out of sync with whatever
// buildUAPIConfig actually reads, the way a hand-maintained field
// list could.
if conf == cur.uapiConfig {
cur.peers = NewPeerIndex(inst.Peers)
cur.inst = inst
applyV6Aliases(diffV6Aliases(oldInst, inst))
return nil
}
if err := cur.dev.IpcSet(conf); err != nil { if err := cur.dev.IpcSet(conf); err != nil {
return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err) return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
} }
cur.peers = NewPeerIndex(inst.Peers) cur.peers = NewPeerIndex(inst.Peers)
cur.inst = inst cur.inst = inst
cur.uapiConfig = conf
applyV6Aliases(diffV6Aliases(oldInst, inst)) applyV6Aliases(diffV6Aliases(oldInst, inst))
return nil return nil
} }
@@ -141,6 +162,13 @@ func (m *Manager) ensureLocked(d Desired) error {
if err != nil { if err != nil {
return err return err
} }
// NewDevice already rendered and applied this exact config internally;
// recomputing it here (cheap, pure, guaranteed to succeed since
// NewDevice just proved these inputs are valid) is simpler than
// threading the string back out of NewDevice's own signature, and gives
// the no-op check above a correct baseline to compare the next tick
// against instead of an empty string.
conf, _ := buildUAPIConfig(inst, opts)
relay := socksRelayForInstance(inst) relay := socksRelayForInstance(inst)
udpRelay := NewUDPRelay(relay, dev.Stack) udpRelay := NewUDPRelay(relay, dev.Stack)
@@ -180,11 +208,12 @@ func (m *Manager) ensureLocked(d Desired) error {
}) })
m.ifaces[inst.Id] = &managed{ m.ifaces[inst.Id] = &managed{
dev: dev, dev: dev,
udpRelay: udpRelay, udpRelay: udpRelay,
peers: NewPeerIndex(inst.Peers), peers: NewPeerIndex(inst.Peers),
inst: inst, inst: inst,
structFP: structFP, structFP: structFP,
uapiConfig: conf,
} }
applyV6Aliases(diffV6Aliases(oldInst, inst)) applyV6Aliases(diffV6Aliases(oldInst, inst))
logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id) logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
+86
View File
@@ -3,6 +3,8 @@ package amneziawgnet
import ( import (
"testing" "testing"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
) )
@@ -83,3 +85,87 @@ func TestManagerLifecycle(t *testing.T) {
t.Error("Lookup succeeded after Reconcile([]) removed the interface") t.Error("Lookup succeeded after Reconcile([]) removed the interface")
} }
} }
// TestEnsureUnchangedInstanceDoesNotResetLivePeers is a regression test for a
// real production bug: an unchanged Ensure call (the common case on every
// 10s AmneziaWGJob reconcile tick when no admin edit happened) was calling
// IpcSet unconditionally. amneziawg-go's IpcSet always includes
// replace_peers=true (see buildUAPIConfig), and its own implementation of
// that op is device.RemoveAllPeers() -- unconditionally, even when the new
// peer list is byte-identical to the old one. That tore down every peer's
// live handshake/session state on every single reconcile tick, so no real
// connection could ever survive past ~10 seconds. Caught via a live test
// connection that reset every ~10s with amneziawg-go's own verbose logging
// enabled (AMNEZIAWGNET_DEBUG) showing "UAPI: Removing all peers" +
// peer "Stopping"/"Starting" on every tick.
//
// Verified here by comparing the *device.Peer pointer LookupPeer returns
// before and after a no-op Ensure: identical pointer proves the peer object
// itself survived (no RemoveAllPeers), not just that some higher-level
// abstraction looks unchanged.
func TestEnsureUnchangedInstanceDoesNotResetLivePeers(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
_, peerPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
m := &Manager{ifaces: map[int]*managed{}}
inst := amneziawg.Instance{
Id: 4,
InterfaceName: "awgtest4",
ListenPort: 58715,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.204.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation20{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{
{Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.204.0.2/32"}},
},
}
defer m.StopAll()
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (create): %v", err)
}
peerPubHex, err := wireguard.KeyToHex(peerPub)
if err != nil {
t.Fatalf("KeyToHex: %v", err)
}
var npk device.NoisePublicKey
if err := npk.FromHex(peerPubHex); err != nil {
t.Fatalf("NoisePublicKey.FromHex: %v", err)
}
dev, _, ok := m.Lookup(inst.Id)
if !ok {
t.Fatal("Lookup after Ensure: not found")
}
peerBefore := dev.LookupPeer(npk)
if peerBefore == nil {
t.Fatal("LookupPeer returned nil right after Ensure created the peer")
}
// Simulate the reconcile job firing again with byte-identical data --
// this is what AmneziaWGJob does every 10 seconds regardless of whether
// anything actually changed.
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (unchanged, second tick): %v", err)
}
peerAfter := dev.LookupPeer(npk)
if peerAfter == nil {
t.Fatal("LookupPeer returned nil after the unchanged Ensure -- peer was removed and never re-added")
}
if peerBefore != peerAfter {
t.Error("unchanged Ensure recreated the peer object (RemoveAllPeers + re-add) -- " +
"any live handshake/session on this peer would have been reset for no reason")
}
}