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 taggedargos-country-XXin 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 existingAddDecision(which only handles single IPs). Same/v1/alertsenvelope, scope=Range instead of Ip, CIDR instead of bare IP.DeleteDecisionsByOrigin(ctx, origin)— callsDELETE /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:
cscli decisions add --scope Country --value $TEST_COUNTRY— simulates an operator country ban.- Probes
$TEST_HOSTwithX-Forwarded-For: $TEST_IP. - 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_expansionstable 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 testbackend/internal/security/country/expander.go(new)backend/internal/security/country/source.go(new) -- MMDB iteration viamaxminddb.Networks(SkipAliasedNetworks)backend/internal/security/country/expander_test.go(new)backend/internal/crowdsec/client.go-- AddRangeDecision, DeleteDecisionsByOrigin, ListDecisionsByScopebackend/internal/api/security_country.go(new) -- three HTTP handlersbackend/internal/api/handlers.go--CountryExpanderfield in Handlers structbackend/internal/server/server.go-- route wiring + Config fieldbackend/cmd/argos/main.go-- expander construction + startup legacy-detection goroutine
Frontend¶
frontend/src/api/client.ts--securityCountriesList,securityCountriesExpand,securityCountriesRevoke+CountryExpansion/CountryExpansionResulttypesfrontend/src/pages/Settings.tsx-- newCountryBansSectionbetween 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" noteCHANGELOG.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: falseflag 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¶
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: falseemit -- still in place; v1.3.21 inherits it. hosts.true_detect_modeschema 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.