Procházet zdrojové kódy

feat(routing): complete Routing Policy Filters support across all dynamic protocols (OSPF, RIP, BGP, Babel, EIGRP) (v1.8.26)

Proxmox Router před 1 dnem
rodič
revize
db025be664

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.25
+1.8.26

binární
bin/debtik


binární
bin/debtik-arm64


binární
bin/debtik-cli


binární
bin/debtik-cli-arm64


+ 182 - 6
internal/cli/executor.go

@@ -2472,6 +2472,8 @@ func (e *Executor) handleRoutingBGPInstance(parts []string, user *auth.PublicUse
 		out += fmt.Sprintf("                enabled: %t\n", inst.Enabled)
 		out += fmt.Sprintf("                     as: %d\n", inst.AS)
 		out += fmt.Sprintf("              router-id: %s\n", inst.RouterID)
+		out += fmt.Sprintf("              in-filter: %s\n", inst.InFilter)
+		out += fmt.Sprintf("             out-filter: %s\n", inst.OutFilter)
 		out += fmt.Sprintf("              hold-time: %d\n", inst.HoldTime)
 		out += fmt.Sprintf("         keepalive-time: %d\n", inst.KeepaliveTime)
 		out += fmt.Sprintf("  redistribute-connected: %t\n", inst.RedistributeConnected)
@@ -2500,6 +2502,12 @@ func (e *Executor) handleRoutingBGPInstance(parts []string, user *auth.PublicUse
 		if v, ok := params["router-id"]; ok {
 			inst.RouterID = strings.TrimSpace(v)
 		}
+		if v, ok := params["in-filter"]; ok {
+			inst.InFilter = strings.TrimSpace(v)
+		}
+		if v, ok := params["out-filter"]; ok {
+			inst.OutFilter = strings.TrimSpace(v)
+		}
 		if v, ok := params["enabled"]; ok {
 			inst.Enabled = parseCLIEnableDisable(v, inst.Enabled)
 		}
@@ -2788,7 +2796,7 @@ func (e *Executor) handleRoutingRIP(parts []string, user *auth.PublicUser) strin
 func (e *Executor) handleRIPInstance(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		instances := e.dynRoutingMgr.ListRIPInstances()
-		tbl := NewTable("#", "FLAGS", "NAME", "VERSION", "REDISTRIBUTE", "UPDATE-TIME", "STATUS")
+		tbl := NewTable("#", "FLAGS", "NAME", "VERSION", "IN-FILTER", "OUT-FILTER", "REDISTRIBUTE", "UPDATE-TIME", "STATUS")
 		for idx, inst := range instances {
 			st := "enabled"
 			flags := "I"
@@ -2819,7 +2827,15 @@ func (e *Executor) handleRIPInstance(parts []string, user *auth.PublicUser) stri
 			if len(redist) > 0 {
 				redistStr = strings.Join(redist, ",")
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, fmt.Sprintf("%d", inst.Version), redistStr, fmt.Sprintf("%ds", inst.UpdateTime), st)
+			inF := inst.InFilter
+			if inF == "" {
+				inF = "-"
+			}
+			outF := inst.OutFilter
+			if outF == "" {
+				outF = "-"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, fmt.Sprintf("%d", inst.Version), inF, outF, redistStr, fmt.Sprintf("%ds", inst.UpdateTime), st)
 		}
 		return tbl.String()
 	}
@@ -2850,6 +2866,8 @@ func (e *Executor) handleRIPInstance(parts []string, user *auth.PublicUser) stri
 		inst := dynamicrouting.RIPInstance{
 			Name:                  name,
 			Version:               ver,
+			InFilter:              params["in-filter"],
+			OutFilter:             params["out-filter"],
 			Enabled:               true,
 			Disabled:              false,
 			RedistributeConnected: parseCLIEnableDisable(params["redistribute-connected"], false),
@@ -2874,6 +2892,69 @@ func (e *Executor) handleRIPInstance(parts []string, user *auth.PublicUser) stri
 		}
 		return ColorGreen + fmt.Sprintf("RIP instance %s added.\n", name) + ColorReset
 
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		} else if v, ok := params["name"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing rip instance set <#|name|id> [in-filter=...] [out-filter=...] [default-metric=...]\n" + ColorReset
+		}
+		instances := e.dynRoutingMgr.ListRIPInstances()
+		var targetInst *dynamicrouting.RIPInstance
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(instances) {
+			targetInst = &instances[idx]
+		} else {
+			for i := range instances {
+				if strings.EqualFold(instances[i].Name, target) || instances[i].ID == target {
+					targetInst = &instances[i]
+					break
+				}
+			}
+		}
+		if targetInst == nil {
+			return ColorRed + "RIP instance not found: " + target + "\n" + ColorReset
+		}
+		if v, ok := params["in-filter"]; ok {
+			targetInst.InFilter = strings.TrimSpace(v)
+		}
+		if v, ok := params["out-filter"]; ok {
+			targetInst.OutFilter = strings.TrimSpace(v)
+		}
+		if v, ok := params["default-metric"]; ok {
+			fmt.Sscanf(v, "%d", &targetInst.DefaultMetric)
+		}
+		if v, ok := params["update-time"]; ok {
+			fmt.Sscanf(v, "%d", &targetInst.UpdateTime)
+		}
+		if v, ok := params["timeout-time"]; ok {
+			fmt.Sscanf(v, "%d", &targetInst.TimeoutTime)
+		}
+		if v, ok := params["redistribute-connected"]; ok {
+			targetInst.RedistributeConnected = parseCLIEnableDisable(v, targetInst.RedistributeConnected)
+		}
+		if v, ok := params["redistribute-static"]; ok {
+			targetInst.RedistributeStatic = parseCLIEnableDisable(v, targetInst.RedistributeStatic)
+		}
+		if v, ok := params["redistribute-bgp"]; ok {
+			targetInst.RedistributeBGP = parseCLIEnableDisable(v, targetInst.RedistributeBGP)
+		}
+		if v, ok := params["redistribute-ospf"]; ok {
+			targetInst.RedistributeOSPF = parseCLIEnableDisable(v, targetInst.RedistributeOSPF)
+		}
+		if v, ok := params["redistribute-default"]; ok {
+			targetInst.RedistributeDefault = parseCLIEnableDisable(v, targetInst.RedistributeDefault)
+		}
+		if _, err := e.dynRoutingMgr.SaveRIPInstance(*targetInst); err != nil {
+			return ColorRed + "Error updating RIP instance: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("RIP instance %s updated.\n", targetInst.Name) + ColorReset
+
 	case "remove", "rem", "delete":
 		pos, _ := parseArgs(parts[1:])
 		if len(pos) == 0 {
@@ -4096,7 +4177,7 @@ func (e *Executor) handleRoutingEIGRP(parts []string, user *auth.PublicUser) str
 func (e *Executor) handleEIGRPInstance(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		instances := e.dynRoutingMgr.ListEIGRPInstances()
-		tbl := NewTable("#", "FLAGS", "NAME", "AS", "ROUTER-ID", "REDISTRIBUTE", "VARIANCE", "STATUS")
+		tbl := NewTable("#", "FLAGS", "NAME", "AS", "ROUTER-ID", "IN-FILTER", "OUT-FILTER", "REDISTRIBUTE", "VARIANCE", "STATUS")
 		for idx, inst := range instances {
 			st := "enabled"
 			flags := "I"
@@ -4124,7 +4205,15 @@ func (e *Executor) handleEIGRPInstance(parts []string, user *auth.PublicUser) st
 			if len(redist) > 0 {
 				redistStr = strings.Join(redist, ",")
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, fmt.Sprintf("%d", inst.AS), inst.RouterID, redistStr, fmt.Sprintf("%d", inst.Variance), st)
+			inF := inst.InFilter
+			if inF == "" {
+				inF = "-"
+			}
+			outF := inst.OutFilter
+			if outF == "" {
+				outF = "-"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, fmt.Sprintf("%d", inst.AS), inst.RouterID, inF, outF, redistStr, fmt.Sprintf("%d", inst.Variance), st)
 		}
 		return tbl.String()
 	}
@@ -4153,6 +4242,8 @@ func (e *Executor) handleEIGRPInstance(parts []string, user *auth.PublicUser) st
 			Name:                  name,
 			AS:                    as,
 			RouterID:              params["router-id"],
+			InFilter:              params["in-filter"],
+			OutFilter:             params["out-filter"],
 			Variance:              variance,
 			MaximumPaths:          maxPaths,
 			Enabled:               true,
@@ -4171,6 +4262,75 @@ func (e *Executor) handleEIGRPInstance(parts []string, user *auth.PublicUser) st
 		}
 		return ColorGreen + fmt.Sprintf("EIGRP instance %s (AS %d) added.\n", name, as) + ColorReset
 
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		target := ""
+		if len(pos) > 0 {
+			target = pos[0]
+		} else if v, ok := params["numbers"]; ok {
+			target = v
+		} else if v, ok := params["name"]; ok {
+			target = v
+		}
+		if target == "" {
+			return ColorRed + "Usage: /routing eigrp instance set <#|name|id> [in-filter=...] [out-filter=...] [as=...] [router-id=...] [variance=...]\n" + ColorReset
+		}
+		instances := e.dynRoutingMgr.ListEIGRPInstances()
+		var targetInst *dynamicrouting.EIGRPInstance
+		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(instances) {
+			targetInst = &instances[idx]
+		} else {
+			for i := range instances {
+				if strings.EqualFold(instances[i].Name, target) || instances[i].ID == target {
+					targetInst = &instances[i]
+					break
+				}
+			}
+		}
+		if targetInst == nil {
+			return ColorRed + "EIGRP instance not found: " + target + "\n" + ColorReset
+		}
+		if v, ok := params["in-filter"]; ok {
+			targetInst.InFilter = strings.TrimSpace(v)
+		}
+		if v, ok := params["out-filter"]; ok {
+			targetInst.OutFilter = strings.TrimSpace(v)
+		}
+		if v, ok := params["as"]; ok {
+			fmt.Sscanf(v, "%d", &targetInst.AS)
+		}
+		if v, ok := params["router-id"]; ok {
+			targetInst.RouterID = strings.TrimSpace(v)
+		}
+		if v, ok := params["variance"]; ok {
+			fmt.Sscanf(v, "%d", &targetInst.Variance)
+		}
+		if v, ok := params["maximum-paths"]; ok {
+			fmt.Sscanf(v, "%d", &targetInst.MaximumPaths)
+		}
+		if v, ok := params["default-metric"]; ok {
+			fmt.Sscanf(v, "%d", &targetInst.DefaultMetric)
+		}
+		if v, ok := params["redistribute-connected"]; ok {
+			targetInst.RedistributeConnected = parseCLIEnableDisable(v, targetInst.RedistributeConnected)
+		}
+		if v, ok := params["redistribute-static"]; ok {
+			targetInst.RedistributeStatic = parseCLIEnableDisable(v, targetInst.RedistributeStatic)
+		}
+		if v, ok := params["redistribute-bgp"]; ok {
+			targetInst.RedistributeBGP = parseCLIEnableDisable(v, targetInst.RedistributeBGP)
+		}
+		if v, ok := params["redistribute-ospf"]; ok {
+			targetInst.RedistributeOSPF = parseCLIEnableDisable(v, targetInst.RedistributeOSPF)
+		}
+		if v, ok := params["comment"]; ok {
+			targetInst.Comment = v
+		}
+		if _, err := e.dynRoutingMgr.SaveEIGRPInstance(*targetInst); err != nil {
+			return ColorRed + "Error updating EIGRP instance: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("EIGRP instance %s updated.\n", targetInst.Name) + ColorReset
+
 	case "remove", "rem", "delete":
 		pos, _ := parseArgs(parts[1:])
 		if len(pos) == 0 {
@@ -4491,7 +4651,7 @@ func (e *Executor) handleRoutingOSPF(parts []string, user *auth.PublicUser) stri
 func (e *Executor) handleOSPFInstance(parts []string, user *auth.PublicUser) string {
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		instances := e.dynRoutingMgr.ListOSPFInstances()
-		tbl := NewTable("#", "FLAGS", "NAME", "ROUTER-ID", "VERSION", "REDISTRIBUTE", "DEFAULT-METRIC", "STATUS")
+		tbl := NewTable("#", "FLAGS", "NAME", "ROUTER-ID", "VERSION", "IN-FILTER", "OUT-FILTER", "REDISTRIBUTE", "DEFAULT-METRIC", "STATUS")
 		for idx, inst := range instances {
 			st := "enabled"
 			flags := "I"
@@ -4527,7 +4687,15 @@ func (e *Executor) handleOSPFInstance(parts []string, user *auth.PublicUser) str
 			if inst.DefaultMetric > 0 {
 				metricStr = fmt.Sprintf("%d", inst.DefaultMetric)
 			}
-			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, inst.RouterID, fmt.Sprintf("%d", inst.Version), redistStr, metricStr, st)
+			inF := inst.InFilter
+			if inF == "" {
+				inF = "-"
+			}
+			outF := inst.OutFilter
+			if outF == "" {
+				outF = "-"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), flags, inst.Name, inst.RouterID, fmt.Sprintf("%d", inst.Version), inF, outF, redistStr, metricStr, st)
 		}
 		return tbl.String()
 	}
@@ -4585,6 +4753,8 @@ func (e *Executor) handleOSPFInstance(parts []string, user *auth.PublicUser) str
 			VRF:                   vrf,
 			RouterID:              routerID,
 			RoutingTable:          rt,
+			InFilter:              params["in-filter"],
+			OutFilter:             params["out-filter"],
 			Enabled:               !disabled,
 			Disabled:              disabled,
 			RedistributeConnected: redistConn,
@@ -4634,6 +4804,12 @@ func (e *Executor) handleOSPFInstance(parts []string, user *auth.PublicUser) str
 		if v, ok := params["router-id"]; ok {
 			targetInst.RouterID = v
 		}
+		if v, ok := params["in-filter"]; ok {
+			targetInst.InFilter = strings.TrimSpace(v)
+		}
+		if v, ok := params["out-filter"]; ok {
+			targetInst.OutFilter = strings.TrimSpace(v)
+		}
 		if v, ok := params["redistribute-connected"]; ok {
 			targetInst.RedistributeConnected = parseCLIEnableDisable(v, false)
 		}

+ 173 - 8
internal/dynamicrouting/manager.go

@@ -538,15 +538,23 @@ func (m *Manager) generateBirdConfig() string {
 			if peer.LocalRole == "ibgp" || (localAS > 0 && peer.RemoteAS > 0 && localAS == peer.RemoteAS) {
 				defaultAD = 200
 			}
-			if peer.InFilter != "" && len(chainRules[peer.InFilter]) > 0 {
-				sb.WriteString(fmt.Sprintf("        import filter %s;\n", sanitizeIdentifier("filter", peer.InFilter)))
+			inFilter := peer.InFilter
+			if inFilter == "" {
+				inFilter = m.config.BGPInstance.InFilter
+			}
+			outFilter := peer.OutFilter
+			if outFilter == "" {
+				outFilter = m.config.BGPInstance.OutFilter
+			}
+			if inFilter != "" && len(chainRules[inFilter]) > 0 {
+				sb.WriteString(fmt.Sprintf("        import filter %s;\n", sanitizeIdentifier("filter", inFilter)))
 			} else {
 				sb.WriteString(fmt.Sprintf("        import filter { krt_metric = %d; accept; };\n", defaultAD))
 			}
 
 			// Export Filter
-			if peer.OutFilter != "" && len(chainRules[peer.OutFilter]) > 0 {
-				fnName := fmt.Sprintf("fn_%s", sanitizeIdentifier("filter", peer.OutFilter))
+			if outFilter != "" && len(chainRules[outFilter]) > 0 {
+				fnName := fmt.Sprintf("fn_%s", sanitizeIdentifier("filter", outFilter))
 				sb.WriteString("        export filter {\n")
 				sb.WriteString(fmt.Sprintf("            %s();\n", fnName))
 				if peerNetsProto != "" {
@@ -672,7 +680,11 @@ func (m *Manager) generateBirdConfig() string {
 		if inst.RoutingTable != "" && inst.RoutingTable != "main" {
 			sb.WriteString(fmt.Sprintf("        table %s;\n", inst.RoutingTable))
 		}
-		sb.WriteString("        import filter { krt_metric = 110; accept; };\n")
+		if inst.InFilter != "" && len(chainRules[inst.InFilter]) > 0 {
+			sb.WriteString(fmt.Sprintf("        import filter %s;\n", sanitizeIdentifier("filter", inst.InFilter)))
+		} else {
+			sb.WriteString("        import filter { krt_metric = 110; accept; };\n")
+		}
 
 		var ospfExports []string
 		if inst.RedistributeStatic {
@@ -686,7 +698,45 @@ func (m *Manager) generateBirdConfig() string {
 		}
 
 		hasDefaultOrig := origDefault == "always" || origDefault == "if-installed"
-		if len(ospfExports) > 0 || hasDefaultOrig {
+		if inst.OutFilter != "" && len(chainRules[inst.OutFilter]) > 0 {
+			fnName := fmt.Sprintf("fn_%s", sanitizeIdentifier("filter", inst.OutFilter))
+			sb.WriteString("        export filter {\n")
+			if len(ospfExports) > 0 || hasDefaultOrig {
+				var conds []string
+				if origDefault == "always" {
+					conds = append(conds, fmt.Sprintf("proto = \"%s\"", defProtoName))
+				}
+				if hasDefaultOrig {
+					conds = append(conds, "(net = 0.0.0.0/0 && source != RTS_OSPF)")
+				}
+				if len(ospfExports) > 0 {
+					conds = append(conds, fmt.Sprintf("source ~ [ %s ]", strings.Join(ospfExports, ", ")))
+				}
+				sb.WriteString(fmt.Sprintf("            if !( %s ) then reject;\n", strings.Join(conds, " || ")))
+			}
+			sb.WriteString(fmt.Sprintf("            %s();\n", fnName))
+			if origDefault == "always" {
+				sb.WriteString(fmt.Sprintf("            if proto = \"%s\" || (net = 0.0.0.0/0 && source != RTS_OSPF) then {\n", defProtoName))
+				sb.WriteString(fmt.Sprintf("                ospf_metric1 = %d;\n", defaultMetric))
+				sb.WriteString("                accept;\n")
+				sb.WriteString("            }\n")
+			} else if origDefault == "if-installed" {
+				sb.WriteString("            if net = 0.0.0.0/0 && source != RTS_OSPF then {\n")
+				sb.WriteString(fmt.Sprintf("                ospf_metric1 = %d;\n", defaultMetric))
+				sb.WriteString("                accept;\n")
+				sb.WriteString("            }\n")
+			}
+			if len(ospfExports) > 0 {
+				sb.WriteString(fmt.Sprintf("            if source ~ [ %s ] then {\n", strings.Join(ospfExports, ", ")))
+				if inst.DefaultMetric > 0 {
+					sb.WriteString(fmt.Sprintf("                ospf_metric1 = %d;\n", inst.DefaultMetric))
+				}
+				sb.WriteString("                accept;\n")
+				sb.WriteString("            }\n")
+			}
+			sb.WriteString("            reject;\n")
+			sb.WriteString("        };\n")
+		} else if len(ospfExports) > 0 || hasDefaultOrig {
 			sb.WriteString("        export filter {\n")
 			if origDefault == "always" {
 				sb.WriteString(fmt.Sprintf("            if proto = \"%s\" || (net = 0.0.0.0/0 && source != RTS_OSPF) then {\n", defProtoName))
@@ -1020,7 +1070,11 @@ func (m *Manager) generateBirdConfig() string {
 		if inst.RoutingTable != "" && inst.RoutingTable != "main" {
 			sb.WriteString(fmt.Sprintf("        table %s;\n", inst.RoutingTable))
 		}
-		sb.WriteString("        import filter { krt_metric = 120; accept; };\n")
+		if inst.InFilter != "" && len(chainRules[inst.InFilter]) > 0 {
+			sb.WriteString(fmt.Sprintf("        import filter %s;\n", sanitizeIdentifier("filter", inst.InFilter)))
+		} else {
+			sb.WriteString("        import filter { krt_metric = 120; accept; };\n")
+		}
 
 		var ripExports []string
 		if inst.RedistributeStatic {
@@ -1036,7 +1090,29 @@ func (m *Manager) generateBirdConfig() string {
 			ripExports = append(ripExports, "RTS_BGP")
 		}
 
-		if len(ripExports) > 0 || inst.RedistributeDefault {
+		if inst.OutFilter != "" && len(chainRules[inst.OutFilter]) > 0 {
+			fnName := fmt.Sprintf("fn_%s", sanitizeIdentifier("filter", inst.OutFilter))
+			sb.WriteString("        export filter {\n")
+			if len(ripExports) > 0 || inst.RedistributeDefault {
+				var conds []string
+				if inst.RedistributeDefault {
+					conds = append(conds, "net = 0.0.0.0/0", "net = ::/0")
+				}
+				if len(ripExports) > 0 {
+					conds = append(conds, fmt.Sprintf("source ~ [ %s ]", strings.Join(ripExports, ", ")))
+				}
+				sb.WriteString(fmt.Sprintf("            if !( %s ) then reject;\n", strings.Join(conds, " || ")))
+			}
+			sb.WriteString(fmt.Sprintf("            %s();\n", fnName))
+			if inst.RedistributeDefault {
+				sb.WriteString("            if net = 0.0.0.0/0 || net = ::/0 then accept;\n")
+			}
+			if len(ripExports) > 0 {
+				sb.WriteString(fmt.Sprintf("            if source ~ [ %s ] then accept;\n", strings.Join(ripExports, ", ")))
+			}
+			sb.WriteString("            reject;\n")
+			sb.WriteString("        };\n")
+		} else if len(ripExports) > 0 || inst.RedistributeDefault {
 			sb.WriteString("        export filter {\n")
 			if inst.RedistributeDefault {
 				sb.WriteString("            if net = 0.0.0.0/0 || net = ::/0 then accept;\n")
@@ -2636,6 +2712,15 @@ func translateRos7RuleToBird(r *RoutingFilterRule) string {
 		s = reIfThen.ReplaceAllString(s, ") then {")
 		s = strings.ReplaceAll(s, ") then then {", ") then {")
 
+		// If rule was a bare condition (no if, no accept/reject/action statement), wrap it with Action if specified
+		if !strings.HasPrefix(strings.TrimSpace(s), "if ") && !strings.Contains(s, "then") && !strings.Contains(s, "accept") && !strings.Contains(s, "reject") {
+			act := strings.TrimSpace(r.Action)
+			if act == "" {
+				act = "accept"
+			}
+			s = fmt.Sprintf("if %s then { %s; }", s, act)
+		}
+
 		return s
 	}
 
@@ -4099,6 +4184,44 @@ func (m *Manager) GetSummary() DynamicRoutingSummary {
 // EIGRP Management (FRRouting eigrpd backend)
 // ==========================================
 
+func generateFRRPrefixList(name string, rules []RoutingFilterRule) string {
+	var sb strings.Builder
+	seq := 10
+	reDstRange := regexp.MustCompile(`dst\s+in\s+([0-9\.\/]+)\{([0-9]+),([0-9]+)\}`)
+	reDstIn := regexp.MustCompile(`dst\s+in\s+([0-9\.\/]+)`)
+	reDstEq := regexp.MustCompile(`\bdst\s*={1,2}\s*([0-9\.\/]+)`)
+
+	for _, r := range rules {
+		if r.Disabled {
+			continue
+		}
+		action := "permit"
+		if strings.EqualFold(r.Action, "reject") || strings.EqualFold(r.Action, "discard") || strings.EqualFold(r.Action, "deny") || strings.Contains(r.Rule, "reject") || strings.Contains(r.Rule, "discard") {
+			action = "deny"
+		}
+		prefix := r.Prefix
+		if prefix == "" {
+			if m := reDstRange.FindStringSubmatch(r.Rule); len(m) == 4 {
+				prefix = fmt.Sprintf("%s ge %s le %s", m[1], m[2], m[3])
+			} else if m := reDstIn.FindStringSubmatch(r.Rule); len(m) == 2 {
+				prefix = fmt.Sprintf("%s le 32", m[1])
+			} else if m := reDstEq.FindStringSubmatch(r.Rule); len(m) == 2 {
+				prefix = m[1]
+			}
+		} else {
+			if r.PrefixLen != "" {
+				prefix = fmt.Sprintf("%s %s", prefix, r.PrefixLen)
+			}
+		}
+		if prefix != "" {
+			sb.WriteString(fmt.Sprintf("ip prefix-list %s seq %d %s %s\n", name, seq, action, prefix))
+			seq += 10
+		}
+	}
+	sb.WriteString(fmt.Sprintf("ip prefix-list %s seq 65535 deny 0.0.0.0/0 le 32\n", name))
+	return sb.String()
+}
+
 func (m *Manager) generateFRREigrpConfig() string {
 	var sb strings.Builder
 	sb.WriteString("frr version 10.3\n")
@@ -4120,6 +4243,40 @@ func (m *Manager) generateFRREigrpConfig() string {
 		return sb.String()
 	}
 
+	chainRules := make(map[string][]RoutingFilterRule)
+	for _, rule := range m.config.RoutingFilters {
+		if rule.Disabled {
+			continue
+		}
+		chain := strings.TrimSpace(rule.Chain)
+		if chain == "" {
+			continue
+		}
+		chainRules[chain] = append(chainRules[chain], rule)
+	}
+
+	generatedPLs := make(map[string]bool)
+	for _, inst := range m.config.EIGRPInstances {
+		if inst.Disabled {
+			continue
+		}
+		if inst.InFilter != "" && len(chainRules[inst.InFilter]) > 0 {
+			plName := sanitizeIdentifier("pl_in", inst.InFilter)
+			if !generatedPLs[plName] {
+				sb.WriteString(generateFRRPrefixList(plName, chainRules[inst.InFilter]))
+				generatedPLs[plName] = true
+			}
+		}
+		if inst.OutFilter != "" && len(chainRules[inst.OutFilter]) > 0 {
+			plName := sanitizeIdentifier("pl_out", inst.OutFilter)
+			if !generatedPLs[plName] {
+				sb.WriteString(generateFRRPrefixList(plName, chainRules[inst.OutFilter]))
+				generatedPLs[plName] = true
+			}
+		}
+	}
+	sb.WriteString("!\n")
+
 	// Interface configurations
 	for _, iface := range m.config.EIGRPInterfaces {
 		if iface.Disabled || iface.Interface == "" || iface.Interface == "*" {
@@ -4201,6 +4358,14 @@ func (m *Manager) generateFRREigrpConfig() string {
 		if inst.K1 != 0 || inst.K2 != 0 || inst.K3 != 0 || inst.K4 != 0 || inst.K5 != 0 {
 			sb.WriteString(fmt.Sprintf(" metric weights %d %d %d %d %d\n", inst.K1, inst.K2, inst.K3, inst.K4, inst.K5))
 		}
+		if inst.InFilter != "" && len(chainRules[inst.InFilter]) > 0 {
+			plName := sanitizeIdentifier("pl_in", inst.InFilter)
+			sb.WriteString(fmt.Sprintf(" distribute-list prefix %s in\n", plName))
+		}
+		if inst.OutFilter != "" && len(chainRules[inst.OutFilter]) > 0 {
+			plName := sanitizeIdentifier("pl_out", inst.OutFilter)
+			sb.WriteString(fmt.Sprintf(" distribute-list prefix %s out\n", plName))
+		}
 		sb.WriteString("!\n")
 	}
 

+ 173 - 0
internal/dynamicrouting/protocols_test.go

@@ -455,3 +455,176 @@ func TestRIPAndOSPFInstanceJSONUnmarshal(t *testing.T) {
 		}
 	}
 }
+
+func TestDynamicRoutingFiltersAllProtocols(t *testing.T) {
+	mgr := &Manager{
+		config: DynamicConfig{
+			RoutingFilters: []RoutingFilterRule{
+				{
+					ID:       "rule-in-1",
+					Chain:    "filter-in",
+					Rule:     "dst in 10.0.0.0/8",
+					Action:   "accept",
+					Disabled: false,
+				},
+				{
+					ID:       "rule-out-1",
+					Chain:    "filter-out",
+					Rule:     "dst in 192.168.0.0/16",
+					Action:   "accept",
+					Disabled: false,
+				},
+			},
+			BGPInstance: BGPInstance{
+				Enabled:   true,
+				AS:        65001,
+				RouterID:  "10.42.62.254",
+				InFilter:  "filter-in",
+				OutFilter: "filter-out",
+			},
+			BGPPeers: []BGPPeer{
+				{
+					ID:            "peer-1",
+					Name:          "DefaultPeer",
+					RemoteAddress: "10.42.36.253",
+					RemoteAS:      65002,
+				},
+			},
+			OSPFInstances: []OSPFInstance{
+				{
+					ID:                 "ospf-1",
+					Name:               "Default",
+					Version:            2,
+					RouterID:           "10.42.62.254",
+					Enabled:            true,
+					InFilter:           "filter-in",
+					OutFilter:          "filter-out",
+					RedistributeStatic: true,
+				},
+			},
+			OSPFAreas: []OSPFArea{
+				{
+					ID:       "area-0",
+					Name:     "backbone",
+					AreaID:   "0.0.0.0",
+					AreaType: "default",
+				},
+			},
+			OSPFInterfaces: []OSPFInterface{
+				{
+					ID:         "ospf-if-1",
+					Interfaces: "eth0",
+					Area:       "backbone",
+				},
+			},
+			RIPInstances: []RIPInstance{
+				{
+					ID:                    "rip-1",
+					Name:                  "default",
+					Version:               2,
+					Enabled:               true,
+					InFilter:              "filter-in",
+					OutFilter:             "filter-out",
+					RedistributeConnected: true,
+				},
+			},
+			RIPInterfaces: []RIPInterface{
+				{
+					ID:        "rip-if-1",
+					Instance:  "default",
+					Interface: "eth0",
+				},
+			},
+			BabelInstances: []BabelInstance{
+				{
+					ID:                    "babel-1",
+					Name:                  "default",
+					RouterID:              "10.42.62.254",
+					Enabled:               true,
+					InFilter:              "filter-in",
+					OutFilter:             "filter-out",
+					RedistributeConnected: true,
+				},
+			},
+			BabelInterfaces: []BabelInterface{
+				{
+					ID:        "babel-if-1",
+					Instance:  "default",
+					Interface: "eth0",
+				},
+			},
+			EIGRPInstances: []EIGRPInstance{
+				{
+					ID:                    "eigrp-1",
+					Name:                  "default",
+					AS:                    100,
+					RouterID:              "10.42.62.254",
+					Enabled:               true,
+					InFilter:              "filter-in",
+					OutFilter:             "filter-out",
+					RedistributeConnected: true,
+				},
+			},
+		},
+	}
+
+	// 1. Test BIRD Config Generation
+	birdCfg := mgr.generateBirdConfig()
+
+	// Verify BGP uses global fallback filter
+	if !strings.Contains(birdCfg, "import filter filter_filter_in;") {
+		t.Errorf("BGP config missing import filter filter_filter_in;\nConfig:\n%s", birdCfg)
+	}
+	if !strings.Contains(birdCfg, "fn_filter_filter_out();") {
+		t.Errorf("BGP config missing fn_filter_filter_out();\nConfig:\n%s", birdCfg)
+	}
+
+	// Verify OSPF in_filter & out_filter
+	if !strings.Contains(birdCfg, "protocol ospf v2 ospf_Default") || !strings.Contains(birdCfg, "import filter filter_filter_in;") {
+		t.Errorf("OSPF config missing import filter filter_filter_in;\nConfig:\n%s", birdCfg)
+	}
+
+	// Verify RIP in_filter & out_filter
+	if !strings.Contains(birdCfg, "protocol rip rip_default") || !strings.Contains(birdCfg, "fn_filter_filter_out();") {
+		t.Errorf("RIP config missing fn_filter_filter_out();\nConfig:\n%s", birdCfg)
+	}
+
+	// Verify Babel in_filter & out_filter
+	if !strings.Contains(birdCfg, "protocol babel babel_default") || !strings.Contains(birdCfg, "fn_filter_filter_out();") {
+		t.Errorf("Babel config missing fn_filter_filter_out();\nConfig:\n%s", birdCfg)
+	}
+
+	// Verify BIRD syntax with bird -p if installed
+	if _, err := exec.LookPath("bird"); err == nil {
+		tmpFile, err := os.CreateTemp("", "bird_filters_val_*.conf")
+		if err != nil {
+			t.Fatalf("Failed to create temp file: %v", err)
+		}
+		defer os.Remove(tmpFile.Name())
+		if _, err := tmpFile.WriteString(birdCfg); err != nil {
+			t.Fatalf("Failed to write temp file: %v", err)
+		}
+		tmpFile.Close()
+
+		cmd := exec.Command("bird", "-p", "-c", tmpFile.Name())
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			t.Fatalf("bird -p validation failed on all protocol filters config: %s\nConfig:\n%s", string(out), birdCfg)
+		}
+	}
+
+	// 2. Test FRR EIGRP Config Generation
+	frrCfg := mgr.generateFRREigrpConfig()
+	if !strings.Contains(frrCfg, "ip prefix-list pl_in_filter_in seq 10 permit 10.0.0.0/8") {
+		t.Errorf("FRR config missing in-filter prefix list;\nConfig:\n%s", frrCfg)
+	}
+	if !strings.Contains(frrCfg, "ip prefix-list pl_out_filter_out seq 10 permit 192.168.0.0/16") {
+		t.Errorf("FRR config missing out-filter prefix list;\nConfig:\n%s", frrCfg)
+	}
+	if !strings.Contains(frrCfg, "distribute-list prefix pl_in_filter_in in") {
+		t.Errorf("FRR config missing distribute-list prefix in;\nConfig:\n%s", frrCfg)
+	}
+	if !strings.Contains(frrCfg, "distribute-list prefix pl_out_filter_out out") {
+		t.Errorf("FRR config missing distribute-list prefix out;\nConfig:\n%s", frrCfg)
+	}
+}

+ 2 - 0
internal/dynamicrouting/types.go

@@ -13,6 +13,8 @@ type BGPInstance struct {
 	RouterID              string `json:"router_id"`
 	HoldTime              int    `json:"hold_time"`      // Default 180
 	KeepaliveTime         int    `json:"keepalive_time"` // Default 60
+	InFilter              string `json:"in_filter,omitempty"`
+	OutFilter             string `json:"out_filter,omitempty"`
 	RedistributeConnected bool   `json:"redistribute_connected"`
 	RedistributeStatic    bool   `json:"redistribute_static"`
 	RedistributeOSPF      bool   `json:"redistribute_ospf"`

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

@@ -10315,6 +10315,10 @@ function renderOspfInstances() {
         ? '<span class="px-1.5 py-0.5 rounded text-[10px] bg-sky-500/20 text-sky-300 font-bold border border-sky-500/30">if-installed</span>'
         : '<span class="text-slate-500 text-[10px]">never</span>');
 
+    const filterBadges = [];
+    if (inst.in_filter) filterBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-blue-950 text-blue-300 border border-blue-800/50" title="In Filter">IN: ${escapeHtml(inst.in_filter)}</span>`);
+    if (inst.out_filter) filterBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-950 text-purple-300 border border-purple-800/50" title="Out Filter">OUT: ${escapeHtml(inst.out_filter)}</span>`);
+
     return `
       <tr class="${selClass} cursor-pointer transition select-none" onclick="selectOspfRow('instances', '${inst.id}')" ondblclick="openEditOspfInstanceModal('${inst.id}')">
         <td class="py-1.5 px-3 w-8 text-center text-slate-500">${idx}</td>
@@ -10322,6 +10326,7 @@ function renderOspfInstances() {
         <td class="py-1.5 px-3 text-slate-400 italic">${escapeHtml(inst.comment || '')}</td>
         <td class="py-1.5 px-3 font-semibold text-white flex items-center gap-1.5">
           <span class="text-sky-400">↳</span> <span>${escapeHtml(inst.name || 'Default')}</span>
+          ${filterBadges.join(' ')}
         </td>
         <td class="py-1.5 px-3">${inst.version || 2}</td>
         <td class="py-1.5 px-3 font-mono text-slate-300">${escapeHtml(inst.vrf || 'main')}</td>
@@ -10594,6 +10599,8 @@ function openBgpInstanceModal() {
   document.getElementById('modal-bgp-redist-conn').checked = !!inst.redistribute_connected;
   document.getElementById('modal-bgp-redist-static').checked = !!inst.redistribute_static;
   document.getElementById('modal-bgp-redist-ospf').checked = !!inst.redistribute_ospf;
+  document.getElementById('modal-bgp-in-filter').value = inst.in_filter || '';
+  document.getElementById('modal-bgp-out-filter').value = inst.out_filter || '';
   document.getElementById('bgp-instance-modal').classList.remove('hidden');
 }
 
@@ -10609,6 +10616,8 @@ async function submitBgpInstance(e) {
     router_id: document.getElementById('modal-bgp-router-id').value.trim(),
     keepalive_time: parseInt(document.getElementById('modal-bgp-keepalive').value, 10),
     hold_time: parseInt(document.getElementById('modal-bgp-holdtime').value, 10),
+    in_filter: (document.getElementById('modal-bgp-in-filter')?.value || '').trim(),
+    out_filter: (document.getElementById('modal-bgp-out-filter')?.value || '').trim(),
     redistribute_connected: document.getElementById('modal-bgp-redist-conn').checked,
     redistribute_static: document.getElementById('modal-bgp-redist-static').checked,
     redistribute_ospf: document.getElementById('modal-bgp-redist-ospf').checked
@@ -11075,6 +11084,8 @@ function openOspfInstanceModal(id = null) {
   document.getElementById('modal-ospf-inst-redist-default').checked = isDefaultRedist;
   document.getElementById('modal-ospf-inst-originate-default').value = origMode;
   document.getElementById('modal-ospf-inst-default-metric').value = inst.default_metric || '';
+  document.getElementById('modal-ospf-inst-in-filter').value = inst.in_filter || '';
+  document.getElementById('modal-ospf-inst-out-filter').value = inst.out_filter || '';
 
   const btnRem = document.getElementById('btn-ospf-inst-modal-remove');
   if (btnRem) btnRem.style.display = isEdit ? 'block' : 'none';
@@ -11124,7 +11135,9 @@ async function submitOspfInstance(e) {
     redistribute_bgp: document.getElementById('modal-ospf-inst-redist-bgp').checked,
     redistribute_default: document.getElementById('modal-ospf-inst-redist-default').checked,
     originate_default: document.getElementById('modal-ospf-inst-originate-default').value,
-    default_metric: parseInt(document.getElementById('modal-ospf-inst-default-metric').value, 10) || 0
+    default_metric: parseInt(document.getElementById('modal-ospf-inst-default-metric').value, 10) || 0,
+    in_filter: (document.getElementById('modal-ospf-inst-in-filter')?.value || '').trim(),
+    out_filter: (document.getElementById('modal-ospf-inst-out-filter')?.value || '').trim()
   };
 
   try {
@@ -19738,6 +19751,9 @@ function renderRipInstances() {
     if (inst.redistribute_bgp) redist.push('bgp');
     if (inst.redistribute_ospf) redist.push('ospf');
     if (inst.redistribute_default) redist.push('default');
+    const filterBadges = [];
+    if (inst.in_filter) filterBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-blue-950 text-blue-300 border border-blue-800/50" title="In Filter">IN: ${escapeHtml(inst.in_filter)}</span>`);
+    if (inst.out_filter) filterBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-950 text-purple-300 border border-purple-800/50" title="Out Filter">OUT: ${escapeHtml(inst.out_filter)}</span>`);
     return `
       <tr class="${selClass} cursor-pointer transition select-none" onclick="selectRipRow('instances', '${inst.id}')" ondblclick="openEditRipInstanceModal('${inst.id}')">
         <td class="py-1.5 px-3 w-8 text-center text-slate-500">${idx}</td>
@@ -19745,6 +19761,7 @@ function renderRipInstances() {
         <td class="py-1.5 px-3 text-slate-400 italic">${escapeHtml(inst.comment || '')}</td>
         <td class="py-1.5 px-3 font-semibold text-white flex items-center gap-1.5">
           <span class="text-rose-400">↳</span> <span>${escapeHtml(inst.name || 'default')}</span>
+          ${filterBadges.join(' ')}
         </td>
         <td class="py-1.5 px-3 uppercase text-rose-300 font-bold">${inst.version == 1 || inst.version === 'ripng' ? 'RIPng' : 'RIPv2'}</td>
         <td class="py-1.5 px-3 text-slate-400">${inst.update_time || 30}s</td>
@@ -19831,6 +19848,8 @@ function openRipInstanceModal() {
   document.getElementById('modal-rip-instance-garbage').value = '120';
   document.getElementById('modal-rip-instance-metric').value = '1';
   document.getElementById('modal-rip-instance-comment').value = '';
+  document.getElementById('modal-rip-instance-in-filter').value = '';
+  document.getElementById('modal-rip-instance-out-filter').value = '';
   document.getElementById('modal-rip-instance-enabled').checked = true;
   document.getElementById('modal-rip-instance-redist-conn').checked = false;
   document.getElementById('modal-rip-instance-redist-static').checked = false;
@@ -19852,6 +19871,8 @@ function openEditRipInstanceModal(id) {
   document.getElementById('modal-rip-instance-garbage').value = inst.garbage_time || 120;
   document.getElementById('modal-rip-instance-metric').value = inst.default_metric || 1;
   document.getElementById('modal-rip-instance-comment').value = inst.comment || '';
+  document.getElementById('modal-rip-instance-in-filter').value = inst.in_filter || '';
+  document.getElementById('modal-rip-instance-out-filter').value = inst.out_filter || '';
   document.getElementById('modal-rip-instance-enabled').checked = !inst.disabled;
   document.getElementById('modal-rip-instance-redist-conn').checked = !!inst.redistribute_connected;
   document.getElementById('modal-rip-instance-redist-static').checked = !!inst.redistribute_static;
@@ -19877,6 +19898,8 @@ async function submitRipInstance(e) {
     garbage_time: parseInt(document.getElementById('modal-rip-instance-garbage').value, 10) || 120,
     default_metric: parseInt(document.getElementById('modal-rip-instance-metric').value, 10) || 1,
     comment: document.getElementById('modal-rip-instance-comment').value.trim(),
+    in_filter: (document.getElementById('modal-rip-instance-in-filter')?.value || '').trim(),
+    out_filter: (document.getElementById('modal-rip-instance-out-filter')?.value || '').trim(),
     disabled: !document.getElementById('modal-rip-instance-enabled').checked,
     redistribute_connected: document.getElementById('modal-rip-instance-redist-conn').checked,
     redistribute_static: document.getElementById('modal-rip-instance-redist-static').checked,
@@ -21239,6 +21262,9 @@ function renderEigrpInstances() {
     if (inst.redistribute_static) redist.push('static');
     if (inst.redistribute_ospf) redist.push('ospf');
     if (inst.redistribute_bgp) redist.push('bgp');
+    const filterBadges = [];
+    if (inst.in_filter) filterBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-blue-950 text-blue-300 border border-blue-800/50" title="In Filter">IN: ${escapeHtml(inst.in_filter)}</span>`);
+    if (inst.out_filter) filterBadges.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-950 text-purple-300 border border-purple-800/50" title="Out Filter">OUT: ${escapeHtml(inst.out_filter)}</span>`);
     return `
       <tr class="${selClass} cursor-pointer transition select-none" onclick="selectEigrpRow('instances', '${inst.id}')" ondblclick="openEditEigrpInstanceModal('${inst.id}')">
         <td class="py-1.5 px-3 w-8 text-center text-slate-500">${idx}</td>
@@ -21246,6 +21272,7 @@ function renderEigrpInstances() {
         <td class="py-1.5 px-3 text-slate-400 italic">${escapeHtml(inst.comment || '')}</td>
         <td class="py-1.5 px-3 font-semibold text-white flex items-center gap-1.5">
           <span class="text-purple-400">⚡</span> <span>${escapeHtml(inst.name || 'default')}</span>
+          ${filterBadges.join(' ')}
         </td>
         <td class="py-1.5 px-3 font-mono text-purple-300 font-bold">${inst.as || 100}</td>
         <td class="py-1.5 px-3 font-mono text-cyan-300">${escapeHtml(inst.router_id || 'auto')}</td>
@@ -21264,6 +21291,8 @@ function openEigrpInstanceModal() {
   document.getElementById('modal-eigrp-instance-as').value = '100';
   document.getElementById('modal-eigrp-instance-routerid').value = '';
   document.getElementById('modal-eigrp-instance-comment').value = '';
+  document.getElementById('modal-eigrp-instance-in-filter').value = '';
+  document.getElementById('modal-eigrp-instance-out-filter').value = '';
   document.getElementById('modal-eigrp-instance-variance').value = '1';
   document.getElementById('modal-eigrp-instance-maxpaths').value = '4';
   document.getElementById('modal-eigrp-instance-metric').value = '10000';
@@ -21284,6 +21313,8 @@ function openEditEigrpInstanceModal(id) {
   document.getElementById('modal-eigrp-instance-as').value = inst.as || 100;
   document.getElementById('modal-eigrp-instance-routerid').value = inst.router_id || '';
   document.getElementById('modal-eigrp-instance-comment').value = inst.comment || '';
+  document.getElementById('modal-eigrp-instance-in-filter').value = inst.in_filter || '';
+  document.getElementById('modal-eigrp-instance-out-filter').value = inst.out_filter || '';
   document.getElementById('modal-eigrp-instance-variance').value = inst.variance || 1;
   document.getElementById('modal-eigrp-instance-maxpaths').value = inst.max_paths || 4;
   document.getElementById('modal-eigrp-instance-metric').value = inst.default_metric || 10000;
@@ -21307,6 +21338,8 @@ async function submitEigrpInstance(e) {
     as: parseInt(document.getElementById('modal-eigrp-instance-as').value, 10) || 100,
     router_id: document.getElementById('modal-eigrp-instance-routerid').value.trim(),
     comment: document.getElementById('modal-eigrp-instance-comment').value.trim(),
+    in_filter: (document.getElementById('modal-eigrp-instance-in-filter')?.value || '').trim(),
+    out_filter: (document.getElementById('modal-eigrp-instance-out-filter')?.value || '').trim(),
     variance: parseInt(document.getElementById('modal-eigrp-instance-variance').value, 10) || 1,
     max_paths: parseInt(document.getElementById('modal-eigrp-instance-maxpaths').value, 10) || 4,
     default_metric: parseInt(document.getElementById('modal-eigrp-instance-metric').value, 10) || 10000,

+ 53 - 0
internal/web/static/index.html

@@ -11068,6 +11068,19 @@
           </div>
         </div>
 
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Default In Filter</label>
+            <input type="text" list="filter-chains-list" id="modal-bgp-in-filter" placeholder="e.g. Internal, 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">Global import filter for peers without custom In Filter</p>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Default Out Filter</label>
+            <input type="text" list="filter-chains-list" id="modal-bgp-out-filter" placeholder="e.g. Internal, 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">Global export filter for peers without custom Out Filter</p>
+          </div>
+        </div>
+
         <div class="border-t border-slate-800 pt-3">
           <label class="block font-semibold text-slate-300 mb-2">Redistribute Routes into BGP</label>
           <div class="space-y-2">
@@ -11466,6 +11479,22 @@
               </div>
             </div>
 
+            <div class="grid grid-cols-3 items-center gap-3">
+              <label class="text-slate-400 font-medium">In Filter</label>
+              <div class="col-span-2">
+                <input type="text" list="filter-chains-list" id="modal-ospf-inst-in-filter" placeholder="e.g. Internal, ospf-in" 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">Filter chain for routes imported from OSPF</p>
+              </div>
+            </div>
+
+            <div class="grid grid-cols-3 items-center gap-3">
+              <label class="text-slate-400 font-medium">Out Filter</label>
+              <div class="col-span-2">
+                <input type="text" list="filter-chains-list" id="modal-ospf-inst-out-filter" placeholder="e.g. Internal, ospf-out" 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">Filter chain for external routes redistributed into OSPF</p>
+              </div>
+            </div>
+
             <div class="grid grid-cols-3 items-start gap-3 pt-2 border-t border-slate-800">
               <label class="text-slate-400 font-medium pt-1">Redistribute</label>
               <div class="col-span-2 space-y-2">
@@ -12233,6 +12262,18 @@
             <input type="text" id="modal-rip-instance-comment" placeholder="Optional notes" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
           </div>
         </div>
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">In Filter</label>
+            <input type="text" list="filter-chains-list" id="modal-rip-instance-in-filter" placeholder="e.g. Internal, rip-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 routes received from RIP neighbors</p>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Out Filter</label>
+            <input type="text" list="filter-chains-list" id="modal-rip-instance-out-filter" placeholder="e.g. Internal, rip-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 routes advertised to RIP neighbors</p>
+          </div>
+        </div>
         <div class="border-t border-slate-800 pt-3">
           <label class="block font-semibold text-slate-300 mb-2">Route Redistribution</label>
           <div class="grid grid-cols-3 gap-2 text-slate-300">
@@ -12712,6 +12753,18 @@
             <input type="number" id="modal-eigrp-instance-metric" value="10000" min="1" max="4294967295" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
           </div>
         </div>
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">In Filter</label>
+            <input type="text" list="filter-chains-list" id="modal-eigrp-instance-in-filter" placeholder="e.g. Internal, eigrp-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 routes received from EIGRP neighbors</p>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Out Filter</label>
+            <input type="text" list="filter-chains-list" id="modal-eigrp-instance-out-filter" placeholder="e.g. Internal, eigrp-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 routes advertised to EIGRP neighbors</p>
+          </div>
+        </div>
         <div class="border-t border-slate-800 pt-3">
           <label class="block font-semibold text-slate-300 mb-2">Route Redistribution</label>
           <div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-slate-300">