Prechádzať zdrojové kódy

Fix static route editing, kernel static route adoption, and MikroTik route flags

Proxmox Router 1 týždeň pred
rodič
commit
745b02234d

+ 160 - 38
internal/routing/manager.go

@@ -117,11 +117,23 @@ func (m *RoutingManager) applyAll() {
 
 // ==================== Routes (/ip route) ====================
 
-func (m *RoutingManager) ListRoutes() []RouteItem {
-	m.mu.Lock()
-	defer m.mu.Unlock()
+func isDynamicProtocol(proto netlink.RouteProtocol) bool {
+	switch int(proto) {
+	case 11, // RTPROT_ZEBRA
+		16,  // RTPROT_DHCP
+		42,  // RTPROT_BABEL
+		186, // RTPROT_BGP
+		187, // RTPROT_ISIS
+		188, // RTPROT_OSPF
+		189, // RTPROT_RIP
+		192: // RTPROT_EIGRP
+		return true
+	default:
+		return false
+	}
+}
 
-	// 1. Get live routes from netlink across all tables
+func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
 	tableNames := m.getTableMap()
 	links, _ := netlink.LinkList()
 	linkMap := make(map[int]string)
@@ -150,10 +162,19 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 			}
 
 			ifName := linkMap[r.LinkIndex]
-			isDirect := r.Scope == netlink.SCOPE_LINK
+			isDirect := r.Scope == netlink.SCOPE_LINK || r.Protocol == netlink.RouteProtocol(2)
+			isDyn := isDynamicProtocol(r.Protocol)
+			isStatic := !isDirect && !isDyn
+
+			cleanDst := strings.ReplaceAll(dst, "/", "_")
+			cleanGw := gw
+			if cleanGw == "" {
+				cleanGw = ifName
+			}
+			routeID := fmt.Sprintf("kr-%d-%s-%s", r.Table, cleanDst, cleanGw)
 
 			liveRoutes = append(liveRoutes, RouteItem{
-				ID:           fmt.Sprintf("kr-%d-%s-%s", r.Table, dst, gw),
+				ID:           routeID,
 				DstAddress:   dst,
 				Gateway:      gw,
 				Interface:    ifName,
@@ -162,21 +183,33 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 				TableID:      r.Table,
 				Active:       true,
 				Connect:      isDirect,
-				Dynamic:      !isDirect && (r.Protocol == netlink.RouteProtocol(2) || r.Protocol == netlink.RouteProtocol(3)),
-				Static:       !isDirect && r.Protocol != netlink.RouteProtocol(2) && r.Protocol != netlink.RouteProtocol(3),
+				Dynamic:      isDyn,
+				Static:       isStatic,
 				Disabled:     false,
 			})
 		}
 	}
+	return liveRoutes
+}
 
-	// Merge static routes that might be disabled or pending
+func (m *RoutingManager) ListRoutes() []RouteItem {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	liveRoutes := m.listLiveRoutesLocked()
+
+	// Merge static routes that might be disabled or have custom metadata
 	for _, sr := range m.staticRoutes {
 		found := false
 		for i, lr := range liveRoutes {
-			if lr.DstAddress == sr.DstAddress && lr.RoutingTable == sr.RoutingTable && (lr.Gateway == sr.Gateway || sr.Gateway == "") {
+			if lr.DstAddress == sr.DstAddress && lr.RoutingTable == sr.RoutingTable && (sr.Gateway == "" || lr.Gateway == sr.Gateway) {
 				liveRoutes[i].Comment = sr.Comment
 				liveRoutes[i].Static = true
+				liveRoutes[i].Dynamic = false
 				liveRoutes[i].ID = sr.ID
+				if sr.Distance > 0 && liveRoutes[i].Distance == 0 {
+					liveRoutes[i].Distance = sr.Distance
+				}
 				found = true
 				break
 			}
@@ -186,6 +219,7 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 			srCopy := sr
 			srCopy.Active = false
 			srCopy.Static = true
+			srCopy.Dynamic = false
 			liveRoutes = append(liveRoutes, srCopy)
 		}
 	}
@@ -200,23 +234,58 @@ func (m *RoutingManager) SaveRoute(r RouteItem) error {
 	if r.DstAddress == "" {
 		return fmt.Errorf("destination address is required")
 	}
+	if r.DstAddress == "default" {
+		r.DstAddress = "0.0.0.0/0"
+	}
 	if r.RoutingTable == "" {
 		r.RoutingTable = "main"
 	}
-	if r.ID == "" {
-		r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
-	}
 	r.Static = true
 
-	found := false
+	// 1. If old route details are provided from edit modal, remove old route first
+	if r.OldDstAddress != "" {
+		oldDist := 0
+		if r.OldDistance != nil {
+			oldDist = *r.OldDistance
+		}
+		oldRoute := RouteItem{
+			DstAddress:   r.OldDstAddress,
+			Gateway:      r.OldGateway,
+			RoutingTable: r.OldRoutingTable,
+			Distance:     oldDist,
+			Interface:    r.OldInterface,
+		}
+		_ = m.removeRouteFromKernel(oldRoute)
+	}
+
+	// 2. If existing entry in m.staticRoutes exists, remove its old route from kernel
+	foundIndex := -1
 	for i, existing := range m.staticRoutes {
-		if existing.ID == r.ID {
-			m.staticRoutes[i] = r
-			found = true
+		if existing.ID == r.ID || (r.ID == "" && existing.DstAddress == r.DstAddress && existing.RoutingTable == r.RoutingTable) {
+			foundIndex = i
+			_ = m.removeRouteFromKernel(existing)
 			break
 		}
 	}
-	if !found {
+
+	// 3. If r.ID starts with kr- (kernel static route being edited/adopted), remove it from kernel
+	if strings.HasPrefix(r.ID, "kr-") {
+		for _, lr := range m.listLiveRoutesLocked() {
+			if lr.ID == r.ID {
+				_ = m.removeRouteFromKernel(lr)
+				break
+			}
+		}
+		r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
+	}
+
+	if r.ID == "" {
+		r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
+	}
+
+	if foundIndex >= 0 {
+		m.staticRoutes[foundIndex] = r
+	} else {
 		m.staticRoutes = append(m.staticRoutes, r)
 	}
 
@@ -249,9 +318,19 @@ func (m *RoutingManager) DeleteRoute(id string) error {
 
 	if target != nil {
 		_ = m.removeRouteFromKernel(*target)
+		m.staticRoutes = updated
+		return m.save()
 	}
-	m.staticRoutes = updated
-	return m.save()
+
+	// If not in staticRoutes, could be a live route (e.g. kr-...)
+	for _, lr := range m.listLiveRoutesLocked() {
+		if lr.ID == id {
+			_ = m.removeRouteFromKernel(lr)
+			return nil
+		}
+	}
+
+	return fmt.Errorf("route not found")
 }
 
 func (m *RoutingManager) SetRouteState(id string, disabled bool) error {
@@ -270,29 +349,47 @@ func (m *RoutingManager) SetRouteState(id string, disabled bool) error {
 			return nil
 		}
 	}
+
+	// If not in staticRoutes, could be a kernel static route to disable
+	if disabled {
+		for _, lr := range m.listLiveRoutesLocked() {
+			if lr.ID == id {
+				lr.Disabled = true
+				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()
+			}
+		}
+	}
+
 	return fmt.Errorf("route not found")
 }
 
 func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
-	args := []string{"route", "replace"}
-
-	if r.DstAddress == "default" || r.DstAddress == "0.0.0.0/0" {
-		args = append(args, "default")
-	} else {
-		args = append(args, r.DstAddress)
+	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"
 	}
 
-	if r.Gateway != "" {
-		// check if gateway is IP or device
-		if net.ParseIP(r.Gateway) != nil {
-			args = append(args, "via", r.Gateway)
+	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", r.Gateway)
+			args = append(args, "dev", gw)
 		}
 	}
 
-	if r.Interface != "" && r.Gateway != r.Interface {
-		args = append(args, "dev", r.Interface)
+	iface := strings.TrimSpace(r.Interface)
+	if iface != "" && gw != iface && (gw == "" || net.ParseIP(gw) != nil) {
+		args = append(args, "dev", iface)
 	}
 
 	if r.Distance > 0 {
@@ -303,6 +400,8 @@ func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
 		args = append(args, "table", r.RoutingTable)
 	}
 
+	args = append(args, "proto", "static")
+
 	cmd := exec.Command("ip", args...)
 	out, err := cmd.CombinedOutput()
 	if err != nil {
@@ -312,11 +411,26 @@ func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
 }
 
 func (m *RoutingManager) removeRouteFromKernel(r RouteItem) error {
-	args := []string{"route", "del"}
-	if r.DstAddress == "default" || r.DstAddress == "0.0.0.0/0" {
-		args = append(args, "default")
-	} else {
-		args = append(args, r.DstAddress)
+	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"
+	}
+
+	args := []string{"route", "del", 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 r.Distance > 0 {
+		args = append(args, "metric", strconv.Itoa(r.Distance))
 	}
 
 	if r.RoutingTable != "" && r.RoutingTable != "main" {
@@ -324,6 +438,14 @@ func (m *RoutingManager) removeRouteFromKernel(r RouteItem) error {
 	}
 
 	_ = 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()
+
 	return nil
 }
 

+ 7 - 0
internal/routing/types.go

@@ -15,6 +15,13 @@ type RouteItem struct {
 	Static       bool   `json:"static"`        // MikroTik 'S' flag (user defined)
 	Disabled     bool   `json:"disabled"`      // MikroTik 'X' flag
 	Comment      string `json:"comment,omitempty"`
+
+	// Old values for modifying existing routes
+	OldDstAddress   string `json:"old_dst_address,omitempty"`
+	OldGateway      string `json:"old_gateway,omitempty"`
+	OldRoutingTable string `json:"old_routing_table,omitempty"`
+	OldDistance     *int   `json:"old_distance,omitempty"`
+	OldInterface    string `json:"old_interface,omitempty"`
 }
 
 // PolicyRuleItem represents a MikroTik /ip route rule (Linux `ip rule`)

+ 118 - 34
internal/web/static/app.js

@@ -3094,7 +3094,8 @@ function renderRoutesList() {
 
   routes.forEach(r => {
     const tr = document.createElement('tr');
-    tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
+    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
     let flags = '';
@@ -3114,16 +3115,18 @@ function renderRoutesList() {
       </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 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 || 1}</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>
       <td class="py-2.5 px-3 text-emerald-400 font-mono">${r.interface || '-'}</td>
       <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[140px]">${r.comment || '-'}</td>
       <td class="py-2.5 px-3 text-right space-x-2 font-sans whitespace-nowrap">
         ${r.static ? `
           <button onclick="toggleRouteState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
-          <button onclick="openEditRouteModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+          <button onclick="openEditRouteModal('${r.id}')" class="text-xs text-cyan-400 hover:text-cyan-300 font-semibold">Edit</button>
           <button onclick="deleteRoute('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
-        ` : `<span class="text-slate-600 text-[10px]">Kernel</span>`}
+        ` : `
+          <button onclick="openEditRouteModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">View</button>
+        `}
       </td>
     `;
     tbody.appendChild(tr);
@@ -3133,23 +3136,45 @@ function renderRoutesList() {
 function openAddRouteModal() {
   document.getElementById('route-modal-title').innerText = 'Add Static Route';
   document.getElementById('route-modal-id').value = '';
-  document.getElementById('route-modal-dst').value = '0.0.0.0/0';
-  document.getElementById('route-modal-gw').value = '';
-  document.getElementById('route-modal-distance').value = '1';
-  document.getElementById('route-modal-comment').value = '';
-  document.getElementById('route-modal-disabled').checked = false;
+  document.getElementById('route-modal-old-dst').value = '';
+  document.getElementById('route-modal-old-gw').value = '';
+  document.getElementById('route-modal-old-distance').value = '';
+  document.getElementById('route-modal-old-table').value = '';
+  document.getElementById('route-modal-old-iface').value = '';
+
+  const dstInput = document.getElementById('route-modal-dst');
+  const gwInput = document.getElementById('route-modal-gw');
+  const distInput = document.getElementById('route-modal-distance');
+  const ifaceSelect = document.getElementById('route-modal-iface');
+  const tableSelect = document.getElementById('route-modal-table');
+  const commentInput = document.getElementById('route-modal-comment');
+  const disCheck = document.getElementById('route-modal-disabled');
+  const saveBtn = document.querySelector('#route-modal button[type="submit"]');
+
+  dstInput.disabled = false;
+  dstInput.value = '0.0.0.0/0';
+  gwInput.disabled = false;
+  gwInput.value = '';
+  distInput.disabled = false;
+  distInput.value = '1';
+  ifaceSelect.disabled = false;
+  tableSelect.disabled = false;
+  commentInput.disabled = false;
+  commentInput.value = '';
+  disCheck.disabled = false;
+  disCheck.checked = false;
+  if (saveBtn) saveBtn.classList.remove('hidden');
 
   populateInterfaceSelect('route-modal-iface');
 
   // Populate tables
-  const sel = document.getElementById('route-modal-table');
-  sel.innerHTML = '';
+  tableSelect.innerHTML = '';
   (state.routingTables || [{ name: 'main' }]).forEach(t => {
     const opt = document.createElement('option');
     opt.value = t.name;
     opt.innerText = t.name;
     if (t.name === 'main') opt.selected = true;
-    sel.appendChild(opt);
+    tableSelect.appendChild(opt);
   });
 
   document.getElementById('route-modal').classList.remove('hidden');
@@ -3159,24 +3184,58 @@ function openEditRouteModal(id) {
   const r = (state.routes || []).find(route => route.id === id);
   if (!r) return;
 
-  document.getElementById('route-modal-title').innerText = `Edit Route: ${r.dst_address}`;
+  const isStatic = !!r.static;
+  document.getElementById('route-modal-title').innerText = isStatic
+    ? `Edit Route: ${r.dst_address}`
+    : `Route Properties (Read Only): ${r.dst_address}`;
+
   document.getElementById('route-modal-id').value = r.id;
-  document.getElementById('route-modal-dst').value = r.dst_address;
-  document.getElementById('route-modal-gw').value = r.gateway || '';
-  document.getElementById('route-modal-distance').value = r.distance || 1;
-  document.getElementById('route-modal-comment').value = r.comment || '';
-  document.getElementById('route-modal-disabled').checked = !!r.disabled;
+  document.getElementById('route-modal-old-dst').value = r.dst_address || '';
+  document.getElementById('route-modal-old-gw').value = r.gateway || '';
+  document.getElementById('route-modal-old-distance').value = (r.distance !== undefined && r.distance !== null) ? r.distance : '';
+  document.getElementById('route-modal-old-table').value = r.routing_table || 'main';
+  document.getElementById('route-modal-old-iface').value = r.interface || '';
+
+  const dstInput = document.getElementById('route-modal-dst');
+  const gwInput = document.getElementById('route-modal-gw');
+  const distInput = document.getElementById('route-modal-distance');
+  const ifaceSelect = document.getElementById('route-modal-iface');
+  const tableSelect = document.getElementById('route-modal-table');
+  const commentInput = document.getElementById('route-modal-comment');
+  const disCheck = document.getElementById('route-modal-disabled');
+  const saveBtn = document.querySelector('#route-modal button[type="submit"]');
+
+  dstInput.value = r.dst_address;
+  gwInput.value = r.gateway || '';
+  distInput.value = (r.distance !== undefined && r.distance !== null) ? r.distance : 1;
+  commentInput.value = r.comment || '';
+  disCheck.checked = !!r.disabled;
+
+  dstInput.disabled = !isStatic;
+  gwInput.disabled = !isStatic;
+  distInput.disabled = !isStatic;
+  ifaceSelect.disabled = !isStatic;
+  tableSelect.disabled = !isStatic;
+  commentInput.disabled = !isStatic;
+  disCheck.disabled = !isStatic;
+
+  if (saveBtn) {
+    if (isStatic) {
+      saveBtn.classList.remove('hidden');
+    } else {
+      saveBtn.classList.add('hidden');
+    }
+  }
 
   populateInterfaceSelect('route-modal-iface', r.interface);
 
-  const sel = document.getElementById('route-modal-table');
-  sel.innerHTML = '';
+  tableSelect.innerHTML = '';
   (state.routingTables || [{ name: 'main' }]).forEach(t => {
     const opt = document.createElement('option');
     opt.value = t.name;
     opt.innerText = t.name;
-    if (t.name === r.routing_table) opt.selected = true;
-    sel.appendChild(opt);
+    if (t.name === (r.routing_table || 'main')) opt.selected = true;
+    tableSelect.appendChild(opt);
   });
 
   document.getElementById('route-modal').classList.remove('hidden');
@@ -3189,28 +3248,53 @@ function closeRouteModal() {
 async function submitSaveRoute(e) {
   e.preventDefault();
   const id = document.getElementById('route-modal-id').value;
+  const oldDst = document.getElementById('route-modal-old-dst')?.value || '';
+  const oldGw = document.getElementById('route-modal-old-gw')?.value || '';
+  const oldDistStr = document.getElementById('route-modal-old-distance')?.value;
+  const oldTable = document.getElementById('route-modal-old-table')?.value || '';
+  const oldIface = document.getElementById('route-modal-old-iface')?.value || '';
+
   const dst = document.getElementById('route-modal-dst').value.trim();
   const gw = document.getElementById('route-modal-gw').value.trim();
   const table = document.getElementById('route-modal-table').value;
-  const distance = parseInt(document.getElementById('route-modal-distance').value, 10) || 1;
+  const distance = parseInt(document.getElementById('route-modal-distance').value, 10) || 0;
   const iface = document.getElementById('route-modal-iface').value;
   const comment = document.getElementById('route-modal-comment').value.trim();
   const disabled = document.getElementById('route-modal-disabled').checked;
 
+  if (!dst) {
+    showToast('Error', 'Destination address is required', 'error');
+    return;
+  }
+  if (!gw && !iface) {
+    showToast('Error', 'Either Gateway IP or Interface must be specified', 'error');
+    return;
+  }
+
+  const payload = {
+    id,
+    old_dst_address: oldDst,
+    old_gateway: oldGw,
+    old_routing_table: oldTable,
+    old_interface: oldIface,
+    dst_address: dst,
+    gateway: gw || iface,
+    routing_table: table,
+    distance,
+    interface: iface,
+    comment,
+    disabled
+  };
+
+  if (oldDistStr !== undefined && oldDistStr !== '') {
+    payload.old_distance = parseInt(oldDistStr, 10);
+  }
+
   try {
     const res = await fetch(`${API_BASE}/routes`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        id,
-        dst_address: dst,
-        gateway: gw,
-        routing_table: table,
-        distance,
-        interface: iface,
-        comment,
-        disabled
-      })
+      body: JSON.stringify(payload)
     });
     if (res.ok) {
       showToast('Route Saved', `Route to ${dst} installed`, 'success');
@@ -3218,7 +3302,7 @@ async function submitSaveRoute(e) {
       loadRoutes();
     } else {
       const err = await res.json();
-      showToast('Error', err.message, 'error');
+      showToast('Error', err.message || 'Failed to save route', 'error');
     }
   } catch (err) {
     showToast('Error', err.message, 'error');

+ 9 - 4
internal/web/static/index.html

@@ -2688,6 +2688,11 @@
       </div>
       <form onsubmit="submitSaveRoute(event)" class="p-6 space-y-4 text-xs">
         <input type="hidden" id="route-modal-id">
+        <input type="hidden" id="route-modal-old-dst">
+        <input type="hidden" id="route-modal-old-gw">
+        <input type="hidden" id="route-modal-old-distance">
+        <input type="hidden" id="route-modal-old-table">
+        <input type="hidden" id="route-modal-old-iface">
 
         <div>
           <label class="block font-semibold text-slate-300 mb-1.5">Dst. Address (CIDR) *</label>
@@ -2696,8 +2701,8 @@
 
         <div class="grid grid-cols-2 gap-4">
           <div>
-            <label class="block font-semibold text-slate-300 mb-1.5">Gateway (IP or Interface) *</label>
-            <input type="text" id="route-modal-gw" placeholder="192.168.1.1 or wg0" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+            <label class="block font-semibold text-slate-300 mb-1.5">Gateway (IP or Interface)</label>
+            <input type="text" id="route-modal-gw" placeholder="10.42.36.1 or wg0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
           </div>
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Routing Table / Mark</label>
@@ -2710,11 +2715,11 @@
         <div class="grid grid-cols-2 gap-4">
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Distance (Metric)</label>
-            <input type="number" id="route-modal-distance" value="1" min="1" max="255" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <input type="number" id="route-modal-distance" value="1" min="0" max="255" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
           </div>
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">Interface (Optional)</label>
-            <select id="route-modal-iface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono"></select>
+            <select id="route-modal-iface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500"></select>
           </div>
         </div>