Kaynağa Gözat

feat: interface renaming, fix DHCP server address leasing, and add ECMP/Multicast route detection

Proxmox Router 1 hafta önce
ebeveyn
işleme
9a5ccdb45a

BIN
bin/proxmox-router


+ 62 - 0
internal/api/api.go

@@ -127,6 +127,7 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/interfaces/{name}/ip", s.handleAddInterfaceIP)
 	s.mux.HandleFunc("DELETE /api/interfaces/{name}/ip", s.handleDeleteInterfaceIP)
 	s.mux.HandleFunc("POST /api/interfaces/{name}/metadata", s.handleSetInterfaceMetadata)
+	s.mux.HandleFunc("POST /api/interfaces/{name}/rename", s.handleRenameInterface)
 
 	s.mux.HandleFunc("GET /api/vlans", s.handleListVLANs)
 	s.mux.HandleFunc("POST /api/vlans", s.handleCreateVLAN)
@@ -555,6 +556,67 @@ func (s *Server) handleSetInterfaceMetadata(w http.ResponseWriter, r *http.Reque
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": name, "role": req.Role, "description": req.Description})
 }
 
+func (s *Server) handleRenameInterface(w http.ResponseWriter, r *http.Request) {
+	name := r.PathValue("name")
+	var req struct {
+		Name    string `json:"name"`
+		NewName string `json:"new_name"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	targetName := req.NewName
+	if targetName == "" {
+		targetName = req.Name
+	}
+	targetName = strings.TrimSpace(targetName)
+	if targetName == "" {
+		writeError(w, http.StatusBadRequest, "new interface name is required")
+		return
+	}
+
+	info, err := s.netMgr.RenameInterface(name, targetName)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	// Propagate rename to all referencing subsystems
+	if s.bridgeMgr != nil {
+		s.bridgeMgr.RenameInterface(name, targetName)
+	}
+	if s.dhcpMgr != nil {
+		s.dhcpMgr.RenameInterface(name, targetName)
+	}
+	if s.dhcpServerMgr != nil {
+		s.dhcpServerMgr.RenameInterface(name, targetName)
+	}
+	if s.vrrpMgr != nil {
+		s.vrrpMgr.RenameInterface(name, targetName)
+	}
+	if s.routingMgr != nil {
+		s.routingMgr.RenameInterface(name, targetName)
+	}
+	if s.fwMgr != nil {
+		s.fwMgr.RenameInterface(name, targetName)
+	}
+	if s.vpnMgr != nil {
+		s.vpnMgr.RenameInterface(name, targetName)
+	}
+	if s.persistMgr != nil {
+		_, _ = s.persistMgr.SaveDebianConfig()
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success":   true,
+		"old_name":  name,
+		"new_name":  targetName,
+		"interface": info,
+	})
+}
+
 func (s *Server) handleListVLANs(w http.ResponseWriter, r *http.Request) {
 	vlans, err := s.netMgr.ListVLANs()
 	if err != nil {

+ 41 - 0
internal/bridge/manager.go

@@ -811,6 +811,47 @@ func (m *BridgeManager) SyncAll() {
 	}
 }
 
+// RenameInterface updates bridge, port, and VLAN configurations when an interface is renamed.
+func (m *BridgeManager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	changed := false
+	for _, b := range m.bridges {
+		if b.Name == oldName {
+			b.Name = newName
+			changed = true
+		}
+	}
+	for _, p := range m.ports {
+		if p.Interface == oldName {
+			p.Interface = newName
+			changed = true
+		}
+	}
+	for _, v := range m.vlans {
+		if v.Bridge == oldName {
+			v.Bridge = newName
+			changed = true
+		}
+		for i, tagged := range v.Tagged {
+			if tagged == oldName {
+				v.Tagged[i] = newName
+				changed = true
+			}
+		}
+		for i, untagged := range v.Untagged {
+			if untagged == oldName {
+				v.Untagged[i] = newName
+				changed = true
+			}
+		}
+	}
+	if changed {
+		_ = m.save()
+	}
+}
+
 // Helper: parse "10" or "10,20,30" or "10-20" into int slice
 func parseVLANRange(s string) []int {
 	var result []int

+ 21 - 0
internal/dhcp/manager.go

@@ -380,6 +380,27 @@ func (m *Manager) DeleteClient(id string) error {
 	return m.Save()
 }
 
+// RenameInterface updates DHCP client interface bindings when an interface is renamed.
+func (m *Manager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for _, c := range m.clients {
+		if c.Interface == oldName {
+			wasRunning := c.Enabled
+			if wasRunning {
+				_ = m.stopClient(c)
+			}
+			c.Interface = newName
+			if wasRunning {
+				_ = m.startClient(c)
+			}
+			_ = m.Save()
+			break
+		}
+	}
+}
+
 func (m *Manager) startClient(c *DHCPClient) error {
 	pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
 	leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)

+ 50 - 6
internal/dhcp/server_manager.go

@@ -194,10 +194,18 @@ func (sm *ServerManager) applyDnsmasqConfigLocked() error {
 		}
 
 		if startIP != "" && endIP != "" {
-			if netmask != "" {
-				sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,interface:%s,%s,%s,%s,%s\n", tag, srv.Interface, startIP, endIP, netmask, leaseTime))
+			if srv.Interface != "" {
+				if netmask != "" {
+					sb.WriteString(fmt.Sprintf("dhcp-range=tag:%s,set:%s,%s,%s,%s,%s\n", srv.Interface, tag, startIP, endIP, netmask, leaseTime))
+				} else {
+					sb.WriteString(fmt.Sprintf("dhcp-range=tag:%s,set:%s,%s,%s,%s\n", srv.Interface, tag, startIP, endIP, leaseTime))
+				}
 			} else {
-				sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,interface:%s,%s,%s,%s\n", tag, srv.Interface, startIP, endIP, leaseTime))
+				if netmask != "" {
+					sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,%s,%s,%s,%s\n", tag, startIP, endIP, netmask, leaseTime))
+				} else {
+					sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,%s,%s,%s\n", tag, startIP, endIP, leaseTime))
+				}
 			}
 		}
 
@@ -245,11 +253,23 @@ func (sm *ServerManager) applyDnsmasqConfigLocked() error {
 	for _, netw := range sm.networks {
 		sb.WriteString(fmt.Sprintf("# Subnet: %s\n", netw.Address))
 		_, ipNet, err := net.ParseCIDR(netw.Address)
+
+		isCoveredByServer := false
+		if ipNet != nil {
+			for _, srv := range sm.servers {
+				if !srv.Disabled && (srv.Network == netw.ID || srv.Network == netw.Address) {
+					isCoveredByServer = true
+					break
+				}
+			}
+		}
+
 		networkTag := ""
 		if err == nil && ipNet != nil {
 			networkTag = fmt.Sprintf("net_%s", strings.ReplaceAll(ipNet.IP.String(), ".", "_"))
-			// Match server range tag or subnet tag
-			sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,%s,static\n", networkTag, ipNet.IP.String()))
+			if !isCoveredByServer {
+				sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,%s,static\n", networkTag, ipNet.IP.String()))
+			}
 		}
 
 		// Determine target tag (either network tag or global/matched)
@@ -331,7 +351,12 @@ func (sm *ServerManager) applyDnsmasqConfigLocked() error {
 
 	// Restart or reload dnsmasq
 	if activeServerCount > 0 {
-		_ = exec.Command("systemctl", "restart", "dnsmasq").Run()
+		out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput()
+		if err != nil {
+			log.Printf("[DHCPServer] Failed to restart dnsmasq: %v, output: %s", err, string(out))
+		} else {
+			log.Printf("[DHCPServer] dnsmasq restarted successfully with %d active DHCP server(s)", activeServerCount)
+		}
 	} else {
 		_ = exec.Command("systemctl", "reload", "dnsmasq").Run()
 	}
@@ -339,6 +364,25 @@ func (sm *ServerManager) applyDnsmasqConfigLocked() error {
 	return nil
 }
 
+// RenameInterface updates DHCP server interface bindings when an interface is renamed.
+func (sm *ServerManager) RenameInterface(oldName, newName string) {
+	sm.mu.Lock()
+	defer sm.mu.Unlock()
+
+	changed := false
+	for _, srv := range sm.servers {
+		if srv.Interface == oldName {
+			srv.Interface = newName
+			changed = true
+		}
+	}
+	if changed {
+		_ = sm.saveLocked()
+		_ = sm.applyDnsmasqConfigLocked()
+		log.Printf("[DHCPServer] Updated DHCP server interface binding: %s -> %s", oldName, newName)
+	}
+}
+
 func (sm *ServerManager) ListServers() []DHCPServerInstance {
 	sm.mu.RLock()
 	defer sm.mu.RUnlock()

+ 42 - 0
internal/firewall/manager.go

@@ -593,3 +593,45 @@ func (m *FirewallManager) GetSummary() FirewallSummary {
 		ActiveConnections: len(conns),
 	}
 }
+
+// RenameInterface updates firewall rule interface names when an interface is renamed.
+func (m *FirewallManager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	changed := false
+	for i := range m.filterRules {
+		if m.filterRules[i].InInterface == oldName {
+			m.filterRules[i].InInterface = newName
+			changed = true
+		}
+		if m.filterRules[i].OutInterface == oldName {
+			m.filterRules[i].OutInterface = newName
+			changed = true
+		}
+	}
+	for i := range m.natRules {
+		if m.natRules[i].InInterface == oldName {
+			m.natRules[i].InInterface = newName
+			changed = true
+		}
+		if m.natRules[i].OutInterface == oldName {
+			m.natRules[i].OutInterface = newName
+			changed = true
+		}
+	}
+	for i := range m.mangleRules {
+		if m.mangleRules[i].InInterface == oldName {
+			m.mangleRules[i].InInterface = newName
+			changed = true
+		}
+		if m.mangleRules[i].OutInterface == oldName {
+			m.mangleRules[i].OutInterface = newName
+			changed = true
+		}
+	}
+	if changed {
+		_ = m.save()
+		_ = m.Apply()
+	}
+}

+ 70 - 0
internal/network/manager.go

@@ -3,6 +3,7 @@ package network
 import (
 	"fmt"
 	"net"
+	"regexp"
 	"strings"
 	"sync"
 	"time"
@@ -216,6 +217,75 @@ func (m *Manager) SetInterfaceMTU(name string, mtu int) error {
 	return netlink.LinkSetMTU(link, mtu)
 }
 
+var ifaceNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$`)
+
+// RenameInterface safely renames an existing network device using Netlink.
+func (m *Manager) RenameInterface(oldName, newName string) (*InterfaceInfo, error) {
+	oldName = strings.TrimSpace(oldName)
+	newName = strings.TrimSpace(newName)
+
+	if oldName == "" || newName == "" {
+		return nil, fmt.Errorf("interface name cannot be empty")
+	}
+	if oldName == newName {
+		return m.GetInterface(newName)
+	}
+	if oldName == "lo" || newName == "lo" {
+		return nil, fmt.Errorf("cannot rename the loopback interface")
+	}
+	if len(newName) > 15 {
+		return nil, fmt.Errorf("interface name %q exceeds Linux limit of 15 characters", newName)
+	}
+	if !ifaceNameRegex.MatchString(newName) {
+		return nil, fmt.Errorf("invalid interface name %q: must contain only letters, numbers, underscores, dots, or hyphens", newName)
+	}
+
+	link, err := netlink.LinkByName(oldName)
+	if err != nil {
+		return nil, fmt.Errorf("interface %q not found: %w", oldName, err)
+	}
+
+	if _, err := netlink.LinkByName(newName); err == nil {
+		return nil, fmt.Errorf("interface %q already exists", newName)
+	}
+
+	wasUp := (link.Attrs().Flags & net.FlagUp) != 0
+
+	// Linux kernel requires interface to be DOWN to change dev name
+	if wasUp {
+		if err := netlink.LinkSetDown(link); err != nil {
+			return nil, fmt.Errorf("failed to bring interface %q down for renaming: %w", oldName, err)
+		}
+	}
+
+	if err := netlink.LinkSetName(link, newName); err != nil {
+		if wasUp {
+			_ = netlink.LinkSetUp(link)
+		}
+		return nil, fmt.Errorf("failed to rename interface %q to %q: %w", oldName, newName, err)
+	}
+
+	// Restore UP state if it was originally UP
+	if wasUp {
+		if newLink, err := netlink.LinkByName(newName); err == nil {
+			_ = netlink.LinkSetUp(newLink)
+		}
+	}
+
+	// Migrate metadata
+	_ = m.metadata.Rename(oldName, newName)
+
+	// Update traffic rate history
+	m.mu.Lock()
+	if prev, ok := m.prevStats[oldName]; ok {
+		delete(m.prevStats, oldName)
+		m.prevStats[newName] = prev
+	}
+	m.mu.Unlock()
+
+	return m.GetInterface(newName)
+}
+
 // AddInterfaceIP adds an IP address with subnet CIDR to an interface.
 func (m *Manager) AddInterfaceIP(name string, cidr string) error {
 	link, err := netlink.LinkByName(name)

+ 13 - 0
internal/network/metadata.go

@@ -83,3 +83,16 @@ func (mm *MetadataManager) Delete(name string) error {
 	delete(mm.store.Interfaces, name)
 	return mm.save()
 }
+
+// Rename migrates interface metadata when an interface is renamed.
+func (mm *MetadataManager) Rename(oldName, newName string) error {
+	mm.mu.Lock()
+	defer mm.mu.Unlock()
+
+	if meta, exists := mm.store.Interfaces[oldName]; exists {
+		delete(mm.store.Interfaces, oldName)
+		mm.store.Interfaces[newName] = meta
+		return mm.save()
+	}
+	return nil
+}

+ 7 - 0
internal/network/types.go

@@ -56,12 +56,19 @@ type CreateVLANRequest struct {
 
 // UpdateInterfaceRequest specifies parameters for updating interface attributes.
 type UpdateInterfaceRequest struct {
+	Name        *string `json:"name,omitempty"`
 	State       *string `json:"state,omitempty"` // "up" or "down"
 	MTU         *int    `json:"mtu,omitempty"`
 	Role        *string `json:"role,omitempty"`
 	Description *string `json:"description,omitempty"`
 }
 
+// RenameInterfaceRequest specifies the new name for an interface.
+type RenameInterfaceRequest struct {
+	Name    string `json:"name,omitempty"`
+	NewName string `json:"new_name,omitempty"`
+}
+
 // AddIPRequest defines the payload to add an IP address to an interface.
 type AddIPRequest struct {
 	IP string `json:"ip"` // CIDR, e.g. "192.168.20.1/24"

+ 2 - 2
internal/routing/health.go

@@ -187,8 +187,8 @@ func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
 
 				// If it was offline, restore route in kernel
 				if wasOffline || !targetRoute.Active {
-					_ = m.applyRouteToKernel(*targetRoute)
 					targetRoute.Active = true
+					_ = m.applyRouteToKernel(*targetRoute)
 					log.Printf("[RouteHealth] Route %s via %s (target %s) Internet RESTORED (RTT: %.1fms) -> Re-installed in kernel",
 						targetRoute.DstAddress, targetRoute.Gateway, probeTarget, rtt)
 					_ = m.save()
@@ -201,8 +201,8 @@ func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
 
 					// If it was online, withdraw route from kernel to allow failover
 					if wasOnline || targetRoute.Active {
-						_ = m.removeRouteFromKernel(*targetRoute)
 						targetRoute.Active = false
+						_ = m.removeRouteFromKernel(*targetRoute)
 						log.Printf("[RouteHealth] Route %s via %s (target %s) Internet DOWN (threshold %d reached) -> Removed from kernel for failover",
 							targetRoute.DstAddress, targetRoute.Gateway, probeTarget, checkThreshold)
 						_ = m.save()

+ 230 - 76
internal/routing/manager.go

@@ -71,6 +71,11 @@ func (m *RoutingManager) load() {
 	if data, err := os.ReadFile(CustomTablesStorePath); err == nil {
 		_ = json.Unmarshal(data, &m.customTables)
 	}
+	for i := range m.staticRoutes {
+		if !m.staticRoutes[i].Disabled && m.staticRoutes[i].HealthStatus != "offline" {
+			m.staticRoutes[i].Active = true
+		}
+	}
 }
 
 func (m *RoutingManager) save() error {
@@ -106,10 +111,23 @@ func (m *RoutingManager) syncCustomTablesToSystem() {
 }
 
 func (m *RoutingManager) applyAll() {
-	// Apply all static routes
+	// Apply all static routes grouped by (dst, table, distance) for ECMP
+	type groupKey struct {
+		dst   string
+		table string
+		dist  int
+	}
+	seen := make(map[groupKey]bool)
 	for _, r := range m.staticRoutes {
-		if !r.Disabled {
-			_ = m.applyRouteToKernel(r)
+		normDst := normalizeDst(r.DstAddress)
+		tbl := strings.TrimSpace(r.RoutingTable)
+		if tbl == "" {
+			tbl = "main"
+		}
+		k := groupKey{dst: normDst, table: tbl, dist: r.Distance}
+		if !seen[k] {
+			seen[k] = true
+			_ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
 		}
 	}
 
@@ -165,10 +183,47 @@ func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
 				tableName = strconv.Itoa(r.Table)
 			}
 
-			ifName := linkMap[r.LinkIndex]
 			isDirect := r.Scope == netlink.SCOPE_LINK || r.Protocol == netlink.RouteProtocol(2)
 			isDyn := isDynamicProtocol(r.Protocol)
 			isStatic := !isDirect && !isDyn
+			isMulticast := r.Type == 5 || strings.HasPrefix(dst, "224.") || strings.HasPrefix(dst, "ff") || dst == "224.0.0.0/4"
+
+			if len(r.MultiPath) > 0 {
+				for _, nh := range r.MultiPath {
+					nhGw := ""
+					if nh.Gw != nil {
+						nhGw = nh.Gw.String()
+					}
+					nhIfName := linkMap[nh.LinkIndex]
+					cleanDst := strings.ReplaceAll(dst, "/", "_")
+					cleanGw := nhGw
+					if cleanGw == "" {
+						cleanGw = nhIfName
+					}
+					routeID := fmt.Sprintf("kr-%d-%s-%s", r.Table, cleanDst, cleanGw)
+
+					liveRoutes = append(liveRoutes, RouteItem{
+						ID:           routeID,
+						DstAddress:   dst,
+						Gateway:      nhGw,
+						Interface:    nhIfName,
+						Distance:     r.Priority,
+						RoutingTable: tableName,
+						TableID:      r.Table,
+						Active:       true,
+						Connect:      isDirect,
+						Dynamic:      isDyn,
+						Static:       isStatic,
+						ECMP:         true,
+						IsMultiPath:  true,
+						Multicast:    isMulticast,
+						Disabled:     false,
+					})
+				}
+				continue
+			}
+
+			ifName := linkMap[r.LinkIndex]
 
 			cleanDst := strings.ReplaceAll(dst, "/", "_")
 			cleanGw := gw
@@ -189,6 +244,7 @@ func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
 				Connect:      isDirect,
 				Dynamic:      isDyn,
 				Static:       isStatic,
+				Multicast:    isMulticast,
 				Disabled:     false,
 			})
 		}
@@ -237,6 +293,25 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 		}
 	}
 
+	// Detect Equal-Cost Multi-Path (ECMP) routes
+	groupCounts := make(map[string]int)
+	for _, r := range liveRoutes {
+		if r.Active && !r.Disabled {
+			key := fmt.Sprintf("%s|%s|%d", r.DstAddress, r.RoutingTable, r.Distance)
+			groupCounts[key]++
+		}
+	}
+
+	for i := range liveRoutes {
+		key := fmt.Sprintf("%s|%s|%d", liveRoutes[i].DstAddress, liveRoutes[i].RoutingTable, liveRoutes[i].Distance)
+		if liveRoutes[i].IsMultiPath || (liveRoutes[i].Active && !liveRoutes[i].Disabled && groupCounts[key] > 1) {
+			liveRoutes[i].ECMP = true
+		}
+		if liveRoutes[i].DstAddress == "224.0.0.0/4" || strings.HasPrefix(liveRoutes[i].DstAddress, "224.") || strings.HasPrefix(liveRoutes[i].DstAddress, "ff") {
+			liveRoutes[i].Multicast = true
+		}
+	}
+
 	sort.Slice(liveRoutes, func(i, j int) bool {
 		if liveRoutes[i].RoutingTable != liveRoutes[j].RoutingTable {
 			return liveRoutes[i].RoutingTable < liveRoutes[j].RoutingTable
@@ -336,13 +411,27 @@ func (m *RoutingManager) SaveRoute(r RouteItem) error {
 		m.staticRoutes = append(m.staticRoutes, r)
 	}
 
+	for i := range m.staticRoutes {
+		if m.staticRoutes[i].ID == r.ID {
+			if !m.staticRoutes[i].Disabled && m.staticRoutes[i].HealthStatus != "offline" {
+				m.staticRoutes[i].Active = true
+			} else {
+				m.staticRoutes[i].Active = false
+			}
+			break
+		}
+	}
+
 	_ = m.save()
 
-	if r.Disabled {
-		_ = m.removeRouteFromKernel(r)
-	} else {
-		if err := m.applyRouteToKernel(r); err != nil {
-			return err
+	_ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
+	if r.OldDstAddress != "" {
+		oldDist := 0
+		if r.OldDistance != nil {
+			oldDist = *r.OldDistance
+		}
+		if r.OldDstAddress != r.DstAddress || r.OldRoutingTable != r.RoutingTable || oldDist != r.Distance {
+			_ = m.syncRouteGroupToKernel(r.OldDstAddress, r.OldRoutingTable, oldDist)
 		}
 	}
 
@@ -364,9 +453,10 @@ func (m *RoutingManager) DeleteRoute(id string) error {
 	}
 
 	if target != nil {
-		_ = m.removeRouteFromKernel(*target)
 		m.staticRoutes = updated
-		return m.save()
+		_ = m.save()
+		_ = m.syncRouteGroupToKernel(target.DstAddress, target.RoutingTable, target.Distance)
+		return nil
 	}
 
 	// If not in staticRoutes, could be a live route (e.g. kr-...)
@@ -387,12 +477,13 @@ func (m *RoutingManager) SetRouteState(id string, disabled bool) error {
 	for i := range m.staticRoutes {
 		if m.staticRoutes[i].ID == id {
 			m.staticRoutes[i].Disabled = disabled
-			_ = m.save()
-			if disabled {
-				_ = m.removeRouteFromKernel(m.staticRoutes[i])
-			} else {
-				_ = m.applyRouteToKernel(m.staticRoutes[i])
+			if !disabled && m.staticRoutes[i].HealthStatus != "offline" {
+				m.staticRoutes[i].Active = true
+			} else if disabled {
+				m.staticRoutes[i].Active = false
 			}
+			_ = m.save()
+			_ = m.syncRouteGroupToKernel(m.staticRoutes[i].DstAddress, m.staticRoutes[i].RoutingTable, m.staticRoutes[i].Distance)
 			return nil
 		}
 	}
@@ -405,9 +496,10 @@ func (m *RoutingManager) SetRouteState(id string, disabled bool) error {
 				lr.Static = true
 				lr.Active = false
 				lr.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
-				_ = m.removeRouteFromKernel(lr)
 				m.staticRoutes = append(m.staticRoutes, lr)
-				return m.save()
+				_ = m.save()
+				_ = m.syncRouteGroupToKernel(lr.DstAddress, lr.RoutingTable, lr.Distance)
+				return nil
 			}
 		}
 	}
@@ -415,85 +507,126 @@ func (m *RoutingManager) SetRouteState(id string, disabled bool) error {
 	return fmt.Errorf("route not found")
 }
 
-func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
-	dst := strings.TrimSpace(r.DstAddress)
-	if dst == "default" || dst == "0.0.0.0/0" {
-		dst = "default"
-	} else if !strings.Contains(dst, "/") && net.ParseIP(dst) != nil {
-		dst = dst + "/32"
+func normalizeDst(dst string) string {
+	dst = strings.TrimSpace(dst)
+	if dst == "" || dst == "default" || dst == "0.0.0.0/0" {
+		return "default"
 	}
-
-	args := []string{"route", "replace", dst}
-
-	gw := strings.TrimSpace(r.Gateway)
-	if gw != "" {
-		if net.ParseIP(gw) != nil {
-			args = append(args, "via", gw)
-		} else {
-			args = append(args, "dev", gw)
-		}
+	if !strings.Contains(dst, "/") && net.ParseIP(dst) != nil {
+		return dst + "/32"
 	}
+	return dst
+}
 
-	iface := strings.TrimSpace(r.Interface)
-	if iface != "" && gw != iface && (gw == "" || net.ParseIP(gw) != nil) {
-		args = append(args, "dev", iface)
+func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string, distance int) error {
+	normDst := normalizeDst(dstAddress)
+	tbl := strings.TrimSpace(routingTable)
+	if tbl == "" {
+		tbl = "main"
 	}
 
-	if r.Distance > 0 {
-		args = append(args, "metric", strconv.Itoa(r.Distance))
+	var activeRoutes []RouteItem
+	for _, r := range m.staticRoutes {
+		rDst := normalizeDst(r.DstAddress)
+		rTbl := strings.TrimSpace(r.RoutingTable)
+		if rTbl == "" {
+			rTbl = "main"
+		}
+		if rDst == normDst && rTbl == tbl && r.Distance == distance {
+			if !r.Disabled && r.Active && r.HealthStatus != "offline" {
+				activeRoutes = append(activeRoutes, r)
+			}
+		}
 	}
 
-	if r.RoutingTable != "" && r.RoutingTable != "main" {
-		args = append(args, "table", r.RoutingTable)
+	if len(activeRoutes) == 0 {
+		args := []string{"route", "del", normDst}
+		if distance > 0 {
+			args = append(args, "metric", strconv.Itoa(distance))
+		}
+		if tbl != "main" {
+			args = append(args, "table", tbl)
+		}
+		args = append(args, "proto", "static")
+		_ = exec.Command("ip", args...).Run()
+
+		fallbackArgs := []string{"route", "del", normDst}
+		if tbl != "main" {
+			fallbackArgs = append(fallbackArgs, "table", tbl)
+		}
+		_ = exec.Command("ip", fallbackArgs...).Run()
+		return nil
 	}
 
-	args = append(args, "proto", "static")
+	if len(activeRoutes) == 1 {
+		r := activeRoutes[0]
+		args := []string{"route", "replace", normDst}
+		gw := strings.TrimSpace(r.Gateway)
+		if gw != "" {
+			if net.ParseIP(gw) != nil {
+				args = append(args, "via", gw)
+			} else {
+				args = append(args, "dev", gw)
+			}
+		}
+		iface := strings.TrimSpace(r.Interface)
+		if iface != "" && gw != iface && (gw == "" || net.ParseIP(gw) != nil) {
+			args = append(args, "dev", iface)
+		}
+		if distance > 0 {
+			args = append(args, "metric", strconv.Itoa(distance))
+		}
+		if tbl != "main" {
+			args = append(args, "table", tbl)
+		}
+		args = append(args, "proto", "static")
 
-	cmd := exec.Command("ip", args...)
-	out, err := cmd.CombinedOutput()
-	if err != nil {
-		return fmt.Errorf("failed to apply route (%s): %s: %w", strings.Join(args, " "), string(out), err)
+		cmd := exec.Command("ip", args...)
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("failed to apply route (%s): %s: %w", strings.Join(args, " "), string(out), err)
+		}
+		return nil
 	}
-	return nil
-}
 
-func (m *RoutingManager) removeRouteFromKernel(r RouteItem) error {
-	dst := strings.TrimSpace(r.DstAddress)
-	if dst == "default" || dst == "0.0.0.0/0" {
-		dst = "default"
-	} else if !strings.Contains(dst, "/") && net.ParseIP(dst) != nil {
-		dst = dst + "/32"
+	// Multiple routes: ECMP multipath route
+	args := []string{"route", "replace", normDst, "scope", "global"}
+	if distance > 0 {
+		args = append(args, "metric", strconv.Itoa(distance))
 	}
+	if tbl != "main" {
+		args = append(args, "table", tbl)
+	}
+	args = append(args, "proto", "static")
 
-	args := []string{"route", "del", dst}
-
-	gw := strings.TrimSpace(r.Gateway)
-	if gw != "" {
-		if net.ParseIP(gw) != nil {
+	for _, r := range activeRoutes {
+		args = append(args, "nexthop")
+		gw := strings.TrimSpace(r.Gateway)
+		if gw != "" && net.ParseIP(gw) != nil {
 			args = append(args, "via", gw)
-		} else {
-			args = append(args, "dev", gw)
 		}
+		iface := strings.TrimSpace(r.Interface)
+		if iface == "" && gw != "" && net.ParseIP(gw) == nil {
+			iface = gw
+		}
+		if iface != "" {
+			args = append(args, "dev", iface)
+		}
+		args = append(args, "weight", "1")
 	}
 
-	if r.Distance > 0 {
-		args = append(args, "metric", strconv.Itoa(r.Distance))
-	}
-
-	if r.RoutingTable != "" && r.RoutingTable != "main" {
-		args = append(args, "table", r.RoutingTable)
+	cmd := exec.Command("ip", args...)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to apply multipath route (%s): %s: %w", strings.Join(args, " "), string(out), err)
 	}
+	return nil
+}
 
-	_ = exec.Command("ip", args...).Run()
-
-	// Fallback deletion without metric
-	fallbackArgs := []string{"route", "del", dst}
-	if r.RoutingTable != "" && r.RoutingTable != "main" {
-		fallbackArgs = append(fallbackArgs, "table", r.RoutingTable)
-	}
-	_ = exec.Command("ip", fallbackArgs...).Run()
+func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
+	return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
+}
 
-	return nil
+func (m *RoutingManager) removeRouteFromKernel(r RouteItem) error {
+	return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
 }
 
 // ==================== Policy Rules (/ip route rule) ====================
@@ -759,3 +892,24 @@ func (m *RoutingManager) Stop() {
 		}
 	}
 }
+
+// RenameInterface updates static routes and policy rules when an interface is renamed.
+func (m *RoutingManager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	changed := false
+	for i := range m.staticRoutes {
+		if m.staticRoutes[i].Interface == oldName {
+			m.staticRoutes[i].Interface = newName
+			changed = true
+		}
+		if m.staticRoutes[i].Gateway == oldName {
+			m.staticRoutes[i].Gateway = newName
+			changed = true
+		}
+	}
+	if changed {
+		_ = m.save()
+	}
+}

+ 3 - 0
internal/routing/types.go

@@ -13,6 +13,9 @@ type RouteItem struct {
 	Dynamic      bool   `json:"dynamic"`       // MikroTik 'D' flag (kernel learned)
 	Connect      bool   `json:"connect"`       // MikroTik 'C' flag (directly connected interface subnet)
 	Static       bool   `json:"static"`        // MikroTik 'S' flag (user defined)
+	ECMP         bool   `json:"ecmp"`          // Equal-Cost Multi-Path route (MikroTik 'E' flag)
+	Multicast    bool   `json:"multicast"`     // Multicast / IGMP route (224.0.0.0/4 or kernel multicast)
+	IsMultiPath  bool   `json:"is_multipath"`  // Linux kernel multipath nexthop
 	Disabled     bool   `json:"disabled"`      // MikroTik 'X' flag
 	Comment      string `json:"comment,omitempty"`
 

+ 7 - 0
internal/vpn/manager.go

@@ -120,3 +120,10 @@ func (m *Manager) GetOverallStatus() OverallVPNStatus {
 		TotalActiveTunnels:  totalActive,
 	}
 }
+
+// RenameInterface updates VPN client interface references when an interface is renamed.
+func (m *Manager) RenameInterface(oldName, newName string) {
+	if m.PPPoE != nil {
+		m.PPPoE.RenameInterface(oldName, newName)
+	}
+}

+ 16 - 0
internal/vpn/pppoe.go

@@ -263,6 +263,22 @@ func (m *PPPoEManager) refreshRuntimeStatus() {
 	}
 }
 
+func (m *PPPoEManager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	changed := false
+	for i := range m.instances {
+		if m.instances[i].Interface == oldName {
+			m.instances[i].Interface = newName
+			changed = true
+		}
+	}
+	if changed {
+		_ = m.save()
+	}
+}
+
 func updateSecrets(path, user, pass string) {
 	content, _ := os.ReadFile(path)
 	str := string(content)

+ 1 - 0
internal/vpn/wireguard.go

@@ -536,6 +536,7 @@ func (m *WireGuardManager) generateWGConfigFile(ifaceName string) error {
 	if targetIface.MTU > 0 {
 		sb.WriteString(fmt.Sprintf("MTU = %d\n", targetIface.MTU))
 	}
+	sb.WriteString("Table = off\n")
 	sb.WriteString("\n")
 
 	for _, p := range m.peers {

+ 19 - 0
internal/vrrp/manager.go

@@ -391,6 +391,25 @@ func (m *Manager) applyConfigLocked() error {
 	return nil
 }
 
+// RenameInterface updates VRRP instance interface bindings when an interface is renamed.
+func (m *Manager) RenameInterface(oldName, newName string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	changed := false
+	for id, inst := range m.instances {
+		if inst.Interface == oldName {
+			inst.Interface = newName
+			m.instances[id] = inst
+			changed = true
+		}
+	}
+	if changed {
+		_ = m.saveLocked()
+		_ = m.applyConfigLocked()
+	}
+}
+
 func sanitizeName(n string) string {
 	var res strings.Builder
 	for _, r := range n {

+ 181 - 14
internal/web/static/app.js

@@ -725,11 +725,13 @@ function renderInterfacesList() {
           </div>
           <div>
             <div class="flex items-center gap-2">
+              <span class="font-bold text-white text-sm cursor-pointer hover:text-cyan-400 flex items-center gap-1.5" onclick="${hasVlans ? `toggleExpand('${parent.name}')` : `openIfaceModal('${parent.name}')`}" title="${hasVlans ? 'Click to expand VLANs, or click pencil to rename' : 'Click to configure or rename interface'}">
+                <span>${parent.name}</span>
+                <svg onclick="event.stopPropagation(); openIfaceModal('${parent.name}')" class="w-3.5 h-3.5 text-slate-500 hover:text-cyan-400 opacity-60 hover:opacity-100 cursor-pointer" title="Configure / Rename ${parent.name}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
+              </span>
               ${hasVlans ? `
-                <span class="font-bold text-white text-sm cursor-pointer hover:text-cyan-400" onclick="toggleExpand('${parent.name}')">${parent.name}</span>
                 <span onclick="toggleExpand('${parent.name}')" class="cursor-pointer text-[10px] bg-cyan-950 text-cyan-400 border border-cyan-800/60 px-2 py-0.5 rounded-full font-bold hover:bg-cyan-900 transition">${childVlans.length} VLAN${childVlans.length > 1 ? 's' : ''}</span>
               ` : `
-                <span class="font-bold text-white text-sm">${parent.name}</span>
                 <span class="text-[10px] bg-slate-800/40 text-slate-500 px-1.5 py-0.5 rounded font-mono select-none">0 VLANs</span>
               `}
             </div>
@@ -855,7 +857,10 @@ function renderInterfacesList() {
                         </td>
                         <td class="py-2.5 px-4 font-bold text-white flex items-center gap-1.5">
                           <span class="text-cyan-500">└─</span>
-                          <span>${v.name}</span>
+                          <span class="cursor-pointer hover:text-cyan-400 flex items-center gap-1" onclick="openIfaceModal('${v.name}')" title="Configure / Rename ${v.name}">
+                            <span>${v.name}</span>
+                            <svg class="w-3 h-3 text-slate-500 hover:text-cyan-400 opacity-60 hover:opacity-100" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
+                          </span>
                         </td>
                         <td class="py-2.5 px-4 text-emerald-400 font-medium">
                           ${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : '<span class="text-slate-600 font-sans">Unassigned</span>'}
@@ -947,7 +952,12 @@ function renderVlansList() {
           VLAN ${v.vlan_id}
         </span>
       </td>
-      <td class="py-3 px-4 font-bold text-white">${v.name}</td>
+      <td class="py-3 px-4 font-bold text-white">
+        <span class="cursor-pointer hover:text-cyan-400 flex items-center gap-1.5" onclick="openIfaceModal('${v.name}')" title="Configure / Rename ${v.name}">
+          <span>${v.name}</span>
+          <svg class="w-3.5 h-3.5 text-slate-500 hover:text-cyan-400 opacity-60 hover:opacity-100" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
+        </span>
+      </td>
       <td class="py-3 px-4 text-slate-300">
         <span class="bg-slate-800 text-slate-200 px-2 py-0.5 rounded text-[11px]">${v.parent_name || 'eth0'}</span>
       </td>
@@ -2822,6 +2832,22 @@ function openIfaceModal(name) {
   document.getElementById('modal-iface-role').value = iface.role || 'LAN';
   document.getElementById('modal-iface-desc').value = iface.description || '';
 
+  const nameInput = document.getElementById('modal-iface-new-name');
+  const renameBtn = document.getElementById('modal-btn-rename');
+  const renameHelp = document.getElementById('modal-iface-rename-help');
+  if (nameInput) {
+    nameInput.value = iface.name;
+    if (iface.name === 'lo' || iface.type === 'loopback') {
+      nameInput.disabled = true;
+      if (renameBtn) renameBtn.disabled = true;
+      if (renameHelp) renameHelp.innerText = 'Loopback interface cannot be renamed.';
+    } else {
+      nameInput.disabled = false;
+      if (renameBtn) renameBtn.disabled = false;
+      if (renameHelp) renameHelp.innerText = 'Device name in Linux (max 15 characters). Automatically preserves operational link state and metadata.';
+    }
+  }
+
   const delBtn = document.getElementById('modal-delete-vlan-btn');
   if (iface.is_vlan) {
     delBtn.classList.remove('hidden');
@@ -2858,9 +2884,11 @@ function closeIfaceModal() {
 }
 
 async function setIfaceState(name, newState) {
-  await toggleInterfaceState(name, newState);
-  const updated = (state.interfaces || []).find(i => i.name === name);
-  if (updated) openIfaceModal(name);
+  const target = name || state.currentModalIface;
+  if (!target) return;
+  await toggleInterfaceState(target, newState);
+  const updated = (state.interfaces || []).find(i => i.name === target);
+  if (updated) openIfaceModal(target);
 }
 
 async function applyMTU() {
@@ -2923,19 +2951,115 @@ async function removeInterfaceIP(ip) {
   }
 }
 
+async function renameInterface() {
+  const oldName = state.currentModalIface;
+  const newNameInput = document.getElementById('modal-iface-new-name');
+  const newName = (newNameInput?.value || '').trim();
+
+  if (!oldName) return;
+  if (!newName) {
+    showToast('Warning', 'Interface name cannot be empty', 'warning');
+    return;
+  }
+  if (newName === oldName) {
+    showToast('Info', `Interface name is already "${oldName}"`, 'info');
+    return;
+  }
+  if (newName.length > 15) {
+    showToast('Validation Error', 'Interface name cannot exceed 15 characters (Linux IFNAMSIZ)', 'error');
+    return;
+  }
+  if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(newName)) {
+    showToast('Validation Error', 'Name must contain only alphanumeric characters, underscores, hyphens or dots', 'error');
+    return;
+  }
+
+  const renameBtn = document.getElementById('modal-btn-rename');
+  if (renameBtn) {
+    renameBtn.disabled = true;
+    renameBtn.innerHTML = `<span class="animate-spin inline-block mr-1">⟳</span> Renaming...`;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(oldName)}/rename`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ new_name: newName })
+    });
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Rename Failed', data.error || data.message || 'Failed to rename interface', 'error');
+      return;
+    }
+
+    showToast('Interface Renamed', `Successfully renamed ${oldName} → ${newName}`, 'success');
+    state.currentModalIface = newName;
+    document.getElementById('modal-iface-name').innerText = newName;
+
+    // Refresh application state
+    await fetchData(false);
+    const updated = (state.interfaces || []).find(i => i.name === newName);
+    if (updated) {
+      openIfaceModal(newName);
+    } else {
+      closeIfaceModal();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  } finally {
+    if (renameBtn) {
+      renameBtn.disabled = false;
+      renameBtn.innerHTML = `
+        <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
+        <span>Rename</span>
+      `;
+    }
+  }
+}
+
 async function saveInterfaceMetadata() {
+  const oldName = state.currentModalIface;
+  const newName = (document.getElementById('modal-iface-new-name')?.value || '').trim();
   const role = document.getElementById('modal-iface-role').value;
   const desc = document.getElementById('modal-iface-desc').value.trim();
+
+  let targetName = oldName;
+
+  // If user changed the name before clicking Save Changes, execute rename first
+  if (newName && newName !== oldName && oldName !== 'lo') {
+    try {
+      const renameRes = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(oldName)}/rename`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ new_name: newName })
+      });
+      const data = await renameRes.json();
+      if (!renameRes.ok) {
+        showToast('Rename Failed', data.error || data.message || 'Could not rename interface', 'error');
+        return;
+      }
+      targetName = newName;
+      state.currentModalIface = newName;
+      showToast('Renamed', `Interface renamed from ${oldName} to ${newName}`, 'success');
+    } catch (err) {
+      showToast('Error', err.message, 'error');
+      return;
+    }
+  }
+
   try {
-    const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/metadata`, {
+    const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(targetName)}/metadata`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ role, description: desc })
     });
     if (res.ok) {
-      showToast('Saved', 'Metadata updated', 'success');
+      showToast('Saved', 'Interface configuration updated', 'success');
       closeIfaceModal();
       fetchData(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || err.message || 'Failed to save metadata', 'error');
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
@@ -5905,6 +6029,7 @@ async function loadRoutes() {
 
 function renderRoutesList() {
   const tableFilter = document.getElementById('routes-filter-table')?.value || '';
+  const typeFilter = document.getElementById('routes-filter-type')?.value || '';
   const tbody = document.getElementById('routes-table-body');
   const empty = document.getElementById('routes-empty');
   if (!tbody) return;
@@ -5924,8 +6049,23 @@ function renderRoutesList() {
     });
   }
 
-  const routes = (state.routes || []).filter(r => !tableFilter || r.routing_table === tableFilter);
-  document.getElementById('routes-count-badge').innerText = `${routes.length} Routes`;
+  const routes = (state.routes || []).filter(r => {
+    if (tableFilter && r.routing_table !== tableFilter) return false;
+    if (typeFilter === 'ecmp' && !r.ecmp) return false;
+    if (typeFilter === 'multicast' && !r.multicast) return false;
+    if (typeFilter === 'static' && !r.static) return false;
+    if (typeFilter === 'dynamic' && !r.dynamic) return false;
+    if (typeFilter === 'connected' && !r.connect) return false;
+    return true;
+  });
+
+  const ecmpCount = (state.routes || []).filter(r => r.ecmp).length;
+  const mcCount = (state.routes || []).filter(r => r.multicast).length;
+  let summaryText = `${routes.length} Routes`;
+  if (ecmpCount > 0) summaryText += ` • ${ecmpCount} ECMP`;
+  if (mcCount > 0) summaryText += ` • ${mcCount} Multicast/IGMP`;
+  const badgeEl = document.getElementById('routes-count-badge');
+  if (badgeEl) badgeEl.innerText = summaryText;
 
   if (routes.length === 0) {
     empty?.classList.remove('hidden');
@@ -5938,12 +6078,14 @@ function renderRoutesList() {
     tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${r.disabled ? 'opacity-50' : ''}`;
     tr.setAttribute('ondblclick', `openEditRouteModal('${r.id}')`);
 
-    // MikroTik Flags string: D=Dynamic, A=Active, C=Connect, S=Static, X=Disabled
+    // Routing Flags string: D=Dynamic, A=Active, C=Connect, S=Static, E=ECMP, M=Multicast, X=Disabled
     let flags = '';
     if (r.dynamic) flags += 'D';
     if (r.active) flags += 'A';
     if (r.connect) flags += 'C';
     if (r.static) flags += 'S';
+    if (r.ecmp) flags += 'E';
+    if (r.multicast) flags += 'M';
     if (r.disabled) flags += 'X';
 
     const isDefault = r.dst_address === 'default' || r.dst_address === '0.0.0.0/0';
@@ -5991,13 +6133,38 @@ function renderRoutesList() {
       }
     }
 
+    const flagDescriptions = [];
+    if (r.dynamic) flagDescriptions.push('Dynamic (D)');
+    if (r.active) flagDescriptions.push('Active (A)');
+    if (r.connect) flagDescriptions.push('Connected (C)');
+    if (r.static) flagDescriptions.push('Static (S)');
+    if (r.ecmp) flagDescriptions.push('ECMP - Equal-Cost Multi-Path (E)');
+    if (r.multicast) flagDescriptions.push('Multicast / IGMP Group (M)');
+    if (r.disabled) flagDescriptions.push('Disabled (X)');
+
     tr.innerHTML = `
       <td class="py-2.5 px-3 text-center">
-        <span class="px-1.5 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 ${r.active ? 'text-cyan-400 border border-cyan-800/40' : (r.health_status === 'offline' ? 'text-rose-400 border border-rose-800/40' : 'text-slate-500')}">
+        <span class="px-1.5 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 ${
+          r.active ? (r.ecmp ? 'text-purple-300 border border-purple-500/40' : (r.multicast ? 'text-amber-300 border border-amber-500/40' : 'text-cyan-400 border border-cyan-800/40')) : (r.health_status === 'offline' ? 'text-rose-400 border border-rose-800/40' : 'text-slate-500')
+        }" title="Flags: ${flagDescriptions.join(', ')}">
           ${flags || '-'}
         </span>
       </td>
-      <td class="py-2.5 px-3 font-bold ${isDefault ? 'text-cyan-300' : 'text-white'}">${r.dst_address}</td>
+      <td class="py-2.5 px-3">
+        <div class="flex items-center gap-1.5 flex-wrap">
+          <span class="font-bold ${isDefault ? 'text-cyan-300' : 'text-white'}">${escapeHtml(r.dst_address)}</span>
+          ${r.ecmp ? `
+            <span class="px-1.5 py-0.2 rounded text-[9px] font-mono font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30 inline-flex items-center gap-1" title="Equal-Cost Multi-Path (ECMP / Multi-gateway)">
+              <span>⚡ ECMP</span>
+            </span>
+          ` : ''}
+          ${r.multicast ? `
+            <span class="px-1.5 py-0.2 rounded text-[9px] font-mono font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30 inline-flex items-center gap-1" title="Multicast / IGMP Group Route">
+              <span>IGMP</span>
+            </span>
+          ` : ''}
+        </div>
+      </td>
       <td class="py-2.5 px-3 text-slate-300 font-mono">${r.gateway || '<span class="text-slate-600 font-sans">Direct link</span>'}</td>
       <td class="py-2.5 px-3 text-slate-400 font-mono">${(r.distance !== undefined && r.distance !== null) ? r.distance : 0}</td>
       <td class="py-2.5 px-3 font-mono font-semibold text-purple-300">${r.routing_table || 'main'}</td>

+ 28 - 3
internal/web/static/index.html

@@ -2315,6 +2315,15 @@
                   <option value="">All Tables</option>
                   <option value="main">main</option>
                 </select>
+                <label class="text-xs text-slate-400 font-sans ml-2">Type Filter:</label>
+                <select id="routes-filter-type" onchange="renderRoutesList()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white font-mono">
+                  <option value="">All Types</option>
+                  <option value="ecmp">⚡ ECMP (Multi-Path)</option>
+                  <option value="multicast">Multicast / IGMP</option>
+                  <option value="static">Static</option>
+                  <option value="dynamic">Dynamic</option>
+                  <option value="connected">Connected</option>
+                </select>
                 <button onclick="loadRoutes()" class="text-xs text-cyan-400 hover:text-cyan-300 font-semibold px-2 py-1 bg-slate-800 rounded border border-slate-700">Refresh</button>
               </div>
             </div>
@@ -4165,12 +4174,28 @@
         <button onclick="closeIfaceModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
       </div>
       <div class="p-6 space-y-5">
+        <!-- Interface Name & Rename -->
+        <div class="bg-slate-950/60 rounded-xl border border-slate-800/80 p-3.5 space-y-2">
+          <div class="flex items-center justify-between">
+            <label class="block text-xs font-semibold text-slate-300">Interface Name</label>
+            <span id="modal-iface-rename-hint" class="text-[10px] text-slate-400 font-mono">Kernel net_device</span>
+          </div>
+          <div class="flex items-center space-x-2">
+            <input type="text" id="modal-iface-new-name" maxlength="15" placeholder="e.g. wan1, eth0, HomeNet" class="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+            <button type="button" id="modal-btn-rename" onclick="renameInterface()" class="px-3.5 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 text-white flex items-center gap-1.5 transition">
+              <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
+              <span>Rename</span>
+            </button>
+          </div>
+          <p id="modal-iface-rename-help" class="text-[10px] text-slate-500">Device name in Linux (max 15 characters). Automatically preserves operational link state and metadata.</p>
+        </div>
+
         <div class="grid grid-cols-2 gap-4 p-3 bg-slate-950/60 rounded-xl border border-slate-800/80">
           <div>
             <span class="block text-[11px] text-slate-400 mb-1">Administrative Link</span>
             <div class="flex items-center space-x-2">
-              <button id="modal-btn-up" onclick="setIfaceState(currentModalIface, 'up')" class="px-3 py-1 rounded text-xs font-semibold bg-emerald-600/30 text-emerald-400 border border-emerald-500/40">UP</button>
-              <button id="modal-btn-down" onclick="setIfaceState(currentModalIface, 'down')" class="px-3 py-1 rounded text-xs font-semibold bg-slate-800 text-slate-400 hover:bg-rose-600/30 hover:text-rose-400">DOWN</button>
+              <button id="modal-btn-up" onclick="setIfaceState(state.currentModalIface, 'up')" class="px-3 py-1 rounded text-xs font-semibold bg-emerald-600/30 text-emerald-400 border border-emerald-500/40">UP</button>
+              <button id="modal-btn-down" onclick="setIfaceState(state.currentModalIface, 'down')" class="px-3 py-1 rounded text-xs font-semibold bg-slate-800 text-slate-400 hover:bg-rose-600/30 hover:text-rose-400">DOWN</button>
             </div>
           </div>
           <div>
@@ -4210,7 +4235,7 @@
         </div>
 
         <div class="pt-4 border-t border-slate-800 flex justify-between items-center">
-          <button id="modal-delete-vlan-btn" onclick="confirmDeleteVlan(currentModalIface)" class="hidden text-xs text-rose-400 hover:text-rose-300">Delete VLAN</button>
+          <button id="modal-delete-vlan-btn" onclick="confirmDeleteVlan(state.currentModalIface)" class="hidden text-xs text-rose-400 hover:text-rose-300">Delete VLAN</button>
           <div class="flex space-x-3">
             <button onclick="closeIfaceModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800">Close</button>
             <button onclick="saveInterfaceMetadata()" class="px-5 py-2 rounded-lg text-xs font-semibold bg-brand-600 hover:bg-brand-500 text-white">Save Changes</button>