Browse Source

feat(routing): add protocol column and tags to routes table, standard ADs, dynamic dhcp server route, and fix dns polling

Proxmox Router 1 tuần trước cách đây
mục cha
commit
4dfcccaf09

+ 74 - 1
internal/dhcp/manager.go

@@ -90,10 +90,15 @@ func (m *Manager) Load() error {
 		m.clients[c.ID] = c
 	}
 
-	// Startup active clients
+	// Startup active clients and run periodic background sync
 	go func() {
 		time.Sleep(1 * time.Second)
 		m.SyncAll()
+		ticker := time.NewTicker(5 * time.Second)
+		defer ticker.Stop()
+		for range ticker.C {
+			m.SyncAll()
+		}
 	}()
 
 	return nil
@@ -424,6 +429,35 @@ func (m *Manager) startClient(c *DHCPClient) error {
 
 	_ = os.WriteFile(confFile, []byte(sb.String()), 0644)
 
+	// Ensure enter & exit hooks prevent dhclient from overriding static routes
+	_ = os.MkdirAll("/etc/dhcp/dhclient-enter-hooks.d", 0755)
+	_ = os.MkdirAll("/etc/dhcp/dhclient-exit-hooks.d", 0755)
+	if !c.AddDefaultRoute {
+		enterHook := fmt.Sprintf("#!/bin/sh\ncase \"$interface\" in\n    %s)\n        unset new_routers\n        ;;\nesac\n", c.Interface)
+		_ = os.WriteFile("/etc/dhcp/dhclient-enter-hooks.d/no-default-route", []byte(enterHook), 0755)
+
+		exitHook := fmt.Sprintf("#!/bin/sh\ncase \"$interface\" in\n    %s)\n        ip -4 route del default dev %s metric 0 >/dev/null 2>&1 || true\n        ;;\nesac\n", c.Interface, c.Interface)
+		_ = os.WriteFile("/etc/dhcp/dhclient-exit-hooks.d/no-default-route", []byte(exitHook), 0755)
+	}
+
+	// Dynamic host route hook for DHCP server reachability (Distance 0, Dynamic)
+	serverRouteHook := fmt.Sprintf(`#!/bin/sh
+case "$reason" in
+    BOUND|RENEW|REBIND|REBOOT)
+        if [ -n "$new_dhcp_server_identifier" ] && [ -n "$new_routers" ]; then
+            gw="${new_routers%%%% *}"
+            ip -4 route replace "${new_dhcp_server_identifier}/32" via "$gw" dev "$interface" proto dhcp metric 0 onlink >/dev/null 2>&1 || true
+        fi
+        ;;
+    EXPIRE|FAIL|RELEASE|STOP)
+        if [ -n "$old_dhcp_server_identifier" ]; then
+            ip -4 route del "${old_dhcp_server_identifier}/32" dev "$interface" proto dhcp >/dev/null 2>&1 || true
+        fi
+        ;;
+esac
+`)
+	_ = os.WriteFile("/etc/dhcp/dhclient-exit-hooks.d/dhcp-server-route", []byte(serverRouteHook), 0755)
+
 	// If already running, kill first
 	_ = m.stopClient(c)
 
@@ -438,6 +472,7 @@ func (m *Manager) startClient(c *DHCPClient) error {
 }
 
 func (m *Manager) stopClient(c *DHCPClient) error {
+	m.removeDHCPServerRoute(c)
 	pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
 
 	// Check PID file
@@ -456,6 +491,41 @@ func (m *Manager) stopClient(c *DHCPClient) error {
 	return nil
 }
 
+func (m *Manager) syncDHCPServerRoute(c *DHCPClient) {
+	if c == nil || c.Interface == "" {
+		return
+	}
+
+	// Only maintain dynamic server route when client is bound
+	if c.Status != "bound" || c.DHCPServer == "" || c.Gateway == "" {
+		return
+	}
+
+	serverIP := net.ParseIP(c.DHCPServer)
+	if serverIP == nil {
+		return
+	}
+
+	// If DHCP server is already part of the directly assigned subnet, no explicit host route is required
+	if c.IPAddress != "" {
+		if _, ipNet, err := net.ParseCIDR(c.IPAddress); err == nil && ipNet.Contains(serverIP) {
+			return
+		}
+	}
+
+	// Install dynamic host route (Distance 0, Dynamic proto dhcp, onlink)
+	cmd := exec.Command("ip", "route", "replace", c.DHCPServer+"/32", "via", c.Gateway, "dev", c.Interface, "proto", "dhcp", "metric", "0", "onlink")
+	_ = cmd.Run()
+}
+
+func (m *Manager) removeDHCPServerRoute(c *DHCPClient) {
+	if c == nil || c.DHCPServer == "" || c.Interface == "" {
+		return
+	}
+	cmd := exec.Command("ip", "route", "del", c.DHCPServer+"/32", "dev", c.Interface, "proto", "dhcp")
+	_ = cmd.Run()
+}
+
 func (m *Manager) updateStatus(c *DHCPClient) {
 	pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
 	leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
@@ -500,6 +570,9 @@ func (m *Manager) updateStatus(c *DHCPClient) {
 
 	// Parse lease file for rich metadata (gateway, dns, lease times)
 	m.parseLeaseFile(leaseFile, c)
+
+	// Dynamically maintain DHCP Server reachability route (Distance 0, Dynamic)
+	m.syncDHCPServerRoute(c)
 }
 
 func (m *Manager) parseLeaseFile(leaseFile string, c *DHCPClient) {

+ 23 - 3
internal/dynamicrouting/manager.go

@@ -214,8 +214,24 @@ func (m *Manager) applyBirdConfig() error {
 	sb.WriteString("}\n\n")
 
 	sb.WriteString("protocol kernel {\n")
+	sb.WriteString("    metric 0;\n")
 	sb.WriteString("    ipv4 {\n")
-	sb.WriteString("        export all;\n")
+	sb.WriteString("        export filter {\n")
+	sb.WriteString("            if source = RTS_DEVICE then reject;\n")
+	sb.WriteString("            if source = RTS_BGP then {\n")
+	sb.WriteString("                if bgp_path.first = bgp_path.last && bgp_path.len = 0 then\n")
+	sb.WriteString("                    krt_metric = 200;\n")
+	sb.WriteString("                else\n")
+	sb.WriteString("                    krt_metric = 20;\n")
+	sb.WriteString("            }\n")
+	sb.WriteString("            if source = RTS_OSPF then {\n")
+	sb.WriteString("                krt_metric = 110;\n")
+	sb.WriteString("            }\n")
+	sb.WriteString("            if source = RTS_RIP then {\n")
+	sb.WriteString("                krt_metric = 120;\n")
+	sb.WriteString("            }\n")
+	sb.WriteString("            accept;\n")
+	sb.WriteString("        };\n")
 	sb.WriteString("    };\n")
 	sb.WriteString("    learn;\n")
 	sb.WriteString("}\n\n")
@@ -397,10 +413,14 @@ func (m *Manager) applyBirdConfig() error {
 			}
 
 			// Import Filter
+			defaultAD := 20
+			if peer.LocalRole == "ibgp" || (localAS > 0 && peer.RemoteAS > 0 && localAS == peer.RemoteAS) {
+				defaultAD = 200
+			}
 			if peer.InFilter != "" && len(chainRules[peer.InFilter]) > 0 {
 				sb.WriteString(fmt.Sprintf("        import filter %s;\n", sanitizeIdentifier("filter", peer.InFilter)))
 			} else {
-				sb.WriteString("        import all;\n")
+				sb.WriteString(fmt.Sprintf("        import filter { krt_metric = %d; accept; };\n", defaultAD))
 			}
 
 			// Export Filter
@@ -467,7 +487,7 @@ func (m *Manager) applyBirdConfig() error {
 		sb.WriteString(fmt.Sprintf("protocol ospf v%d %s {\n", inst.Version, instProtoName))
 		sb.WriteString(fmt.Sprintf("    router id %s;\n", instRouterID))
 		sb.WriteString("    ipv4 {\n")
-		sb.WriteString("        import all;\n")
+		sb.WriteString("        import filter { krt_metric = 110; accept; };\n")
 
 		var ospfExports []string
 		ospfExports = append(ospfExports, "RTS_STATIC")

+ 27 - 7
internal/routing/health.go

@@ -217,13 +217,33 @@ func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
 					wasOnline := targetRoute.HealthStatus != "offline"
 					targetRoute.HealthStatus = "offline"
 
-					// If it was online, withdraw route from kernel to allow failover
-					if wasOnline || targetRoute.Active {
-						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()
+					// Safety guard: if this is a default route and the ONLY active default route remaining,
+					// do NOT withdraw it from the kernel to prevent a total internet blackout!
+					hasOtherActiveDefault := false
+					for _, other := range m.staticRoutes {
+						if other.ID != targetRoute.ID && normalizeDst(other.DstAddress) == "default" && !other.Disabled && other.HealthStatus != "offline" {
+							hasOtherActiveDefault = true
+							break
+						}
+					}
+
+					if !hasOtherActiveDefault && normalizeDst(targetRoute.DstAddress) == "default" {
+						log.Printf("[RouteHealth] Route %s via %s (target %s) failed health check, but keeping active as LAST RESORT default route to prevent total internet blackout",
+							targetRoute.DstAddress, targetRoute.Gateway, probeTarget)
+						if !targetRoute.Active {
+							targetRoute.Active = true
+							_ = m.applyRouteToKernel(*targetRoute)
+							_ = m.save()
+						}
+					} else {
+						// If it was online, withdraw route from kernel to allow failover
+						if wasOnline || targetRoute.Active {
+							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()
+						}
 					}
 				}
 			}

+ 155 - 16
internal/routing/manager.go

@@ -45,6 +45,7 @@ func NewRoutingManager() *RoutingManager {
 	m.syncCustomTablesToSystem()
 	m.applyAll()
 	m.startHealthCheckWorker()
+	m.startRouteSyncWorker()
 	return m
 }
 
@@ -113,16 +114,10 @@ func (m *RoutingManager) syncCustomTablesToSystem() {
 
 func (m *RoutingManager) cleanRogueDefaultRoutes() {
 	hasManagedDefault := false
-	managedGateways := make(map[string]bool)
 	for _, r := range m.staticRoutes {
 		if normalizeDst(r.DstAddress) == "default" && !r.Disabled {
 			hasManagedDefault = true
-			if r.Gateway != "" {
-				managedGateways[r.Gateway] = true
-			}
-			if r.Interface != "" {
-				managedGateways[r.Interface] = true
-			}
+			break
 		}
 	}
 
@@ -148,14 +143,101 @@ func (m *RoutingManager) cleanRogueDefaultRoutes() {
 				gw = r.Gw.String()
 			}
 			ifName := linkMap[r.LinkIndex]
-			if r.Priority == 0 && gw != "" && !managedGateways[gw] && !managedGateways[ifName] {
-				log.Printf("[RouteManager] Removing rogue unmanaged default route: via %s dev %s", gw, ifName)
+			// If priority is 0 (unmetered route from dhclient or boot) and protocol != RTPROT_STATIC:
+			if r.Priority == 0 && int(r.Protocol) != 4 {
+				log.Printf("[RouteManager] Removing rogue unmanaged default route: via %s dev %s proto %d", gw, ifName, r.Protocol)
 				_ = netlink.RouteDel(&r)
 			}
 		}
 	}
 }
 
+func (m *RoutingManager) getTableIDByName(name string) int {
+	name = strings.TrimSpace(name)
+	if name == "" || name == "main" {
+		return 254
+	}
+	if name == "default" {
+		return 253
+	}
+	if name == "local" {
+		return 255
+	}
+	for id, n := range m.getTableMap() {
+		if strings.EqualFold(n, name) {
+			return id
+		}
+	}
+	if id, err := strconv.Atoi(name); err == nil {
+		return id
+	}
+	return 254
+}
+
+func (m *RoutingManager) startRouteSyncWorker() {
+	ticker := time.NewTicker(3 * time.Second)
+	go func() {
+		for {
+			select {
+			case <-m.stopChan:
+				ticker.Stop()
+				return
+			case <-ticker.C:
+				m.syncAllRoutesWorker()
+			}
+		}
+	}()
+}
+
+func (m *RoutingManager) syncAllRoutesWorker() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	m.cleanRogueDefaultRoutes()
+
+	liveRoutes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
+	if err != nil {
+		return
+	}
+
+	type groupKey struct {
+		dst   string
+		table int
+		dist  int
+	}
+
+	kernelRouteGroups := make(map[groupKey]bool)
+	for _, lr := range liveRoutes {
+		dst := "default"
+		if lr.Dst != nil {
+			dst = lr.Dst.String()
+		}
+		tbl := lr.Table
+		if tbl == 0 {
+			tbl = 254
+		}
+		k := groupKey{dst: normalizeDst(dst), table: tbl, dist: lr.Priority}
+		kernelRouteGroups[k] = true
+	}
+
+	seen := make(map[groupKey]bool)
+	for _, r := range m.staticRoutes {
+		if r.Disabled || r.HealthStatus == "offline" {
+			continue
+		}
+		normDst := normalizeDst(r.DstAddress)
+		tableID := m.getTableIDByName(r.RoutingTable)
+		k := groupKey{dst: normDst, table: tableID, dist: r.Distance}
+		if !seen[k] {
+			seen[k] = true
+			if !kernelRouteGroups[k] {
+				log.Printf("[RouteSync] Missing kernel route %s table %s (id %d) metric %d -> Restoring", normDst, r.RoutingTable, tableID, r.Distance)
+				_ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
+			}
+		}
+	}
+}
+
 func (m *RoutingManager) applyAll() {
 	m.cleanRogueDefaultRoutes()
 
@@ -190,6 +272,7 @@ func (m *RoutingManager) applyAll() {
 func isDynamicProtocol(proto netlink.RouteProtocol) bool {
 	switch int(proto) {
 	case 11, // RTPROT_ZEBRA
+		12,  // RTPROT_BIRD
 		16,  // RTPROT_DHCP
 		42,  // RTPROT_BABEL
 		186, // RTPROT_BGP
@@ -203,6 +286,57 @@ func isDynamicProtocol(proto netlink.RouteProtocol) bool {
 	}
 }
 
+func getRouteProtocolName(proto netlink.RouteProtocol, priority int, isDirect bool, isMulticast bool) string {
+	if isMulticast {
+		return "multicast"
+	}
+	switch int(proto) {
+	case 16:
+		return "dhcp"
+	case 4:
+		return "static"
+	case 186:
+		return "bgp"
+	case 188:
+		return "ospf"
+	case 189:
+		return "rip"
+	case 11:
+		return "zebra"
+	case 42:
+		return "babel"
+	case 12: // RTPROT_BIRD
+		if priority == 20 {
+			return "bgp"
+		} else if priority == 200 {
+			return "ibgp"
+		} else if priority == 110 {
+			return "ospf"
+		} else if priority == 120 {
+			return "rip"
+		}
+		return "bgp"
+	case 2:
+		return "connected"
+	case 3:
+		return "boot"
+	default:
+		if isDirect {
+			return "connected"
+		}
+		if priority == 20 {
+			return "bgp"
+		} else if priority == 200 {
+			return "ibgp"
+		} else if priority == 110 {
+			return "ospf"
+		} else if priority == 120 {
+			return "rip"
+		}
+		return "static"
+	}
+}
+
 func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
 	tableNames := m.getTableMap()
 	links, _ := netlink.LinkList()
@@ -231,10 +365,11 @@ func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
 				tableName = strconv.Itoa(r.Table)
 			}
 
-			isDirect := r.Scope == netlink.SCOPE_LINK || r.Protocol == netlink.RouteProtocol(2)
 			isDyn := isDynamicProtocol(r.Protocol)
+			isDirect := (r.Scope == netlink.SCOPE_LINK || r.Protocol == netlink.RouteProtocol(2)) && !isDyn && r.Protocol != 4
 			isStatic := !isDirect && !isDyn
 			isMulticast := r.Type == 5 || strings.HasPrefix(dst, "224.") || strings.HasPrefix(dst, "ff") || dst == "224.0.0.0/4"
+			protoName := getRouteProtocolName(r.Protocol, r.Priority, isDirect, isMulticast)
 
 			if len(r.MultiPath) > 0 {
 				for _, nh := range r.MultiPath {
@@ -256,6 +391,7 @@ func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
 						Gateway:      nhGw,
 						Interface:    nhIfName,
 						Distance:     r.Priority,
+						Protocol:     protoName,
 						RoutingTable: tableName,
 						TableID:      r.Table,
 						Active:       true,
@@ -286,6 +422,7 @@ func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
 				Gateway:      gw,
 				Interface:    ifName,
 				Distance:     r.Priority,
+				Protocol:     protoName,
 				RoutingTable: tableName,
 				TableID:      r.Table,
 				Active:       true,
@@ -314,6 +451,7 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 				liveRoutes[i].Comment = sr.Comment
 				liveRoutes[i].Static = true
 				liveRoutes[i].Dynamic = false
+				liveRoutes[i].Protocol = "static"
 				liveRoutes[i].ID = sr.ID
 				if sr.Distance > 0 && liveRoutes[i].Distance == 0 {
 					liveRoutes[i].Distance = sr.Distance
@@ -337,6 +475,7 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 			srCopy.Active = false
 			srCopy.Static = true
 			srCopy.Dynamic = false
+			srCopy.Protocol = "static"
 			liveRoutes = append(liveRoutes, srCopy)
 		}
 	}
@@ -627,12 +766,6 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 		}
 		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
 	}
 
@@ -651,6 +784,9 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 		if iface != "" && gw != iface && (gw == "" || net.ParseIP(gw) != nil) {
 			args = append(args, "dev", iface)
 		}
+		if gw != "" && iface != "" && net.ParseIP(gw) != nil {
+			args = append(args, "onlink")
+		}
 		if distance > 0 {
 			args = append(args, "metric", strconv.Itoa(distance))
 		}
@@ -689,6 +825,9 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 		if iface != "" {
 			args = append(args, "dev", iface)
 		}
+		if gw != "" && iface != "" && net.ParseIP(gw) != nil {
+			args = append(args, "onlink")
+		}
 		args = append(args, "weight", "1")
 	}
 

+ 1 - 0
internal/routing/types.go

@@ -7,6 +7,7 @@ type RouteItem struct {
 	Gateway      string `json:"gateway"`       // Gateway IP e.g. "192.168.1.1" or interface "wg0"
 	Interface    string `json:"interface"`     // Output device
 	Distance     int    `json:"distance"`      // Metric / Administrative Distance
+	Protocol     string `json:"protocol,omitempty"` // Protocol e.g. "bgp", "ospf", "static", "connected", "dhcp"
 	RoutingTable string `json:"routing_table"` // Table name e.g. "main", "wan2", "vpn_out"
 	TableID      int    `json:"table_id"`
 	Active       bool   `json:"active"`        // MikroTik 'A' flag

+ 57 - 13
internal/web/static/app.js

@@ -6070,6 +6070,30 @@ async function loadRoutes() {
   }
 }
 
+function getRouteProtocolBadge(proto) {
+  const p = (proto || 'static').toLowerCase();
+  switch (p) {
+    case 'bgp':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-amber-500/20 text-amber-300 border border-amber-500/30">bgp</span>`;
+    case 'ibgp':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-yellow-500/20 text-yellow-300 border border-yellow-500/30">ibgp</span>`;
+    case 'ospf':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">ospf</span>`;
+    case 'rip':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-orange-500/20 text-orange-300 border border-orange-500/30">rip</span>`;
+    case 'static':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-blue-500/20 text-blue-300 border border-blue-500/30">static</span>`;
+    case 'connected':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-slate-800 text-slate-300 border border-slate-700">connected</span>`;
+    case 'dhcp':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">dhcp</span>`;
+    case 'multicast':
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-purple-500/20 text-purple-300 border border-purple-500/30">igmp</span>`;
+    default:
+      return `<span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase bg-slate-800 text-slate-400 border border-slate-700">${escapeHtml(p)}</span>`;
+  }
+}
+
 function renderRoutesList() {
   const tableFilter = document.getElementById('routes-filter-table')?.value || '';
   const typeFilter = document.getElementById('routes-filter-type')?.value || '';
@@ -6099,6 +6123,9 @@ function renderRoutesList() {
     if (typeFilter === 'static' && !r.static) return false;
     if (typeFilter === 'dynamic' && !r.dynamic) return false;
     if (typeFilter === 'connected' && !r.connect) return false;
+    if (typeFilter === 'bgp' && (r.protocol || '').toLowerCase() !== 'bgp' && (r.protocol || '').toLowerCase() !== 'ibgp') return false;
+    if (typeFilter === 'ospf' && (r.protocol || '').toLowerCase() !== 'ospf') return false;
+    if (typeFilter === 'dhcp' && (r.protocol || '').toLowerCase() !== 'dhcp') return false;
     return true;
   });
 
@@ -6208,6 +6235,7 @@ function renderRoutesList() {
           ` : ''}
         </div>
       </td>
+      <td class="py-2.5 px-3">${getRouteProtocolBadge(r.protocol)}</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>
@@ -14878,6 +14906,21 @@ async function loadDNSSettings() {
 
 function populateDNSSettingsForm(s) {
   if (!s) return;
+  const dnsForm = document.getElementById('form-dns-settings');
+  const isEditing = dnsForm && (dnsForm.contains(document.activeElement) || dnsForm.dataset.dirty === 'true');
+
+  const dynamicEl = document.getElementById('dns-dynamic-servers-list');
+  if (dynamicEl) {
+    if (Array.isArray(s.dynamic_servers) && s.dynamic_servers.length > 0) {
+      dynamicEl.innerHTML = s.dynamic_servers.map(d => `<span class="px-2 py-0.5 rounded bg-cyan-950 text-cyan-300 border border-cyan-800 font-mono">${escapeHtml(d)}</span>`).join(' ');
+    } else {
+      dynamicEl.innerText = 'None';
+    }
+  }
+
+  // If user is actively typing or editing in the form, do not overwrite their inputs!
+  if (isEditing) return;
+
   const serversInput = document.getElementById('dns-servers-input');
   if (serversInput) serversInput.value = (s.servers || []).join(', ');
 
@@ -14889,15 +14932,6 @@ function populateDNSSettingsForm(s) {
 
   const allowRemoteInput = document.getElementById('dns-allow-remote-input');
   if (allowRemoteInput) allowRemoteInput.checked = s.allow_remote_requests !== false;
-
-  const dynamicEl = document.getElementById('dns-dynamic-servers-list');
-  if (dynamicEl) {
-    if (Array.isArray(s.dynamic_servers) && s.dynamic_servers.length > 0) {
-      dynamicEl.innerHTML = s.dynamic_servers.map(d => `<span class="px-2 py-0.5 rounded bg-cyan-950 text-cyan-300 border border-cyan-800 font-mono">${escapeHtml(d)}</span>`).join(' ');
-    } else {
-      dynamicEl.innerText = 'None';
-    }
-  }
 }
 
 function updateDNSStatusView(st) {
@@ -14921,6 +14955,8 @@ function updateDNSStatusView(st) {
 function appendDNSPreset(preset) {
   const input = document.getElementById('dns-servers-input');
   if (!input) return;
+  const dnsForm = document.getElementById('form-dns-settings');
+  if (dnsForm) dnsForm.dataset.dirty = 'true';
   const current = input.value.trim();
   if (!current) {
     input.value = preset;
@@ -14961,6 +14997,8 @@ async function saveDNSSettings(e) {
     });
     const data = await res.json();
     if (res.ok) {
+      const dnsForm = document.getElementById('form-dns-settings');
+      if (dnsForm) dnsForm.dataset.dirty = 'false';
       showToast('DNS Applied', 'Router DNS forwarders and resolver settings updated successfully', 'success');
       loadDNSSettings();
     } else {
@@ -15131,14 +15169,18 @@ async function toggleStaticDNS(id, disabled) {
 
 async function loadSystemSettings() {
   try {
+    const sysForm = document.getElementById('form-system-settings');
+    const isEditing = sysForm && (sysForm.contains(document.activeElement) || sysForm.dataset.dirty === 'true');
     const res = await fetch(`${API_BASE}/system/settings`);
     if (res.ok) {
       const data = await res.json();
-      const hostInput = document.getElementById('system-hostname-input');
-      if (hostInput) hostInput.value = data.hostname || '';
+      if (!isEditing) {
+        const hostInput = document.getElementById('system-hostname-input');
+        if (hostInput) hostInput.value = data.hostname || '';
 
-      const tzInput = document.getElementById('system-timezone-input');
-      if (tzInput && data.timezone) tzInput.value = data.timezone;
+        const tzInput = document.getElementById('system-timezone-input');
+        if (tzInput && data.timezone) tzInput.value = data.timezone;
+      }
     }
   } catch (err) {
     console.error('Failed to load system settings:', err);
@@ -15158,6 +15200,8 @@ async function saveSystemSettings(e) {
     });
     const data = await res.json();
     if (res.ok) {
+      const sysForm = document.getElementById('form-system-settings');
+      if (sysForm) sysForm.dataset.dirty = 'false';
       showToast('System Updated', 'Hostname and timezone saved successfully', 'success');
       const sideH = document.getElementById('side-hostname');
       if (sideH) sideH.innerText = data.hostname;

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

@@ -2331,10 +2331,13 @@
                 <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="bgp">BGP</option>
+                  <option value="ospf">OSPF</option>
                   <option value="static">Static</option>
                   <option value="dynamic">Dynamic</option>
                   <option value="connected">Connected</option>
+                  <option value="dhcp">DHCP</option>
+                  <option value="multicast">Multicast / IGMP</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>
@@ -2346,6 +2349,7 @@
                   <tr>
                     <th class="py-2.5 px-3 w-16 text-center" title="Flags: D=Dynamic, A=Active, C=Connect, S=Static, X=Disabled">Flags</th>
                     <th class="py-2.5 px-3">Dst. Address</th>
+                    <th class="py-2.5 px-3">Protocol</th>
                     <th class="py-2.5 px-3">Gateway</th>
                     <th class="py-2.5 px-3">Distance</th>
                     <th class="py-2.5 px-3">Table / Mark</th>
@@ -4154,7 +4158,7 @@
                 </div>
               </div>
 
-              <form id="form-dns-settings" onsubmit="saveDNSSettings(event)" class="space-y-4">
+              <form id="form-dns-settings" onsubmit="saveDNSSettings(event)" oninput="this.dataset.dirty='true'" class="space-y-4">
                 <!-- Upstream DNS Servers -->
                 <div>
                   <label class="block text-xs font-semibold text-slate-300 mb-1.5">Upstream DNS Servers (Comma or space separated) *</label>
@@ -4265,7 +4269,7 @@
                 <p class="text-xs text-slate-400 mt-0.5">Set the router appliance identity and timezone.</p>
               </div>
 
-              <form id="form-system-settings" onsubmit="saveSystemSettings(event)" class="space-y-4 max-w-xl">
+              <form id="form-system-settings" onsubmit="saveSystemSettings(event)" oninput="this.dataset.dirty='true'" class="space-y-4 max-w-xl">
                 <div>
                   <label class="block text-xs font-semibold text-slate-300 mb-1.5">Router Identity / Hostname *</label>
                   <input type="text" id="system-hostname-input" required class="w-full 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">