Architecture
cs-routeros-bouncer acts as a bridge between CrowdSec’s threat intelligence and MikroTik’s firewall.
Overview
Section titled “Overview”The two branches on the right of that diagram are not decoration. /metrics is the Prometheus endpoint the shipped dashboard reads, and /health is the endpoint a container health check calls; both are served by the same HTTP server, which starts even when metrics are disabled.


Reading the diagrams
Section titled “Reading the diagrams”Every diagram on this site draws the same distinction between what the bouncer writes and what it reads, because it is the one that matters when you are working out what the bouncer does to your router:
- A solid edge is a write. Router state changes: an address-list entry appears or disappears, a firewall rule is created, moved or removed.
- A dashed edge is a read, a test, or a discard. Nothing on the router changes: decisions stream in from the LAPI, an address list is listed, drift is measured, a decision is dropped.
Three channels carry that distinction, so a diagram still reads in greyscale, with any form of colour vision deficiency, and on paper: the line pattern, then colour, then a legend drawn from those same two edges inside every flowchart. The sequence diagram is the exception: there a solid arrow is a request and a dashed one the reply, which is Mermaid’s own convention and not the read/write distinction, so labelling it with this legend would misread it. The convention is written down in docs/src/styles/diagram.css; the colours come from docs/src/styles/theme.css at build time, so diagrams follow the palette instead of pinning their own.
Deep-dive topics
Section titled “Deep-dive topics”Components
Section titled “Components”The bouncer is composed of several internal packages:
| Package | Responsibility |
|---|---|
cmd/cs-routeros-bouncer | CLI entrypoint, subcommand routing |
internal/config | Configuration loading, validation, environment variable binding |
internal/crowdsec | CrowdSec LAPI streaming client |
internal/routeros | RouterOS API client (addresses, firewall rules) |
internal/manager | Central orchestrator — ties everything together |
internal/metrics | Prometheus metrics and health endpoint |
Data flow
Section titled “Data flow”Shutdown and cleanup
Section titled “Shutdown and cleanup”Stopping the bouncer removes its firewall rules and leaves the blocked addresses where they are. That asymmetry is deliberate: rules are cheap to recreate and confusing to find abandoned on a router, while the address-list entries are what actually carries the protection, and dropping tens of thousands of them on every restart would open a gap and pay for it again on the way back up.
Removal is driven by the ids this run recorded when it created or adopted each rule, and the comment is parsed back to find which menu the rule lives in. A comment the parser does not recognise — after a comment_prefix change mid-run, say — is logged and skipped, which is exactly the case the next startup sweep is for: it searches by the fixed @cs-routeros-bouncer signature, so it finds bouncer rules whatever prefix wrote them. The same sweep is what cleans up after a crash, where Shutdown never ran at all.
Closing the RouterOS connection also marks the bouncer disconnected, so /health reports the true state for whatever is left of the shutdown window.
Design principles
Section titled “Design principles”Comment-based identification
Section titled “Comment-based identification”All resources created by the bouncer in MikroTik are tagged with a structured comment:
{comment_prefix}:{type}-{chain}-{direction}-{protocol} @cs-routeros-bouncerExamples:
crowdsec-bouncer:filter-input-input-v4 @cs-routeros-bouncercrowdsec-bouncer:raw-prerouting-input-v6 @cs-routeros-bouncer
This allows the bouncer to precisely identify and manage its own resources without affecting user-created rules.
Cache-first optimistic add
Section titled “Cache-first optimistic add”When processing a ban decision, the bouncer first checks its in-memory address cache:
- If the address is already in cache, the RouterOS API call is skipped entirely.
- If the address is not in cache, try to add it directly (~1–3 ms).
- If RouterOS returns
already have such entry, treat it as a device-level conflict, keep the connection open, find the existing entry, and update its timeout/comment.
This is significantly faster than the “check-first” approach (~400 ms per IP), which would require listing all entries first.
Connection pool
Section titled “Connection pool”The bouncer maintains a configurable pool of persistent RouterOS API connections. The pool serves removals only: during reconciliation, stale entries are deleted concurrently across the pool using the generic ParallelExec helper. In the RB5009 CAPI test, removing ~26,800 CAPI-only entries took ~77 s of RouterOS removal work. If the pool cannot be opened, removals fall back to the main connection.
Script-based bulk add
Section titled “Script-based bulk add”For initial reconciliation, the bouncer generates RouterOS scripts that add entries in chunks of 100 IPs per script. Each entry uses :do { ... } on-error={} to gracefully skip duplicates. Bulk adds do not use the connection pool: they run sequentially over the main connection. This approach is still ~97× faster than individual sequential API calls for large lists — measured on an RB5009UG+S+ (RouterOS 7.22.1), where ~28,700 CAPI entries took ~35–36 s of RouterOS bulk-add work; see Benchmarking for the methodology.
In-memory address cache
Section titled “In-memory address cache”An in-memory map (map[string]struct{} with sync.RWMutex) tracks all addresses currently on the router. This provides:
- O(1) unban lookups: When an IP is unbanned, the cache is checked first. If the IP is not in the cache (e.g., already expired on the router), the API call is skipped entirely.
- O(1) duplicate-ban fast path: Repeated ban events for addresses already known to be on the router return immediately without creating RouterOS management/API churn.
- Pre-filtering during startup: Deletes received during initial decision collection are pre-filtered against incoming bans to avoid unnecessary work.
Single named address lists
Section titled “Single named address lists”Unlike some bouncers that create timestamped lists, cs-routeros-bouncer uses a single named address list per protocol:
crowdsec-bannedfor IPv4crowdsec6-bannedfor IPv6
Firewall rules reference these lists by name, which is more efficient and avoids the duplication problem.
Diff-based reconciliation
Section titled “Diff-based reconciliation”On startup, and then periodically at crowdsec.reconciliation_interval, the bouncer performs a diff between CrowdSec’s active decisions and MikroTik’s current address list state:
- Fetch all active decisions from CrowdSec
- Fetch all entries in the address list from MikroTik
- Compare the two sets
- Add missing entries (in CrowdSec but not in MikroTik)
- Remove stale entries (in MikroTik but not in CrowdSec)
This keeps membership synchronized regardless of how the bouncer was stopped, what happened while it was offline, or whether RouterOS-side entries expired while CrowdSec still considered them active.