Skip to content

v1.3.20 -- Country geo-blocking emit fix (INCOMPLETE)

Fixed in v1.3.21

v1.3.21 ships the architecturally correct fix: panel-side expansion of country bans into the equivalent list of scope=Range LAPI decisions, which the plugin handles natively. See v1.3.21 release notes for the upgrade path. The smoke script scripts/smoke/country-block.sh now PASSes on v1.3.21 stacks after the operator converts a country via POST /api/security/countries/expand.

The "incomplete fix" banner below remains for historical accuracy.

Incomplete fix -- country blocking still does NOT work

Post-merge investigation (Apr 25 2026, same day) showed this release does not actually solve the country-blocking bug. Setting enable_streaming: false does land in the runtime Caddy config, but the upstream hslatman/caddy-crowdsec-bouncer plugin does not handle scope=Country in either stream OR live mode:

  • Stream mode rejects non-IP scopes outright (store.go L43-L58): default: return fmt.Errorf("got unhandled scope: %s", scope).
  • Live mode (which enable_streaming: false selects) queries LAPI with IPEquals: &value only -- no ScopeEquals. LAPI's IP-equals filter does not match Country-scope decisions.
  • Zero references to country, geoip, maxmind in the plugin OR in the underlying github.com/crowdsecurity/go-cs-bouncer library.
  • No open issue upstream -- this gap has never been filed.

Last verified Apr 25 2026 against plugin commit f1e77b2. Real BR-resolving request still returned HTTP 200 with enable_streaming: false confirmed in runtime config and Country=BR active in cscli decisions list.

Status of country blocking documented since v1.3.17: NOT functional. Apologies for the incorrect documentation.

v1.3.21 will resolve this by expanding Country bans into equivalent Range decisions panel-side, which the plugin handles natively. See docs/planning/v1.3.21-country-expansion.md for the design.

A focused single-bug release. Was intended to close a silent-failure regression that has shipped in argos since at least v1.3.17: the panel emits a Caddy bouncer config that lets the plugin default to stream mode, which only indexes scope=Ip / scope=Range. The flag is correct; the assumption that flipping it solved the problem was wrong.

This was verified Apr 25 2026 with a real request from 149.102.251.103 (BR, AS212238 Datacamp) and Country=BR active in CrowdSec: HTTP 304, no block.

What this release DOES ship: the emit change + tests + smoke script. What it does NOT ship: actual country blocking. That needs v1.3.21.

What ships

Panel emits enable_streaming: false

backend/internal/caddycfg/caddycfg.go adds the flag to the emitted apps.crowdsec block:

csApp := map[string]any{
    "api_url":          crowdsec.LAPIURL,
    "api_key":          CrowdSecBouncerKeyPlaceholder,
    "ticker_interval":  interval,
    "enable_streaming": false,
}

Stream mode (the plugin default) batches decision pulls every ticker_interval, then resolves each request against an in-memory IP/CIDR index. Non-IP scopes are not indexable that way -- the bouncer's optimization assumed all decisions are IP-shape. Setting enable_streaming: false switches the plugin to per-request LAPI lookups with the resolved client IP, which is the only path that honours Country / AS / other scopes.

Trade-off: per-request LAPI roundtrip replaces in-memory index lookup. The bouncer ships an in-process cache that absorbs the steady-state cost; for homelab traffic shapes the latency delta is noise. Hardcoded false for v1.3.20 -- homelab has no real use case for stream mode. v1.3.21 may surface it as a Settings toggle if a workload genuinely needs streamMode performance and is willing to give up non-IP scopes.

Verification: scripts/smoke/country-block.sh

Adds a Country decision via cscli, probes the live stack with X-Forwarded-For spoofing an IP that GeoLite2 resolves to that country, asserts a 403, cleans up the test decision on exit. Refuses to run with placeholder defaults so a bare invocation cannot silently pretend to verify nothing.

TEST_COUNTRY=<ISO-2> \
TEST_IP=<ip-resolving-to-iso> \
TEST_HOST=https://<your-host> \
  ./scripts/smoke/country-block.sh

Exit codes:

  • 0 -- 403 received, country blocking enforced.
  • 1 -- non-403 received, regression. Likely cause: enable_streaming defaulted to true (pre-v1.3.20 bug) or the panel did not reload the Caddy config after the decision was added.
  • 2 -- prerequisite missing (container not running, defaults still in place).

The script lives at scripts/smoke/country-block.sh and is intended to run after every change to the caddycfg crowdsec emit path. Keeping it committed means a future refactor that re-introduces the bug will trip the smoke immediately.

Tests

Three new tests in backend/internal/caddycfg/crowdsec_test.go:

  • TestCrowdSecEmitsEnableStreamingFalse -- asserts the flag is emitted with value false in the minimum-viable config. Failed against main before the fix landed; passes after.
  • TestCrowdSecEmitsEnableStreamingFalseWithAppSec -- same assertion when AppSec wiring is also present, because the country-block bug is independent of AppSec mode.
  • TestCrowdSecBouncerEmitMaintainsTickerInterval -- no-regression assertion that the v1.3.20 emit change did not drop or rename ticker_interval. Covers both the operator-set value and the empty-default fallback.

What the unit tests cover (and what they don't)

Lesson from this release: unit tests asserting "the panel emits X" are necessary but not sufficient. They prove the emit path is correct; they say nothing about whether X actually solves the upstream problem. v1.3.20 shipped because the unit tests passed; the live-stack smoke script (which DOES test upstream behavior) was not run before tagging.

scripts/smoke/country-block.sh IS the oracle of success for the country-blocking work, and it currently FAILS on v1.3.20. That is the correct signal -- v1.3.21 must make it PASS, and the working agreement going forward is smoke-must-pass before any release that claims to fix country blocking.

The unit tests fail on main before the fix is applied:

=== RUN   TestCrowdSecEmitsEnableStreamingFalse
    crowdsec_test.go:114: enable_streaming MUST be emitted
        (default true silently drops Country decisions); got
        block map[api_key:... api_url:... ticker_interval:15s]
--- FAIL: TestCrowdSecEmitsEnableStreamingFalse (0.00s)
=== RUN   TestCrowdSecEmitsEnableStreamingFalseWithAppSec
    crowdsec_test.go:133: enable_streaming must be false even
        when AppSec configured, got <nil>
--- FAIL: TestCrowdSecEmitsEnableStreamingFalseWithAppSec (0.00s)

After the fix:

=== RUN   TestCrowdSecEmitsEnableStreamingFalse
--- PASS: TestCrowdSecEmitsEnableStreamingFalse (0.00s)
=== RUN   TestCrowdSecBouncerEmitMaintainsTickerInterval
--- PASS: TestCrowdSecBouncerEmitMaintainsTickerInterval (0.00s)
=== RUN   TestCrowdSecEmitsEnableStreamingFalseWithAppSec
--- PASS: TestCrowdSecEmitsEnableStreamingFalseWithAppSec (0.00s)

End-to-end verification is on the operator: run scripts/smoke/country-block.sh against the live stack with real TEST_COUNTRY / TEST_IP / TEST_HOST exports.

Files changed

  • backend/internal/caddycfg/caddycfg.go -- emit enable_streaming: false in the apps.crowdsec block
  • backend/internal/caddycfg/crowdsec_test.go -- three new tests (two assertions on the new flag + one no-regression on ticker_interval)
  • scripts/smoke/country-block.sh (new) -- end-to-end verification script with placeholder gate
  • docs/operations/access-control.md -- pre-v1.3.20 silent- failure callout under Country-based blocking + reference to the smoke script
  • docs/release-notes/v1.3.19.md -- known-limitation entry marking this release as the fix
  • CHANGELOG.md, mkdocs.yml, version bump

Upgrade

cd argos-edge
git pull
docker compose build
docker compose up -d

No migrations. No env vars. No compose surface change. The panel reconciler emits the new Caddy config on the next reconcile pass; enable_streaming: false lands in /config/apps/crowdsec/enable_streaming of the running Caddy admin API. Verify with:

docker exec argos-prod-caddy wget -qO- localhost:2019/config/apps/crowdsec | \
  grep -o '"enable_streaming":[^,}]*'

Should print "enable_streaming":false. Then run scripts/smoke/country-block.sh for the end-to-end check.

Not changed

  • v1.3.19's AppSec sane defaults, self-block banner, whitelist lifecycle, migration 028 schema -- all untouched.
  • Bouncer in-memory cache TTL, LAPI auth path, Caddy admin API surface -- untouched.
  • The dormant hosts.true_detect_mode column from v1.3.19 remains dormant; per-host enforcement still queued for v1.3.21 alongside the SelfBlockBanner v2 + audit log work.