From 6c74fccbf797a6df850ed146b6ca199a415c44bc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:32:21 +0100 Subject: [PATCH] fix(amneziawg): only delete IPv6 aliases this panel added itself Teardown and the per-peer diff built the list of IPv6 aliases to delete from configuration alone, while adding an alias treated "already exists" as success. An address the host already owned, ::1 on lo or its own public /128, was therefore deleted when an inbound or peer that named it went away. The manager records the aliases whose add actually created them and deletes only those; an address that was already there is left in place. --- internal/amneziawgnet/manager.go | 15 +++--- internal/amneziawgnet/v6alias.go | 33 +++++++++---- internal/amneziawgnet/v6alias_test.go | 68 +++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 21 deletions(-) diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index 30187bd88..eeef3cf21 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -116,6 +116,9 @@ func (m *managed) close() { type Manager struct { mu sync.Mutex ifaces map[int]*managed + // v6Owned: IPv6 aliases this Manager put on the host, the only ones it + // may delete again (see applyV6Aliases). + v6Owned map[v6Alias]struct{} } var ( @@ -189,7 +192,7 @@ func (m *Manager) ensureLocked(d Desired) error { if conf == cur.uapiConfig { cur.peers.Store(NewPeerIndex(inst.Peers)) cur.inst = inst - applyV6Aliases(diffV6Aliases(oldInst, inst)) + m.applyV6Aliases(diffV6Aliases(oldInst, inst)) // buildUAPIConfig never reads ForwardedPorts (it's a panel-level // concept, not a WireGuard UAPI field), so a ForwardedPorts-only // edit renders byte-identical here and takes this exact no-op @@ -206,7 +209,7 @@ func (m *Manager) ensureLocked(d Desired) error { oldPeers := cur.peers.Swap(NewPeerIndex(inst.Peers)) cur.inst = inst cur.uapiConfig = conf - applyV6Aliases(diffV6Aliases(oldInst, inst)) + m.applyV6Aliases(diffV6Aliases(oldInst, inst)) cur.portForwards.Reconcile(inst) cur.dropReassignedFlows(oldPeers) return nil @@ -262,7 +265,7 @@ func (m *Manager) ensureLocked(d Desired) error { conf, _ := buildUAPIConfig(inst, opts) next.uapiConfig = conf m.ifaces[inst.Id] = next - applyV6Aliases(diffV6Aliases(oldInst, inst)) + m.applyV6Aliases(diffV6Aliases(oldInst, inst)) portForwards.Reconcile(inst) logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id) return nil @@ -302,7 +305,7 @@ func (m *Manager) Reconcile(desired []Desired) { if _, ok := want[id]; ok { continue } - applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) + m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.close() delete(m.ifaces, id) logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) @@ -325,7 +328,7 @@ func (m *Manager) Remove(id int) { if !exists { return } - applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) + m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.close() delete(m.ifaces, id) logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) @@ -336,7 +339,7 @@ func (m *Manager) StopAll() { m.mu.Lock() defer m.mu.Unlock() for id, cur := range m.ifaces { - applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) + m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.close() delete(m.ifaces, id) } diff --git a/internal/amneziawgnet/v6alias.go b/internal/amneziawgnet/v6alias.go index b48f89d20..7c01faa8c 100644 --- a/internal/amneziawgnet/v6alias.go +++ b/internal/amneziawgnet/v6alias.go @@ -117,16 +117,30 @@ const ipCommandTimeout = 3 * time.Second // aliased at once is harmless). Never surfaces an error — an alias failing // only narrows that one peer's own outbound-source-identity feature, never // a reason to fail the tunnel or its SOCKS5 relay. -func applyV6Aliases(add, remove []v6Alias) { - for _, a := range add { - addV6Alias(a) +func (m *Manager) applyV6Aliases(add, remove []v6Alias) { + if m.v6Owned == nil { + m.v6Owned = map[v6Alias]struct{}{} } + for _, a := range add { + if addV6Alias(a) { + m.v6Owned[a] = struct{}{} + } + } + // A peer may name an address the host already owns (::1 on lo, its own + // public /128): only what this Manager itself added is ever deleted. for _, a := range remove { + if _, ours := m.v6Owned[a]; !ours { + logger.Debugf("amneziawgnet: leaving IPv6 address %s on %s, not aliased by this process", a.Addr, a.Iface) + continue + } + delete(m.v6Owned, a) removeV6Alias(a) } } -func addV6Alias(a v6Alias) { +// addV6Alias reports whether the alias is on the host because of this call; +// an address that was already there is the host's own, not ours to remove. +func addV6Alias(a v6Alias) bool { ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout) defer cancel() // nodad: this address is a specific peer's own admin-assigned identity, @@ -136,16 +150,15 @@ func addV6Alias(a v6Alias) { stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad") if err == nil { logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface) - return + return true } if strings.Contains(stderr, "File exists") { - // Already the desired end state -- most commonly hit once, harmlessly, - // right after an ungraceful panel restart (the OS-level alias from - // before the crash outlives the process; the in-memory managed map - // doesn't). - return + // Already there: the host's own address, or an alias an ungraceful + // restart left behind -- indistinguishable, so neither is ours. + return false } logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr)) + return false } func removeV6Alias(a v6Alias) { diff --git a/internal/amneziawgnet/v6alias_test.go b/internal/amneziawgnet/v6alias_test.go index 8f6ea17a0..7b4dce784 100644 --- a/internal/amneziawgnet/v6alias_test.go +++ b/internal/amneziawgnet/v6alias_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) func peerWithIPs(email string, ips ...string) amneziawg.Peer { @@ -264,14 +265,71 @@ func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) { } return "", nil }) - applyV6Aliases( + m := &Manager{ifaces: map[int]*managed{}} + m.applyV6Aliases([]v6Alias{{Addr: "fd86::2", Iface: "eth0"}}, nil) + m.applyV6Aliases( []v6Alias{{Addr: "fd86::99", Iface: "eth0"}}, []v6Alias{{Addr: "fd86::2", Iface: "eth0"}}, ) - if len(*calls) != 2 { - t.Fatalf("expected exactly 2 calls, got %d", len(*calls)) + if len(*calls) != 3 { + t.Fatalf("expected exactly 3 calls, got %d", len(*calls)) } - if order[0] != "add" || order[1] != "del" { - t.Fatalf("expected add before del, got order=%v", order) + if order[1] != "add" || order[2] != "del" { + t.Fatalf("expected add before del, got order=%v", order[1:]) + } +} + +// An address the host already held (::1 on lo, its own /128) answers the add +// with EEXIST; tearing the inbound down must leave it in place. +func TestManagerRemoveLeavesHostOwnedAddressInPlace(t *testing.T) { + calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + if args[2] == "add" && args[3] == "::1/128" { + return "RTNETLINK answers: File exists", errors.New("exit status 2") + } + return "", nil + }) + priv, pub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate keypair: %v", err) + } + _, foreignPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate peer keypair: %v", err) + } + _, hostOwnPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate peer keypair: %v", err) + } + m := &Manager{ifaces: map[int]*managed{}} + defer m.StopAll() + inst := amneziawg.Instance{ + Id: 7, + InterfaceName: "awgtest7", + ListenPort: 58717, + PrivateKey: priv, + PublicKey: pub, + Address: []string{"10.207.0.1/24", "fd86:ea04:1115::1/64"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}, + IPv6Enabled: true, + IPv6ExternalInterface: "lo", + Peers: []amneziawg.Peer{ + {Email: "foreign", PublicKey: foreignPub, AllowedIPs: []string{"10.207.0.2/32", "2001:db8:beef::1/128"}}, + {Email: "host-own", PublicKey: hostOwnPub, AllowedIPs: []string{"10.207.0.3/32", "::1/128"}}, + }, + } + if err := m.Ensure(Desired{Instance: inst}); err != nil { + t.Fatalf("Ensure: %v", err) + } + m.Remove(inst.Id) + + var deleted []string + for _, c := range *calls { + if c[2] == "del" { + deleted = append(deleted, c[3]) + } + } + if len(deleted) != 1 || deleted[0] != "2001:db8:beef::1/128" { + t.Fatalf("deleted %v, want only the alias this process added (2001:db8:beef::1/128)", deleted) } }