fix(amneziawg): recover orphaned interfaces after an ungraceful exit

Two gaps left an AmneziaWG interface stuck outside the manager's
control after a crash (kill -9/OOM/panic skips StopAll):

- ensureRestart's teardown was gated on the in-memory `exists` map,
  which is always empty on a fresh process, so a survived interface
  never got interfaceDown before interfaceUp tried `ip link add`
  against a name the kernel already had — failing forever and never
  populating m.ifaces, so traffic accounting silently stopped and the
  inbound could never be removed. Gate on isInterfaceUp instead, which
  checks real kernel state rather than this process's own bookkeeping.

- An inbound deleted from the database entirely while the panel was
  down has no entry in `desired` ever again, so it never reaches the
  per-id cleanup loop in Reconcile (which only walks m.ifaces). Add a
  one-time sweepOrphansLocked scan of configDir, mirroring
  mtproto.Manager.sweepOrphansLocked, that tears down and removes any
  leftover interface/config not in the current desired set.

Found by the automated review on MHSanaei/3x-ui#6105 (Finding 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 23:55:11 +03:00
parent 65513b4854
commit f5543047b7
2 changed files with 143 additions and 2 deletions
+97 -2
View File
@@ -195,6 +195,9 @@ type managed struct {
type Manager struct {
mu sync.Mutex
ifaces map[int]*managed
// swept records that the one-time startup cleanup of orphaned interfaces
// (survivors of a previous x-ui run) has already run.
swept bool
}
var (
@@ -269,8 +272,16 @@ func (m *Manager) ensureLocked(inst Instance) error {
return err
}
case ensureRestart:
if exists {
_ = interfaceDown(cur.inst.InterfaceName)
// Checked against the interface's actual kernel state, not `exists`:
// after an ungraceful exit (kill -9, OOM, panic) the previous
// process's interface can still be up even though this fresh
// Manager has never seen it (exists is always false on a cold
// start). Skipping the teardown in that case would send
// interfaceUp straight into "ip link add" against a name that
// already exists, which fails and leaves this inbound stuck
// retrying every reconcile forever.
if isInterfaceUp(inst.InterfaceName) {
_ = interfaceDown(inst.InterfaceName)
}
if err := writeConfigFile(inst); err != nil {
return err
@@ -301,6 +312,89 @@ func (m *Manager) Remove(id int) {
}
}
// sweepOrphansLocked tears down any AmneziaWG interface and config file left
// behind by a previous x-ui process whose inbound is no longer in the
// current desired set — most commonly because it was deleted from the
// database entirely while the panel was down, so it will never again appear
// in any future Reconcile call and would otherwise never be discovered (it
// has no entry in m.ifaces for the per-id cleanup loop below to catch,
// because that map always starts empty on a fresh process). Runs once per
// process lifetime, mirroring mtproto.Manager.sweepOrphansLocked.
//
// Deliberately only called from Reconcile, not Ensure: Ensure only ever
// carries a single instance, and a `want` set of just that one id would
// misidentify every other still-desired-but-not-yet-reconciled-this-process
// interface as an orphan. A crashed-but-still-wanted interface is instead
// recovered normally by ensureLocked's ensureRestart branch, which checks
// the interface's actual kernel state rather than this manager's in-memory
// bookkeeping.
func (m *Manager) sweepOrphansLocked(want map[int]struct{}) {
if m.swept {
return
}
m.swept = true
entries, err := os.ReadDir(configDir)
if err != nil {
return
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
names = append(names, entry.Name())
}
}
for _, ifaceName := range orphanedInterfaces(names, want) {
if isInterfaceUp(ifaceName) {
_ = interfaceDown(ifaceName)
logger.Warningf("amneziawg: tore down orphaned interface %s (its inbound no longer exists)", ifaceName)
}
removeConfigFile(ifaceName)
}
}
// orphanedInterfaces returns the interface names among confFileNames (the
// basenames of configDir's entries) whose parsed inbound id is not present
// in want — the pure decision sweepOrphansLocked acts on.
func orphanedInterfaces(confFileNames []string, want map[int]struct{}) []string {
var out []string
for _, name := range confFileNames {
if !strings.HasSuffix(name, ".conf") {
continue
}
ifaceName := strings.TrimSuffix(name, ".conf")
id, ok := inboundIDForInterfaceName(ifaceName)
if !ok {
continue
}
if _, wanted := want[id]; wanted {
continue
}
out = append(out, ifaceName)
}
return out
}
// inboundIDForInterfaceName parses the inbound id back out of an interface
// name produced by interfaceNameForID, e.g. "awg42" -> 42, ok=true. Requires
// the suffix to be all decimal digits so a stray or hand-crafted file name
// (e.g. "awg-1.conf") can never resolve to a negative id.
func inboundIDForInterfaceName(name string) (int, bool) {
suffix, ok := strings.CutPrefix(name, "awg")
if !ok || suffix == "" {
return 0, false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return 0, false
}
}
id, err := strconv.Atoi(suffix)
if err != nil {
return 0, false
}
return id, true
}
// Reconcile drives the running set toward the desired instances: it tears
// down interfaces that are no longer wanted and ensures the rest. Used at
// boot and periodically to recover from crashes or an out-of-band `awg-quick
@@ -312,6 +406,7 @@ func (m *Manager) Reconcile(desired []Instance) {
for _, inst := range desired {
want[inst.Id] = struct{}{}
}
m.sweepOrphansLocked(want)
for id, cur := range m.ifaces {
if _, ok := want[id]; !ok {
_ = interfaceDown(cur.inst.InterfaceName)
+46
View File
@@ -3,6 +3,7 @@ package amneziawg
import (
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
@@ -371,3 +372,48 @@ func TestInterfaceNameForID(t *testing.T) {
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
}
}
func TestInboundIDForInterfaceName(t *testing.T) {
cases := []struct {
name string
wantID int
wantOK bool
}{
{"awg42", 42, true},
{"awg0", 0, true},
{"awg", 0, false}, // no digits after the prefix
{"wg0", 0, false}, // wrong prefix entirely (plain WireGuard)
{"awgabc", 0, false}, // non-numeric suffix
{"awg-1", 0, false}, // Atoi rejects the leading '-' as part of TrimPrefix's leftover, but guard anyway
}
for _, c := range cases {
id, ok := inboundIDForInterfaceName(c.name)
if ok != c.wantOK || (ok && id != c.wantID) {
t.Errorf("inboundIDForInterfaceName(%q) = (%d, %v), want (%d, %v)", c.name, id, ok, c.wantID, c.wantOK)
}
}
}
func TestOrphanedInterfaces(t *testing.T) {
confFiles := []string{
"awg1.conf", // in want -> not orphaned
"awg2.conf", // not in want -> orphaned
"awg3.conf", // not in want -> orphaned
"notes.txt", // wrong suffix -> ignored
"awgxyz.conf", // unparseable id -> ignored
}
want := map[int]struct{}{1: {}}
got := orphanedInterfaces(confFiles, want)
slices.Sort(got)
if wantOut := []string{"awg2", "awg3"}; !slices.Equal(got, wantOut) {
t.Errorf("orphanedInterfaces() = %v, want %v", got, wantOut)
}
}
func TestOrphanedInterfacesEmptyWantOrphansEverything(t *testing.T) {
got := orphanedInterfaces([]string{"awg5.conf"}, map[int]struct{}{})
if want := []string{"awg5"}; !slices.Equal(got, want) {
t.Errorf("orphanedInterfaces() = %v, want %v", got, want)
}
}