Explorar o código

feat(routing): implement MikroTik-style Routing Filters with chain-based rule maps and BGP integration

Proxmox Router hai 1 semana
pai
achega
de68221d20

+ 6 - 0
internal/api/api.go

@@ -252,6 +252,12 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("GET /api/routing/ospf/neighbors", s.handleGetOSPFNeighbors)
 	s.mux.HandleFunc("GET /api/routing/ospf/routes", s.handleGetOSPFRoutes)
 
+	// Routing Filters (/routing filter)
+	s.mux.HandleFunc("GET /api/routing/filters", s.handleListRoutingFilters)
+	s.mux.HandleFunc("POST /api/routing/filters", s.handleSaveRoutingFilter)
+	s.mux.HandleFunc("DELETE /api/routing/filters/{id}", s.handleDeleteRoutingFilter)
+	s.mux.HandleFunc("POST /api/routing/filters/{id}/state", s.handleSetRoutingFilterState)
+
 	// Web UI Static Files
 	if s.fsHandler != nil {
 		s.mux.Handle("/", s.fsHandler)

+ 65 - 0
internal/api/dynamic_routing_handlers.go

@@ -264,3 +264,68 @@ func (s *Server) handleGetOSPFRoutes(w http.ResponseWriter, r *http.Request) {
 	}
 	writeJSON(w, http.StatusOK, routes)
 }
+
+// -------------------------------------------------------------
+// Routing Filters Handlers (/routing filter)
+// -------------------------------------------------------------
+
+func (s *Server) handleListRoutingFilters(w http.ResponseWriter, r *http.Request) {
+	filters := s.dynRoutingMgr.ListRoutingFilters()
+	if filters == nil {
+		filters = []dynamicrouting.RoutingFilterRule{}
+	}
+	writeJSON(w, http.StatusOK, filters)
+}
+
+func (s *Server) handleSaveRoutingFilter(w http.ResponseWriter, r *http.Request) {
+	var rule dynamicrouting.RoutingFilterRule
+	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+	if rule.Chain == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Chain name is required"})
+		return
+	}
+	if rule.Action == "" {
+		rule.Action = "accept"
+	}
+	if err := s.dynRoutingMgr.SaveRoutingFilter(&rule); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "success", "id": rule.ID})
+}
+
+func (s *Server) handleDeleteRoutingFilter(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing filter rule ID"})
+		return
+	}
+	if err := s.dynRoutingMgr.DeleteRoutingFilter(id); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
+func (s *Server) handleSetRoutingFilterState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing filter rule ID"})
+		return
+	}
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
+		return
+	}
+	if err := s.dynRoutingMgr.SetRoutingFilterState(id, req.Disabled); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "disabled": req.Disabled})
+}

+ 181 - 0
internal/dynamicrouting/manager.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"sort"
 	"strings"
 	"sync"
 	"time"
@@ -78,6 +79,7 @@ func (m *Manager) initDefaults() {
 			},
 		},
 		OSPFInterfaces: []OSPFInterface{},
+		RoutingFilters: []RoutingFilterRule{},
 	}
 }
 
@@ -205,6 +207,12 @@ func (m *Manager) applyFRRConfig() error {
 			if peer.NexthopSelf {
 				sb.WriteString(fmt.Sprintf("  neighbor %s next-hop-self\n", peer.RemoteAddress))
 			}
+			if peer.InFilter != "" {
+				sb.WriteString(fmt.Sprintf("  neighbor %s route-map %s in\n", peer.RemoteAddress, peer.InFilter))
+			}
+			if peer.OutFilter != "" {
+				sb.WriteString(fmt.Sprintf("  neighbor %s route-map %s out\n", peer.RemoteAddress, peer.OutFilter))
+			}
 		}
 
 		// Redistribute
@@ -256,6 +264,87 @@ func (m *Manager) applyFRRConfig() error {
 		sb.WriteString("!\n")
 	}
 
+	// 4. Routing Filters (Prefix lists, AS-Path ACLs, Community lists, Route Maps)
+	chainRules := make(map[string][]RoutingFilterRule)
+	for _, rule := range m.config.RoutingFilters {
+		if rule.Disabled || strings.TrimSpace(rule.Chain) == "" {
+			continue
+		}
+		chain := strings.TrimSpace(rule.Chain)
+		chainRules[chain] = append(chainRules[chain], rule)
+	}
+
+	for chain, rules := range chainRules {
+		// Sort rules by RuleOrder
+		sort.Slice(rules, func(i, j int) bool {
+			return rules[i].RuleOrder < rules[j].RuleOrder
+		})
+
+		safeChain := strings.ReplaceAll(chain, "-", "_")
+
+		for _, rule := range rules {
+			order := rule.RuleOrder
+			if order <= 0 {
+				order = 10
+			}
+			act := "permit"
+			if rule.Action == "discard" || rule.Action == "reject" {
+				act = "deny"
+			}
+
+			// Generate Prefix List if prefix specified
+			pfxName := fmt.Sprintf("pfx_%s_%d", safeChain, order)
+			if rule.Prefix != "" {
+				pfxClause := formatPrefixListClause(rule.Action, rule.Prefix, rule.PrefixLen)
+				sb.WriteString(fmt.Sprintf("ip prefix-list %s seq 5 %s\n", pfxName, pfxClause))
+			}
+
+			// Generate AS-Path ACL if specified
+			asPathName := fmt.Sprintf("asp_%s_%d", safeChain, order)
+			if rule.MatchASPath != "" {
+				sb.WriteString(fmt.Sprintf("bgp as-path access-list %s permit %s\n", asPathName, rule.MatchASPath))
+			}
+
+			// Generate Community List if specified
+			commName := fmt.Sprintf("comm_%s_%d", safeChain, order)
+			if rule.MatchCommunity != "" {
+				sb.WriteString(fmt.Sprintf("bgp community-list standard %s permit %s\n", commName, rule.MatchCommunity))
+			}
+
+			// Generate Route-Map
+			sb.WriteString(fmt.Sprintf("route-map %s %s %d\n", chain, act, order))
+			if rule.Prefix != "" {
+				sb.WriteString(fmt.Sprintf(" match ip address prefix-list %s\n", pfxName))
+			}
+			if rule.MatchASPath != "" {
+				sb.WriteString(fmt.Sprintf(" match as-path %s\n", asPathName))
+			}
+			if rule.MatchCommunity != "" {
+				sb.WriteString(fmt.Sprintf(" match community %s\n", commName))
+			}
+
+			// Set modifications if action is permit/accept or passthrough
+			if act == "permit" {
+				if rule.SetLocalPref > 0 {
+					sb.WriteString(fmt.Sprintf(" set local-preference %d\n", rule.SetLocalPref))
+				}
+				if rule.SetMetric > 0 {
+					sb.WriteString(fmt.Sprintf(" set metric %d\n", rule.SetMetric))
+				}
+				if rule.SetASPrepend > 0 {
+					sb.WriteString(fmt.Sprintf(" set as-path prepend last-as %d\n", rule.SetASPrepend))
+				}
+				if rule.SetCommunity != "" {
+					sb.WriteString(fmt.Sprintf(" set community %s\n", rule.SetCommunity))
+				}
+				if rule.SetNextHop != "" {
+					sb.WriteString(fmt.Sprintf(" set ip next-hop %s\n", rule.SetNextHop))
+				}
+			}
+			sb.WriteString("!\n")
+		}
+	}
+
 	sb.WriteString("end\n")
 
 	// Write frr.conf
@@ -785,5 +874,97 @@ func (m *Manager) GetSummary() DynamicRoutingSummary {
 		}
 	}
 
+	summary.RoutingFilterCount = len(m.config.RoutingFilters)
+
 	return summary
 }
+
+func formatPrefixListClause(action, prefix, prefixLen string) string {
+	act := "permit"
+	if action == "discard" || action == "reject" {
+		act = "deny"
+	}
+	clause := fmt.Sprintf("%s %s", act, prefix)
+	prefixLen = strings.TrimSpace(prefixLen)
+	if prefixLen != "" {
+		if strings.Contains(prefixLen, "-") {
+			parts := strings.Split(prefixLen, "-")
+			if len(parts) == 2 {
+				clause += fmt.Sprintf(" ge %s le %s", strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
+			}
+		} else if strings.HasPrefix(prefixLen, "ge ") || strings.HasPrefix(prefixLen, "le ") {
+			clause += " " + prefixLen
+		} else {
+			clause += fmt.Sprintf(" ge %s le %s", prefixLen, prefixLen)
+		}
+	}
+	return clause
+}
+
+// -------------------------------------------------------------
+// Routing Filter API & Operations (/routing filter)
+// -------------------------------------------------------------
+
+func (m *Manager) ListRoutingFilters() []RoutingFilterRule {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	filters := make([]RoutingFilterRule, len(m.config.RoutingFilters))
+	copy(filters, m.config.RoutingFilters)
+	return filters
+}
+
+func (m *Manager) SaveRoutingFilter(rule *RoutingFilterRule) error {
+	m.mu.Lock()
+	if rule.ID == "" {
+		rule.ID = fmt.Sprintf("rf-%d", time.Now().UnixNano()/1e6)
+	}
+	if rule.RuleOrder <= 0 {
+		rule.RuleOrder = (len(m.config.RoutingFilters) + 1) * 10
+	}
+
+	found := false
+	for i, r := range m.config.RoutingFilters {
+		if r.ID == rule.ID {
+			m.config.RoutingFilters[i] = *rule
+			found = true
+			break
+		}
+	}
+	if !found {
+		m.config.RoutingFilters = append(m.config.RoutingFilters, *rule)
+	}
+	_ = m.save()
+	m.mu.Unlock()
+
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) DeleteRoutingFilter(id string) error {
+	m.mu.Lock()
+	newRules := make([]RoutingFilterRule, 0, len(m.config.RoutingFilters))
+	for _, r := range m.config.RoutingFilters {
+		if r.ID != id {
+			newRules = append(newRules, r)
+		}
+	}
+	m.config.RoutingFilters = newRules
+	_ = m.save()
+	m.mu.Unlock()
+
+	return m.applyFRRConfig()
+}
+
+func (m *Manager) SetRoutingFilterState(id string, disabled bool) error {
+	m.mu.Lock()
+	for i := range m.config.RoutingFilters {
+		if m.config.RoutingFilters[i].ID == id {
+			m.config.RoutingFilters[i].Disabled = disabled
+			break
+		}
+	}
+	_ = m.save()
+	m.mu.Unlock()
+
+	return m.applyFRRConfig()
+}

+ 31 - 6
internal/dynamicrouting/types.go

@@ -26,6 +26,8 @@ type BGPPeer struct {
 	Password       string `json:"password,omitempty"`
 	Disabled       bool   `json:"disabled"`
 	Comment        string `json:"comment,omitempty"`
+	InFilter       string `json:"in_filter,omitempty"`  // Routing Filter chain for incoming routes
+	OutFilter      string `json:"out_filter,omitempty"` // Routing Filter chain for outgoing routes
 	// Runtime telemetry
 	State          string `json:"state,omitempty"` // Established, Active, Connect, Idle
 	Uptime         string `json:"uptime,omitempty"`
@@ -109,6 +111,27 @@ type OSPFRoute struct {
 	Interface string `json:"interface"`
 }
 
+// RoutingFilterRule represents a route filter rule (/routing filter)
+type RoutingFilterRule struct {
+	ID             string `json:"id"`
+	Chain          string `json:"chain"`                   // e.g. "bgp-in", "bgp-out", "isp-in"
+	RuleOrder      int    `json:"rule_order"`              // e.g. 10, 20, 30... Sequence number
+	Action         string `json:"action"`                  // "accept" (permit), "discard" (deny), "reject", "passthrough"
+	Prefix         string `json:"prefix,omitempty"`         // CIDR e.g. "10.0.0.0/8" or "0.0.0.0/0"
+	PrefixLen      string `json:"prefix_len,omitempty"`     // e.g. "24-32", "ge 24 le 32", "24"
+	MatchProtocol  string `json:"match_protocol,omitempty"`  // "any", "bgp", "ospf", "connected", "static"
+	MatchASPath    string `json:"match_as_path,omitempty"`  // regex e.g. "^65000_"
+	MatchCommunity string `json:"match_community,omitempty"`// e.g. "65001:100"
+	SetLocalPref   int    `json:"set_local_pref,omitempty"` // BGP Local Preference
+	SetMetric      int    `json:"set_metric,omitempty"`     // BGP MED / OSPF Metric
+	SetASPrepend   int    `json:"set_as_prepend,omitempty"` // AS Path prepend count
+	SetCommunity   string `json:"set_community,omitempty"`  // Set BGP community
+	SetNextHop     string `json:"set_next_hop,omitempty"`   // Custom Next Hop IP
+	SetDistance    int    `json:"set_distance,omitempty"`   // Administrative Distance
+	Disabled       bool   `json:"disabled"`
+	Comment        string `json:"comment,omitempty"`
+}
+
 // DynamicRoutingSummary aggregates status for dashboard & badges
 type DynamicRoutingSummary struct {
 	BGPEnabled          bool `json:"bgp_enabled"`
@@ -119,14 +142,16 @@ type DynamicRoutingSummary struct {
 	OSPFRunning         bool `json:"ospf_running"`
 	OSPFNeighborCount   int  `json:"ospf_neighbor_count"`
 	OSPFAdjacentCount   int  `json:"ospf_adjacent_count"`
+	RoutingFilterCount  int  `json:"routing_filter_count"`
 }
 
 // DynamicConfig stores all dynamic routing data on disk
 type DynamicConfig struct {
-	BGPInstance    BGPInstance     `json:"bgp_instance"`
-	BGPPeers       []BGPPeer       `json:"bgp_peers"`
-	BGPNetworks    []BGPNetwork    `json:"bgp_networks"`
-	OSPFInstance   OSPFInstance    `json:"ospf_instance"`
-	OSPFAreas      []OSPFArea      `json:"ospf_areas"`
-	OSPFInterfaces []OSPFInterface `json:"ospf_interfaces"`
+	BGPInstance    BGPInstance         `json:"bgp_instance"`
+	BGPPeers       []BGPPeer           `json:"bgp_peers"`
+	BGPNetworks    []BGPNetwork        `json:"bgp_networks"`
+	OSPFInstance   OSPFInstance        `json:"ospf_instance"`
+	OSPFAreas      []OSPFArea          `json:"ospf_areas"`
+	OSPFInterfaces []OSPFInterface     `json:"ospf_interfaces"`
+	RoutingFilters []RoutingFilterRule `json:"routing_filters"`
 }

+ 307 - 3
internal/web/static/app.js

@@ -39,7 +39,9 @@ let state = {
   addressLists: [],
   connections: [],
   policyRules: [],
-  routingTables: []
+  routingTables: [],
+  routingFilters: [],
+  filterChainSelected: 'all'
 };
 
 const API_BASE = '/api';
@@ -98,6 +100,7 @@ function navigate(tabName) {
     routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' },
     bgp: { title: 'BGP Routing (/routing bgp)', sub: 'FRRouting BGP peering sessions, advertised networks, and BGP routing table (RIB)' },
     ospf: { title: 'OSPF Routing (/routing ospf)', sub: 'FRRouting OSPFv2 link-state routing, dynamic neighbor adjacencies, and multi-area topology' },
+    'routing-filters': { title: 'Routing Filters (/routing filter)', sub: 'MikroTik-style route filter chains, prefix matching, BGP attributes & policy maps' },
     'dynamic-routing': { title: 'BGP Routing (/routing bgp)', sub: 'FRRouting dynamic routing' },
     ppp: { title: 'PPP Servers & Secrets', sub: 'MikroTik RouterOS user accounts, IP pools and VPN server concentrators' },
     eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP Layer 2 tunneling (MikroTik RouterOS style)' },
@@ -135,6 +138,7 @@ function navigate(tabName) {
     loadDynamicRouting();
     switchOspfSubTab(state.activeOspfSubTab || 'ospf-ifaces');
   }
+  if (tabName === 'routing-filters') loadDynamicRouting();
   if (tabName === 'dynamic-routing') loadDynamicRouting();
   if (tabName === 'ppp') loadPPP();
   if (tabName === 'eoip') loadEoIP();
@@ -330,6 +334,8 @@ function updateBadges() {
     if (bBgp) bBgp.innerText = (state.bgpPeers || []).length;
     const bOspf = document.getElementById('badge-ospf-count');
     if (bOspf) bOspf.innerText = (state.ospfInterfaces || []).length;
+    const bFilters = document.getElementById('badge-filters-count');
+    if (bFilters) bFilters.innerText = (state.routingFilters || []).length;
   }
 }
 
@@ -5128,7 +5134,7 @@ function switchDynSubTab(subTab) {
 
 async function loadDynamicRouting(showToastMsg = false) {
   try {
-    const [sumRes, bgpInstRes, bgpPeersRes, bgpNetsRes, bgpRoutesRes, ospfInstRes, ospfIfacesRes, ospfAreasRes, ospfNbrsRes, ospfRoutesRes] = await Promise.all([
+    const [sumRes, bgpInstRes, bgpPeersRes, bgpNetsRes, bgpRoutesRes, ospfInstRes, ospfIfacesRes, ospfAreasRes, ospfNbrsRes, ospfRoutesRes, filtersRes] = await Promise.all([
       fetch(`${API_BASE}/routing/dynamic/summary`),
       fetch(`${API_BASE}/routing/bgp/instance`),
       fetch(`${API_BASE}/routing/bgp/peers`),
@@ -5138,7 +5144,8 @@ async function loadDynamicRouting(showToastMsg = false) {
       fetch(`${API_BASE}/routing/ospf/interfaces`),
       fetch(`${API_BASE}/routing/ospf/areas`),
       fetch(`${API_BASE}/routing/ospf/neighbors`),
-      fetch(`${API_BASE}/routing/ospf/routes`)
+      fetch(`${API_BASE}/routing/ospf/routes`),
+      fetch(`${API_BASE}/routing/filters`)
     ]);
 
     if (sumRes.ok) state.dynSummary = await sumRes.json();
@@ -5151,6 +5158,7 @@ async function loadDynamicRouting(showToastMsg = false) {
     if (ospfAreasRes.ok) state.ospfAreas = await ospfAreasRes.json();
     if (ospfNbrsRes.ok) state.ospfNeighbors = await ospfNbrsRes.json();
     if (ospfRoutesRes.ok) state.ospfRoutes = await ospfRoutesRes.json();
+    if (filtersRes && filtersRes.ok) state.routingFilters = await filtersRes.json();
 
     try { renderBgpStatusPill(); } catch(e) { console.error('renderBgpStatusPill error:', e); }
     try { renderBgpPeers(); } catch(e) { console.error('renderBgpPeers error:', e); }
@@ -5161,6 +5169,7 @@ async function loadDynamicRouting(showToastMsg = false) {
     try { renderOspfAreas(); } catch(e) { console.error('renderOspfAreas error:', e); }
     try { renderOspfNeighbors(); } catch(e) { console.error('renderOspfNeighbors error:', e); }
     try { renderOspfRoutes(); } catch(e) { console.error('renderOspfRoutes error:', e); }
+    try { renderRoutingFilters(); } catch(e) { console.error('renderRoutingFilters error:', e); }
     try { updateBadges(); } catch(e) { console.error('updateBadges error:', e); }
 
     if (showToastMsg) showToast('Refreshed', 'Dynamic routing telemetry updated', 'info');
@@ -5232,6 +5241,11 @@ function renderBgpPeers() {
           <span class="text-emerald-400 font-bold">↓ ${p.prefix_received || 0}</span> / 
           <span class="text-blue-400 font-bold">↑ ${p.prefix_sent || 0}</span>
         </td>
+        <td class="py-2.5 px-3 font-mono text-[11px] whitespace-nowrap">
+          ${p.in_filter ? `<span class="px-1.5 py-0.5 rounded bg-blue-950 text-blue-300 border border-blue-800/50" title="In Filter">IN: ${escapeHtml(p.in_filter)}</span> ` : ''}
+          ${p.out_filter ? `<span class="px-1.5 py-0.5 rounded bg-amber-950 text-amber-300 border border-amber-800/50" title="Out Filter">OUT: ${escapeHtml(p.out_filter)}</span>` : ''}
+          ${!p.in_filter && !p.out_filter ? '<span class="text-slate-600">-</span>' : ''}
+        </td>
         <td class="py-2.5 px-3 text-slate-400 font-sans truncate max-w-xs">${escapeHtml(p.comment || '')}</td>
         <td class="py-2.5 px-3 text-right space-x-1.5 whitespace-nowrap font-sans">
           <button onclick="toggleBgpPeerState('${p.id}', ${!p.disabled})" class="px-2 py-1 rounded text-[11px] ${p.disabled ? 'bg-emerald-950 hover:bg-emerald-900 text-emerald-400 border border-emerald-800/50' : 'bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700'}">
@@ -5529,6 +5543,10 @@ function openAddBgpPeerModal() {
   document.getElementById('modal-bgp-peer-id').value = '';
   document.getElementById('bgp-peer-modal-title').innerText = 'Add BGP Peer Connection';
   document.getElementById('modal-bgp-peer-nexthop-self').checked = true;
+  const inFilterEl = document.getElementById('modal-bgp-peer-in-filter');
+  if (inFilterEl) inFilterEl.value = '';
+  const outFilterEl = document.getElementById('modal-bgp-peer-out-filter');
+  if (outFilterEl) outFilterEl.value = '';
   document.getElementById('bgp-peer-modal').classList.remove('hidden');
 }
 
@@ -5546,6 +5564,10 @@ function openEditBgpPeerModal(id) {
   document.getElementById('modal-bgp-peer-passive').checked = !!p.passive;
   document.getElementById('modal-bgp-peer-disabled').checked = !!p.disabled;
   document.getElementById('modal-bgp-peer-comment').value = p.comment || '';
+  const inFilterEl = document.getElementById('modal-bgp-peer-in-filter');
+  if (inFilterEl) inFilterEl.value = p.in_filter || '';
+  const outFilterEl = document.getElementById('modal-bgp-peer-out-filter');
+  if (outFilterEl) outFilterEl.value = p.out_filter || '';
   document.getElementById('bgp-peer-modal-title').innerText = 'Edit BGP Peer Connection';
   document.getElementById('bgp-peer-modal').classList.remove('hidden');
 }
@@ -5567,6 +5589,8 @@ async function submitBgpPeer(e) {
     nexthop_self: document.getElementById('modal-bgp-peer-nexthop-self').checked,
     passive: document.getElementById('modal-bgp-peer-passive').checked,
     disabled: document.getElementById('modal-bgp-peer-disabled').checked,
+    in_filter: (document.getElementById('modal-bgp-peer-in-filter')?.value || '').trim(),
+    out_filter: (document.getElementById('modal-bgp-peer-out-filter')?.value || '').trim(),
     comment: document.getElementById('modal-bgp-peer-comment').value.trim()
   };
 
@@ -6729,3 +6753,283 @@ function populateBridgePortCandidateSelect(selectId, currentVal = '') {
   if (grpEoip.children.length > 0) sel.appendChild(grpEoip);
 }
 
+// -------------------------------------------------------------
+// Routing Filters Management (/routing filter)
+// -------------------------------------------------------------
+
+function renderRoutingFilters() {
+  const tbody = document.getElementById('routing-filters-table-body');
+  if (!tbody) return;
+  const empty = document.getElementById('routing-filters-empty');
+  const countBadge = document.getElementById('routing-filters-count-badge');
+  const navBadge = document.getElementById('badge-filters-count');
+  const chainSelect = document.getElementById('routing-filter-chain-select');
+
+  const allRules = state.routingFilters || [];
+  if (navBadge) navBadge.innerText = allRules.length;
+
+  // Populate chain filter select options dynamically
+  if (chainSelect) {
+    const currentVal = state.filterChainSelected || 'all';
+    const uniqueChains = Array.from(new Set(allRules.map(r => r.chain).filter(Boolean))).sort();
+    chainSelect.innerHTML = `<option value="all">All Chains (${allRules.length})</option>` +
+      uniqueChains.map(ch => {
+        const count = allRules.filter(r => r.chain === ch).length;
+        return `<option value="${escapeHtml(ch)}"${currentVal === ch ? ' selected' : ''}>${escapeHtml(ch)} (${count})</option>`;
+      }).join('');
+    chainSelect.value = currentVal;
+  }
+
+  // Filter rules by selected chain
+  const filteredRules = (state.filterChainSelected && state.filterChainSelected !== 'all')
+    ? allRules.filter(r => r.chain === state.filterChainSelected)
+    : allRules.slice();
+
+  // Sort by chain then rule_order
+  filteredRules.sort((a, b) => {
+    if (a.chain !== b.chain) return (a.chain || '').localeCompare(b.chain || '');
+    return (a.rule_order || 0) - (b.rule_order || 0);
+  });
+
+  if (countBadge) countBadge.innerText = `${filteredRules.length} Rules`;
+
+  if (filteredRules.length === 0) {
+    tbody.innerHTML = '';
+    if (empty) empty.classList.remove('hidden');
+    return;
+  }
+  if (empty) empty.classList.add('hidden');
+
+  tbody.innerHTML = filteredRules.map(r => {
+    const flagA = !r.disabled ? '<span class="text-cyan-400 font-bold" title="Active">A</span>' : '';
+    const flagX = r.disabled ? '<span class="text-rose-400 font-bold ml-1" title="Disabled">X</span>' : '';
+
+    let actionBadge = '';
+    if (r.action === 'accept') {
+      actionBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-sans font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">accept</span>';
+    } else if (r.action === 'discard') {
+      actionBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-sans font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">discard</span>';
+    } else if (r.action === 'reject') {
+      actionBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-sans font-semibold bg-red-950 text-red-300 border border-red-800/40">reject</span>';
+    } else {
+      actionBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-sans font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">${escapeHtml(r.action || 'accept')}</span>`;
+    }
+
+    // Prefix match
+    let matchPfx = '';
+    if (r.prefix) {
+      matchPfx = `<span class="font-bold text-white">${escapeHtml(r.prefix)}</span>`;
+      if (r.prefix_len) {
+        matchPfx += ` <span class="text-slate-400 text-[11px] font-normal">[${escapeHtml(r.prefix_len)}]</span>`;
+      }
+    } else {
+      matchPfx = '<span class="text-slate-500 italic">any prefix</span>';
+    }
+
+    // Other matches
+    const matchExtra = [];
+    if (r.match_protocol && r.match_protocol !== 'any') {
+      matchExtra.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700">proto:${escapeHtml(r.match_protocol)}</span>`);
+    }
+    if (r.match_as_path) {
+      matchExtra.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-950 text-purple-300 border border-purple-800/40 font-mono">as-path:${escapeHtml(r.match_as_path)}</span>`);
+    }
+    if (r.match_community) {
+      matchExtra.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-indigo-950 text-indigo-300 border border-indigo-800/40 font-mono">comm:${escapeHtml(r.match_community)}</span>`);
+    }
+    const matchExtraHtml = matchExtra.length > 0 ? matchExtra.join(' ') : '<span class="text-slate-600">-</span>';
+
+    // Set modifications
+    const setBadges = [];
+    if (r.set_local_pref > 0) {
+      setBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-950 text-cyan-300 border border-cyan-800/40">loc-pref: ${r.set_local_pref}</span>`);
+    }
+    if (r.set_metric > 0) {
+      setBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-amber-950 text-amber-300 border border-amber-800/40">med: ${r.set_metric}</span>`);
+    }
+    if (r.set_as_prepend > 0) {
+      setBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-950 text-purple-300 border border-purple-800/40">prepend: +${r.set_as_prepend}</span>`);
+    }
+    if (r.set_community) {
+      setBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-indigo-950 text-indigo-300 border border-indigo-800/40">comm: ${escapeHtml(r.set_community)}</span>`);
+    }
+    if (r.set_next_hop) {
+      setBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-blue-950 text-blue-300 border border-blue-800/40">nh: ${escapeHtml(r.set_next_hop)}</span>`);
+    }
+    const setHtml = setBadges.length > 0 ? setBadges.join(' ') : '<span class="text-slate-600">-</span>';
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-2.5 px-3 text-center tracking-wider">${flagA}${flagX}</td>
+        <td class="py-2.5 px-3 font-mono text-slate-400 font-bold">${r.rule_order || 10}</td>
+        <td class="py-2.5 px-3">
+          <span class="px-2 py-0.5 rounded text-[11px] font-mono bg-slate-800 text-cyan-300 border border-slate-700 font-semibold">${escapeHtml(r.chain)}</span>
+        </td>
+        <td class="py-2.5 px-3">${actionBadge}</td>
+        <td class="py-2.5 px-3 font-mono">${matchPfx}</td>
+        <td class="py-2.5 px-3 space-x-1">${matchExtraHtml}</td>
+        <td class="py-2.5 px-3 space-x-1 font-mono">${setHtml}</td>
+        <td class="py-2.5 px-3 text-slate-400 font-sans truncate max-w-xs">${escapeHtml(r.comment || '')}</td>
+        <td class="py-2.5 px-3 text-right space-x-1.5 whitespace-nowrap font-sans">
+          <button onclick="toggleRoutingFilterState('${r.id}', ${!r.disabled})" class="px-2 py-1 rounded text-[11px] ${r.disabled ? 'bg-emerald-950 hover:bg-emerald-900 text-emerald-400 border border-emerald-800/50' : 'bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700'}">
+            ${r.disabled ? 'Enable' : 'Disable'}
+          </button>
+          <button onclick="openEditRoutingFilterModal('${r.id}')" class="px-2 py-1 rounded text-[11px] bg-slate-800 hover:bg-slate-700 text-emerald-300 border border-slate-700">
+            Edit
+          </button>
+          <button onclick="deleteRoutingFilter('${r.id}')" class="px-2 py-1 rounded text-[11px] bg-rose-950/60 hover:bg-rose-900 text-rose-300 border border-rose-900/50">
+            Delete
+          </button>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function filterRoutingFiltersByChain(chain) {
+  state.filterChainSelected = chain;
+  renderRoutingFilters();
+}
+
+function openAddRoutingFilterModal(defaultChain = '') {
+  const form = document.getElementById('routing-filter-form');
+  if (form) form.reset();
+  const idEl = document.getElementById('modal-rf-id');
+  if (idEl) idEl.value = '';
+  const titleEl = document.getElementById('modal-rf-title');
+  if (titleEl) titleEl.innerText = 'Add Routing Filter Rule';
+  const chainEl = document.getElementById('modal-rf-chain');
+  if (chainEl) chainEl.value = defaultChain || (state.filterChainSelected && state.filterChainSelected !== 'all' ? state.filterChainSelected : 'bgp-in');
+  const orderEl = document.getElementById('modal-rf-order');
+  if (orderEl) orderEl.value = ((state.routingFilters || []).length + 1) * 10;
+  const actEl = document.getElementById('modal-rf-action');
+  if (actEl) actEl.value = 'accept';
+  const protoEl = document.getElementById('modal-rf-protocol');
+  if (protoEl) protoEl.value = 'any';
+  const disEl = document.getElementById('modal-rf-disabled');
+  if (disEl) disEl.checked = false;
+  const modal = document.getElementById('modal-routing-filter');
+  if (modal) modal.classList.remove('hidden');
+}
+
+function openEditRoutingFilterModal(id) {
+  const r = (state.routingFilters || []).find(item => item.id === id);
+  if (!r) return;
+  const idEl = document.getElementById('modal-rf-id');
+  if (idEl) idEl.value = r.id;
+  const titleEl = document.getElementById('modal-rf-title');
+  if (titleEl) titleEl.innerText = 'Edit Routing Filter Rule';
+  const chainEl = document.getElementById('modal-rf-chain');
+  if (chainEl) chainEl.value = r.chain || '';
+  const orderEl = document.getElementById('modal-rf-order');
+  if (orderEl) orderEl.value = r.rule_order || 10;
+  const actEl = document.getElementById('modal-rf-action');
+  if (actEl) actEl.value = r.action || 'accept';
+  const pfxEl = document.getElementById('modal-rf-prefix');
+  if (pfxEl) pfxEl.value = r.prefix || '';
+  const pfxLenEl = document.getElementById('modal-rf-prefix-len');
+  if (pfxLenEl) pfxLenEl.value = r.prefix_len || '';
+  const protoEl = document.getElementById('modal-rf-protocol');
+  if (protoEl) protoEl.value = r.match_protocol || 'any';
+  const aspEl = document.getElementById('modal-rf-as-path');
+  if (aspEl) aspEl.value = r.match_as_path || '';
+  const commEl = document.getElementById('modal-rf-community');
+  if (commEl) commEl.value = r.match_community || '';
+  const lpEl = document.getElementById('modal-rf-set-local-pref');
+  if (lpEl) lpEl.value = r.set_local_pref || '';
+  const metEl = document.getElementById('modal-rf-set-metric');
+  if (metEl) metEl.value = r.set_metric || '';
+  const prepEl = document.getElementById('modal-rf-set-as-prepend');
+  if (prepEl) prepEl.value = r.set_as_prepend || '';
+  const setCommEl = document.getElementById('modal-rf-set-community');
+  if (setCommEl) setCommEl.value = r.set_community || '';
+  const nhEl = document.getElementById('modal-rf-set-next-hop');
+  if (nhEl) nhEl.value = r.set_next_hop || '';
+  const comEl = document.getElementById('modal-rf-comment');
+  if (comEl) comEl.value = r.comment || '';
+  const disEl = document.getElementById('modal-rf-disabled');
+  if (disEl) disEl.checked = !!r.disabled;
+  const modal = document.getElementById('modal-routing-filter');
+  if (modal) modal.classList.remove('hidden');
+}
+
+function closeRoutingFilterModal() {
+  const modal = document.getElementById('modal-routing-filter');
+  if (modal) modal.classList.add('hidden');
+}
+
+async function submitRoutingFilter(e) {
+  e.preventDefault();
+  const payload = {
+    id: document.getElementById('modal-rf-id').value,
+    chain: document.getElementById('modal-rf-chain').value.trim(),
+    rule_order: parseInt(document.getElementById('modal-rf-order').value, 10) || 10,
+    action: document.getElementById('modal-rf-action').value,
+    prefix: document.getElementById('modal-rf-prefix').value.trim(),
+    prefix_len: document.getElementById('modal-rf-prefix-len').value.trim(),
+    match_protocol: document.getElementById('modal-rf-protocol').value,
+    match_as_path: document.getElementById('modal-rf-as-path').value.trim(),
+    match_community: document.getElementById('modal-rf-community').value.trim(),
+    set_local_pref: parseInt(document.getElementById('modal-rf-set-local-pref').value, 10) || 0,
+    set_metric: parseInt(document.getElementById('modal-rf-set-metric').value, 10) || 0,
+    set_as_prepend: parseInt(document.getElementById('modal-rf-set-as-prepend').value, 10) || 0,
+    set_community: document.getElementById('modal-rf-set-community').value.trim(),
+    set_next_hop: document.getElementById('modal-rf-set-next-hop').value.trim(),
+    comment: document.getElementById('modal-rf-comment').value.trim(),
+    disabled: document.getElementById('modal-rf-disabled').checked
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/filters`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      closeRoutingFilterModal();
+      showToast('Success', 'Routing filter rule saved', 'success');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to save filter rule', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteRoutingFilter(id) {
+  if (!confirm('Are you sure you want to delete this routing filter rule?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/routing/filters/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'Routing filter rule removed', 'info');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to delete rule', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleRoutingFilterState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/routing/filters/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      showToast('Status Updated', `Rule ${disabled ? 'disabled' : 'enabled'}`, 'info');
+      loadDynamicRouting();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to toggle rule state', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+

+ 264 - 1
internal/web/static/index.html

@@ -177,6 +177,17 @@
           </div>
         </a>
 
+        <a href="#routing-filters" onclick="navigate('routing-filters')" id="nav-routing-filters"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
+            </svg>
+            <span>Routing Filters</span>
+          </div>
+          <span id="badge-filters-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <!-- CATEGORY: WAN & ACCESS -->
         <div class="px-3 pt-3 pb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">WAN & Access</div>
 
@@ -1791,6 +1802,12 @@
               </svg>
               <span>OSPF (/routing ospf)</span>
             </button>
+            <button onclick="navigate('routing-filters')" class="px-3.5 py-1.5 text-xs font-medium rounded-lg text-slate-400 hover:text-white hover:bg-slate-800/60 transition flex items-center gap-2">
+              <svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
+              </svg>
+              <span>Filters (/routing filter)</span>
+            </button>
           </div>
 
           <div class="bg-gradient-to-r from-cyan-950/40 via-slate-900 to-slate-900 border border-cyan-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
@@ -1944,6 +1961,12 @@
               </svg>
               <span>OSPF (/routing ospf)</span>
             </button>
+            <button onclick="navigate('routing-filters')" class="px-3.5 py-1.5 text-xs font-medium rounded-lg text-slate-400 hover:text-white hover:bg-slate-800/60 transition flex items-center gap-2">
+              <svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
+              </svg>
+              <span>Filters (/routing filter)</span>
+            </button>
           </div>
 
           <div class="bg-gradient-to-r from-purple-950/40 via-slate-900 to-slate-900 border border-purple-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
@@ -2012,6 +2035,7 @@
                     <th class="py-2.5 px-3">State</th>
                     <th class="py-2.5 px-3">Uptime</th>
                     <th class="py-2.5 px-3">Prefixes (RX/TX)</th>
+                    <th class="py-2.5 px-3">Routing Filters</th>
                     <th class="py-2.5 px-3">Comment</th>
                     <th class="py-2.5 px-3 text-right font-sans">Actions</th>
                   </tr>
@@ -2104,6 +2128,12 @@
               </svg>
               <span>OSPF (/routing ospf)</span>
             </button>
+            <button onclick="navigate('routing-filters')" class="px-3.5 py-1.5 text-xs font-medium rounded-lg text-slate-400 hover:text-white hover:bg-slate-800/60 transition flex items-center gap-2">
+              <svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
+              </svg>
+              <span>Filters (/routing filter)</span>
+            </button>
           </div>
 
           <div class="bg-gradient-to-r from-indigo-950/40 via-slate-900 to-slate-900 border border-indigo-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
@@ -2271,6 +2301,99 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: ROUTING FILTERS (/routing filter) ================= -->
+        <section id="section-routing-filters" class="hidden space-y-6">
+          <!-- Top Routing Switcher Bar -->
+          <div class="flex items-center gap-1.5 p-1 bg-slate-900/90 border border-slate-800 rounded-xl w-fit">
+            <button onclick="navigate('routes')" class="px-3.5 py-1.5 text-xs font-medium rounded-lg text-slate-400 hover:text-white hover:bg-slate-800/60 transition flex items-center gap-2">
+              <svg class="w-3.5 h-3.5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
+              </svg>
+              <span>Routes & Policy (/ip route)</span>
+            </button>
+            <button onclick="navigate('bgp')" class="px-3.5 py-1.5 text-xs font-medium rounded-lg text-slate-400 hover:text-white hover:bg-slate-800/60 transition flex items-center gap-2">
+              <svg class="w-3.5 h-3.5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
+              </svg>
+              <span>BGP (/routing bgp)</span>
+            </button>
+            <button onclick="navigate('ospf')" class="px-3.5 py-1.5 text-xs font-medium rounded-lg text-slate-400 hover:text-white hover:bg-slate-800/60 transition flex items-center gap-2">
+              <svg class="w-3.5 h-3.5 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+              </svg>
+              <span>OSPF (/routing ospf)</span>
+            </button>
+            <button onclick="navigate('routing-filters')" class="px-3.5 py-1.5 text-xs font-bold rounded-lg transition bg-emerald-600/20 text-emerald-300 border border-emerald-500/40 shadow-sm flex items-center gap-2">
+              <svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
+              </svg>
+              <span>Filters (/routing filter)</span>
+            </button>
+          </div>
+
+          <!-- Top Banner -->
+          <div class="bg-gradient-to-r from-emerald-950/40 via-slate-900 to-slate-900 border border-emerald-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-semibold">/routing filter</span>
+                <h2 class="text-base font-bold text-white">Routing Filters & Policy Maps</h2>
+              </div>
+              <p class="text-xs text-slate-400">RouterOS-style filter chains (in-filter / out-filter), prefix-lists, community matching, and BGP attribute modifications</p>
+            </div>
+
+            <div class="flex items-center gap-2.5">
+              <div class="flex items-center gap-2">
+                <label class="text-[11px] text-slate-400 font-medium">Chain:</label>
+                <select id="routing-filter-chain-select" onchange="filterRoutingFiltersByChain(this.value)" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1.5 text-xs text-white font-mono">
+                  <option value="all">All Chains</option>
+                </select>
+              </div>
+              <button onclick="openAddRoutingFilterModal()" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20 transition flex items-center gap-1.5">
+                <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="M12 4v16m8-8H4" />
+                </svg>
+                <span>+ Add Filter Rule</span>
+              </button>
+              <button onclick="loadDynamicRouting(true)" class="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition" title="Refresh routing telemetry">
+                ↻
+              </button>
+            </div>
+          </div>
+
+          <!-- Filter Rules Table -->
+          <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <div class="flex items-center gap-2">
+                <h3 class="text-sm font-bold text-white">Filter Rules</h3>
+                <span id="routing-filters-count-badge" class="px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold">0 Rules</span>
+              </div>
+              <span class="text-xs text-slate-500 font-sans">Evaluated sequentially by order</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 text-center w-12">Flags</th>
+                    <th class="py-2.5 px-3 w-16">#</th>
+                    <th class="py-2.5 px-3">Chain</th>
+                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3">Match Prefix / Length</th>
+                    <th class="py-2.5 px-3">Match AS-Path / Community</th>
+                    <th class="py-2.5 px-3">Set Modifications</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="routing-filters-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+            <div id="routing-filters-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No routing filter rules configured. Click "+ Add Filter Rule" to create a filter chain.
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: DEBIAN CONFIG PERSISTENCE ================= -->
         <section id="section-persistence" class="hidden space-y-6">
           <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
@@ -3878,6 +4001,19 @@
           </div>
         </div>
 
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">In Filter (Routing Filter Chain)</label>
+            <input type="text" id="modal-bgp-peer-in-filter" placeholder="e.g. bgp-in" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <p class="text-[10px] text-slate-500 mt-0.5">Filter chain for inbound routes from peer</p>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Out Filter (Routing Filter Chain)</label>
+            <input type="text" id="modal-bgp-peer-out-filter" placeholder="e.g. bgp-out" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <p class="text-[10px] text-slate-500 mt-0.5">Filter chain for outbound routes to peer</p>
+          </div>
+        </div>
+
         <div class="grid grid-cols-2 gap-3 p-3 rounded-xl bg-slate-800/40 border border-slate-700/50">
           <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
             <input type="checkbox" id="modal-bgp-peer-multihop" class="rounded text-purple-600 bg-slate-800 border-slate-700">
@@ -4124,6 +4260,133 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: ADD / EDIT ROUTING FILTER ================= -->
+  <div id="modal-routing-filter" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div>
+          <h3 id="modal-rf-title" class="text-sm font-bold text-white">Add Routing Filter Rule</h3>
+          <p class="text-[11px] text-slate-400">Match routing prefixes, AS-Path, communities, and set BGP attributes (/routing filter)</p>
+        </div>
+        <button onclick="closeRoutingFilterModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="routing-filter-form" onsubmit="submitRoutingFilter(event)" class="p-6 space-y-4 text-xs max-h-[85vh] overflow-y-auto">
+        <input type="hidden" id="modal-rf-id">
+
+        <div class="grid grid-cols-3 gap-3">
+          <div class="col-span-1">
+            <label class="block font-semibold text-slate-300 mb-1.5">Chain Name *</label>
+            <input type="text" id="modal-rf-chain" required placeholder="bgp-in" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <p class="text-[10px] text-slate-500 mt-0.5">e.g. bgp-in, bgp-out</p>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Rule Order *</label>
+            <input type="number" id="modal-rf-order" value="10" min="1" max="65535" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+            <p class="text-[10px] text-slate-500 mt-0.5">Sequence (10, 20...)</p>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Action *</label>
+            <select id="modal-rf-action" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+              <option value="accept">accept (Permit)</option>
+              <option value="discard">discard (Deny)</option>
+              <option value="reject">reject (Deny)</option>
+              <option value="passthrough">passthrough (Continue)</option>
+            </select>
+            <p class="text-[10px] text-slate-500 mt-0.5">Filter action</p>
+          </div>
+        </div>
+
+        <!-- Match Criteria Box -->
+        <div class="p-3.5 rounded-xl bg-slate-800/40 border border-slate-700/50 space-y-3">
+          <div class="text-[11px] font-bold text-cyan-400 uppercase tracking-wider">Match Conditions</div>
+          
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Prefix (CIDR)</label>
+              <input type="text" id="modal-rf-prefix" placeholder="e.g. 10.0.0.0/8 or 0.0.0.0/0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+              <p class="text-[10px] text-slate-500 mt-0.5">Leave blank to match all prefixes</p>
+            </div>
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Prefix Length (Range)</label>
+              <input type="text" id="modal-rf-prefix-len" placeholder="e.g. 16-24 or ge 24 le 32" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+              <p class="text-[10px] text-slate-500 mt-0.5">Optional subnet mask length filter</p>
+            </div>
+          </div>
+
+          <div class="grid grid-cols-3 gap-3">
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Match Protocol</label>
+              <select id="modal-rf-protocol" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="any">Any Protocol</option>
+                <option value="bgp">BGP</option>
+                <option value="ospf">OSPF</option>
+                <option value="connected">Connected</option>
+                <option value="static">Static</option>
+              </select>
+            </div>
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Match AS-Path (Regex)</label>
+              <input type="text" id="modal-rf-as-path" placeholder="e.g. ^65001_ or _65000$" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Match Community</label>
+              <input type="text" id="modal-rf-community" placeholder="e.g. 65001:100" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+        </div>
+
+        <!-- Set Attributes Box (Modifications) -->
+        <div class="p-3.5 rounded-xl bg-slate-800/40 border border-slate-700/50 space-y-3">
+          <div class="text-[11px] font-bold text-purple-400 uppercase tracking-wider">Set Modifications (On Accept)</div>
+          
+          <div class="grid grid-cols-3 gap-3">
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Set BGP Local Pref</label>
+              <input type="number" id="modal-rf-set-local-pref" placeholder="e.g. 150" min="0" max="4294967295" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Set Metric / MED</label>
+              <input type="number" id="modal-rf-set-metric" placeholder="e.g. 20" min="0" max="4294967295" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Set AS Prepend (Count)</label>
+              <input type="number" id="modal-rf-set-as-prepend" placeholder="e.g. 2" min="1" max="10" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Set Community</label>
+              <input type="text" id="modal-rf-set-community" placeholder="e.g. 65001:200 or no-export" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-medium text-slate-300 mb-1">Set Next Hop (IP)</label>
+              <input type="text" id="modal-rf-set-next-hop" placeholder="e.g. 192.168.1.1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-3 gap-3">
+          <div class="col-span-2">
+            <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+            <input type="text" id="modal-rf-comment" placeholder="Filter upstream customer prefix" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+          </div>
+          <div class="flex items-center pt-6">
+            <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+              <input type="checkbox" id="modal-rf-disabled" class="rounded text-emerald-600 bg-slate-800 border-slate-700">
+              <span class="font-medium">Disabled (X)</span>
+            </label>
+          </div>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeRoutingFilterModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20">Save Filter Rule</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <!-- ================= MODAL: ADD / EDIT BRIDGE ================= -->
   <div id="bridge-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
     <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
@@ -4368,6 +4631,6 @@
     </div>
   </div>
 
-  <script src="app.js?v=3.3"></script>
+  <script src="app.js?v=3.4"></script>
 </body>
 </html>