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.
This commit is contained in:
MHSanaei
2026-11-01 13:32:21 +01:00
parent 8b525b5be6
commit 6c74fccbf7
3 changed files with 95 additions and 21 deletions
+9 -6
View File
@@ -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)
}
+23 -10
View File
@@ -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) {
+63 -5
View File
@@ -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)
}
}