mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 02:37:14 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user