Routing filters are where RouterOS 7 stopped pretending. The v6 filter system was a pile of flat match-action rules that couldn’t express “and”, couldn’t express “or”, and turned any real BGP policy into twenty rules of copy-paste. V7 threw it away and replaced it with an actual little language: if (condition && condition) { action; action } else { action }. It reads like code because it is code.
That’s the good news. The bad news is a behavioral trap that has put garbage into more than one routing table: a filter chain with rules rejects what it doesn’t match — but an empty or missing chain filters nothing at all. Get that wrong on an eBGP session and you’ve accepted a full table, or announced one. We’ll come back to it, because it deserves more than a footnote.
The previous article left BGP sessions running with filter hooks (input.filter, output.filter-chain) pointing at chains that didn’t exist. This one builds them.
The Rule Language
Filters live in /routing filter rule. Each rule belongs to a chain and carries a rule string:
/routing filter rule add chain=demo \ rule="if (dst in 10.0.0.0/8 && dst-len <= 24) { accept } else { reject }"The shape is always if ( matchers ) { actions } else { actions }, with &&, ||, and not as boolean operators. Rules in a chain run top-down; accept and reject are terminal — they stop chain processing for that prefix. Non-terminal actions (the set/append family) modify the route and let evaluation continue, so a rule can adjust attributes and leave the accept/reject decision to a later rule.
Chains don’t exist as separate objects — a chain exists because rules reference it. That detail matters for the trap later.
Prefix Matching: dst and dst-len
The two matchers you’ll use most:
dst— the destination prefix.dst == 192.0.2.0/24matches exactly that prefix;dst in 192.0.2.0/24matches it and anything inside it.dst-len— the prefix length, with comparisons and ranges:dst-len == 24,dst-len <= 24,dst-len in 8-24.
Together they replace the prefix / prefix-length pair from v6 and the ge/le modifiers you know from Cisco or VyOS prefix-lists:
# v6: prefix=172.16.0.0/16 prefix-length=24# v7 equivalent:/routing filter rule add chain=demo \ rule="if (dst in 172.16.0.0/16 && dst-len == 24) { accept }"
# Accept anything in our aggregate down to /24, reject more-specifics/routing filter rule add chain=demo \ rule="if (dst in 203.0.113.0/24 && dst-len in 24-24) { accept }"Other matchers you’ll reach for: protocol (bgp, ospf, static, connected, …) — essential in redistribution chains; afi (ipv4, ipv6, vpnv4, …); rpki (valid, invalid, unknown) if you’ve wired an RTR session; and the BGP attribute family: bgp-as-path, bgp-communities, bgp-path-len, bgp-med, bgp-local-pref.
Actions: Deciding and Rewriting
Terminal actions are accept and reject. Everything else mutates the route:
# Prefer this peer's routes/routing filter rule add chain=peer-in \ rule="if (dst-len <= 24) { set bgp-local-pref 200; accept }"
# Traffic engineering outbound: prepend our AS three times/routing filter rule add chain=backup-out \ rule="if (dst in 203.0.113.0/24) { set bgp-path-prepend 3; accept }"
# Tag routes on ingress/routing filter rule add chain=cust-in \ rule="if (dst-len == 24) { append bgp-communities 65500:100; accept }"The attribute verbs: set (assign; also does arithmetic — set distance +1), append (add to a list attribute like communities), delete, and unset (e.g. unset bgp-med). Multiple actions chain with semicolons inside the braces. Note set bgp-path-prepend 3 takes a count — the stack prepends your own ASN; you don’t type the AS number three times as in v6.
Communities: Lists and Matching
One-off community matching goes straight in the rule with an operator — equal (exact set), includes (contains these), any (at least one of), subset:
/routing filter rule add chain=cust-in \ rule="if (bgp-communities includes 64600:666) { set bgp-local-pref 10; accept }"For anything you’ll match in more than one place, define a community list and reference it:
/routing filter community-list add list=blackhole communities=65500:666
/routing filter rule add chain=cust-in \ rule="if (bgp-communities equal-list blackhole) { reject }"Well-known communities have symbolic handling — the docs’ own example strips them with delete bgp-communities wk,other. And remember from the VyOS BGP article: communities are policy at scale. Tag on ingress (where the route came from), act on egress (who gets to see it) — the filters here are the enforcement points.
Chains, Jump, and Structure
jump and return turn chains into subroutines. The pattern I use everywhere: one shared bogon chain, called from every per-peer input chain.
# --- Shared chain: reject garbage, return otherwise ---/routing filter rule add chain=bogons rule="if (dst in 0.0.0.0/8) { reject }"/routing filter rule add chain=bogons rule="if (dst in 10.0.0.0/8) { reject }"/routing filter rule add chain=bogons rule="if (dst in 100.64.0.0/10) { reject }"/routing filter rule add chain=bogons rule="if (dst in 127.0.0.0/8) { reject }"/routing filter rule add chain=bogons rule="if (dst in 169.254.0.0/16) { reject }"/routing filter rule add chain=bogons rule="if (dst in 172.16.0.0/12) { reject }"/routing filter rule add chain=bogons rule="if (dst in 192.0.2.0/24) { reject }"/routing filter rule add chain=bogons rule="if (dst in 192.168.0.0/16) { reject }"/routing filter rule add chain=bogons rule="if (dst in 198.18.0.0/15) { reject }"/routing filter rule add chain=bogons rule="if (dst in 198.51.100.0/24) { reject }"/routing filter rule add chain=bogons rule="if (dst in 203.0.113.0/24) { reject }"/routing filter rule add chain=bogons rule="if (dst in 224.0.0.0/3) { reject }"/routing filter rule add chain=bogons rule="return"The trailing return is load-bearing: without it, a prefix that survives every bogon check hits the chain’s implicit end — and in a chain that has rules, unmatched means rejected. return hands the still-undecided prefix back to the calling chain.
Now the per-peer chains from the BGP article:
# --- Inbound from upstream ---/routing filter rule add chain=upstream-in rule="jump bogons"# never accept our own prefix from outside/routing filter rule add chain=upstream-in \ rule="if (dst in 203.0.113.0/24) { reject }"# no default unless we asked for one, nothing longer than /24/routing filter rule add chain=upstream-in rule="if (dst-len == 0) { reject }"/routing filter rule add chain=upstream-in rule="if (dst-len > 24) { reject }"/routing filter rule add chain=upstream-in \ rule="if (protocol bgp) { set bgp-local-pref 200; accept }"
# --- Outbound to upstream: our prefix and nothing else ---/routing filter rule add chain=upstream-out \ rule="if (dst == 203.0.113.0/24) { accept }"/routing filter rule add chain=upstream-out rule="reject"Attach them and the session from the previous article is finally production-worthy:
/routing bgp connection set upstream1 \ input.filter=upstream-in output.filter-chain=upstream-outThe same chains plug into OSPF (out-filter-chain= on the instance) — one language, every protocol. AS-path matching also exists (bgp-as-path with v7’s numeric-regex syntax, which is not v6-compatible); develop those interactively with the built-in tester /routing filter test-as-path-regexp before committing them to a chain.
The Trap: When “No Filter” Means “Accept Everything”
Now the behavior that earns this article its subtitle. Three states, three very different outcomes:
- No filter attached (
input.filterunset): no filtering happens. Every route is accepted. This is the default state of a new BGP connection. - Filter attached, chain has rules: unmatched prefixes hit the implicit end-of-chain default, which is reject. Explicit is still better — end every chain with
acceptorrejectso the policy is readable — but the safety net exists. - Filter attached, chain has no rules — because you typo’d the chain name, or attached the filter before creating the rules, or deleted the last rule during an edit: there is no chain, so there is no filtering, so everything is accepted. Exactly like state 1.
State 3 is the killer, because it looks like state 2 in the config. input.filter=upstream-in reads as “filtered” whether or not upstream-in contains a single rule. Chains aren’t objects, so nothing errors on a dangling reference. The failure modes write themselves: attach filters first and write rules second — the session accepts a full table in the gap; rename a chain and miss one connection — that peer is now unfiltered, silently.
Discipline that prevents it: create rules before attaching chains; grep for every chain name after renames (/routing filter rule print where chain=upstream-in); and after any filter change, verify the effect, not the config.
Verification
Filters act between the protocol and the routing table, so compare the two sides:
# Everything received, including what filters rejected/routing route print detail where bgp
# Only what survived filtering and became active/ip route print where bgp
# Count both — a suspiciously equal number means your input filter isn't attached/routing route print count-only where bgp/ip route print count-only where bgpFor outbound policy, /routing bgp advertisements print shows what’s actually leaving (with output.keep-sent-attributes=yes for the full post-hoc dump, as covered in the BGP article). And when a prefix isn’t doing what you expect, test the chain logic by temporarily adding a counting rule — a rule whose condition matches the prefix and whose action is just accept — and watching whether the route’s attributes change on the next refresh. Crude, effective.
Closing Thoughts
The v7 filter language is the best thing to happen to MikroTik routing policy:
if (matchers) { actions }— one syntax for BGP, OSPF, and redistribution.dst+dst-lenreplace prefix-list ge/le; ranges likedst-len in 8-24cover the rest.- Structure with chains: shared
bogonssubroutine,jump/return, per-peer chains. - End every chain explicitly. The implicit reject saves you only when the chain exists.
- The trap, once more: rules-then-attach, never attach-then-rules. An attached chain with zero rules filters zero routes.
Filtering philosophy hasn’t changed since the VyOS series: every session gets an input filter, an output filter, and a prefix limit, no exceptions. RouterOS 7 finally gives you a language worthy of writing them in.