github jmrplens/cs-routeros-bouncer v1.5.0

one hour ago

This release is mostly the result of pointing instrumentation at a production router and believing what it said. Two of the changes below started as performance work and ended as correctness fixes, which is the honest summary of the cycle: measuring the thing found bugs that reading the code had not.

Nothing here requires a configuration change. Upgrading is a binary swap.


Correctness

A $ in a comment or list name was silently deleted on its way to the router. The bulk-add script escaped backslashes and quotes but not $, and RouterOS expands $name inside double quotes at parse time. A name the script never declares expands to nothing, so the text is destroyed rather than mangled — cs$bouncer|crowdsec|sshd-bf arrives as cs|crowdsec|sshd-bf.

Cosmetic in a scenario name. Not cosmetic in firewall.comment_prefix: every entry then fails the prefix filter in ListAddresses, never joins the reconcile diff's present set, and is re-added on every cycle — an address list growing without bound, with nothing in any log to explain it. All four interpolated values now go through one escaper.

The startup snapshot silently dropped decisions against a capping LAPI. The paging loop advanced its offset by the page size it requested rather than the number it received, and stopped as soon as a page came back short. Measured against a live Local API returning 900 for a 1,000-request: the loop collected 250 of 900 and reported success. 650 decisions never enforced.

It now advances by what it received and stops only on an empty page — the condition that actually means "no more".

A negative RouterOS timeout is refused at startup. Neither mikrotik.connection_timeout nor mikrotik.command_timeout had any validation, and each reaches code that treats a negative value as something other than an error. A negative connection_timeout makes net.Dialer compute a deadline in the past, so every dial fails instantly with a bare i/o timeout that implicates the network. A negative command_timeout falls through the > 0 guard that arms the deadline, silently restoring the unbounded client the timeout exists to prevent — the dangerous one, because nothing fails.


Performance

All figures from a production RB5009 (RouterOS 7.24.1) holding ~22,000 IPv4 entries plus ~525 IPv6.

Change Before After
Individual unban 1,155 ms 11.8 ms
Startup decision snapshot 24 requests / 346 ms 3 requests / 166 ms
Steady reconciliation 2,153 ms 1,861 ms
Parse allocations per entry ~101 ~25
Wire bytes per IPv4 pass 4.07 MB 3.14 MB

The unban was spending a full address-list traversal rediscovering a RouterOS .id that the reconcile pass had already fetched and thrown away. The cache now carries it. Safety was verified on the device rather than assumed: ids are allocated monotonically and never reused (60 add/remove cycles, 60 distinct increasing ids), a vanished id returns no such item which already maps to ErrNotFound, and exposure to wraparound is bounded by the cache's one-interval lifetime.

The reconcile pass stopped asking for two properties nothing reads. list is whatever the query just pinned with ?list=, and timeout is never read off an entry that came from the router. The two prints are 98.5% of the cycle, and every one of 48 paired comparisons favoured the new binary.

Half the cycle is a floor this does not reach: a count-only print of the same list still costs 1.18 s. There is no cheap way to ask a RouterOS device whether anything changed.


The protocol layer is ours now

Upstream go-routeros/routeros has had no maintainer activity for eighteen months and no maintained alternative exists, so v3.0.1 lives in internal/rosapi (MIT, provenance recorded).

Pruning it was not cosmetic. The async/listen mode — which nothing in this repository ever called — kept the reader dispatching every Read to a fresh goroutine so that Cancel() could work. At production scale that layer cost ~294,000 goroutine spawns and ~62 MB of garbage per reconcile cycle, for a feature with no callers. MD5 challenge login went too.

The rewrite is pinned by a committed fingerprint test over a 22,037-entry reply plus error-shape tests, and upstream's own suite passes under -race.


Features

  • metrics.pprof_enabled serves Go's runtime profiler under /debug/pprof, off by default. The metrics listener binds 0.0.0.0 and a heap profile carries fragments of whatever the process has held — the CrowdSec API key and RouterOS password among them — so the docs pair it with binding to localhost and the bouncer warns on every start while it is on.
  • mikrotik.command_timeout does something. It was parsed, defaulted, documented with tuning advice, and read by nothing: a router that accepted the connection and then stalled held a command forever.
  • logging.file is implemented. Lines go to both stderr and the file, so journalctl and docker logs keep working. Append mode, 0640, and a failure to open is a startup error naming the path rather than a silent fallback.
  • crowdsec.supported_decisions_types is honoured. It was declared and read by nothing while parseDecision hardcoded ban.

Documentation

The site was rebuilt around things that can be checked rather than things that were typed: the configuration reference is generated from the Go struct and CI fails on drift, the firewall rules render from a module read out of internal/manager (which corrected a long-standing undercount — a stock configuration writes eight rules, not four), and both locales are gated for structural parity on the MDX AST.

New: a per-page social card, four architecture diagrams under one written convention (solid edge = write, dashed = read), a new brand mark, and a 100 ms-resolution chart of the full startup lifecycle drawn by cmd/perfmon, which also installs the router-side sampler with one command.

Several published numbers were wrong and are corrected — including two from this cycle's own measurements. A no-drift reconciliation is 1.86 s, not the 3–4 s previously stated. And the bulk chunk-size sweep, first written up as a clean two-term cost model, does not survive proper sampling: the interquartile range within a single chunk size reaches 2.55 s while the entire spread between sizes is 1.43 s, so the sweep resolves nothing and which size "wins" depends on the estimator. The conclusion (100 stays) never rested on the ranking, but the precision did not survive, and the benchmarking page now carries the episode as the lesson.


Also

Go 1.27.0, CodeQL moved to an advanced workflow with javascript-typescript added, make analyze runs all twelve steps and names every failure instead of dying on the first, hadolint over both Dockerfiles, a GitLab mirror, and the full dependency closure refreshed.

Full changelog: CHANGELOG.md — 52 entries.

Don't miss a new cs-routeros-bouncer release

NewReleases is sending notifications on new releases.