Berik Ashimov // senior.it.engineer
Download CV
POSTS / MIKROTIK

Routing Filters in RouterOS 7: A Real Language, With One Nasty Trap

// series · MikroTik RouterOS 7 Guide part 14 of 35
  1. ... 11 earlier
  2. OSPF on RouterOS 7: Templates, Not Network Statements
  3. BGP on RouterOS 7: The Peer Is Dead, Long Live the Connection
  4. Routing Filters in RouterOS 7: A Real Language, With One Nasty Trap
  5. VRFs on RouterOS 7: Real Separation, Not Firewall Hope
  6. MPLS and LDP on RouterOS 7: Labels on a Budget
  7. ... 19 later
view the full series →

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:

Terminal window
/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/24 matches exactly that prefix; dst in 192.0.2.0/24 matches 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:

Terminal window
# 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:

Terminal window
# 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:

Terminal window
/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:

Terminal window
/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.

Terminal window
# --- 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:

Terminal window
# --- 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:

Terminal window
/routing bgp connection set upstream1 \
input.filter=upstream-in output.filter-chain=upstream-out

The 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:

  1. No filter attached (input.filter unset): no filtering happens. Every route is accepted. This is the default state of a new BGP connection.
  2. 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 accept or reject so the policy is readable — but the safety net exists.
  3. 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:

Terminal window
# 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 bgp

For 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:

  1. if (matchers) { actions } — one syntax for BGP, OSPF, and redistribution.
  2. dst + dst-len replace prefix-list ge/le; ranges like dst-len in 8-24 cover the rest.
  3. Structure with chains: shared bogons subroutine, jump/return, per-peer chains.
  4. End every chain explicitly. The implicit reject saves you only when the chain exists.
  5. 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.

// share