Skip to content

v1.3.21 -- Country bans actually enforce

The honest fix v1.3.20 was missing. Country geo-blocking has been silently broken since at least v1.3.17 (cscli reports the ban active, the request still gets through). v1.3.20 attempted a one-flag workaround that did not work because the upstream hslatman/caddy-crowdsec-bouncer plugin lacks scope=Country support entirely (verified Apr 25 2026 against plugin commit f1e77b2; see v1.3.20 release notes for the full citation).

v1.3.21 ships the architecturally correct fix: the panel expands an operator-issued country ban into the equivalent list of scope=Range LAPI decisions, which the plugin handles natively in either operating mode.

What ships

Migration 029: country_ban_expansions table

CREATE TABLE country_ban_expansions (
    id                       INTEGER PRIMARY KEY AUTOINCREMENT,
    country_code             TEXT NOT NULL,
    decision_ids             TEXT NOT NULL,
    cidr_count               INTEGER NOT NULL,
    reason                   TEXT NOT NULL DEFAULT '',
    duration                 TEXT NOT NULL,
    created_at               TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_by               TEXT NOT NULL,
    mmdb_version_at_creation TEXT NOT NULL,
    UNIQUE(country_code)
);

UNIQUE(country_code) is deliberate: an operator-issued country ban is an idempotent intent. Re-issuing replaces the existing expansion; the new MMDB version recorded.

The decision_ids column is named generically per the design doc but stores the JSON array of CIDR strings rather than LAPI-internal decision IDs. This keeps revocation cheap (one DELETE call by origin tag) and the contents human-readable.

country.Expander service

New package backend/internal/security/country/. Three operations:

  • Ban(ctx, code, duration, reason, actor) — looks up CIDRs in the country MMDB, pushes one Range decision per CIDR with origin=argos-country-XX, persists the tracking row. Idempotent: a second call with the same country code revokes the previous LAPI decisions and replaces the row. Unwinds on partial-failure: if any single LAPI push errors, the partial set is dropped via the shared origin tag and the table is not written.
  • Revoke(ctx, code) — drops every LAPI decision tagged argos-country-XX in one HTTP call, removes the tracking row. Missing row is not an error.
  • List(ctx) — returns active expansions ordered by country_code for the UI.

The CIDR source is an interface; production binds the MMDBSource (which iterates the existing country.mmdb shipped for the geoip enrichment feature). Tests inject a fake source so unit coverage doesn't depend on a real MMDB.

Three new endpoints

POST   /api/security/countries/expand
       body: {country_code, duration, reason}
       -> 201 + {country_code, cidr_count, mmdb_version, ...}

GET    /api/security/countries
       -> [ {country_code, cidrs, cidr_count, ...}, ... ]

DELETE /api/security/countries/{cc}
       -> 200 + {country_code, removed_decision_count}

All three sit behind the existing session middleware. All three audit-log via h.audit() so post-incident review can reconstruct who banned what country and when.

Crowdsec client extensions

backend/internal/crowdsec/client.go gains:

  • AddRangeDecision(ctx, AddRangeDecisionInput) — the Range-scope sibling of the existing AddDecision (which only handles single IPs). Same /v1/alerts envelope, scope=Range instead of Ip, CIDR instead of bare IP.
  • DeleteDecisionsByOrigin(ctx, origin) — calls DELETE /v1/decisions?origins=<X>, returns the count of removed decisions. Used by Revoke and by the partial- failure unwind path.
  • ListDecisionsByScope(ctx, scope) — bouncer-key- authenticated GET, used by the legacy-detection startup scan.

Startup legacy-detection warning

On panel boot (after a 5s grace for the LAPI socket to come up), the panel lists active LAPI decisions with scope=Country and emits one slog.Warn per finding:

WARN msg="country: legacy scope=Country decision found
     (NOT enforced at Caddy edge)"
     value="BR" origin="cscli"
     hint="POST /api/security/countries/expand to convert
           into enforced Range decisions"

We do NOT auto-convert. The operator decides which legacy bans matter. A surprise burst of "you have 14 country bans, expanded them all to 4500 Range decisions" on first boot would erode trust in the panel.

Minimum-viable UI

The Settings page gets a new "Country bans (expanded)" section between "DNS providers" and "Logs". Functional, not pretty:

  • Inline form: country_code (2 letters) + duration (Go duration string, default 168h) + optional reason.
  • Table of active expansions: country, CIDR count, duration, creator, MMDB version, created_at, Revoke button.
  • Toast on add: added BR: 287 CIDR ranges, mmdb 2026-04.
  • Toast on revoke: revoked BR: 287 LAPI decisions removed.

The richer UI (flag picker, world-map heatmap, multi-select) is queued for v1.3.22. v1.3.21 just needs to prove the underlying flow end-to-end works, which the smoke script verifies and the UI surfaces.

Smoke gate

scripts/smoke/country-block.sh MUST pass in the live prod stack before this release is tagged. The script:

  1. cscli decisions add --scope Country --value $TEST_COUNTRY — simulates an operator country ban.
  2. Probes $TEST_HOST with X-Forwarded-For: $TEST_IP.
  3. Asserts HTTP 403.

Pre-v1.3.21 stacks fail this script (the upstream plugin ignores Country-scope decisions). v1.3.21 stacks should pass after the operator runs the new expand endpoint to convert the legacy Country decision into Range decisions.

The intended dogfood sequence:

# 1. Verify pre-fix behaviour (FAIL on v1.3.20).
TEST_COUNTRY=BR TEST_IP=<br-ip> TEST_HOST=https://<host> \
  ./scripts/smoke/country-block.sh
# Expected: HTTP 200, exit 1.

# 2. Upgrade.
docker compose build && docker compose up -d

# 3. Convert via the panel API.
curl -X POST https://<host>/api/security/countries/expand \
  -H 'Content-Type: application/json' \
  -d '{"country_code":"BR","duration":"168h","reason":"smoke test"}'
# Expected: 201 + {cidr_count: ~250-1000}.

# 4. Re-run the smoke.
TEST_COUNTRY=BR TEST_IP=<br-ip> TEST_HOST=https://<host> \
  ./scripts/smoke/country-block.sh
# Expected: HTTP 403, exit 0 (the cscli decision is now a
# bunch of Range decisions, all of which the plugin handles).

Working agreement (clarified after v1.3.20): unit tests are necessary but not sufficient for upstream-behavior fixes. The live-stack smoke is the oracle.

Tests

Unit (10 tests, all passing):

  • country_ban_expansions table CRUD + UNIQUE(country_code) rejection (db/migrate_test.go::TestMigration029CountryBanExpansions).
  • Migration 029 rollback chain (db/migrate_test.go::TestRollbackLastMigration).
  • Happy-path Ban: pushes N decisions, persists row, returns result.
  • Code validation: rejects empty / wrong-length / non-letter codes; lowercase auto-uppercased.
  • Unknown country: surfaces ErrCountryNotFound.
  • Replace-on-conflict: second Ban for same country revokes previous + replaces row, no row stacking.
  • Partial-failure unwind: LAPI push fails on the 2nd CIDR -> origin-tagged delete cleans up the partial set, no row persisted.
  • Revoke: drops LAPI decisions + row.
  • Revoke missing row: not an error (idempotent).
  • List ordering: country_code ASC.

Integration (smoke gate above): scripts/smoke/country-block.sh must PASS in the live prod stack post-deploy + post-conversion.

Files changed

Backend

  • backend/migrations/029_country_ban_expansions.up.sql (new)
  • backend/migrations/029_country_ban_expansions.down.sql (new)
  • backend/internal/db/migrate_test.go -- rollback chain extended; new forward-shape test
  • backend/internal/security/country/expander.go (new)
  • backend/internal/security/country/source.go (new) -- MMDB iteration via maxminddb.Networks(SkipAliasedNetworks)
  • backend/internal/security/country/expander_test.go (new)
  • backend/internal/crowdsec/client.go -- AddRangeDecision, DeleteDecisionsByOrigin, ListDecisionsByScope
  • backend/internal/api/security_country.go (new) -- three HTTP handlers
  • backend/internal/api/handlers.go -- CountryExpander field in Handlers struct
  • backend/internal/server/server.go -- route wiring + Config field
  • backend/cmd/argos/main.go -- expander construction + startup legacy-detection goroutine

Frontend

  • frontend/src/api/client.ts -- securityCountriesList, securityCountriesExpand, securityCountriesRevoke + CountryExpansion / CountryExpansionResult types
  • frontend/src/pages/Settings.tsx -- new CountryBansSection between DNS providers and Logs

Docs

  • docs/release-notes/v1.3.21.md (this file)
  • docs/operations/access-control.md -- country-blocking section moved from "doesn't work" to "this is how the expansion works"
  • docs/release-notes/v1.3.20.md -- "Fixed in v1.3.21" note
  • CHANGELOG.md, mkdocs.yml, version bump

Trade-offs

  • CIDR list size scales with country. Small countries (Andorra, Vatican): a few CIDRs. Large countries (CN, US, RU, IN): 500-1500 CIDRs. The bouncer's radix tree handles the count trivially; LAPI's decision list grows by that count for the duration of the ban.
  • MMDB age affects accuracy. The expansion is computed once at Ban time. If MaxMind / DB-IP issue an updated MMDB and a previously-not-listed CIDR moves into the country, the existing expansion does NOT auto-adopt it. v1.3.22 will add a reconcile pass that runs alongside the existing monthly MMDB refresh cron.
  • Per-request LAPI lookup. v1.3.20's enable_streaming: false flag remains in place; v1.3.21 inherits the trade- off (per-request LAPI roundtrip vs in-memory index lookup). The bouncer's in-process cache absorbs the steady-state cost.
  • No country whitelist. "Only allow X, Y" is the same upstream gap on the allow side. Out of scope.

Upgrade

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

Migration 029 applies automatically. Existing country decisions in cscli are NOT auto-converted — the operator sees a startup warning naming each one and converts them via the new endpoint at their pace.

Not changed

  • v1.3.19's AppSec sane defaults, self-block banner, whitelist lifecycle, migration 028 schema -- all untouched.
  • v1.3.20's enable_streaming: false emit -- still in place; v1.3.21 inherits it.
  • hosts.true_detect_mode schema column from v1.3.19 stays dormant; per-host enforcement still queued.
  • SelfBlockBanner v2 multi-IP, audit log table, public_ip_self ipify polling, full Banned IPs / Whitelist / Activity tabs -- all queued for v1.3.22.