Browse Source

fix(network): persist interface names across reboots via MAC reconciler and early boot service (v1.8.4)

Proxmox Router 1 ngày trước cách đây
mục cha
commit
a73d9a19e4

+ 5 - 1
Makefile

@@ -21,9 +21,13 @@ install: build
 	cp $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/$(BINARY_NAME)
 	cp $(BUILD_DIR)/debtik-cli /usr/local/bin/debtik-cli
 	chmod +x /usr/local/bin/$(BINARY_NAME) /usr/local/bin/debtik-cli
-	@mkdir -p /etc/debtik /etc/network/interfaces.d
+	@mkdir -p /etc/debtik /etc/network/interfaces.d /usr/local/libexec /etc/network/if-pre-up.d
+	install -m 0755 scripts/debtik-ifnames /usr/local/libexec/debtik-ifnames
+	ln -sf /usr/local/libexec/debtik-ifnames /etc/network/if-pre-up.d/debtik-ifnames
 	cp systemd/debtik.service /etc/systemd/system/debtik.service
+	cp systemd/debtik-ifnames.service /etc/systemd/system/debtik-ifnames.service
 	systemctl daemon-reload
+	systemctl enable debtik-ifnames.service || true
 	systemctl enable --now debtik
 	@echo "Installed and started service debtik successfully!"
 

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.3
+1.8.4

BIN
bin/debtik


BIN
bin/debtik-arm64


BIN
bin/debtik-cli


BIN
bin/debtik-cli-arm64


+ 7 - 0
install.sh

@@ -45,6 +45,9 @@ install -d -m 0755 /usr/local/libexec
 install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update
 install -m 0750 scripts/release-version.py /usr/local/libexec/debtik-release-version
 install -m 0750 scripts/install-dependencies.sh /usr/local/libexec/debtik-install-dependencies
+install -m 0755 scripts/debtik-ifnames /usr/local/libexec/debtik-ifnames
+mkdir -p /etc/network/if-pre-up.d
+ln -sf /usr/local/libexec/debtik-ifnames /etc/network/if-pre-up.d/debtik-ifnames
 install -d -m 0755 /etc/network/interfaces.d
 for name in debtik debtik-cli; do
   if [[ -f /usr/local/bin/$name ]]; then cp -p "/usr/local/bin/$name" "/usr/local/bin/$name.previous"; fi
@@ -55,6 +58,9 @@ done
 ln -sf /usr/local/bin/debtik /usr/local/bin/proxmox-router
 if [[ -f /etc/network/interfaces ]] && ! grep -Eq '^source[[:space:]]+/etc/network/interfaces\.d/\*' /etc/network/interfaces; then printf '\nsource /etc/network/interfaces.d/*\n' >> /etc/network/interfaces; fi
 install -m 0644 systemd/debtik.service /etc/systemd/system/debtik.service
+if [[ -f systemd/debtik-ifnames.service ]]; then
+  install -m 0644 systemd/debtik-ifnames.service /etc/systemd/system/debtik-ifnames.service
+fi
 if [[ -f /etc/systemd/system/proxmox-router.service.d/mstpd.conf && ! -f /etc/systemd/system/debtik.service.d/mstpd.conf ]]; then
   install -d -m 0755 /etc/systemd/system/debtik.service.d
   cp /etc/systemd/system/proxmox-router.service.d/mstpd.conf /etc/systemd/system/debtik.service.d/mstpd.conf
@@ -64,6 +70,7 @@ if systemctl is-active --quiet proxmox-router.service; then
   systemctl disable proxmox-router.service || true
 fi
 systemctl daemon-reload
+systemctl enable debtik-ifnames.service || true
 systemctl enable debtik.service
 systemctl restart debtik.service
 systemctl is-active --quiet debtik.service

+ 3 - 0
internal/api/api.go

@@ -936,6 +936,9 @@ func (s *Server) handleRenameInterface(w http.ResponseWriter, r *http.Request) {
 	if s.vpnMgr != nil {
 		s.vpnMgr.RenameInterface(name, targetName)
 	}
+	if s.sdwanMgr != nil {
+		s.sdwanMgr.RenameInterface(name, targetName)
+	}
 	if s.persistMgr != nil {
 		if _, err := s.persistMgr.SaveDebianConfig(); err != nil {
 			writeError(w, 500, "Live change applied but persistence failed: "+err.Error())

+ 129 - 1
internal/network/manager.go

@@ -49,11 +49,124 @@ func NewManager(mm *MetadataManager) *Manager {
 		prevStats:   make(map[string]prevStat),
 		subscribers: make(map[chan *TrafficSnapshot]bool),
 	}
+	mgr.reconcileInterfaceNames()
+	mgr.recordInterfaceMACs()
 	mgr.startIPSyncWorker()
 	mgr.startTrafficSampler()
 	return mgr
 }
 
+// reconcileInterfaceNames ensures kernel interfaces are named according to MAC addresses in metadata.
+// This is critical for Proxmox LXC containers where interfaces revert to host default names (e.g. eth0, ISP-Router) on boot.
+func (m *Manager) reconcileInterfaceNames() {
+	if m.metadata == nil {
+		return
+	}
+
+	targetMACs := m.metadata.GetAllMACs()
+	if len(targetMACs) == 0 {
+		return
+	}
+
+	links, err := netlink.LinkList()
+	if err != nil {
+		log.Printf("[NetSync] Error listing links for name reconciliation: %v", err)
+		return
+	}
+
+	macToLink := make(map[string]netlink.Link)
+	nameToLink := make(map[string]netlink.Link)
+	for _, link := range links {
+		attrs := link.Attrs()
+		if attrs == nil {
+			continue
+		}
+		nameToLink[attrs.Name] = link
+		mac := strings.ToLower(attrs.HardwareAddr.String())
+		if mac != "" && mac != "00:00:00:00:00:00" {
+			macToLink[mac] = link
+		}
+	}
+
+	for targetName, mac := range targetMACs {
+		currentLink, exists := macToLink[mac]
+		if !exists {
+			continue
+		}
+		currentName := currentLink.Attrs().Name
+		if currentName == targetName {
+			continue
+		}
+
+		if conflictingLink, conflict := nameToLink[targetName]; conflict {
+			conflictMac := strings.ToLower(conflictingLink.Attrs().HardwareAddr.String())
+			if conflictMac != mac {
+				tempName := fmt.Sprintf("tmp_%d", time.Now().UnixNano()%10000)
+				_ = netlink.LinkSetDown(conflictingLink)
+				if err := netlink.LinkSetName(conflictingLink, tempName); err == nil {
+					log.Printf("[NetSync] Temporarily renamed conflicting interface %s -> %s", targetName, tempName)
+					delete(nameToLink, targetName)
+					nameToLink[tempName] = conflictingLink
+				}
+			}
+		}
+
+		wasUp := (currentLink.Attrs().Flags & net.FlagUp) != 0
+		_ = netlink.LinkSetDown(currentLink)
+		if err := netlink.LinkSetName(currentLink, targetName); err != nil {
+			log.Printf("[NetSync] Failed to rename %s to %s (MAC %s): %v", currentName, targetName, mac, err)
+			if wasUp {
+				_ = netlink.LinkSetUp(currentLink)
+			}
+			continue
+		}
+
+		log.Printf("[NetSync] Successfully reconciled interface %s -> %s (MAC %s)", currentName, targetName, mac)
+		delete(nameToLink, currentName)
+		if newLink, err := netlink.LinkByName(targetName); err == nil {
+			nameToLink[targetName] = newLink
+			meta := m.metadata.Get(targetName)
+			if wasUp || meta.AutoUp || len(meta.IPAddresses) > 0 {
+				_ = netlink.LinkSetUp(newLink)
+			}
+		}
+	}
+}
+
+// recordInterfaceMACs registers MAC addresses for known interfaces in metadata.
+func (m *Manager) recordInterfaceMACs() {
+	if m.metadata == nil {
+		return
+	}
+
+	links, err := netlink.LinkList()
+	if err != nil {
+		return
+	}
+
+	for _, link := range links {
+		attrs := link.Attrs()
+		if attrs == nil {
+			continue
+		}
+		name := attrs.Name
+		mac := strings.ToLower(attrs.HardwareAddr.String())
+		if mac == "" || mac == "00:00:00:00:00:00" || name == "lo" {
+			continue
+		}
+		if isDynamicOrVirtualInterface(name) {
+			continue
+		}
+
+		meta := m.metadata.Get(name)
+		if meta.Role != "" || len(meta.IPAddresses) > 0 || meta.AutoUp {
+			if meta.MAC == "" || meta.MAC != mac {
+				_ = m.metadata.SetMAC(name, mac)
+			}
+		}
+	}
+}
+
 // syncPersistedIPs ensures that all IP addresses saved in metadata are applied to the kernel interfaces
 func (m *Manager) syncPersistedIPs() {
 	if m.metadata == nil {
@@ -63,7 +176,14 @@ func (m *Manager) syncPersistedIPs() {
 	for name, ips := range configured {
 		link, err := netlink.LinkByName(name)
 		if err != nil {
-			continue // Interface might not be attached/created yet by Proxmox host
+			meta := m.metadata.Get(name)
+			if meta.MAC != "" {
+				m.reconcileInterfaceNames()
+				link, err = netlink.LinkByName(name)
+			}
+			if err != nil {
+				continue // Interface might not be attached/created yet by Proxmox host
+			}
 		}
 
 		// Ensure link is UP if it has configured IPs
@@ -336,6 +456,11 @@ func (m *Manager) RenameInterface(oldName, newName string) (*InterfaceInfo, erro
 		return nil, fmt.Errorf("interface %q already exists", newName)
 	}
 
+	hwAddr := ""
+	if link.Attrs() != nil && link.Attrs().HardwareAddr != nil {
+		hwAddr = strings.ToLower(link.Attrs().HardwareAddr.String())
+	}
+
 	wasUp := (link.Attrs().Flags & net.FlagUp) != 0
 
 	// Linux kernel requires interface to be DOWN to change dev name
@@ -361,6 +486,9 @@ func (m *Manager) RenameInterface(oldName, newName string) (*InterfaceInfo, erro
 
 	// Migrate metadata
 	_ = m.metadata.Rename(oldName, newName)
+	if hwAddr != "" && hwAddr != "00:00:00:00:00:00" {
+		_ = m.metadata.SetMAC(newName, hwAddr)
+	}
 
 	// Update traffic rate history
 	m.mu.Lock()

+ 29 - 0
internal/network/metadata.go

@@ -243,6 +243,35 @@ func (mm *MetadataManager) Set(name string, role, desc string) error {
 	return mm.saveLocked()
 }
 
+// SetMAC associates a physical MAC address with an interface name.
+func (mm *MetadataManager) SetMAC(name, mac string) error {
+	mm.mu.Lock()
+	defer mm.mu.Unlock()
+
+	mac = strings.ToLower(strings.TrimSpace(mac))
+	meta := mm.store.Interfaces[name]
+	if meta.MAC == mac {
+		return nil
+	}
+	meta.MAC = mac
+	mm.store.Interfaces[name] = meta
+	return mm.saveLocked()
+}
+
+// GetAllMACs returns a map of target interface name to its recorded MAC address.
+func (mm *MetadataManager) GetAllMACs() map[string]string {
+	mm.mu.RLock()
+	defer mm.mu.RUnlock()
+
+	res := make(map[string]string)
+	for name, meta := range mm.store.Interfaces {
+		if meta.MAC != "" {
+			res[name] = strings.ToLower(meta.MAC)
+		}
+	}
+	return res
+}
+
 func (mm *MetadataManager) AddIP(name, cidr string) error {
 	mm.mu.Lock()
 	defer mm.mu.Unlock()

+ 2 - 1
internal/network/types.go

@@ -117,12 +117,13 @@ type RoleTagWithUsage struct {
 	Interfaces    []string `json:"interfaces"`
 }
 
-// InterfaceMetadata stores custom persistent labels and IP assignments per interface.
+// InterfaceMetadata stores custom persistent labels, MAC mappings, and IP assignments per interface.
 type InterfaceMetadata struct {
 	Role        string   `json:"role"`
 	Description string   `json:"description"`
 	IPAddresses []string `json:"ip_addresses,omitempty"`
 	AutoUp      bool     `json:"auto_up,omitempty"`
+	MAC         string   `json:"mac,omitempty"`
 }
 
 // MetadataStore is written to disk to preserve user labels across restarts.

+ 20 - 0
internal/sdwan/manager.go

@@ -442,3 +442,23 @@ func (m *Manager) findLocked(idOrName string) *SDWANGroup {
 	}
 	return nil
 }
+
+// RenameInterface updates SD-WAN member references when an interface is renamed.
+func (m *Manager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	modified := false
+	for _, g := range m.groups {
+		for _, mem := range g.Members {
+			if mem.Interface == oldName {
+				mem.Interface = newName
+				modified = true
+			}
+		}
+	}
+	if modified {
+		_ = m.save()
+		_ = m.engine.Sync(m.listLocked())
+	}
+}

+ 101 - 0
scripts/debtik-ifnames

@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""
+DebTik Network Interface Name Reconciler
+Runs before networking.service / network-pre.target on boot.
+Reads /etc/debtik/metadata.json (or legacy /etc/proxmox-router/metadata.json).
+Matches physical/veth devices by hardware MAC address and renames them to match the target interface name.
+Also brings UP interfaces that have configured IP addresses or auto_up set.
+"""
+import json
+import os
+import subprocess
+import sys
+import time
+
+METADATA_PATHS = [
+    "/etc/debtik/metadata.json",
+    "/etc/proxmox-router/metadata.json"
+]
+
+def log(msg):
+    print(f"[debtik-ifnames] {msg}", file=sys.stderr)
+
+def get_current_interfaces():
+    res = {}
+    net_dir = "/sys/class/net"
+    if not os.path.exists(net_dir):
+        return res
+    for dev in os.listdir(net_dir):
+        if dev == "lo":
+            continue
+        addr_file = os.path.join(net_dir, dev, "address")
+        if os.path.exists(addr_file):
+            try:
+                with open(addr_file, "r") as f:
+                    mac = f.read().strip().lower()
+                if mac and mac != "00:00:00:00:00:00":
+                    res[dev] = mac
+            except Exception:
+                pass
+    return res
+
+def rename_device(old_name, new_name):
+    try:
+        if os.path.exists(f"/sys/class/net/{new_name}"):
+            tmp_name = f"tmp{int(time.time() * 1000) % 100000}"
+            subprocess.run(["ip", "link", "set", "dev", new_name, "down"], check=False)
+            subprocess.run(["ip", "link", "set", "dev", new_name, "name", tmp_name], check=False)
+            log(f"Moved conflicting {new_name} -> {tmp_name}")
+
+        subprocess.run(["ip", "link", "set", "dev", old_name, "down"], check=False)
+        r = subprocess.run(["ip", "link", "set", "dev", old_name, "name", new_name], capture_output=True, text=True)
+        if r.returncode == 0:
+            log(f"Successfully renamed {old_name} -> {new_name}")
+            return True
+        else:
+            log(f"Failed to rename {old_name} -> {new_name}: {r.stderr.strip()}")
+            return False
+    except Exception as e:
+        log(f"Exception renaming {old_name} -> {new_name}: {e}")
+        return False
+
+def main():
+    metadata = None
+    for p in METADATA_PATHS:
+        if os.path.exists(p):
+            try:
+                with open(p, "r") as f:
+                    metadata = json.load(f)
+                break
+            except Exception as e:
+                log(f"Error reading {p}: {e}")
+
+    if not metadata or "interfaces" not in metadata:
+        sys.exit(0)
+
+    target_map = {}
+    auto_up_names = set()
+    for name, item in metadata["interfaces"].items():
+        mac = item.get("mac", "").strip().lower()
+        if mac:
+            target_map[mac] = name
+        if item.get("auto_up") or item.get("ip_addresses"):
+            auto_up_names.add(name)
+
+    if not target_map:
+        sys.exit(0)
+
+    current_devs = get_current_interfaces()
+    for dev_name, mac in list(current_devs.items()):
+        if mac in target_map:
+            target_name = target_map[mac]
+            if dev_name != target_name:
+                log(f"Device {dev_name} has MAC {mac}, reconciling to {target_name}")
+                if rename_device(dev_name, target_name):
+                    current_devs[target_name] = mac
+                    del current_devs[dev_name]
+                    if target_name in auto_up_names:
+                        subprocess.run(["ip", "link", "set", "dev", target_name, "up"], check=False)
+
+if __name__ == "__main__":
+    main()

+ 9 - 1
scripts/debtik-update

@@ -94,7 +94,7 @@ CGO_ENABLED=0 "$go_cmd" build -trimpath -ldflags "-X debtik/internal/api.BuildCo
 CGO_ENABLED=0 "$go_cmd" build -trimpath -o "$work/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 "$base/previous"
 for name in debtik debtik-cli; do cp -p "/usr/local/bin/$name" "$base/previous/$name"; done
-for name in debtik-update debtik-release-version debtik-install-dependencies; do
+for name in debtik-update debtik-release-version debtik-install-dependencies debtik-ifnames; do
   if [[ -f "/usr/local/libexec/$name" ]]; then cp -p "/usr/local/libexec/$name" "$base/previous/$name"; fi
 done
 cp -p "$base/installed_version" "$base/previous_version"
@@ -121,9 +121,13 @@ ln -sf /usr/local/bin/debtik /usr/local/bin/proxmox-router
 install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update.next
 install -m 0750 scripts/release-version.py /usr/local/libexec/debtik-release-version.next
 install -m 0750 scripts/install-dependencies.sh /usr/local/libexec/debtik-install-dependencies.next
+install -m 0755 scripts/debtik-ifnames /usr/local/libexec/debtik-ifnames.next
 mv -f /usr/local/libexec/debtik-update.next /usr/local/libexec/debtik-update
 mv -f /usr/local/libexec/debtik-release-version.next /usr/local/libexec/debtik-release-version
 mv -f /usr/local/libexec/debtik-install-dependencies.next /usr/local/libexec/debtik-install-dependencies
+mv -f /usr/local/libexec/debtik-ifnames.next /usr/local/libexec/debtik-ifnames
+mkdir -p /etc/network/if-pre-up.d
+ln -sf /usr/local/libexec/debtik-ifnames /etc/network/if-pre-up.d/debtik-ifnames
 # Migrate legacy /etc/proxmox-router config directory to /etc/debtik
 if [[ -d /etc/proxmox-router && ! -L /etc/proxmox-router ]]; then
   install -d -m 0700 /etc/debtik
@@ -137,11 +141,15 @@ if [[ ! -e /etc/proxmox-router ]]; then
 fi
 if [[ -f systemd/debtik.service ]]; then
   install -m 0644 systemd/debtik.service /etc/systemd/system/debtik.service
+  if [[ -f systemd/debtik-ifnames.service ]]; then
+    install -m 0644 systemd/debtik-ifnames.service /etc/systemd/system/debtik-ifnames.service
+  fi
   if [[ -f /etc/systemd/system/proxmox-router.service.d/mstpd.conf && ! -f /etc/systemd/system/debtik.service.d/mstpd.conf ]]; then
     install -d -m 0755 /etc/systemd/system/debtik.service.d
     cp /etc/systemd/system/proxmox-router.service.d/mstpd.conf /etc/systemd/system/debtik.service.d/mstpd.conf
   fi
   systemctl daemon-reload
+  systemctl enable debtik-ifnames.service || true
   systemctl enable debtik.service
 fi
 systemctl start debtik.service

+ 14 - 0
systemd/debtik-ifnames.service

@@ -0,0 +1,14 @@
+[Unit]
+Description=DebTik Network Interface Name Reconciler
+DefaultDependencies=no
+Before=networking.service network-pre.target
+Wants=network-pre.target
+After=systemd-udev-settle.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/libexec/debtik-ifnames
+RemainAfterExit=yes
+
+[Install]
+WantedBy=network-pre.target basic.target multi-user.target