Skip to content

feat(geoip): per-policy country allowlist/blocklist filtering - #286

Merged
bihius merged 3 commits into
mainfrom
feat/geoip-country-filtering
Jul 29, 2026
Merged

feat(geoip): per-policy country allowlist/blocklist filtering#286
bihius merged 3 commits into
mainfrom
feat/geoip-country-filtering

Conversation

@bihius

@bihius bihius commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

Adds country-level GeoIP filtering per policy (geoip_mode = off / allowlist / blocklist, plus geoip_countries), configurable on the existing policy create/edit screen.

The lookup is native to HAProxy: the backend converts a country-level MMDB into a plain CIDR -> ISO code map file and the template resolves src through map_ip(). No Lua, no HAProxy MMDB module, no new runtime dependency in the proxy.

Database source is ip66.dev, not MaxMind GeoLite2. It needs no license key and no registration, is rebuilt daily, and is CC BY 4.0 (attributed in docs/architecture.md and in the generated map header). Dropping the key also removed a leak path: httpx embeds the full request URL — query string included — in HTTPStatusError messages, which reached both the logs and the API response body. GEOIP_DATABASE_URL is configurable for mirrors and air-gapped installs.

Operational notes for reviewers

  • The generated map is ~920k lines / ~18 MB, and costs HAProxy about 280 MB RSS to load (measured on haproxy:3.0-alpine). That is the main running cost of this feature.
  • Map generation streams and collapses adjacent networks per run rather than buffering them all: identical output, ~70 MB peak instead of ~780 MB.
  • Unresolved addresses fail open by default (GEOIP_FAIL_OPEN). Note ~306k networks carry no country and a further ~90k are labelled EU, which is not an ISO country — so an allowlist of PL alone still admits every EU network. Documented in docs/architecture.md.
  • Refresh runs daily via APScheduler, plus POST /geoip/refresh on demand, and reloads HAProxy only when the map actually changed.

Issues found and fixed before opening this PR

Review of the first implementation, plus verification against the real database and the real HAProxy image, turned up:

  • /health was a full bypass — a host-independent path match with no use_backend of its own, so a blocked country could reach the customer origin just by requesting /health. It is no longer exempt; ACME still is, since it has its own local backend.
  • The config was fatally invalid with long country lists — HAProxy truncates a config line after 64 words. Codes are now emitted as repeated same-name ACLs of 50.
  • Map generation could not run at all — the pure-Python maxminddb reader raises ::1:0:0/0 has host bits set partway through the real database, so MODE_MEMORY was unusable. Iteration goes through the C extension; a fallback raises an actionable error.
  • A 304 could leave a stub map in place, so a current database sat behind an empty map that silently failed open.
  • alembic upgrade head failed on PostgreSQL — the enum type was not created before the column referencing it.
  • A GeoIP refresh could reload HAProxy mid-apply, activating a release that apply was rolling back; the refresh and apply now share one lock.
  • The map stayed a stub for a full day after every boot — the interval job had no initial run.
  • UK vs GB — the database labels a few networks UK, which blocking GB would have missed. Aliased.
  • The policy form submitted hidden country text when the mode was off, producing a 422 the user could neither see nor fix.

Test plan

  • uv run pytest --cov=app — 683 passed, 6 skipped
  • uv run mypy app/ — clean
  • uv run ruff check app/ — clean
  • pnpm run type-check, pnpm run lint, pnpm test — clean, 135 frontend tests pass
  • haproxy -c against a rendered config carrying all 249 ISO codes, run in haproxy:3.0-alpine — valid; also started HAProxy with the real 920k-entry map to confirm it loads and to measure its memory
  • New tests cover the reload-failure and stub-regeneration paths, the unusable reader, ACL chunking, the /health non-exemption, the map-file OSError branch, and the scheduler job, which previously had no coverage

Closes #175

bihius added 2 commits July 28, 2026 15:57
Adds GeoIP country filtering to WAF policies: a MaxMind GeoLite2 MMDB
pipeline that generates a HAProxy CIDR-to-country map consumed natively
via map_ip(), admin CRUD for geoip_mode/geoip_countries on policies, a
manual refresh endpoint, and a weekly scheduled refresh job.

Closes #175
Switch the GeoIP database from MaxMind GeoLite2-Country to ip66.dev, and
fix the issues a review of the initial implementation surfaced.

Database source:
- ip66.dev needs no license key, so MAXMIND_LICENSE_KEY, the tarball
  extraction and the whole "feature not configured" state (including
  GeoipNotConfiguredError and GeoipRefreshResult.configured) are gone.
  Removing the key also removes a leak: httpx embedded the full request
  URL, query string included, in HTTPStatusError messages, which reached
  both the logs and the API response body.
- Add GEOIP_DATABASE_URL (defaulting to ip66.dev) so operators can point
  at a mirror or an air-gapped copy.
- Download is a conditional GET on the stored ETag/Last-Modified; the
  refresh interval drops from 7 days to 1 to match the daily rebuild.
- Attribute ip66.dev per its CC BY 4.0 licence.

Correctness and safety:
- Iterate the MMDB through the C extension. The pure Python reader
  (MODE_MEMORY) raises "::1:0:0/0 has host bits set" partway through the
  real database, so map generation could not run at all; a fallback to it
  now raises an actionable GeoipError.
- Collapse networks per run while streaming instead of buffering every
  network first: same output, ~70 MB peak instead of ~780 MB.
- Regenerate the map when it is still a stub even if the download was a
  304, so a current database can no longer sit behind an empty map that
  silently fails open.
- Stop exempting /health from the deny rules. It is a host-independent
  path match with no use_backend of its own, so it routed to the customer
  origin and was a trivial full bypass. ACME stays exempt; it has its own
  local backend.
- Emit country codes as repeated same-name ACLs of 50. HAProxy truncates
  a config line after 64 words, so a long list produced a fatally invalid
  config (verified against haproxy:3.0-alpine).
- Alias UK to GB; the database uses UK for a few networks that ISO 3166-1
  spells GB, which blocking GB would otherwise miss.
- Serialise reload_haproxy() on the same lock as apply(), so a refresh can
  no longer reload HAProxy mid-apply and activate a release being rolled
  back.
- Create the enum type before adding the column on PostgreSQL, matching
  the existing pattern; alembic upgrade head failed there otherwise.
- Run an initial GeoIP refresh shortly after boot instead of one interval
  later, which left the map a stub for a full day after every start.
- Do not submit geoip_countries while the mode is off; the input is
  hidden then, so leftover text caused a 422 the user could not see.

Tests cover the reload-failure and stub-regeneration paths, the unusable
reader, the ACL chunking, the /health non-exemption, the map-file OSError
branch and the scheduler job, which had no coverage at all.
Copilot AI review requested due to automatic review settings July 28, 2026 20:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Addresses review findings on the refresh path, where the filter could
silently degrade to bad data.

- Move the refresh lock from the router into geoip_service. The scheduled
  job called refresh() directly and so bypassed the router's lock, while
  both paths write the same fixed temp files (.country.mmdb.tmp,
  .country.map.tmp). Concurrent runs could interleave writes, and each
  finally-unlink would delete the other's temp file before its
  os.replace(), publishing a corrupt map. The API now uses try_refresh(),
  which returns None instead of blocking, so it still answers 409.
- Catch maxminddb.InvalidDatabaseError. It subclasses RuntimeError, so
  neither OSError nor GeoipError caught it, and refresh() claimed never to
  raise. A mirror answering 200 with an HTML error page therefore turned
  POST /geoip/refresh into a 500 and killed the scheduled job.
- Drop the cached ETag/Last-Modified when the downloaded file turns out
  not to be a usable database. The validators are recorded as soon as the
  transfer succeeds, which is before the file is known to be valid, so
  keeping them made every later refresh a 304 and the corruption
  permanent.
- Filter generated map entries through MAPPABLE_COUNTRY_CODES rather than
  VALID_COUNTRY_CODES, so the internal ZZ sentinel can never be emitted as
  a resolved country. In allowlist mode ZZ would have made the -m found
  guard true while matching no allowed code, denying a request that
  fail-open promises to allow.
- Stream the map file when hashing it instead of loading ~18 MB twice per
  run.
- Document the ~560 MB peak across a reload, that map_ip() runs for all
  frontend traffic, and that GEOIP_FAIL_OPEN is baked in at config
  generation so it needs a full re-apply.
@bihius
bihius merged commit 4881e3a into main Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P2-10 — GeoIP-based traffic filtering

2 participants