瀏覽代碼

feat(routing-filters): optimize filters for BIRD 2, remove unused select-rules, fix equality and passthrough, bump to 1.3.11

Proxmox Router 2 天之前
父節點
當前提交
0e402a644c

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.3.10
+1.3.11

二進制
bin/debtik-cli


二進制
bin/proxmox-router


+ 0 - 5
internal/api/api.go

@@ -351,11 +351,6 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("DELETE /api/routing/filters/rules/{id}", s.handleDeleteRoutingFilter)
 	s.mux.HandleFunc("POST /api/routing/filters/rules/{id}/state", s.handleSetRoutingFilterState)
 
-	s.mux.HandleFunc("GET /api/routing/filters/select-rules", s.handleListSelectRules)
-	s.mux.HandleFunc("POST /api/routing/filters/select-rules", s.handleSaveSelectRule)
-	s.mux.HandleFunc("DELETE /api/routing/filters/select-rules/{id}", s.handleDeleteSelectRule)
-	s.mux.HandleFunc("POST /api/routing/filters/select-rules/{id}/state", s.handleSetSelectRuleState)
-
 	s.mux.HandleFunc("GET /api/routing/filters/community-lists", s.handleListCommunityLists)
 	s.mux.HandleFunc("POST /api/routing/filters/community-lists", s.handleSaveCommunityList)
 	s.mux.HandleFunc("DELETE /api/routing/filters/community-lists/{id}", s.handleDeleteCommunityList)

+ 0 - 62
internal/api/dynamic_routing_handlers.go

@@ -490,68 +490,6 @@ func (s *Server) handleSetRoutingFilterState(w http.ResponseWriter, r *http.Requ
 	writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "disabled": req.Disabled})
 }
 
-// -------------------------------------------------------------
-// Select Rules Handlers (/routing filter select-rule)
-// -------------------------------------------------------------
-
-func (s *Server) handleListSelectRules(w http.ResponseWriter, r *http.Request) {
-	rules := s.dynRoutingMgr.ListSelectRules()
-	if rules == nil {
-		rules = []dynamicrouting.RoutingFilterSelectRule{}
-	}
-	writeJSON(w, http.StatusOK, rules)
-}
-
-func (s *Server) handleSaveSelectRule(w http.ResponseWriter, r *http.Request) {
-	var rule dynamicrouting.RoutingFilterSelectRule
-	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 err := s.dynRoutingMgr.SaveSelectRule(&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) handleDeleteSelectRule(w http.ResponseWriter, r *http.Request) {
-	id := r.PathValue("id")
-	if id == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing select rule ID"})
-		return
-	}
-	if err := s.dynRoutingMgr.DeleteSelectRule(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) handleSetSelectRuleState(w http.ResponseWriter, r *http.Request) {
-	id := r.PathValue("id")
-	if id == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing select 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.SetSelectRuleState(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})
-}
-
 // -------------------------------------------------------------
 // Community Lists Handlers (/routing filter community-list)
 // -------------------------------------------------------------

+ 52 - 0
internal/dynamicrouting/bgp_test.go

@@ -107,3 +107,55 @@ func TestParseBirdSinceToUptime(t *testing.T) {
 		})
 	}
 }
+
+func TestTranslateRos7RuleToBird(t *testing.T) {
+	tests := []struct {
+		name     string
+		rule     RoutingFilterRule
+		contains []string
+		omits    []string
+	}{
+		{
+			name: "Equals conversion in script",
+			rule: RoutingFilterRule{
+				Rule: "if (dst in 10.0.0.0/8 && dst-len == 24) { accept; }",
+			},
+			contains: []string{"net ~ [ 10.0.0.0/8+ ]", "net.len = 24", ") then {"},
+			omits:    []string{"==", "dst-len"},
+		},
+		{
+			name: "Inequality preserved",
+			rule: RoutingFilterRule{
+				Rule: "if (dst != 192.168.1.0/24) { accept; }",
+			},
+			contains: []string{"net != 192.168.1.0/24"},
+			omits:    []string{"dst !="},
+		},
+		{
+			name: "Passthrough action in structured form",
+			rule: RoutingFilterRule{
+				Prefix:       "10.0.0.0/8",
+				Action:       "passthrough",
+				SetLocalPref: 150,
+			},
+			contains: []string{"bgp_local_pref = 150;"},
+			omits:    []string{"accept;", "reject;"},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := translateRos7RuleToBird(&tc.rule)
+			for _, exp := range tc.contains {
+				if !strings.Contains(got, exp) {
+					t.Errorf("%s: expected to contain %q, got: %s", tc.name, exp, got)
+				}
+			}
+			for _, om := range tc.omits {
+				if strings.Contains(got, om) {
+					t.Errorf("%s: expected to NOT contain %q, got: %s", tc.name, om, got)
+				}
+			}
+		})
+	}
+}

+ 42 - 79
internal/dynamicrouting/manager.go

@@ -99,7 +99,6 @@ func (m *Manager) initDefaults() {
 		OSPFInterfaces:        []OSPFInterface{},
 		OSPFStaticNbrs:        []OSPFStaticNeighbor{},
 		RoutingFilters:        []RoutingFilterRule{},
-		RoutingSelectRules:    []RoutingFilterSelectRule{},
 		RoutingCommunityLists: []RoutingFilterCommunityList{},
 		RoutingASLists:        []RoutingFilterASList{},
 	}
@@ -117,9 +116,6 @@ func (m *Manager) load() error {
 		return err
 	}
 
-	if m.config.RoutingSelectRules == nil {
-		m.config.RoutingSelectRules = []RoutingFilterSelectRule{}
-	}
 	if m.config.RoutingCommunityLists == nil {
 		m.config.RoutingCommunityLists = []RoutingFilterCommunityList{}
 	}
@@ -306,8 +302,10 @@ func (m *Manager) applyBirdConfig() error {
 		})
 
 		filterName := sanitizeIdentifier("filter", chain)
-		sb.WriteString(fmt.Sprintf("filter %s {\n", filterName))
+		fnName := fmt.Sprintf("fn_%s", filterName)
 
+		// Procedural function for this chain
+		sb.WriteString(fmt.Sprintf("function %s() {\n", fnName))
 		for _, r := range rules {
 			rendered := translateRos7RuleToBird(&r)
 			if rendered != "" {
@@ -320,8 +318,11 @@ func (m *Manager) applyBirdConfig() error {
 				}
 			}
 		}
+		sb.WriteString("}\n\n")
 
-		// RouterOS v7 default chain policy: reject unmatched routes
+		// Standalone filter for import and general use (unmatched routes rejected by default)
+		sb.WriteString(fmt.Sprintf("filter %s {\n", filterName))
+		sb.WriteString(fmt.Sprintf("    %s();\n", fnName))
 		sb.WriteString("    reject;\n")
 		sb.WriteString("}\n\n")
 	}
@@ -432,7 +433,26 @@ func (m *Manager) applyBirdConfig() error {
 
 			// Export Filter
 			if peer.OutFilter != "" && len(chainRules[peer.OutFilter]) > 0 {
-				sb.WriteString(fmt.Sprintf("        export filter %s;\n", sanitizeIdentifier("filter", peer.OutFilter)))
+				fnName := fmt.Sprintf("fn_%s", sanitizeIdentifier("filter", peer.OutFilter))
+				sb.WriteString("        export filter {\n")
+				sb.WriteString(fmt.Sprintf("            %s();\n", fnName))
+				if peerNetsProto != "" {
+					sb.WriteString(fmt.Sprintf("            if proto = \"%s\" then accept;\n", peerNetsProto))
+				}
+				if len(activeNets) > 0 {
+					sb.WriteString("            if proto = \"bgp_static_nets\" then accept;\n")
+				}
+				if peer.RedistConnected || m.config.BGPInstance.RedistributeConnected {
+					sb.WriteString("            if source = RTS_DEVICE then accept;\n")
+				}
+				if peer.RedistStatic || m.config.BGPInstance.RedistributeStatic {
+					sb.WriteString("            if source ~ [ RTS_STATIC, RTS_INHERIT ] then accept;\n")
+				}
+				if peer.RedistOSPF || m.config.BGPInstance.RedistributeOSPF {
+					sb.WriteString("            if source = RTS_OSPF then accept;\n")
+				}
+				sb.WriteString("            reject;\n")
+				sb.WriteString("        };\n")
 			} else {
 				var exportSources []string
 				if peerNetsProto != "" {
@@ -1835,6 +1855,12 @@ func translateRos7RuleToBird(r *RoutingFilterRule) string {
 	if script != "" {
 		s := script
 
+		// 0. Convert double-equals (==) to single-equals (=) for BIRD 2, preserving !=, <=, >=, ~=
+		reDoubleEq := regexp.MustCompile(`([^!<>~=])\s*==\s*([^=])`)
+		for reDoubleEq.MatchString(s) {
+			s = reDoubleEq.ReplaceAllString(s, "$1 = $2")
+		}
+
 		// 1. Convert dst in A.B.C.D/M{L,H} -> net ~ [ A.B.C.D/M{L,H} ]
 		reDstRange := regexp.MustCompile(`dst\s+in\s+([0-9\.\/]+)\{([0-9]+),([0-9]+)\}`)
 		s = reDstRange.ReplaceAllString(s, "net ~ [ $1{$2,$3} ]")
@@ -1843,16 +1869,16 @@ func translateRos7RuleToBird(r *RoutingFilterRule) string {
 		reDstIn := regexp.MustCompile(`dst\s+in\s+([0-9\.\/]+)`)
 		s = reDstIn.ReplaceAllString(s, "net ~ [ $1+ ]")
 
-		// 3. Convert dst == A.B.C.D/M -> net = A.B.C.D/M
-		reDstEq := regexp.MustCompile(`dst\s*==\s*([0-9\.\/]+)`)
+		// 3. Convert dst = A.B.C.D/M or dst == A.B.C.D/M -> net = A.B.C.D/M
+		reDstEq := regexp.MustCompile(`\bdst\s*=\s*([0-9\.\/]+)`)
 		s = reDstEq.ReplaceAllString(s, "net = $1")
 
 		// 4. Convert dst != A.B.C.D/M -> net != A.B.C.D/M
-		reDstNeq := regexp.MustCompile(`dst\s*!=\s*([0-9\.\/]+)`)
+		reDstNeq := regexp.MustCompile(`\bdst\s*!=\s*([0-9\.\/]+)`)
 		s = reDstNeq.ReplaceAllString(s, "net != $1")
 
 		// 5. Convert dst-len -> net.len
-		s = strings.ReplaceAll(s, "dst-len", "net.len")
+		s = regexp.MustCompile(`\bdst-len\b`).ReplaceAllString(s, "net.len")
 
 		// 6. Convert protocol matches
 		s = regexp.MustCompile(`protocol\s+bgp\b`).ReplaceAllString(s, "source = RTS_BGP")
@@ -1925,6 +1951,9 @@ func translateRos7RuleToBird(r *RoutingFilterRule) string {
 		s = regexp.MustCompile(`\baccept(?:\s*;)?`).ReplaceAllString(s, "accept;")
 		s = regexp.MustCompile(`\b(reject|discard)(?:\s*;)?`).ReplaceAllString(s, "reject;")
 
+		// Convert passthrough keyword to no-op
+		s = regexp.MustCompile(`\bpassthrough(?:\s*;)?`).ReplaceAllString(s, "")
+
 		// Ensure "then" before opening brace of if statement
 		reIfThen := regexp.MustCompile(`\)\s*\{`)
 		s = reIfThen.ReplaceAllString(s, ") then {")
@@ -2012,6 +2041,8 @@ func translateRos7RuleToBird(r *RoutingFilterRule) string {
 
 	if r.Action == "discard" || r.Action == "reject" {
 		sb.WriteString("    reject;\n")
+	} else if r.Action == "passthrough" {
+		// Passthrough: do not emit terminating accept or reject
 	} else {
 		sb.WriteString("    accept;\n")
 	}
@@ -2019,74 +2050,6 @@ func translateRos7RuleToBird(r *RoutingFilterRule) string {
 	return sb.String()
 }
 
-// -------------------------------------------------------------
-// Select Rules (/routing filter select-rule)
-// -------------------------------------------------------------
-
-func (m *Manager) ListSelectRules() []RoutingFilterSelectRule {
-	m.mu.RLock()
-	defer m.mu.RUnlock()
-
-	res := make([]RoutingFilterSelectRule, len(m.config.RoutingSelectRules))
-	copy(res, m.config.RoutingSelectRules)
-	return res
-}
-
-func (m *Manager) SaveSelectRule(rule *RoutingFilterSelectRule) error {
-	m.mu.Lock()
-	if rule.ID == "" {
-		rule.ID = fmt.Sprintf("sr-%d", time.Now().UnixNano()/1e6)
-	}
-	if rule.RuleOrder <= 0 {
-		rule.RuleOrder = (len(m.config.RoutingSelectRules) + 1) * 10
-	}
-
-	found := false
-	for i, r := range m.config.RoutingSelectRules {
-		if r.ID == rule.ID {
-			m.config.RoutingSelectRules[i] = *rule
-			found = true
-			break
-		}
-	}
-	if !found {
-		m.config.RoutingSelectRules = append(m.config.RoutingSelectRules, *rule)
-	}
-	_ = m.save()
-	m.mu.Unlock()
-
-	return m.applyBirdConfig()
-}
-
-func (m *Manager) DeleteSelectRule(id string) error {
-	m.mu.Lock()
-	newRules := make([]RoutingFilterSelectRule, 0, len(m.config.RoutingSelectRules))
-	for _, r := range m.config.RoutingSelectRules {
-		if r.ID != id {
-			newRules = append(newRules, r)
-		}
-	}
-	m.config.RoutingSelectRules = newRules
-	_ = m.save()
-	m.mu.Unlock()
-
-	return m.applyBirdConfig()
-}
-
-func (m *Manager) SetSelectRuleState(id string, disabled bool) error {
-	m.mu.Lock()
-	for i := range m.config.RoutingSelectRules {
-		if m.config.RoutingSelectRules[i].ID == id {
-			m.config.RoutingSelectRules[i].Disabled = disabled
-			break
-		}
-	}
-	_ = m.save()
-	m.mu.Unlock()
-
-	return m.applyBirdConfig()
-}
-
 // -------------------------------------------------------------
 // Community Lists (/routing filter community-list)
 // -------------------------------------------------------------

+ 2 - 2
internal/dynamicrouting/types.go

@@ -257,7 +257,7 @@ type RoutingFilterRule struct {
 	Comment        string `json:"comment,omitempty"`
 }
 
-// RoutingFilterSelectRule represents a route selection rule (/routing filter select-rule)
+// RoutingFilterSelectRule represents a deprecated route selection rule (/routing filter select-rule)
 type RoutingFilterSelectRule struct {
 	ID        string `json:"id"`
 	Chain     string `json:"chain"`
@@ -311,7 +311,7 @@ type DynamicConfig struct {
 	OSPFInterfaces        []OSPFInterface              `json:"ospf_interfaces"`
 	OSPFStaticNbrs        []OSPFStaticNeighbor         `json:"ospf_static_nbrs"`
 	RoutingFilters        []RoutingFilterRule          `json:"routing_filters"`
-	RoutingSelectRules    []RoutingFilterSelectRule    `json:"routing_select_rules"`
+	RoutingSelectRules    []RoutingFilterSelectRule    `json:"routing_select_rules,omitempty"` // Deprecated: unused in BIRD 2
 	RoutingCommunityLists []RoutingFilterCommunityList `json:"routing_community_lists"`
 	RoutingASLists        []RoutingFilterASList        `json:"routing_as_lists"`
 }

+ 6 - 176
internal/web/static/app.js

@@ -9468,7 +9468,6 @@ async function loadDynamicRouting(showToastMsg = false) {
       fetchSafeJson(`${API_BASE}/routing/ospf/neighbors`),
       fetchSafeJson(`${API_BASE}/routing/ospf/lsa`),
       fetchSafeJson(`${API_BASE}/routing/filters`),
-      fetchSafeJson(`${API_BASE}/routing/filters/select-rules`),
       fetchSafeJson(`${API_BASE}/routing/filters/community-lists`),
       fetchSafeJson(`${API_BASE}/routing/filters/as-lists`)
     ]);
@@ -9487,7 +9486,6 @@ async function loadDynamicRouting(showToastMsg = false) {
     if (ospfNbrs) state.ospfNeighbors = ospfNbrs;
     if (ospfLsa) state.ospfLSA = ospfLsa;
     if (filters) state.routingFilters = filters;
-    if (selectRules) state.routingSelectRules = selectRules;
     if (commLists) state.routingCommunityLists = commLists;
     if (asLists) state.routingASLists = asLists;
 
@@ -12171,7 +12169,7 @@ if (!state.rfSort) state.rfSort = {};
 
 function switchRoutingFilterTab(tab) {
   state.activeRoutingFilterTab = tab;
-  const tabs = ['rules', 'select-rules', 'community-lists', 'as-lists'];
+  const tabs = ['rules', 'community-lists', 'as-lists'];
 
   tabs.forEach(t => {
     const btn = document.getElementById(`tab-rf-${t}`);
@@ -12191,7 +12189,7 @@ function switchRoutingFilterTab(tab) {
 
   const chainContainer = document.getElementById('rf-chain-filter-container');
   if (chainContainer) {
-    if (tab === 'rules' || tab === 'select-rules') chainContainer.classList.remove('hidden');
+    if (tab === 'rules') chainContainer.classList.remove('hidden');
     else chainContainer.classList.add('hidden');
   }
 
@@ -12205,7 +12203,6 @@ function selectFilterRow(tab, id) {
     state.selectedFilterRow[tab] = id;
   }
   if (tab === 'rules') renderRoutingFilterRules();
-  else if (tab === 'select-rules') renderRoutingFilterSelectRules();
   else if (tab === 'community-lists') renderRoutingFilterCommunityLists();
   else if (tab === 'as-lists') renderRoutingFilterASLists();
 }
@@ -12213,7 +12210,6 @@ function selectFilterRow(tab, id) {
 function openRoutingFilterNewModal() {
   const tab = state.activeRoutingFilterTab || 'rules';
   if (tab === 'rules') openAddRoutingFilterModal();
-  else if (tab === 'select-rules') openAddSelectRuleModal();
   else if (tab === 'community-lists') openAddCommunityListModal();
   else if (tab === 'as-lists') openAddASListModal();
 }
@@ -12226,7 +12222,6 @@ function toggleSelectedRoutingFilter(disabled) {
     return;
   }
   if (tab === 'rules') toggleRoutingFilterState(id, disabled);
-  else if (tab === 'select-rules') toggleSelectRuleState(id, disabled);
   else if (tab === 'community-lists') toggleCommunityListState(id, disabled);
   else if (tab === 'as-lists') toggleASListState(id, disabled);
 }
@@ -12239,7 +12234,6 @@ function removeSelectedRoutingFilter() {
     return;
   }
   if (tab === 'rules') deleteRoutingFilter(id);
-  else if (tab === 'select-rules') deleteSelectRule(id);
   else if (tab === 'community-lists') deleteCommunityList(id);
   else if (tab === 'as-lists') deleteASList(id);
 }
@@ -12262,7 +12256,6 @@ function sortFilterTable(tab, col) {
 
   let list = [];
   if (tab === 'rules') list = state.routingFilters || [];
-  else if (tab === 'select-rules') list = state.routingSelectRules || [];
   else if (tab === 'community-lists') list = state.routingCommunityLists || [];
   else if (tab === 'as-lists') list = state.routingASLists || [];
 
@@ -12286,7 +12279,6 @@ function sortFilterTable(tab, col) {
   });
 
   if (tab === 'rules') renderRoutingFilterRules();
-  else if (tab === 'select-rules') renderRoutingFilterSelectRules();
   else if (tab === 'community-lists') renderRoutingFilterCommunityLists();
   else if (tab === 'as-lists') renderRoutingFilterASLists();
 }
@@ -12316,7 +12308,6 @@ function renderRoutingFilters() {
   }
 
   renderRoutingFilterRules();
-  renderRoutingFilterSelectRules();
   renderRoutingFilterCommunityLists();
   renderRoutingFilterASLists();
 }
@@ -12415,73 +12406,7 @@ function renderRoutingFilterRules() {
 }
 
 // -------------------------------------------------------------
-// TAB 2: SELECT RULES (/routing filter select-rule)
-// -------------------------------------------------------------
-
-function renderRoutingFilterSelectRules() {
-  const tbody = document.getElementById('routing-filter-select-rules-tbody');
-  if (!tbody) return;
-  const empty = document.getElementById('rf-select-rules-empty');
-  const statusCount = document.getElementById('rf-status-count');
-
-  let list = (state.routingSelectRules || []).slice();
-
-  if (state.filterChainSelected && state.filterChainSelected !== 'all') {
-    list = list.filter(r => r.chain === state.filterChainSelected);
-  }
-
-  const q = state.rfSearchQuery;
-  if (q) {
-    list = list.filter(r =>
-      (r.chain && r.chain.toLowerCase().includes(q)) ||
-      (r.rule && r.rule.toLowerCase().includes(q)) ||
-      (r.comment && r.comment.toLowerCase().includes(q))
-    );
-  }
-
-  if (statusCount && state.activeRoutingFilterTab === 'select-rules') {
-    statusCount.innerText = `${list.length} item${list.length === 1 ? '' : 's'}`;
-  }
-
-  if (list.length === 0) {
-    tbody.innerHTML = '';
-    if (empty) empty.classList.remove('hidden');
-    return;
-  }
-  if (empty) empty.classList.add('hidden');
-
-  tbody.innerHTML = list.map(r => {
-    const isSelected = state.selectedFilterRow?.['select-rules'] === r.id;
-    const flagA = !r.disabled ? '<span class="text-cyan-400 font-bold">A</span>' : '';
-    const flagX = r.disabled ? '<span class="text-rose-400 font-bold">X</span>' : '';
-
-    return `
-      <tr onclick="selectFilterRow('select-rules', '${r.id}')" class="cursor-pointer transition select-none ${isSelected ? 'bg-emerald-950/40 ring-1 ring-emerald-500/40' : 'hover:bg-slate-800/40'}">
-        <td class="py-2 px-2 text-center tracking-wider">${flagA}${flagX}</td>
-        <td class="py-2 px-2 font-mono text-slate-400 font-bold">${r.rule_order || 10}</td>
-        <td class="py-2 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 px-3 break-all max-w-xl font-mono text-emerald-400">${escapeHtml(r.rule || '')}</td>
-        <td class="py-2 px-3 text-slate-400 font-sans truncate max-w-xs">${escapeHtml(r.comment || '')}</td>
-        <td class="py-2 px-3 text-right space-x-1 whitespace-nowrap font-sans" onclick="event.stopPropagation()">
-          <button onclick="toggleSelectRuleState('${r.id}', ${!r.disabled})" class="px-2 py-0.5 rounded text-[10px] ${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="openEditSelectRuleModal('${r.id}')" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 hover:bg-slate-700 text-emerald-300 border border-slate-700">
-            Edit
-          </button>
-          <button onclick="deleteSelectRule('${r.id}')" class="px-2 py-0.5 rounded text-[10px] bg-rose-950/60 hover:bg-rose-900 text-rose-300 border border-rose-900/50">
-            ✕
-          </button>
-        </td>
-      </tr>
-    `;
-  }).join('');
-}
-
-// -------------------------------------------------------------
-// TAB 3: COMMUNITY LISTS (/routing filter community-list)
+// TAB 2: COMMUNITY LISTS (/routing filter community-list)
 // -------------------------------------------------------------
 
 function renderRoutingFilterCommunityLists() {
@@ -12681,7 +12606,9 @@ function generateScriptFromWizard() {
   const actions = [];
   if (lp > 0) actions.push(`set bgp-local-pref ${lp};`);
   if (prep > 0) actions.push(`set bgp-path-prepend ${prep};`);
-  actions.push(`${act};`);
+  if (act && act !== 'passthrough') {
+    actions.push(`${act};`);
+  }
 
   let code = '';
   if (conds.length > 0) {
@@ -12878,103 +12805,6 @@ async function toggleRoutingFilterState(id, disabled) {
   }
 }
 
-// -------------------------------------------------------------
-// MODAL CRUD: SELECT RULES (/routing filter select-rule)
-// -------------------------------------------------------------
-
-function openAddSelectRuleModal() {
-  document.getElementById('modal-rf-sr-id').value = '';
-  document.getElementById('modal-rf-sr-title').innerText = 'Add Routing Select Rule';
-  document.getElementById('modal-rf-sr-chain').value = state.filterChainSelected && state.filterChainSelected !== 'all' ? state.filterChainSelected : 'bgp-out';
-  document.getElementById('modal-rf-sr-order').value = ((state.routingSelectRules || []).length + 1) * 10;
-  document.getElementById('modal-rf-sr-rule').value = 'if (protocol bgp && dst in 10.0.0.0/8) { accept; }';
-  document.getElementById('modal-rf-sr-comment').value = '';
-  document.getElementById('modal-rf-sr-disabled').checked = false;
-  document.getElementById('modal-rf-select-rule').classList.remove('hidden');
-}
-
-function openEditSelectRuleModal(id) {
-  const r = (state.routingSelectRules || []).find(item => item.id === id);
-  if (!r) return;
-  document.getElementById('modal-rf-sr-id').value = r.id;
-  document.getElementById('modal-rf-sr-title').innerText = 'Edit Routing Select Rule';
-  document.getElementById('modal-rf-sr-chain').value = r.chain || '';
-  document.getElementById('modal-rf-sr-order').value = r.rule_order || 10;
-  document.getElementById('modal-rf-sr-rule').value = r.rule || '';
-  document.getElementById('modal-rf-sr-comment').value = r.comment || '';
-  document.getElementById('modal-rf-sr-disabled').checked = !!r.disabled;
-  document.getElementById('modal-rf-select-rule').classList.remove('hidden');
-}
-
-function closeSelectRuleModal() {
-  document.getElementById('modal-rf-select-rule').classList.add('hidden');
-}
-
-async function submitSelectRule(e) {
-  e.preventDefault();
-  const payload = {
-    id: document.getElementById('modal-rf-sr-id').value,
-    chain: document.getElementById('modal-rf-sr-chain').value.trim(),
-    rule_order: parseInt(document.getElementById('modal-rf-sr-order').value, 10) || 10,
-    rule: document.getElementById('modal-rf-sr-rule').value.trim(),
-    comment: document.getElementById('modal-rf-sr-comment').value.trim(),
-    disabled: document.getElementById('modal-rf-sr-disabled').checked
-  };
-
-  try {
-    const res = await fetch(`${API_BASE}/routing/filters/select-rules`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify(payload)
-    });
-    if (res.ok) {
-      closeSelectRuleModal();
-      showToast('Success', 'Select rule saved', 'success');
-      loadDynamicRouting();
-    } else {
-      const err = await res.json();
-      showToast('Error', err.error || 'Failed to save select rule', 'error');
-    }
-  } catch (err) {
-    showToast('Error', err.message, 'error');
-  }
-}
-
-async function deleteSelectRule(id) {
-  if (!confirm('Are you sure you want to delete this select rule?')) return;
-  try {
-    const res = await fetch(`${API_BASE}/routing/filters/select-rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
-    if (res.ok) {
-      showToast('Deleted', 'Select rule removed', 'info');
-      loadDynamicRouting();
-    } else {
-      const err = await res.json();
-      showToast('Error', err.error || 'Failed to delete select rule', 'error');
-    }
-  } catch (err) {
-    showToast('Error', err.message, 'error');
-  }
-}
-
-async function toggleSelectRuleState(id, disabled) {
-  try {
-    const res = await fetch(`${API_BASE}/routing/filters/select-rules/${encodeURIComponent(id)}/state`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ disabled })
-    });
-    if (res.ok) {
-      showToast('Status Updated', `Select rule ${disabled ? 'disabled' : 'enabled'}`, 'info');
-      loadDynamicRouting();
-    } else {
-      const err = await res.json();
-      showToast('Error', err.error || 'Failed to update select rule state', 'error');
-    }
-  } catch (err) {
-    showToast('Error', err.message, 'error');
-  }
-}
-
 // -------------------------------------------------------------
 // MODAL CRUD: COMMUNITY LISTS (/routing filter community-list)
 // -------------------------------------------------------------

+ 2 - 75
internal/web/static/index.html

@@ -3319,9 +3319,6 @@
                   <button id="tab-rf-rules" onclick="switchRoutingFilterTab('rules')" class="px-3 py-1 text-xs font-semibold rounded bg-emerald-600/20 text-emerald-300 border border-emerald-500/30 transition">
                     Rules
                   </button>
-                  <button id="tab-rf-select-rules" onclick="switchRoutingFilterTab('select-rules')" class="px-3 py-1 text-xs font-semibold rounded text-slate-400 hover:text-white hover:bg-slate-800 transition">
-                    Select Rules
-                  </button>
                   <button id="tab-rf-community-lists" onclick="switchRoutingFilterTab('community-lists')" class="px-3 py-1 text-xs font-semibold rounded text-slate-400 hover:text-white hover:bg-slate-800 transition">
                     Community Lists
                   </button>
@@ -3393,27 +3390,7 @@
               </div>
             </div>
 
-            <!-- TAB 2: SELECT RULES -->
-            <div id="tab-content-rf-select-rules" class="hidden overflow-x-auto">
-              <table class="w-full text-left text-xs font-mono">
-                <thead class="text-slate-400 bg-slate-950/70 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans select-none">
-                  <tr>
-                    <th class="py-2 px-2 text-center w-10 cursor-pointer hover:text-white" onclick="sortFilterTable('select-rules', 'flags')">Flags</th>
-                    <th class="py-2 px-2 w-12 cursor-pointer hover:text-white" onclick="sortFilterTable('select-rules', 'rule_order')"># <span id="sort-icon-select-rules-rule_order">▲</span></th>
-                    <th class="py-2 px-3 cursor-pointer hover:text-white" onclick="sortFilterTable('select-rules', 'chain')">Chain <span id="sort-icon-select-rules-chain"></span></th>
-                    <th class="py-2 px-3 cursor-pointer hover:text-white" onclick="sortFilterTable('select-rules', 'rule')">Rule Syntax <span id="sort-icon-select-rules-rule"></span></th>
-                    <th class="py-2 px-3 cursor-pointer hover:text-white" onclick="sortFilterTable('select-rules', 'comment')">Comment <span id="sort-icon-select-rules-comment"></span></th>
-                    <th class="py-2 px-3 text-right font-sans">Actions</th>
-                  </tr>
-                </thead>
-                <tbody id="routing-filter-select-rules-tbody" class="divide-y divide-slate-800/60"></tbody>
-              </table>
-              <div id="rf-select-rules-empty" class="hidden p-8 text-center text-slate-500 text-xs font-sans">
-                No select rules configured. Click "+ New" to add a route selection rule.
-              </div>
-            </div>
-
-            <!-- TAB 3: COMMUNITY LISTS -->
+            <!-- TAB 2: COMMUNITY LISTS -->
             <div id="tab-content-rf-community-lists" class="hidden overflow-x-auto">
               <table class="w-full text-left text-xs font-mono">
                 <thead class="text-slate-400 bg-slate-950/70 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans select-none">
@@ -10156,6 +10133,7 @@
                 <select id="modal-rf-action" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-white font-mono">
                   <option value="accept">accept</option>
                   <option value="reject">reject</option>
+                  <option value="passthrough">passthrough</option>
                 </select>
               </div>
               <div>
@@ -10198,57 +10176,6 @@
     </div>
   </div>
 
-  <!-- ================= MODAL: SELECT RULE ================= -->
-  <div id="modal-rf-select-rule" 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">
-      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/60">
-        <div>
-          <h3 id="modal-rf-sr-title" class="text-sm font-bold text-white">Routing Select Rule</h3>
-          <p class="text-[11px] text-slate-400">Controls route preference and candidate selection</p>
-        </div>
-        <button onclick="closeSelectRuleModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
-      </div>
-
-      <form onsubmit="submitSelectRule(event)" class="p-6 space-y-4 text-xs">
-        <input type="hidden" id="modal-rf-sr-id">
-
-        <div class="grid grid-cols-3 gap-3">
-          <div class="col-span-2">
-            <label class="block font-semibold text-slate-300 mb-1">Chain *</label>
-            <input type="text" id="modal-rf-sr-chain" required 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">
-          </div>
-          <div>
-            <label class="block font-semibold text-slate-300 mb-1">Order *</label>
-            <input type="number" id="modal-rf-sr-order" value="10" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
-          </div>
-        </div>
-
-        <div>
-          <label class="block font-semibold text-slate-300 mb-1">Selection Rule Script *</label>
-          <textarea id="modal-rf-sr-rule" rows="3" required placeholder="if (protocol bgp &amp;&amp; dst in 10.0.0.0/8) { accept; }" class="w-full bg-slate-950 border border-slate-700 rounded-lg p-3 text-emerald-400 font-mono text-xs focus:outline-none"></textarea>
-        </div>
-
-        <div class="grid grid-cols-3 gap-3">
-          <div class="col-span-2">
-            <label class="block font-semibold text-slate-300 mb-1">Comment</label>
-            <input type="text" id="modal-rf-sr-comment" placeholder="Select rule note" 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-sr-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-2">
-          <button type="button" onclick="closeSelectRuleModal()" class="px-4 py-1.5 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
-          <button type="submit" class="px-5 py-1.5 rounded-lg font-bold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20">OK</button>
-        </div>
-      </form>
-    </div>
-  </div>
-
   <!-- ================= MODAL: COMMUNITY LIST ================= -->
   <div id="modal-rf-community-list" 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">