Bläddra i källkod

feat(M11): MMDB enrichment with DB-IP / MaxMind / IPinfo providers

- EnrichmentService backed by MaxMind\Db\Reader (open MMDB format)
- GeoIpDownloader abstraction; DB-IP default, MaxMind & IPinfo opt-in
- enrich-pending job (replaces M05 skeleton): 200 per tick, no-ops cleanly without DBs
- refresh-geoip job: provider-aware download + verify + atomic replace
  - 412 only when an opt-in provider's credential is unset
  - dry_run=1 query flag returns 202 without taking the lock
- IP detail UI shows country flag + ASN (linked to bgp.he.net) with
  provider attribution (DB-IP / IPinfo) and enriched_at timestamp
- /healthz reports provider, configured state, DB presence + mtimes
- country/asn filters on IPs list now functional;
  /admin/ips/countries dropdown source

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 vecka sedan
förälder
incheckning
2c14cba864
42 ändrade filer med 2704 tillägg och 91 borttagningar
  1. 11 3
      .env.example
  2. 99 0
      PROGRESS.md
  3. 6 4
      api/composer.json
  4. 507 1
      api/composer.lock
  5. 9 0
      api/config/settings.php
  6. 50 2
      api/src/App/AppFactory.php
  7. 101 1
      api/src/App/Container.php
  8. 11 0
      api/src/Application/Admin/IpsController.php
  9. 55 6
      api/src/Application/Internal/JobsController.php
  10. 54 6
      api/src/Application/Jobs/EnrichPendingJob.php
  11. 173 0
      api/src/Application/Jobs/RefreshGeoipJob.php
  12. 30 0
      api/src/Domain/Enrichment/EnrichmentResult.php
  13. 18 0
      api/src/Domain/Enrichment/EnrichmentService.php
  14. 127 0
      api/src/Infrastructure/Enrichment/Downloaders/DbipDownloader.php
  15. 17 0
      api/src/Infrastructure/Enrichment/Downloaders/DownloaderException.php
  16. 31 0
      api/src/Infrastructure/Enrichment/Downloaders/GeoIpDownloader.php
  17. 69 0
      api/src/Infrastructure/Enrichment/Downloaders/IPinfoDownloader.php
  18. 136 0
      api/src/Infrastructure/Enrichment/Downloaders/MaxMindDownloader.php
  19. 62 0
      api/src/Infrastructure/Enrichment/Downloaders/MmdbVerifier.php
  20. 38 0
      api/src/Infrastructure/Enrichment/IpinfoRecordAdapter.php
  21. 44 0
      api/src/Infrastructure/Enrichment/MaxMindRecordAdapter.php
  22. 164 0
      api/src/Infrastructure/Enrichment/MmdbEnrichmentService.php
  23. 31 0
      api/src/Infrastructure/Enrichment/RecordAdapter.php
  24. 116 7
      api/src/Infrastructure/Reputation/IpEnrichmentRepository.php
  25. 14 0
      api/tests/Fixtures/geoip/README.md
  26. BIN
      api/tests/Fixtures/geoip/asn.mmdb
  27. BIN
      api/tests/Fixtures/geoip/country.mmdb
  28. 60 0
      api/tests/Integration/Admin/CountriesEndpointTest.php
  29. 168 0
      api/tests/Integration/Enrichment/EnrichPendingJobTest.php
  30. 68 2
      api/tests/Integration/Internal/JobsEndpointsTest.php
  31. 9 0
      api/tests/Integration/Support/AppTestCase.php
  32. 37 0
      api/tests/Unit/Enrichment/IpinfoRecordAdapterTest.php
  33. 46 0
      api/tests/Unit/Enrichment/MaxMindRecordAdapterTest.php
  34. 101 0
      api/tests/Unit/Enrichment/MmdbEnrichmentServiceTest.php
  35. 175 54
      files/M11-enrichment.md
  36. 5 0
      ui/config/settings.php
  37. 18 3
      ui/resources/views/pages/ips/detail.twig
  38. 11 2
      ui/resources/views/pages/ips/index.twig
  39. 24 0
      ui/src/ApiClient/AdminClient.php
  40. 2 0
      ui/src/App/Container.php
  41. 3 0
      ui/src/Controllers/IpsController.php
  42. 4 0
      ui/tests/Integration/App/IpsPageTest.php

+ 11 - 3
.env.example

@@ -55,11 +55,19 @@ CIDR_EVALUATOR_TTL_SECONDS=60
 # allowlist invalidate explicitly; this is the cross-replica window.
 BLOCKLIST_CACHE_TTL_SECONDS=30
 
-# GeoIP
+# GeoIP / ASN enrichment
+# Three pluggable MMDB providers — pick one. The on-disk paths below are
+# provider-agnostic; the refresh-geoip job atomic-replaces them with the
+# selected provider's files.
+#   - dbip    (default, no auth required, CC BY 4.0 — UI shows attribution)
+#   - maxmind (opt-in, requires MAXMIND_LICENSE_KEY)
+#   - ipinfo  (opt-in, requires IPINFO_TOKEN — UI shows attribution)
 GEOIP_ENABLED=true
-GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb
-GEOIP_ASN_DB=/data/geoip/GeoLite2-ASN.mmdb
+GEOIP_PROVIDER=dbip
+GEOIP_COUNTRY_DB=/data/geoip/country.mmdb
+GEOIP_ASN_DB=/data/geoip/asn.mmdb
 MAXMIND_LICENSE_KEY=
+IPINFO_TOKEN=
 
 # CORS — origin of the ui container (or future SPA frontend)
 UI_ORIGIN=http://localhost:8080

+ 99 - 0
PROGRESS.md

@@ -269,3 +269,102 @@
 - `IpScoreRepository::final` was already dropped in M09 to allow a test stub; no further class-modifier changes this milestone.
 
 **Added dependencies:** none.
+
+## M11 — Enrichment (done)
+
+**Built:** MMDB wrapper, three pluggable downloaders (DB-IP / MaxMind / IPinfo),
+both jobs (`enrich-pending` fully implemented; `refresh-geoip` replacing the M05 stub),
+UI display + provider attribution, healthz fields, country dropdown source.
+
+**Notes for next milestone:**
+- DBs live at `/data/geoip/{country,asn}.mmdb` (renamed from SPEC §9 defaults to be
+  provider-agnostic; documented in `.env.example`).
+- Default provider is **DB-IP** — no credential required, never returns 412.
+- MaxMind and IPinfo paths return 412 when their credential is empty (controller
+  short-circuits before lock acquire so the `job_runs` lock isn't dirtied).
+- License key / IPinfo token never logged: error messages substitute `***` for the
+  real value before throwing `DownloaderException`.
+- Re-enrichment is opt-in via `?reenrich=true` on `refresh-geoip`. The flag clears
+  `enriched_at` after a successful refresh so `findPending` re-picks the rows up
+  on the next `enrich-pending` tick.
+- DB-IP and IPinfo: no upstream integrity file; verification is gzip-decode
+  (DB-IP only) + MMDB metadata + node-count sanity (`MmdbVerifier`). MaxMind keeps
+  SHA-256.
+- Attribution rendered in UI for DB-IP and IPinfo per their license terms;
+  MaxMind requires no attribution. The provider name flows from `GEOIP_PROVIDER`
+  through the UI's settings into a Twig global, so the detail page picks the
+  right footer.
+- `/admin/ips/countries` returns `[{code, count}]` sorted by code; the IPs-list
+  page renders a dropdown when the list is non-empty, falls back to the free-text
+  input otherwise (so empty installs still let you type a code).
+- New `dry_run=1` query flag on `POST /internal/jobs/refresh-geoip` returns 202
+  with `provider` + `dry_run: true` without taking the lock — used by the
+  acceptance script to confirm the controller doesn't 412 under DB-IP.
+- `MmdbEnrichmentService::isReady()` is the fast-path check the EnrichPendingJob
+  uses to no-op cleanly when neither DB is on disk yet — avoids running 200
+  per-tick lookups that all return empty.
+- Atomic file replace: tempnam + write + rename within the same target dir so
+  POSIX rename is atomic. tempnam creates 0600; we relax to 0644 so other procs
+  can open the new file.
+
+**Schema:** none. The existing `ip_enrichment` table from M02 took the new
+write paths verbatim; no migration needed.
+
+**Test surface added (api):** Unit: `MaxMindRecordAdapterTest`,
+`IpinfoRecordAdapterTest`, `MmdbEnrichmentServiceTest` (drives the vendored
+MaxMind test fixtures end-to-end including v6 lookup and missing-DB
+warn-once). Integration: `EnrichPendingJobTest` (4 tests covering happy
+path, no-op-on-missing-DB, idempotence, ?reenrich loop), `CountriesEndpointTest`
+(empty + populated + RBAC). `JobsEndpointsTest` updated: replaced the
+"M05 returns 412 not_implemented" assertion with three new ones: dry-run
+under DB-IP doesn't 412, MaxMind without key returns 412 + provider/missing
+fields, IPinfo without token same. Total: **316 tests / 882 assertions**, 0
+deprecations, 0 failures, 0 errors.
+
+**Test surface added (ui):** Updated existing `IpsPageTest` to enqueue the
+extra `listCountries` API call the controller now issues alongside
+`searchIps`. No new test file. Total: **71 tests / 177 assertions**, 0
+failures.
+
+**Acceptance:** `composer cs && composer stan && composer test` clean on both
+subprojects. The full Block A/B/C acceptance script in the M11 brief is
+gated on a fresh `docker compose` boot, which the development environment
+in this session can't run end-to-end (no Docker daemon at the host level
+during the milestone implementation phase). The unit + integration tests
+cover every controller and job code path that the bash acceptance script
+exercises; the bash script is preserved verbatim for the next operator to
+run against a clean compose stack.
+
+**Deviations from SPEC:**
+- SPEC §9 names `GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb`; renamed
+  to `/data/geoip/country.mmdb` (and asn likewise) so the runtime paths are
+  provider-agnostic. Documented inline in `.env.example`.
+- SPEC §2 names MaxMind GeoLite2 specifically; MaxMind stays a first-class
+  provider but the default for new installs is **DB-IP** (also MMDB,
+  CC BY 4.0) for friction-free self-hosting. The ADR sits in this PROGRESS
+  entry and the milestone brief.
+- The MMDB lookup uses `MaxMind\Db\Reader::get($ip)` directly rather than
+  the higher-level `Geoip2\Database\Reader::country()` accessor — the latter
+  is shape-specific and breaks on IPinfo's flat record schema. Per the
+  milestone brief.
+- `JobsController::refreshGeoip` accepts `?dry_run=1` (returns 202 without
+  taking the lock or running the job). Adds the only public surface change
+  beyond the spec: the brief's acceptance script needs a way to confirm
+  "controller doesn't 412 under DB-IP" without pulling 100 MB of MMDBs over
+  the wire in CI.
+
+**Added dependencies:** `geoip2/geoip2` (named in SPEC §2 as the planned
+package; we use its underlying `MaxMind\Db\Reader` for cross-provider
+support), `guzzlehttp/guzzle` (named in SPEC §2 — first time used in api;
+the ui already had it). `maxmind-db/reader` and `maxmind/web-service-common`
+came in transitively.
+
+**Added env vars:** `GEOIP_PROVIDER` (default `dbip`; values
+`dbip|maxmind|ipinfo`), `IPINFO_TOKEN` (used only when `provider=ipinfo`).
+`MAXMIND_LICENSE_KEY` was already in `.env.example`.
+
+**Added test fixtures:** `api/tests/Fixtures/geoip/{country,asn}.mmdb`
+vendored from `maxmind/MaxMind-DB` (Apache-2.0). Cover IP `81.2.69.142` (GB)
+plus a small IPv6 set. Schema is MaxMind-shape so `MaxMindRecordAdapter`
+drives them; the IPinfo adapter is exercised via direct unit tests since
+no public IPinfo-shape MMDB fixture is available.

+ 6 - 4
api/composer.json

@@ -5,13 +5,15 @@
     "license": "proprietary",
     "require": {
         "php": "^8.3",
-        "slim/slim": "^4.12",
-        "slim/psr7": "^1.6",
         "doctrine/dbal": "^4.0",
-        "robmorgan/phinx": "^0.16",
+        "geoip2/geoip2": "^3.0",
+        "guzzlehttp/guzzle": "^7.8",
+        "guzzlehttp/psr7": "^2.6",
         "monolog/monolog": "^3.5",
         "php-di/php-di": "^7.0",
-        "guzzlehttp/psr7": "^2.6"
+        "robmorgan/phinx": "^0.16",
+        "slim/psr7": "^1.6",
+        "slim/slim": "^4.12"
     },
     "require-dev": {
         "phpunit/phpunit": "^11.0",

+ 507 - 1
api/composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "1707572c0f900c2df05fe3a3b81d0153",
+    "content-hash": "180f2a670b99a3c40aca759079cad74e",
     "packages": [
         {
             "name": "cakephp/chronos",
@@ -330,6 +330,78 @@
             },
             "time": "2026-03-09T09:38:36+00:00"
         },
+        {
+            "name": "composer/ca-bundle",
+            "version": "1.5.11",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/ca-bundle.git",
+                "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/ca-bundle/zipball/68ff39175e8e94a4bb1d259407ce51a6a60f09e6",
+                "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6",
+                "shasum": ""
+            },
+            "require": {
+                "ext-openssl": "*",
+                "ext-pcre": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.10",
+                "phpunit/phpunit": "^8 || ^9",
+                "psr/log": "^1.0 || ^2.0 || ^3.0",
+                "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\CaBundle\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                }
+            ],
+            "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
+            "keywords": [
+                "cabundle",
+                "cacert",
+                "certificate",
+                "ssl",
+                "tls"
+            ],
+            "support": {
+                "irc": "irc://irc.freenode.org/composer",
+                "issues": "https://github.com/composer/ca-bundle/issues",
+                "source": "https://github.com/composer/ca-bundle/tree/1.5.11"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-03-30T09:16:10+00:00"
+        },
         {
             "name": "doctrine/dbal",
             "version": "4.4.3",
@@ -540,6 +612,273 @@
             },
             "time": "2020-11-24T22:02:12+00:00"
         },
+        {
+            "name": "geoip2/geoip2",
+            "version": "v3.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/maxmind/GeoIP2-php.git",
+                "reference": "49fceddd694295e76e970a32848e03bb19e56b42"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/49fceddd694295e76e970a32848e03bb19e56b42",
+                "reference": "49fceddd694295e76e970a32848e03bb19e56b42",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "maxmind-db/reader": "^1.13.0",
+                "maxmind/web-service-common": "~0.11",
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "3.*",
+                "phpstan/phpstan": "*",
+                "phpunit/phpunit": "^10.0",
+                "squizlabs/php_codesniffer": "4.*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "GeoIp2\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Gregory J. Oschwald",
+                    "email": "goschwald@maxmind.com",
+                    "homepage": "https://www.maxmind.com/"
+                }
+            ],
+            "description": "MaxMind GeoIP2 PHP API",
+            "homepage": "https://github.com/maxmind/GeoIP2-php",
+            "keywords": [
+                "IP",
+                "geoip",
+                "geoip2",
+                "geolocation",
+                "maxmind"
+            ],
+            "support": {
+                "issues": "https://github.com/maxmind/GeoIP2-php/issues",
+                "source": "https://github.com/maxmind/GeoIP2-php/tree/v3.3.0"
+            },
+            "time": "2025-11-20T18:50:15+00:00"
+        },
+        {
+            "name": "guzzlehttp/guzzle",
+            "version": "7.10.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/guzzle.git",
+                "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+                "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "guzzlehttp/promises": "^2.3",
+                "guzzlehttp/psr7": "^2.8",
+                "php": "^7.2.5 || ^8.0",
+                "psr/http-client": "^1.0",
+                "symfony/deprecation-contracts": "^2.2 || ^3.0"
+            },
+            "provide": {
+                "psr/http-client-implementation": "1.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "ext-curl": "*",
+                "guzzle/client-integration-tests": "3.0.2",
+                "php-http/message-factory": "^1.1",
+                "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+                "psr/log": "^1.1 || ^2.0 || ^3.0"
+            },
+            "suggest": {
+                "ext-curl": "Required for CURL handler support",
+                "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+                "psr/log": "Required for using the Log middleware"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/functions_include.php"
+                ],
+                "psr-4": {
+                    "GuzzleHttp\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Jeremy Lindblom",
+                    "email": "jeremeamia@gmail.com",
+                    "homepage": "https://github.com/jeremeamia"
+                },
+                {
+                    "name": "George Mponos",
+                    "email": "gmponos@gmail.com",
+                    "homepage": "https://github.com/gmponos"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "tobias.nyholm@gmail.com",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "mark.sagikazar@gmail.com",
+                    "homepage": "https://github.com/sagikazarmark"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "webmaster@tubo-world.de",
+                    "homepage": "https://github.com/Tobion"
+                }
+            ],
+            "description": "Guzzle is a PHP HTTP client library",
+            "keywords": [
+                "client",
+                "curl",
+                "framework",
+                "http",
+                "http client",
+                "psr-18",
+                "psr-7",
+                "rest",
+                "web service"
+            ],
+            "support": {
+                "issues": "https://github.com/guzzle/guzzle/issues",
+                "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-23T22:36:01+00:00"
+        },
+        {
+            "name": "guzzlehttp/promises",
+            "version": "2.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/promises.git",
+                "reference": "481557b130ef3790cf82b713667b43030dc9c957"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
+                "reference": "481557b130ef3790cf82b713667b43030dc9c957",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "GuzzleHttp\\Promise\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "tobias.nyholm@gmail.com",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "webmaster@tubo-world.de",
+                    "homepage": "https://github.com/Tobion"
+                }
+            ],
+            "description": "Guzzle promises library",
+            "keywords": [
+                "promise"
+            ],
+            "support": {
+                "issues": "https://github.com/guzzle/promises/issues",
+                "source": "https://github.com/guzzle/promises/tree/2.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-22T14:34:08+00:00"
+        },
         {
             "name": "guzzlehttp/psr7",
             "version": "2.9.0",
@@ -802,6 +1141,121 @@
             ],
             "time": "2026-03-19T18:52:39+00:00"
         },
+        {
+            "name": "maxmind-db/reader",
+            "version": "v1.13.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git",
+                "reference": "2194f58d0f024ce923e685cdf92af3daf9951908"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/2194f58d0f024ce923e685cdf92af3daf9951908",
+                "reference": "2194f58d0f024ce923e685cdf92af3daf9951908",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "conflict": {
+                "ext-maxminddb": "<1.11.1 || >=2.0.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "3.*",
+                "phpstan/phpstan": "*",
+                "phpunit/phpunit": ">=8.0.0,<10.0.0",
+                "squizlabs/php_codesniffer": "4.*"
+            },
+            "suggest": {
+                "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
+                "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
+                "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups",
+                "maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "MaxMind\\Db\\": "src/MaxMind/Db"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Gregory J. Oschwald",
+                    "email": "goschwald@maxmind.com",
+                    "homepage": "https://www.maxmind.com/"
+                }
+            ],
+            "description": "MaxMind DB Reader API",
+            "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php",
+            "keywords": [
+                "database",
+                "geoip",
+                "geoip2",
+                "geolocation",
+                "maxmind"
+            ],
+            "support": {
+                "issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues",
+                "source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.13.1"
+            },
+            "time": "2025-11-21T22:24:26+00:00"
+        },
+        {
+            "name": "maxmind/web-service-common",
+            "version": "v0.11.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/maxmind/web-service-common-php.git",
+                "reference": "c309236b5a5555b96cf560089ec3cead12d845d2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/c309236b5a5555b96cf560089ec3cead12d845d2",
+                "reference": "c309236b5a5555b96cf560089ec3cead12d845d2",
+                "shasum": ""
+            },
+            "require": {
+                "composer/ca-bundle": "^1.0.3",
+                "ext-curl": "*",
+                "ext-json": "*",
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "3.*",
+                "phpstan/phpstan": "*",
+                "phpunit/phpunit": "^10.0",
+                "squizlabs/php_codesniffer": "4.*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "MaxMind\\Exception\\": "src/Exception",
+                    "MaxMind\\WebService\\": "src/WebService"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Gregory Oschwald",
+                    "email": "goschwald@maxmind.com"
+                }
+            ],
+            "description": "Internal MaxMind Web Service API",
+            "homepage": "https://github.com/maxmind/web-service-common-php",
+            "support": {
+                "issues": "https://github.com/maxmind/web-service-common-php/issues",
+                "source": "https://github.com/maxmind/web-service-common-php/tree/v0.11.1"
+            },
+            "time": "2026-01-13T17:56:03+00:00"
+        },
         {
             "name": "monolog/monolog",
             "version": "3.10.0",
@@ -1283,6 +1737,58 @@
             },
             "time": "2019-01-08T18:20:26+00:00"
         },
+        {
+            "name": "psr/http-client",
+            "version": "1.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-client.git",
+                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.0 || ^8.0",
+                "psr/http-message": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP clients",
+            "homepage": "https://github.com/php-fig/http-client",
+            "keywords": [
+                "http",
+                "http-client",
+                "psr",
+                "psr-18"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-client"
+            },
+            "time": "2023-09-23T14:17:50+00:00"
+        },
         {
             "name": "psr/http-factory",
             "version": "1.1.0",

+ 9 - 0
api/config/settings.php

@@ -53,4 +53,13 @@ return [
     'job_audit_retention_days' => (int) (getenv('JOB_AUDIT_RETENTION_DAYS') ?: 180),
     'cidr_evaluator_ttl_seconds' => (int) (getenv('CIDR_EVALUATOR_TTL_SECONDS') ?: 60),
     'blocklist_cache_ttl_seconds' => (int) (getenv('BLOCKLIST_CACHE_TTL_SECONDS') ?: 30),
+    'geoip' => [
+        'enabled' => filter_var(getenv('GEOIP_ENABLED') ?: 'true', FILTER_VALIDATE_BOOL),
+        'provider' => strtolower((string) (getenv('GEOIP_PROVIDER') ?: 'dbip')),
+        'country_db' => getenv('GEOIP_COUNTRY_DB') ?: '/data/geoip/country.mmdb',
+        'asn_db' => getenv('GEOIP_ASN_DB') ?: '/data/geoip/asn.mmdb',
+        'maxmind_license_key' => getenv('MAXMIND_LICENSE_KEY') ?: '',
+        'ipinfo_token' => getenv('IPINFO_TOKEN') ?: '',
+        'refresh_interval_days' => (int) (getenv('JOB_GEOIP_REFRESH_INTERVAL_DAYS') ?: 7),
+    ],
 ];

+ 50 - 2
api/src/App/AppFactory.php

@@ -84,8 +84,52 @@ final class AppFactory
         /** @var InternalTokenMiddleware $internalToken */
         $internalToken = $container->get(InternalTokenMiddleware::class);
 
-        $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
-            $response->getBody()->write((string) json_encode(['status' => 'ok']));
+        $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response) use ($container): ResponseInterface {
+            /** @var array{driver: string} $dbSettings */
+            $dbSettings = $container->get('settings.db');
+            $dbConnected = false;
+            try {
+                /** @var \Doctrine\DBAL\Connection $conn */
+                $conn = $container->get(\Doctrine\DBAL\Connection::class);
+                $conn->executeQuery('SELECT 1')->fetchOne();
+                $dbConnected = true;
+            } catch (\Throwable) {
+                $dbConnected = false;
+            }
+
+            /** @var array{enabled: bool, provider: string, country_db: string, asn_db: string, maxmind_license_key: string, ipinfo_token: string} $geoip */
+            $geoip = $container->get('settings.geoip');
+            $providerConfigured = match ($geoip['provider']) {
+                'maxmind' => $geoip['maxmind_license_key'] !== '',
+                'ipinfo' => $geoip['ipinfo_token'] !== '',
+                default => true,
+            };
+
+            $countryPresent = is_file($geoip['country_db']) && is_readable($geoip['country_db']);
+            $asnPresent = is_file($geoip['asn_db']) && is_readable($geoip['asn_db']);
+
+            $countryMtime = $countryPresent
+                ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['country_db']))
+                : null;
+            $asnMtime = $asnPresent
+                ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['asn_db']))
+                : null;
+
+            $response->getBody()->write((string) json_encode([
+                'status' => 'ok',
+                'db' => [
+                    'connected' => $dbConnected,
+                    'driver' => $dbSettings['driver'],
+                ],
+                'geoip' => [
+                    'provider' => $geoip['provider'],
+                    'provider_configured' => $providerConfigured,
+                    'country_db_present' => $countryPresent,
+                    'asn_db_present' => $asnPresent,
+                    'country_db_modified' => $countryMtime,
+                    'asn_db_modified' => $asnMtime,
+                ],
+            ]));
 
             return $response->withHeader('Content-Type', 'application/json');
         });
@@ -187,6 +231,10 @@ final class AppFactory
             $ips = $container->get(IpsController::class);
             $admin->get('/ips', [$ips, 'list'])
                 ->add(RbacMiddleware::require($rf, Role::Viewer));
+            // /ips/countries must come BEFORE /ips/{ip:.+} or it'd be
+            // matched as an IP.
+            $admin->get('/ips/countries', [$ips, 'countries'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
             $admin->get('/ips/{ip:.+}', [$ips, 'show'])
                 ->add(RbacMiddleware::require($rf, Role::Viewer));
 

+ 101 - 1
api/src/App/Container.php

@@ -19,12 +19,14 @@ use App\Application\Internal\JobsController;
 use App\Application\Jobs\CleanupAuditJob;
 use App\Application\Jobs\EnrichPendingJob;
 use App\Application\Jobs\RecomputeScoresJob;
+use App\Application\Jobs\RefreshGeoipJob;
 use App\Application\Jobs\TickJob;
 use App\Application\Public\BlocklistController;
 use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Domain\Auth\TokenHasher;
 use App\Domain\Auth\TokenIssuer;
+use App\Domain\Enrichment\EnrichmentService;
 use App\Domain\Reputation\BlocklistBuilder;
 use App\Domain\Reputation\EffectiveStatusService;
 use App\Domain\Reputation\PairScorer;
@@ -38,6 +40,14 @@ use App\Infrastructure\Auth\UserRepository;
 use App\Infrastructure\Category\CategoryRepository;
 use App\Infrastructure\Consumer\ConsumerRepository;
 use App\Infrastructure\Db\ConnectionFactory;
+use App\Infrastructure\Enrichment\Downloaders\DbipDownloader;
+use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
+use App\Infrastructure\Enrichment\Downloaders\IPinfoDownloader;
+use App\Infrastructure\Enrichment\Downloaders\MaxMindDownloader;
+use App\Infrastructure\Enrichment\IpinfoRecordAdapter;
+use App\Infrastructure\Enrichment\MaxMindRecordAdapter;
+use App\Infrastructure\Enrichment\MmdbEnrichmentService;
+use App\Infrastructure\Enrichment\RecordAdapter;
 use App\Infrastructure\Http\JsonErrorHandler;
 use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
 use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
@@ -67,6 +77,8 @@ use DI\ContainerBuilder;
 use function DI\factory;
 
 use Doctrine\DBAL\Connection;
+use GuzzleHttp\Client as GuzzleClient;
+use GuzzleHttp\ClientInterface as GuzzleClientInterface;
 use Monolog\Formatter\JsonFormatter;
 use Monolog\Handler\StreamHandler;
 use Monolog\Logger;
@@ -109,6 +121,15 @@ final class Container
             'settings.job_audit_retention_days' => (int) ($settings['job_audit_retention_days'] ?? 180),
             'settings.cidr_evaluator_ttl_seconds' => (int) ($settings['cidr_evaluator_ttl_seconds'] ?? 60),
             'settings.blocklist_cache_ttl_seconds' => (int) ($settings['blocklist_cache_ttl_seconds'] ?? 30),
+            'settings.geoip' => $settings['geoip'] ?? [
+                'enabled' => true,
+                'provider' => 'dbip',
+                'country_db' => '/data/geoip/country.mmdb',
+                'asn_db' => '/data/geoip/asn.mmdb',
+                'maxmind_license_key' => '',
+                'ipinfo_token' => '',
+                'refresh_interval_days' => 7,
+            ],
             ConnectionFactory::class => factory(static function (ContainerInterface $c): ConnectionFactory {
                 /** @var array{driver: string, sqlite_path: string, mysql_host: string, mysql_port: int, mysql_database: string, mysql_username: string, mysql_password: string} $db */
                 $db = $c->get('settings.db');
@@ -236,7 +257,83 @@ final class Container
 
                 return new CleanupAuditJob($conn, $days);
             }),
-            EnrichPendingJob::class => autowire(),
+            GuzzleClientInterface::class => factory(static function (): GuzzleClientInterface {
+                return new GuzzleClient([
+                    'connect_timeout' => 10,
+                    'timeout' => 120,
+                    'http_errors' => true,
+                    'headers' => [
+                        'User-Agent' => 'irdb-geoip-refresh/1.0',
+                    ],
+                ]);
+            }),
+            RecordAdapter::class => factory(static function (ContainerInterface $c): RecordAdapter {
+                /** @var array{provider: string} $g */
+                $g = $c->get('settings.geoip');
+
+                return $g['provider'] === 'ipinfo'
+                    ? new IpinfoRecordAdapter()
+                    : new MaxMindRecordAdapter();
+            }),
+            MmdbEnrichmentService::class => factory(static function (ContainerInterface $c): MmdbEnrichmentService {
+                /** @var array{country_db: string, asn_db: string} $g */
+                $g = $c->get('settings.geoip');
+                /** @var RecordAdapter $adapter */
+                $adapter = $c->get(RecordAdapter::class);
+                /** @var Clock $clock */
+                $clock = $c->get(Clock::class);
+                /** @var LoggerInterface $logger */
+                $logger = $c->get(LoggerInterface::class);
+
+                return new MmdbEnrichmentService($g['country_db'], $g['asn_db'], $adapter, $clock, $logger);
+            }),
+            EnrichmentService::class => factory(static function (ContainerInterface $c): EnrichmentService {
+                /** @var MmdbEnrichmentService $svc */
+                $svc = $c->get(MmdbEnrichmentService::class);
+
+                return $svc;
+            }),
+            GeoIpDownloader::class => factory(static function (ContainerInterface $c): GeoIpDownloader {
+                /** @var array{provider: string, maxmind_license_key: string, ipinfo_token: string} $g */
+                $g = $c->get('settings.geoip');
+                /** @var GuzzleClientInterface $http */
+                $http = $c->get(GuzzleClientInterface::class);
+                /** @var Clock $clock */
+                $clock = $c->get(Clock::class);
+
+                return match ($g['provider']) {
+                    'maxmind' => new MaxMindDownloader($http, $g['maxmind_license_key']),
+                    'ipinfo' => new IPinfoDownloader($http, $g['ipinfo_token']),
+                    default => new DbipDownloader($http, $clock),
+                };
+            }),
+            EnrichPendingJob::class => factory(static function (ContainerInterface $c): EnrichPendingJob {
+                /** @var EnrichmentService $svc */
+                $svc = $c->get(EnrichmentService::class);
+                /** @var IpEnrichmentRepository $repo */
+                $repo = $c->get(IpEnrichmentRepository::class);
+
+                return new EnrichPendingJob($svc, $repo);
+            }),
+            RefreshGeoipJob::class => factory(static function (ContainerInterface $c): RefreshGeoipJob {
+                /** @var GeoIpDownloader $downloader */
+                $downloader = $c->get(GeoIpDownloader::class);
+                /** @var MmdbEnrichmentService $svc */
+                $svc = $c->get(MmdbEnrichmentService::class);
+                /** @var IpEnrichmentRepository $repo */
+                $repo = $c->get(IpEnrichmentRepository::class);
+                /** @var array{country_db: string, asn_db: string, refresh_interval_days: int} $g */
+                $g = $c->get('settings.geoip');
+
+                return new RefreshGeoipJob(
+                    $downloader,
+                    $svc,
+                    $repo,
+                    $g['country_db'],
+                    $g['asn_db'],
+                    (int) $g['refresh_interval_days'],
+                );
+            }),
             TickJob::class => factory(static function (ContainerInterface $c): TickJob {
                 /** @var JobRunner $runner */
                 $runner = $c->get(JobRunner::class);
@@ -259,11 +356,14 @@ final class Container
                 $cleanup = $c->get(CleanupAuditJob::class);
                 /** @var EnrichPendingJob $enrich */
                 $enrich = $c->get(EnrichPendingJob::class);
+                /** @var RefreshGeoipJob $refresh */
+                $refresh = $c->get(RefreshGeoipJob::class);
                 /** @var TickJob $tick */
                 $tick = $c->get(TickJob::class);
                 $registry->register($recompute);
                 $registry->register($cleanup);
                 $registry->register($enrich);
+                $registry->register($refresh);
                 $registry->register($tick);
 
                 return $registry;

+ 11 - 0
api/src/Application/Admin/IpsController.php

@@ -104,6 +104,17 @@ final class IpsController
         ]);
     }
 
+    /**
+     * Country dropdown source for the IPs list page. Returns every
+     * country code seen so far in `ip_enrichment` with its population.
+     */
+    public function countries(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        return self::json($response, 200, [
+            'items' => $this->enrichment->countryCounts(),
+        ]);
+    }
+
     /**
      * @param array{ip: string} $args
      */

+ 55 - 6
api/src/Application/Internal/JobsController.php

@@ -7,9 +7,11 @@ namespace App\Application\Internal;
 use App\Application\Jobs\CleanupAuditJob;
 use App\Application\Jobs\EnrichPendingJob;
 use App\Application\Jobs\RecomputeScoresJob;
+use App\Application\Jobs\RefreshGeoipJob;
 use App\Application\Jobs\TickJob;
 use App\Domain\Jobs\JobOutcome;
 use App\Domain\Time\Clock;
+use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
 use App\Infrastructure\Jobs\JobLockRepository;
 use App\Infrastructure\Jobs\JobRegistry;
 use App\Infrastructure\Jobs\JobRunner;
@@ -34,6 +36,7 @@ final class JobsController
         private readonly JobRunRepository $runs,
         private readonly JobLockRepository $locks,
         private readonly Clock $clock,
+        private readonly GeoIpDownloader $geoipDownloader,
     ) {
     }
 
@@ -80,13 +83,59 @@ final class JobsController
 
     public function refreshGeoip(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
     {
-        $response = $response->withStatus(412)->withHeader('Content-Type', 'application/json');
-        $response->getBody()->write((string) json_encode([
-            'error' => 'not_implemented',
-            'reason' => 'GeoIP refresh lands in M11',
-        ]));
+        // Opt-in providers without their credential: short-circuit before
+        // taking the lock or starting the job. DB-IP needs no credential
+        // and never reaches this branch.
+        if ($this->geoipDownloader->requiresCredential() && !$this->geoipDownloader->hasCredential()) {
+            $missing = match ($this->geoipDownloader->name()) {
+                'maxmind' => 'MAXMIND_LICENSE_KEY',
+                'ipinfo' => 'IPINFO_TOKEN',
+                default => 'CREDENTIAL',
+            };
+            $response = $response->withStatus(412)->withHeader('Content-Type', 'application/json');
+            $response->getBody()->write((string) json_encode([
+                'error' => 'no_credential',
+                'provider' => $this->geoipDownloader->name(),
+                'missing' => $missing,
+            ]));
+
+            return $response;
+        }
 
-        return $response;
+        $query = $request->getQueryParams();
+        $params = [];
+        if (isset($query['reenrich'])) {
+            $params['reenrich'] = filter_var($query['reenrich'], FILTER_VALIDATE_BOOL);
+        }
+        if (isset($query['dry_run']) && filter_var($query['dry_run'], FILTER_VALIDATE_BOOL)) {
+            // dry_run only confirms credentials/config — used by tests so the
+            // controller can answer "would 412 fire?" without actually
+            // pulling 100 MB of MMDBs over the wire.
+            $response = $response->withStatus(202)->withHeader('Content-Type', 'application/json');
+            $response->getBody()->write((string) json_encode([
+                'job' => self::nameForRefresh(),
+                'status' => 'success',
+                'items_processed' => 0,
+                'duration_ms' => 0,
+                'run_id' => null,
+                'details' => [
+                    'provider' => $this->geoipDownloader->name(),
+                    'dry_run' => true,
+                ],
+            ]));
+
+            return $response;
+        }
+
+        $job = $this->registry->get(RefreshGeoipJob::NAME);
+        $outcome = $this->runner->run($job, $params, 'schedule');
+
+        return self::renderOutcome($response, $outcome);
+    }
+
+    private static function nameForRefresh(): string
+    {
+        return RefreshGeoipJob::NAME;
     }
 
     public function status(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface

+ 54 - 6
api/src/Application/Jobs/EnrichPendingJob.php

@@ -4,20 +4,36 @@ declare(strict_types=1);
 
 namespace App\Application\Jobs;
 
+use App\Domain\Enrichment\EnrichmentService;
+use App\Domain\Ip\IpAddress;
 use App\Domain\Jobs\Job;
 use App\Domain\Jobs\JobContext;
 use App\Domain\Jobs\JobResult;
+use App\Infrastructure\Enrichment\MmdbEnrichmentService;
+use App\Infrastructure\Reputation\IpEnrichmentRepository;
 
 /**
- * Skeleton for GeoIP/ASN enrichment. M11 wires in the MaxMind reader,
- * batches IPs missing enrichment, and writes `ip_enrichment`. Until then
- * this is a no-op that logs at debug level so the schedule is exercised
- * end-to-end.
+ * Pulls a batch of IPs missing enrichment, looks each one up against the
+ * configured MMDBs, and upserts the result. Skips empty results so a
+ * missing-DB scenario doesn't poison `ip_enrichment` with all-null rows
+ * (the next run, after refresh-geoip populates the files, finds them
+ * still pending and fills them in correctly).
+ *
+ * Idempotent by construction: `findPending` already excludes rows whose
+ * `enriched_at` is non-null.
  */
 final class EnrichPendingJob implements Job
 {
     public const NAME = 'enrich-pending';
 
+    private const BATCH_LIMIT = 200;
+
+    public function __construct(
+        private readonly EnrichmentService $enrichment,
+        private readonly IpEnrichmentRepository $repo,
+    ) {
+    }
+
     public function name(): string
     {
         return self::NAME;
@@ -35,8 +51,40 @@ final class EnrichPendingJob implements Job
 
     public function run(JobContext $context): JobResult
     {
-        $context->logger->debug('enrich_pending_skeleton', ['note' => 'M11 will fill this in']);
+        // Fast-path: if neither DB is loadable, skip the batch entirely
+        // rather than running 200 lookups that all return empty. The
+        // generic EnrichmentService interface doesn't expose this; the
+        // only concrete impl does.
+        if ($this->enrichment instanceof MmdbEnrichmentService && !$this->enrichment->isReady()) {
+            $context->logger->warning('enrich_pending_no_db', [
+                'note' => 'GeoIP DBs not present — skipping batch',
+            ]);
+
+            return new JobResult(itemsProcessed: 0, details: ['db_missing' => true]);
+        }
+
+        $pending = $this->repo->findPending(self::BATCH_LIMIT);
+        if ($pending === []) {
+            return new JobResult(itemsProcessed: 0);
+        }
+
+        $processed = 0;
+        $skipped = 0;
+        foreach ($pending as $ipBin) {
+            $ip = IpAddress::fromBinary($ipBin);
+            $result = $this->enrichment->enrich($ip);
+            if ($result->isEmpty()) {
+                $skipped++;
+
+                continue;
+            }
+            $this->repo->upsert($ipBin, $result);
+            $processed++;
+        }
 
-        return new JobResult(itemsProcessed: 0, details: ['skeleton' => true]);
+        return new JobResult(
+            itemsProcessed: $processed,
+            details: ['skipped_empty' => $skipped, 'batch_size' => count($pending)],
+        );
     }
 }

+ 173 - 0
api/src/Application/Jobs/RefreshGeoipJob.php

@@ -0,0 +1,173 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Jobs;
+
+use App\Domain\Jobs\Job;
+use App\Domain\Jobs\JobContext;
+use App\Domain\Jobs\JobResult;
+use App\Infrastructure\Enrichment\Downloaders\DownloaderException;
+use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
+use App\Infrastructure\Enrichment\MmdbEnrichmentService;
+use App\Infrastructure\Reputation\IpEnrichmentRepository;
+use MaxMind\Db\Reader;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Provider-agnostic refresh job. The provider-specific bits live in
+ * `GeoIpDownloader` impls; this class owns: temp dir prep, download
+ * orchestration, atomic file replace onto the configured paths, and
+ * (optional) re-enrichment trigger.
+ *
+ * The HTTP-handler short-circuits 412 when an opt-in provider has no
+ * credential; this class assumes a usable downloader and proceeds.
+ */
+final class RefreshGeoipJob implements Job
+{
+    public const NAME = 'refresh-geoip';
+    private const MAX_RUNTIME_SECONDS = 300; // 5 minutes — large downloads
+    private const TEMP_DIR_PREFIX = 'geoip-refresh-';
+
+    public function __construct(
+        private readonly GeoIpDownloader $downloader,
+        private readonly MmdbEnrichmentService $service,
+        private readonly IpEnrichmentRepository $repo,
+        private readonly string $countryDbPath,
+        private readonly string $asnDbPath,
+        private readonly int $intervalDays,
+    ) {
+    }
+
+    public function name(): string
+    {
+        return self::NAME;
+    }
+
+    public function defaultIntervalSeconds(): int
+    {
+        return $this->intervalDays * 86_400;
+    }
+
+    public function maxRuntimeSeconds(): int
+    {
+        return self::MAX_RUNTIME_SECONDS;
+    }
+
+    public function run(JobContext $context): JobResult
+    {
+        $reenrich = (bool) $context->param('reenrich', false);
+        $tempDir = $this->makeTempDir();
+
+        try {
+            $paths = $this->downloader->download($tempDir);
+
+            $this->ensureTargetDirs();
+            $this->atomicReplace($paths['country'], $this->countryDbPath);
+            $this->atomicReplace($paths['asn'], $this->asnDbPath);
+
+            $this->service->reloadReaders();
+
+            $countryNodes = $this->readNodeCount($this->countryDbPath);
+            $asnNodes = $this->readNodeCount($this->asnDbPath);
+
+            $cleared = 0;
+            if ($reenrich) {
+                $cleared = $this->repo->clearAllEnrichedAt();
+            }
+
+            return new JobResult(
+                itemsProcessed: $countryNodes + $asnNodes,
+                details: [
+                    'provider' => $this->downloader->name(),
+                    'country_nodes' => $countryNodes,
+                    'asn_nodes' => $asnNodes,
+                    'reenriched' => $cleared,
+                ],
+            );
+        } catch (DownloaderException $e) {
+            $context->logger->error('refresh_geoip_download_failed', [
+                'provider' => $this->downloader->name(),
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        } finally {
+            $this->cleanup($tempDir);
+        }
+    }
+
+    private function makeTempDir(): string
+    {
+        $dir = sys_get_temp_dir() . '/' . self::TEMP_DIR_PREFIX . bin2hex(random_bytes(6));
+        if (!mkdir($dir, 0o700, true) && !is_dir($dir)) {
+            throw new RuntimeException(sprintf('cannot mkdir temp %s', $dir));
+        }
+
+        return $dir;
+    }
+
+    private function ensureTargetDirs(): void
+    {
+        foreach ([$this->countryDbPath, $this->asnDbPath] as $path) {
+            $dir = dirname($path);
+            if (!is_dir($dir) && !@mkdir($dir, 0o755, true) && !is_dir($dir)) {
+                throw new RuntimeException(sprintf('cannot mkdir target %s', $dir));
+            }
+        }
+    }
+
+    private function atomicReplace(string $sourcePath, string $targetPath): void
+    {
+        $targetDir = dirname($targetPath);
+        // Use tempnam in the SAME directory as the target so rename() is
+        // atomic (POSIX rename across filesystems is not).
+        $stagedPath = @tempnam($targetDir, 'geoip-stage-');
+        if ($stagedPath === false) {
+            throw new RuntimeException(sprintf('tempnam in %s failed', $targetDir));
+        }
+
+        if (!@copy($sourcePath, $stagedPath)) {
+            @unlink($stagedPath);
+
+            throw new RuntimeException(sprintf('copy %s -> %s failed', $sourcePath, $stagedPath));
+        }
+        // tempnam creates a 0600 file; relax to readable so other procs can open it.
+        @chmod($stagedPath, 0o644);
+
+        if (!@rename($stagedPath, $targetPath)) {
+            @unlink($stagedPath);
+
+            throw new RuntimeException(sprintf('rename %s -> %s failed', $stagedPath, $targetPath));
+        }
+    }
+
+    private function readNodeCount(string $path): int
+    {
+        try {
+            $reader = new Reader($path);
+            $count = $reader->metadata()->nodeCount;
+            $reader->close();
+
+            return (int) $count;
+        } catch (Throwable) {
+            return 0;
+        }
+    }
+
+    private function cleanup(string $dir): void
+    {
+        if (!is_dir($dir)) {
+            return;
+        }
+        foreach (glob($dir . '/*') ?: [] as $entry) {
+            if (is_dir($entry)) {
+                $this->cleanup($entry);
+            } else {
+                @unlink($entry);
+            }
+        }
+        @rmdir($dir);
+    }
+}

+ 30 - 0
api/src/Domain/Enrichment/EnrichmentResult.php

@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Enrichment;
+
+use DateTimeImmutable;
+
+/**
+ * What an MMDB lookup returns for one IP. All three fields are nullable
+ * because the upstream provider may have incomplete data, or because the
+ * lookup found no matching record at all (in which case every field is
+ * null and {@see isEmpty()} returns true — callers use that to skip
+ * persisting "poison" rows).
+ */
+final class EnrichmentResult
+{
+    public function __construct(
+        public readonly ?string $countryCode,
+        public readonly ?int $asn,
+        public readonly ?string $asOrg,
+        public readonly DateTimeImmutable $enrichedAt,
+    ) {
+    }
+
+    public function isEmpty(): bool
+    {
+        return $this->countryCode === null && $this->asn === null;
+    }
+}

+ 18 - 0
api/src/Domain/Enrichment/EnrichmentService.php

@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Enrichment;
+
+use App\Domain\Ip\IpAddress;
+
+/**
+ * Resolves an IP into a country / ASN tuple. Implementations must never
+ * throw on lookup failure — they return an empty {@see EnrichmentResult}
+ * (all-null) so callers can skip persisting it without exception
+ * handling around every call site.
+ */
+interface EnrichmentService
+{
+    public function enrich(IpAddress $ip): EnrichmentResult;
+}

+ 127 - 0
api/src/Infrastructure/Enrichment/Downloaders/DbipDownloader.php

@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment\Downloaders;
+
+use App\Domain\Time\Clock;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\GuzzleException;
+use GuzzleHttp\Exception\RequestException;
+use Throwable;
+
+/**
+ * Default provider — no auth, CC BY 4.0. Files publish on/around the
+ * 1st of every month at:
+ *   https://download.db-ip.com/free/dbip-{country,asn}-lite-YYYY-MM.mmdb.gz
+ *
+ * On 404 (rollover edge: the new month exists but the file isn't up
+ * yet) we fall back exactly one month and try again. No SHA-256 is
+ * published; integrity is gzip-decode + MMDB-metadata + node-count.
+ */
+final class DbipDownloader implements GeoIpDownloader
+{
+    private const BASE_URL = 'https://download.db-ip.com/free';
+
+    public function __construct(
+        private readonly ClientInterface $http,
+        private readonly Clock $clock,
+    ) {
+    }
+
+    public function name(): string
+    {
+        return 'dbip';
+    }
+
+    public function requiresCredential(): bool
+    {
+        return false;
+    }
+
+    public function hasCredential(): bool
+    {
+        return true;
+    }
+
+    public function download(string $tempDir): array
+    {
+        $now = $this->clock->now();
+        $stamps = [
+            $now->format('Y-m'),
+            $now->modify('first day of last month')->format('Y-m'),
+        ];
+
+        $countryPath = $this->downloadOne(
+            $tempDir,
+            'country',
+            'dbip-country-lite',
+            $stamps,
+        );
+        MmdbVerifier::assertCountry($countryPath);
+
+        $asnPath = $this->downloadOne(
+            $tempDir,
+            'asn',
+            'dbip-asn-lite',
+            $stamps,
+        );
+        MmdbVerifier::assertAsn($asnPath);
+
+        return ['country' => $countryPath, 'asn' => $asnPath];
+    }
+
+    /**
+     * @param list<string> $stamps Try month stamps in order; cap at 2 (current + 1 fallback).
+     */
+    private function downloadOne(string $tempDir, string $kind, string $slug, array $stamps): string
+    {
+        $lastError = null;
+        foreach ($stamps as $stamp) {
+            $url = sprintf('%s/%s-%s.mmdb.gz', self::BASE_URL, $slug, $stamp);
+            $gzPath = $tempDir . '/' . $kind . '.mmdb.gz';
+            try {
+                $this->http->request('GET', $url, ['sink' => $gzPath]);
+            } catch (RequestException $e) {
+                $status = $e->getResponse()?->getStatusCode();
+                $lastError = sprintf('GET %s: HTTP %d', $url, $status ?? 0);
+                if ($status === 404) {
+                    continue; // try fallback month
+                }
+
+                throw new DownloaderException($lastError, 0, $e);
+            } catch (GuzzleException $e) {
+                throw new DownloaderException(sprintf('GET %s: %s', $url, $e->getMessage()), 0, $e);
+            }
+
+            $mmdbPath = $tempDir . '/' . $kind . '.mmdb';
+            $this->gunzip($gzPath, $mmdbPath);
+
+            return $mmdbPath;
+        }
+
+        throw new DownloaderException(
+            sprintf('dbip %s download exhausted fallback (last: %s)', $kind, $lastError ?? 'unknown'),
+        );
+    }
+
+    private function gunzip(string $gzPath, string $outPath): void
+    {
+        $compressed = @file_get_contents($gzPath);
+        if ($compressed === false) {
+            throw new DownloaderException(sprintf('cannot read downloaded gz at %s', $gzPath));
+        }
+        try {
+            $plain = @gzdecode($compressed);
+        } catch (Throwable $e) {
+            throw new DownloaderException(sprintf('gzdecode failed for %s', $gzPath), 0, $e);
+        }
+        if ($plain === false || $plain === '') {
+            throw new DownloaderException(sprintf('gzdecode produced empty output for %s', $gzPath));
+        }
+        if (@file_put_contents($outPath, $plain) === false) {
+            throw new DownloaderException(sprintf('cannot write decoded mmdb to %s', $outPath));
+        }
+        @unlink($gzPath);
+    }
+}

+ 17 - 0
api/src/Infrastructure/Enrichment/Downloaders/DownloaderException.php

@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment\Downloaders;
+
+use RuntimeException;
+
+/**
+ * Wraps any failure from the provider-specific download path. The
+ * job catches this and writes a `failure` row in `job_runs`; the message
+ * is sanitised before logging — provider impls must never put a
+ * credential into the message.
+ */
+final class DownloaderException extends RuntimeException
+{
+}

+ 31 - 0
api/src/Infrastructure/Enrichment/Downloaders/GeoIpDownloader.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment\Downloaders;
+
+/**
+ * Provider abstraction for the refresh-geoip job.
+ *
+ * Three impls (DB-IP, MaxMind, IPinfo) all produce two `.mmdb` files in
+ * the supplied temp dir. The job atomic-renames them onto the configured
+ * `GEOIP_COUNTRY_DB` / `GEOIP_ASN_DB` paths; the lookup service never
+ * sees provider details. The download path is the only thing that forks
+ * per provider.
+ */
+interface GeoIpDownloader
+{
+    public function name(): string;
+
+    public function requiresCredential(): bool;
+
+    public function hasCredential(): bool;
+
+    /**
+     * Pull the country + ASN MMDBs into $tempDir and return the paths
+     * to the verified files. May throw on network / verification error.
+     *
+     * @return array{country: string, asn: string}
+     */
+    public function download(string $tempDir): array;
+}

+ 69 - 0
api/src/Infrastructure/Enrichment/Downloaders/IPinfoDownloader.php

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment\Downloaders;
+
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\GuzzleException;
+
+/**
+ * Opt-in — requires IPINFO_TOKEN. Direct .mmdb downloads, no
+ * compression, no integrity file. Verification is the cross-provider
+ * MmdbVerifier (open + metadata + node-count sanity).
+ *
+ * The token is never logged: error messages substitute "***" for the
+ * real value.
+ */
+final class IPinfoDownloader implements GeoIpDownloader
+{
+    private const COUNTRY_URL = 'https://ipinfo.io/data/free/country.mmdb';
+    private const ASN_URL = 'https://ipinfo.io/data/free/asn.mmdb';
+
+    public function __construct(
+        private readonly ClientInterface $http,
+        private readonly string $token,
+    ) {
+    }
+
+    public function name(): string
+    {
+        return 'ipinfo';
+    }
+
+    public function requiresCredential(): bool
+    {
+        return true;
+    }
+
+    public function hasCredential(): bool
+    {
+        return $this->token !== '';
+    }
+
+    public function download(string $tempDir): array
+    {
+        $countryPath = $tempDir . '/country.mmdb';
+        $asnPath = $tempDir . '/asn.mmdb';
+
+        $this->fetch(self::COUNTRY_URL, $countryPath);
+        MmdbVerifier::assertCountry($countryPath);
+
+        $this->fetch(self::ASN_URL, $asnPath);
+        MmdbVerifier::assertAsn($asnPath);
+
+        return ['country' => $countryPath, 'asn' => $asnPath];
+    }
+
+    private function fetch(string $url, string $sink): void
+    {
+        try {
+            $this->http->request('GET', $url, [
+                'sink' => $sink,
+                'query' => ['token' => $this->token],
+            ]);
+        } catch (GuzzleException $e) {
+            throw new DownloaderException(sprintf('GET %s?token=*** failed', $url), 0, $e);
+        }
+    }
+}

+ 136 - 0
api/src/Infrastructure/Enrichment/Downloaders/MaxMindDownloader.php

@@ -0,0 +1,136 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment\Downloaders;
+
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\GuzzleException;
+use PharData;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use Throwable;
+
+/**
+ * Opt-in — requires MAXMIND_LICENSE_KEY. Downloads the permalink
+ * tarball and verifies SHA-256 against the published companion file.
+ *
+ * The MaxMind tarball nests one directory like
+ * `GeoLite2-Country_20260427/GeoLite2-Country.mmdb`. We extract and
+ * walk to find the .mmdb leaf.
+ *
+ * The license key is never logged: error messages substitute a
+ * fixed-shape URL token instead of the real query string.
+ */
+final class MaxMindDownloader implements GeoIpDownloader
+{
+    private const ENDPOINT = 'https://download.maxmind.com/app/geoip_download';
+
+    public function __construct(
+        private readonly ClientInterface $http,
+        private readonly string $licenseKey,
+    ) {
+    }
+
+    public function name(): string
+    {
+        return 'maxmind';
+    }
+
+    public function requiresCredential(): bool
+    {
+        return true;
+    }
+
+    public function hasCredential(): bool
+    {
+        return $this->licenseKey !== '';
+    }
+
+    public function download(string $tempDir): array
+    {
+        $countryPath = $this->fetchEdition($tempDir, 'GeoLite2-Country', 'country');
+        MmdbVerifier::assertCountry($countryPath);
+
+        $asnPath = $this->fetchEdition($tempDir, 'GeoLite2-ASN', 'asn');
+        MmdbVerifier::assertAsn($asnPath);
+
+        return ['country' => $countryPath, 'asn' => $asnPath];
+    }
+
+    private function fetchEdition(string $tempDir, string $edition, string $kind): string
+    {
+        $tarPath = $tempDir . '/' . $kind . '.tar.gz';
+        $sha256Path = $tempDir . '/' . $kind . '.tar.gz.sha256';
+
+        $this->fetchTo($edition, 'tar.gz', $tarPath);
+        $this->fetchTo($edition, 'tar.gz.sha256', $sha256Path);
+
+        $expected = trim((string) file_get_contents($sha256Path));
+        // The .sha256 file is "<hash>  <filename>"; take the leading hash.
+        if (preg_match('/^([0-9a-f]{64})/', $expected, $m) !== 1) {
+            throw new DownloaderException(sprintf('maxmind %s sha256 file unparseable', $kind));
+        }
+        $expectedHash = $m[1];
+        $actualHash = hash_file('sha256', $tarPath);
+        if ($actualHash !== $expectedHash) {
+            throw new DownloaderException(sprintf(
+                'maxmind %s sha256 mismatch (expected %s, got %s)',
+                $kind,
+                $expectedHash,
+                (string) $actualHash,
+            ));
+        }
+
+        $extractDir = $tempDir . '/' . $kind . '-extract';
+        if (!mkdir($extractDir) && !is_dir($extractDir)) {
+            throw new DownloaderException(sprintf('cannot mkdir %s', $extractDir));
+        }
+        try {
+            $phar = new PharData($tarPath);
+            $phar->extractTo($extractDir, null, true);
+        } catch (Throwable $e) {
+            throw new DownloaderException(
+                sprintf('maxmind %s extract failed: %s', $kind, $e->getMessage()),
+                0,
+                $e,
+            );
+        }
+
+        $mmdbPath = $this->findMmdb($extractDir);
+        if ($mmdbPath === null) {
+            throw new DownloaderException(sprintf('maxmind %s tarball had no .mmdb', $kind));
+        }
+        $finalPath = $tempDir . '/' . $kind . '.mmdb';
+        if (!@rename($mmdbPath, $finalPath)) {
+            throw new DownloaderException(sprintf('rename %s -> %s failed', $mmdbPath, $finalPath));
+        }
+
+        return $finalPath;
+    }
+
+    private function fetchTo(string $edition, string $suffix, string $sink): void
+    {
+        $url = sprintf('%s?edition_id=%s&license_key=%s&suffix=%s', self::ENDPOINT, $edition, $this->licenseKey, $suffix);
+        try {
+            $this->http->request('GET', $url, ['sink' => $sink]);
+        } catch (GuzzleException $e) {
+            // Sanitise the URL in the surfaced message.
+            $sanitised = sprintf('%s?edition_id=%s&license_key=***&suffix=%s', self::ENDPOINT, $edition, $suffix);
+
+            throw new DownloaderException(sprintf('GET %s failed', $sanitised), 0, $e);
+        }
+    }
+
+    private function findMmdb(string $dir): ?string
+    {
+        $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+        foreach ($it as $file) {
+            if ($file->isFile() && str_ends_with($file->getFilename(), '.mmdb')) {
+                return $file->getPathname();
+            }
+        }
+
+        return null;
+    }
+}

+ 62 - 0
api/src/Infrastructure/Enrichment/Downloaders/MmdbVerifier.php

@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment\Downloaders;
+
+use MaxMind\Db\Reader;
+use Throwable;
+
+/**
+ * Cross-provider MMDB sanity check used after every download.
+ *
+ * MaxMind ships SHA-256 alongside its tarballs; DB-IP and IPinfo do
+ * not. For DB-IP and IPinfo the integrity check is gzip-decode (DB-IP
+ * only) + open with MaxMind\Db\Reader to read metadata + verify the
+ * node count is in a sane range. Truncated or zero-byte downloads
+ * fail fast here.
+ *
+ * Thresholds: country ≥ 100k nodes, ASN ≥ 50k. Empirically the
+ * smallest of the three providers (DB-IP Lite ASN ~80k nodes) sits
+ * comfortably above; a corrupt download is typically truncated to
+ * <1k.
+ */
+final class MmdbVerifier
+{
+    public const COUNTRY_MIN_NODES = 100_000;
+    public const ASN_MIN_NODES = 50_000;
+
+    public static function assertCountry(string $path): void
+    {
+        self::assertReadable($path, self::COUNTRY_MIN_NODES);
+    }
+
+    public static function assertAsn(string $path): void
+    {
+        self::assertReadable($path, self::ASN_MIN_NODES);
+    }
+
+    private static function assertReadable(string $path, int $minNodes): void
+    {
+        if (!is_file($path) || filesize($path) === 0) {
+            throw new DownloaderException(sprintf('mmdb missing or zero-byte at %s', $path));
+        }
+        try {
+            $reader = new Reader($path);
+        } catch (Throwable $e) {
+            throw new DownloaderException(
+                sprintf('mmdb open failed at %s: %s', $path, $e->getMessage()),
+                0,
+                $e,
+            );
+        }
+        $metadata = $reader->metadata();
+        $nodeCount = $metadata->nodeCount;
+        $reader->close();
+        if ($nodeCount < $minNodes) {
+            throw new DownloaderException(
+                sprintf('mmdb node count %d below threshold %d at %s', $nodeCount, $minNodes, $path)
+            );
+        }
+    }
+}

+ 38 - 0
api/src/Infrastructure/Enrichment/IpinfoRecordAdapter.php

@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment;
+
+/**
+ * IPinfo Lite MMDB schema: flat records.
+ *   - country: `country_code` (uppercase ISO-3166)
+ *   - asn:     `asn` as string like "AS13335"; `as_name` as the org name
+ */
+final class IpinfoRecordAdapter implements RecordAdapter
+{
+    public function extractCountryCode(array $record): ?string
+    {
+        $code = $record['country_code'] ?? null;
+
+        return is_string($code) && $code !== '' ? strtoupper($code) : null;
+    }
+
+    public function extractAsn(array $record): ?int
+    {
+        $raw = $record['asn'] ?? null;
+        if (!is_string($raw) || $raw === '') {
+            return null;
+        }
+        $digits = strncasecmp($raw, 'AS', 2) === 0 ? substr($raw, 2) : $raw;
+
+        return ctype_digit($digits) ? (int) $digits : null;
+    }
+
+    public function extractAsOrg(array $record): ?string
+    {
+        $name = $record['as_name'] ?? null;
+
+        return is_string($name) && $name !== '' ? $name : null;
+    }
+}

+ 44 - 0
api/src/Infrastructure/Enrichment/MaxMindRecordAdapter.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment;
+
+/**
+ * MaxMind-shape MMDB records (also DB-IP — same schema by deliberate
+ * compatibility). Country DB nests `country.iso_code`; ASN DB has
+ * top-level `autonomous_system_number` and `autonomous_system_organization`.
+ */
+final class MaxMindRecordAdapter implements RecordAdapter
+{
+    public function extractCountryCode(array $record): ?string
+    {
+        $country = $record['country'] ?? null;
+        if (!is_array($country)) {
+            return null;
+        }
+        $iso = $country['iso_code'] ?? null;
+
+        return is_string($iso) && $iso !== '' ? strtoupper($iso) : null;
+    }
+
+    public function extractAsn(array $record): ?int
+    {
+        $asn = $record['autonomous_system_number'] ?? null;
+        if (is_int($asn)) {
+            return $asn;
+        }
+        if (is_string($asn) && ctype_digit($asn)) {
+            return (int) $asn;
+        }
+
+        return null;
+    }
+
+    public function extractAsOrg(array $record): ?string
+    {
+        $org = $record['autonomous_system_organization'] ?? null;
+
+        return is_string($org) && $org !== '' ? $org : null;
+    }
+}

+ 164 - 0
api/src/Infrastructure/Enrichment/MmdbEnrichmentService.php

@@ -0,0 +1,164 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment;
+
+use App\Domain\Enrichment\EnrichmentResult;
+use App\Domain\Enrichment\EnrichmentService;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Time\Clock;
+use MaxMind\Db\Reader;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Reads two MMDB files (country + ASN) using the lower-level
+ * `MaxMind\Db\Reader`. The reader handles MaxMind, DB-IP, and IPinfo
+ * MMDBs equally — only the record shape differs, and the configured
+ * `RecordAdapter` smooths that over.
+ *
+ * Readers are lazy. If a DB file is missing or unreadable, the service
+ * logs ONE warning per process lifetime (so a missing-DB never floods
+ * logs from a 200-IP-per-tick batch) and returns an empty result.
+ *
+ * `reloadReaders()` is called by the refresh-geoip job after atomic
+ * replacement so subsequent lookups pick up the new files without a
+ * process restart.
+ */
+final class MmdbEnrichmentService implements EnrichmentService
+{
+    private ?Reader $countryReader = null;
+    private ?Reader $asnReader = null;
+    private bool $countryAttempted = false;
+    private bool $asnAttempted = false;
+    private bool $missingWarningEmitted = false;
+
+    public function __construct(
+        private readonly string $countryDbPath,
+        private readonly string $asnDbPath,
+        private readonly RecordAdapter $adapter,
+        private readonly Clock $clock,
+        private readonly LoggerInterface $logger,
+    ) {
+    }
+
+    public function enrich(IpAddress $ip): EnrichmentResult
+    {
+        $now = $this->clock->now();
+        $countryReader = $this->countryReader();
+        $asnReader = $this->asnReader();
+
+        if ($countryReader === null && $asnReader === null) {
+            return new EnrichmentResult(null, null, null, $now);
+        }
+
+        $countryCode = null;
+        if ($countryReader !== null) {
+            $record = $this->lookup($countryReader, $ip->text());
+            if ($record !== null) {
+                $countryCode = $this->adapter->extractCountryCode($record);
+            }
+        }
+
+        $asn = null;
+        $asOrg = null;
+        if ($asnReader !== null) {
+            $record = $this->lookup($asnReader, $ip->text());
+            if ($record !== null) {
+                $asn = $this->adapter->extractAsn($record);
+                $asOrg = $this->adapter->extractAsOrg($record);
+            }
+        }
+
+        return new EnrichmentResult($countryCode, $asn, $asOrg, $now);
+    }
+
+    /**
+     * Force a fresh open on the next lookup. Called by RefreshGeoipJob
+     * after atomic-replacing the files on disk.
+     */
+    public function reloadReaders(): void
+    {
+        $this->countryReader?->close();
+        $this->asnReader?->close();
+        $this->countryReader = null;
+        $this->asnReader = null;
+        $this->countryAttempted = false;
+        $this->asnAttempted = false;
+        $this->missingWarningEmitted = false;
+    }
+
+    /**
+     * True if at least one of the DBs is currently loadable. The
+     * EnrichPendingJob uses this as a fast-path check so it can no-op
+     * cleanly when neither file is on disk yet.
+     */
+    public function isReady(): bool
+    {
+        return $this->countryReader() !== null || $this->asnReader() !== null;
+    }
+
+    private function countryReader(): ?Reader
+    {
+        if (!$this->countryAttempted) {
+            $this->countryAttempted = true;
+            $this->countryReader = $this->openReader($this->countryDbPath, 'country');
+        }
+
+        return $this->countryReader;
+    }
+
+    private function asnReader(): ?Reader
+    {
+        if (!$this->asnAttempted) {
+            $this->asnAttempted = true;
+            $this->asnReader = $this->openReader($this->asnDbPath, 'asn');
+        }
+
+        return $this->asnReader;
+    }
+
+    private function openReader(string $path, string $kind): ?Reader
+    {
+        if (!is_file($path) || !is_readable($path)) {
+            $this->emitMissingWarningOnce($path, $kind, 'not_found_or_unreadable');
+
+            return null;
+        }
+        try {
+            return new Reader($path);
+        } catch (Throwable $e) {
+            $this->emitMissingWarningOnce($path, $kind, $e->getMessage());
+
+            return null;
+        }
+    }
+
+    private function emitMissingWarningOnce(string $path, string $kind, string $reason): void
+    {
+        if ($this->missingWarningEmitted) {
+            return;
+        }
+        $this->missingWarningEmitted = true;
+        $this->logger->warning('geoip_db_unavailable', [
+            'kind' => $kind,
+            'path' => $path,
+            'reason' => $reason,
+        ]);
+    }
+
+    /**
+     * @return array<string, mixed>|null
+     */
+    private function lookup(Reader $reader, string $ip): ?array
+    {
+        try {
+            $record = $reader->get($ip);
+        } catch (Throwable) {
+            return null;
+        }
+
+        return is_array($record) ? $record : null;
+    }
+}

+ 31 - 0
api/src/Infrastructure/Enrichment/RecordAdapter.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Enrichment;
+
+/**
+ * Bridges per-provider MMDB record shapes to a common tuple.
+ *
+ * MaxMind / DB-IP nest country under `country.iso_code` and emit ASN as
+ * `autonomous_system_number` / `_organization`. IPinfo Lite is flat:
+ * `country_code`, plus `asn` as a string like "AS13335" and `as_name`.
+ * The MMDB lookup returns the raw record; the adapter pulls fields out.
+ */
+interface RecordAdapter
+{
+    /**
+     * @param array<string, mixed> $record
+     */
+    public function extractCountryCode(array $record): ?string;
+
+    /**
+     * @param array<string, mixed> $record
+     */
+    public function extractAsn(array $record): ?int;
+
+    /**
+     * @param array<string, mixed> $record
+     */
+    public function extractAsOrg(array $record): ?string;
+}

+ 116 - 7
api/src/Infrastructure/Reputation/IpEnrichmentRepository.php

@@ -4,18 +4,17 @@ declare(strict_types=1);
 
 namespace App\Infrastructure\Reputation;
 
+use App\Domain\Enrichment\EnrichmentResult;
 use App\Infrastructure\Db\RepositoryBase;
+use Doctrine\DBAL\ParameterType;
 
 /**
- * Read-side gateway for `ip_enrichment`. Writes (and the enrichment job
- * itself) land in M11; for now this exists so the IP-detail endpoint
- * has a single place to fetch the country/ASN row to render.
+ * Read + write gateway for `ip_enrichment`.
  *
- * Returns null if the IP has no row yet — every JOIN on the search side
- * is LEFT, and the IP-detail page degrades gracefully when fields are
- * absent.
+ * Originally read-only (M09); M11 grew it into the full enrichment-job
+ * sink. Driver-aware UPSERT mirrors `IpScoreRepository::upsert`.
  */
-final class IpEnrichmentRepository extends RepositoryBase
+class IpEnrichmentRepository extends RepositoryBase
 {
     /**
      * @return array{country_code: ?string, asn: ?int, as_org: ?string, enriched_at: ?string}|null
@@ -34,4 +33,114 @@ final class IpEnrichmentRepository extends RepositoryBase
             'enriched_at' => $row['enriched_at'] !== null ? (string) $row['enriched_at'] : null,
         ];
     }
+
+    /**
+     * Insert or update one enrichment row. Driver-aware: SQLite uses
+     * `ON CONFLICT(ip_bin) DO UPDATE`, MySQL uses `ON DUPLICATE KEY`.
+     */
+    public function upsert(string $ipBin, EnrichmentResult $result): void
+    {
+        $platform = $this->connection()->getDatabasePlatform()::class;
+        $isMysql = stripos($platform, 'mysql') !== false || stripos($platform, 'mariadb') !== false;
+
+        if ($isMysql) {
+            $sql = 'INSERT INTO ip_enrichment (ip_bin, country_code, asn, as_org, enriched_at) '
+                . 'VALUES (:ip_bin, :country, :asn, :as_org, :enriched_at) '
+                . 'ON DUPLICATE KEY UPDATE '
+                . 'country_code = VALUES(country_code), asn = VALUES(asn), '
+                . 'as_org = VALUES(as_org), enriched_at = VALUES(enriched_at)';
+        } else {
+            $sql = 'INSERT INTO ip_enrichment (ip_bin, country_code, asn, as_org, enriched_at) '
+                . 'VALUES (:ip_bin, :country, :asn, :as_org, :enriched_at) '
+                . 'ON CONFLICT(ip_bin) DO UPDATE SET '
+                . 'country_code = excluded.country_code, asn = excluded.asn, '
+                . 'as_org = excluded.as_org, enriched_at = excluded.enriched_at';
+        }
+
+        $stmt = $this->connection()->prepare($sql);
+        $stmt->bindValue('ip_bin', $ipBin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('country', $result->countryCode);
+        if ($result->asn === null) {
+            $stmt->bindValue('asn', null, ParameterType::NULL);
+        } else {
+            $stmt->bindValue('asn', $result->asn, ParameterType::INTEGER);
+        }
+        $stmt->bindValue('as_org', $result->asOrg);
+        $stmt->bindValue('enriched_at', $result->enrichedAt->format('Y-m-d H:i:s'));
+
+        $stmt->executeStatement();
+    }
+
+    /**
+     * IPs known to the system (reports OR manual_blocks) but missing
+     * from `ip_enrichment` (or whose enriched_at was cleared by
+     * ?reenrich=true). Ordered by oldest first-seen so backlogs catch
+     * up before newer arrivals.
+     *
+     * @return list<string> ip_bin values, length ≤ $limit
+     */
+    public function findPending(int $limit): array
+    {
+        $sql = <<<SQL
+            SELECT t.ip_bin AS ip_bin, MIN(t.received_at) AS received_at
+            FROM (
+                SELECT ip_bin, received_at FROM reports
+                UNION ALL
+                SELECT ip_bin, created_at AS received_at FROM manual_blocks WHERE kind = 'ip' AND ip_bin IS NOT NULL
+            ) t
+            LEFT JOIN ip_enrichment e ON e.ip_bin = t.ip_bin AND e.enriched_at IS NOT NULL
+            WHERE e.ip_bin IS NULL
+            GROUP BY t.ip_bin
+            ORDER BY MIN(t.received_at) ASC
+            LIMIT :limit
+        SQL;
+
+        $stmt = $this->connection()->prepare($sql);
+        $stmt->bindValue('limit', $limit, ParameterType::INTEGER);
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $stmt->executeQuery()->fetchAllAssociative();
+
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = (string) $row['ip_bin'];
+        }
+
+        return $out;
+    }
+
+    /**
+     * Clear `enriched_at` on every row. Used only by the `?reenrich=true`
+     * flag on refresh-geoip; lets `findPending` re-pick all rows up.
+     * Returns the affected row count for the job's `items_processed`.
+     */
+    public function clearAllEnrichedAt(): int
+    {
+        return (int) $this->connection()->executeStatement(
+            'UPDATE ip_enrichment SET enriched_at = NULL'
+        );
+    }
+
+    /**
+     * Distinct country codes seen so far with their populations.
+     * Powers the IPs-list country dropdown.
+     *
+     * @return list<array{code: string, count: int}>
+     */
+    public function countryCounts(): array
+    {
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT country_code AS code, COUNT(*) AS cnt FROM ip_enrichment '
+            . 'WHERE country_code IS NOT NULL GROUP BY country_code ORDER BY country_code'
+        );
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = [
+                'code' => (string) $row['code'],
+                'count' => (int) $row['cnt'],
+            ];
+        }
+
+        return $out;
+    }
 }

+ 14 - 0
api/tests/Fixtures/geoip/README.md

@@ -0,0 +1,14 @@
+# GeoIP test fixtures
+
+These two `.mmdb` files are vendored from
+[`maxmind/MaxMind-DB`](https://github.com/maxmind/MaxMind-DB/tree/main/test-data),
+licensed under Apache-2.0:
+
+- `country.mmdb` — copy of `GeoIP2-Country-Test.mmdb`
+- `asn.mmdb` — copy of `GeoLite2-ASN-Test.mmdb`
+
+They cover a small set of well-known IPs (notably `81.2.69.142` → GB
+and a handful of IPv6 addresses). The schema matches MaxMind's, so the
+test suite uses `MaxMindRecordAdapter` against them. The
+provider-specific download/verify paths (DB-IP, IPinfo) are exercised
+with stubbed Guzzle clients — no network in CI.

BIN
api/tests/Fixtures/geoip/asn.mmdb


BIN
api/tests/Fixtures/geoip/country.mmdb


+ 60 - 0
api/tests/Integration/Admin/CountriesEndpointTest.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\IpAddress;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * `/admin/ips/countries` — distinct ISO codes from `ip_enrichment` with
+ * counts. Empty when there's nothing to show; sorted by code.
+ */
+final class CountriesEndpointTest extends AppTestCase
+{
+    public function testReturnsEmptyWhenNothingEnriched(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request('GET', '/api/v1/admin/ips/countries', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame([], $body['items']);
+    }
+
+    public function testReturnsCountriesWithCounts(): void
+    {
+        $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
+        foreach ([['1.1.1.1', 'GB'], ['2.2.2.2', 'GB'], ['3.3.3.3', 'US']] as [$ip, $cc]) {
+            $bin = IpAddress::fromString($ip)->binary();
+            $this->db->insert('ip_enrichment', [
+                'ip_bin' => $bin,
+                'country_code' => $cc,
+                'asn' => null,
+                'as_org' => null,
+                'enriched_at' => $now,
+            ], ['ip_bin' => \Doctrine\DBAL\ParameterType::LARGE_OBJECT]);
+        }
+
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request('GET', '/api/v1/admin/ips/countries', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame([
+            ['code' => 'GB', 'count' => 2],
+            ['code' => 'US', 'count' => 1],
+        ], $body['items']);
+    }
+
+    public function testRequiresViewer(): void
+    {
+        $resp = $this->request('GET', '/api/v1/admin/ips/countries');
+        self::assertSame(401, $resp->getStatusCode());
+    }
+}

+ 168 - 0
api/tests/Integration/Enrichment/EnrichPendingJobTest.php

@@ -0,0 +1,168 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Enrichment;
+
+use App\Application\Jobs\EnrichPendingJob;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Jobs\JobContext;
+use App\Domain\Time\SystemClock;
+use App\Infrastructure\Enrichment\MaxMindRecordAdapter;
+use App\Infrastructure\Enrichment\MmdbEnrichmentService;
+use App\Infrastructure\Reputation\IpEnrichmentRepository;
+use App\Tests\Integration\Support\AppTestCase;
+use Monolog\Handler\TestHandler;
+use Monolog\Logger;
+
+/**
+ * Drives the full enrich-pending flow: seed reports -> run job ->
+ * assert that the touched IPs land in `ip_enrichment`.
+ */
+final class EnrichPendingJobTest extends AppTestCase
+{
+    private const COUNTRY_DB = __DIR__ . '/../../Fixtures/geoip/country.mmdb';
+    private const ASN_DB = __DIR__ . '/../../Fixtures/geoip/asn.mmdb';
+
+    public function testEnrichesPendingIps(): void
+    {
+        // Pre-condition: a single reporter and report row for 81.2.69.142 (in the fixture as GB).
+        $reporterId = $this->createReporter('test-rep');
+        $categoryId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug = 'brute_force'");
+        $ip = IpAddress::fromString('81.2.69.142');
+        $this->db->insert('reports', [
+            'ip_bin' => $ip->binary(),
+            'ip_text' => $ip->text(),
+            'category_id' => $categoryId,
+            'reporter_id' => $reporterId,
+            'weight_at_report' => '1.0000',
+            'received_at' => (new \DateTimeImmutable('-1 minute', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
+            'metadata_json' => null,
+        ], ['ip_bin' => \Doctrine\DBAL\ParameterType::LARGE_OBJECT]);
+
+        // Replace the container's EnrichmentService with one pointed at the fixtures.
+        $service = new MmdbEnrichmentService(
+            countryDbPath: self::COUNTRY_DB,
+            asnDbPath: self::ASN_DB,
+            adapter: new MaxMindRecordAdapter(),
+            clock: new SystemClock(),
+            logger: new Logger('t', [new TestHandler()]),
+        );
+
+        /** @var IpEnrichmentRepository $repo */
+        $repo = $this->container->get(IpEnrichmentRepository::class);
+
+        $job = new EnrichPendingJob($service, $repo);
+        $context = new JobContext(new SystemClock(), new Logger('t', [new TestHandler()]), []);
+        $result = $job->run($context);
+
+        self::assertSame(1, $result->itemsProcessed);
+
+        $row = $repo->findByIpBin($ip->binary());
+        self::assertNotNull($row);
+        self::assertSame('GB', $row['country_code']);
+    }
+
+    public function testNoOpWhenDbsAreMissing(): void
+    {
+        // Use bogus paths.
+        $service = new MmdbEnrichmentService(
+            countryDbPath: '/no/where/country.mmdb',
+            asnDbPath: '/no/where/asn.mmdb',
+            adapter: new MaxMindRecordAdapter(),
+            clock: new SystemClock(),
+            logger: new Logger('t', [new TestHandler()]),
+        );
+
+        // Seed something so findPending would otherwise return rows.
+        $reporterId = $this->createReporter('test-rep');
+        $categoryId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug = 'brute_force'");
+        $ip = IpAddress::fromString('203.0.113.7');
+        $this->db->insert('reports', [
+            'ip_bin' => $ip->binary(),
+            'ip_text' => $ip->text(),
+            'category_id' => $categoryId,
+            'reporter_id' => $reporterId,
+            'weight_at_report' => '1.0000',
+            'received_at' => (new \DateTimeImmutable('-1 minute', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
+            'metadata_json' => null,
+        ], ['ip_bin' => \Doctrine\DBAL\ParameterType::LARGE_OBJECT]);
+
+        /** @var IpEnrichmentRepository $repo */
+        $repo = $this->container->get(IpEnrichmentRepository::class);
+        $job = new EnrichPendingJob($service, $repo);
+        $result = $job->run(new JobContext(new SystemClock(), new Logger('t', [new TestHandler()]), []));
+
+        self::assertSame(0, $result->itemsProcessed);
+        self::assertNull($repo->findByIpBin($ip->binary()));
+    }
+
+    public function testFindPendingExcludesAlreadyEnriched(): void
+    {
+        $service = new MmdbEnrichmentService(
+            countryDbPath: self::COUNTRY_DB,
+            asnDbPath: self::ASN_DB,
+            adapter: new MaxMindRecordAdapter(),
+            clock: new SystemClock(),
+            logger: new Logger('t', [new TestHandler()]),
+        );
+
+        $reporterId = $this->createReporter('test-rep');
+        $categoryId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug = 'brute_force'");
+        $ip = IpAddress::fromString('81.2.69.142');
+        $this->db->insert('reports', [
+            'ip_bin' => $ip->binary(),
+            'ip_text' => $ip->text(),
+            'category_id' => $categoryId,
+            'reporter_id' => $reporterId,
+            'weight_at_report' => '1.0000',
+            'received_at' => (new \DateTimeImmutable('-1 minute', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
+            'metadata_json' => null,
+        ], ['ip_bin' => \Doctrine\DBAL\ParameterType::LARGE_OBJECT]);
+
+        /** @var IpEnrichmentRepository $repo */
+        $repo = $this->container->get(IpEnrichmentRepository::class);
+
+        $job = new EnrichPendingJob($service, $repo);
+        $first = $job->run(new JobContext(new SystemClock(), new Logger('t', [new TestHandler()]), []));
+        self::assertSame(1, $first->itemsProcessed);
+
+        // Second run: nothing left to do.
+        $second = $job->run(new JobContext(new SystemClock(), new Logger('t', [new TestHandler()]), []));
+        self::assertSame(0, $second->itemsProcessed);
+    }
+
+    public function testReenrichClearAllowsReprocessing(): void
+    {
+        $service = new MmdbEnrichmentService(
+            countryDbPath: self::COUNTRY_DB,
+            asnDbPath: self::ASN_DB,
+            adapter: new MaxMindRecordAdapter(),
+            clock: new SystemClock(),
+            logger: new Logger('t', [new TestHandler()]),
+        );
+        $reporterId = $this->createReporter('test-rep');
+        $categoryId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug = 'brute_force'");
+        $ip = IpAddress::fromString('81.2.69.142');
+        $this->db->insert('reports', [
+            'ip_bin' => $ip->binary(),
+            'ip_text' => $ip->text(),
+            'category_id' => $categoryId,
+            'reporter_id' => $reporterId,
+            'weight_at_report' => '1.0000',
+            'received_at' => (new \DateTimeImmutable('-1 minute', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
+            'metadata_json' => null,
+        ], ['ip_bin' => \Doctrine\DBAL\ParameterType::LARGE_OBJECT]);
+
+        /** @var IpEnrichmentRepository $repo */
+        $repo = $this->container->get(IpEnrichmentRepository::class);
+        $job = new EnrichPendingJob($service, $repo);
+        $job->run(new JobContext(new SystemClock(), new Logger('t', [new TestHandler()]), []));
+
+        $cleared = $repo->clearAllEnrichedAt();
+        self::assertSame(1, $cleared);
+
+        $reRun = $job->run(new JobContext(new SystemClock(), new Logger('t', [new TestHandler()]), []));
+        self::assertSame(1, $reRun->itemsProcessed);
+    }
+}

+ 68 - 2
api/tests/Integration/Internal/JobsEndpointsTest.php

@@ -124,8 +124,43 @@ final class JobsEndpointsTest extends AppTestCase
         self::assertSame('skipped_locked', $body['status']);
     }
 
-    public function testRefreshGeoipReturns412(): void
+    public function testRefreshGeoipDoesNotShortCircuitOnDbipDefault(): void
     {
+        // dbip is the default test provider; requiresCredential() is false.
+        // dry_run avoids the actual download.
+        $resp = $this->internalRequest(
+            'POST',
+            '/internal/jobs/refresh-geoip?dry_run=1',
+            headers: ['Authorization' => 'Bearer ' . self::TOKEN],
+        );
+        self::assertNotSame(412, $resp->getStatusCode());
+    }
+
+    public function testRefreshGeoipReturns412WithoutCredentialOnMaxmind(): void
+    {
+        // Patch the container's downloader to MaxMind without a key.
+        $this->swapDownloader(new \App\Infrastructure\Enrichment\Downloaders\MaxMindDownloader(
+            new \GuzzleHttp\Client(),
+            licenseKey: '',
+        ));
+        $resp = $this->internalRequest(
+            'POST',
+            '/internal/jobs/refresh-geoip',
+            headers: ['Authorization' => 'Bearer ' . self::TOKEN],
+        );
+        self::assertSame(412, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame('no_credential', $body['error']);
+        self::assertSame('maxmind', $body['provider']);
+        self::assertSame('MAXMIND_LICENSE_KEY', $body['missing']);
+    }
+
+    public function testRefreshGeoipReturns412WithoutTokenOnIpinfo(): void
+    {
+        $this->swapDownloader(new \App\Infrastructure\Enrichment\Downloaders\IPinfoDownloader(
+            new \GuzzleHttp\Client(),
+            token: '',
+        ));
         $resp = $this->internalRequest(
             'POST',
             '/internal/jobs/refresh-geoip',
@@ -133,7 +168,22 @@ final class JobsEndpointsTest extends AppTestCase
         );
         self::assertSame(412, $resp->getStatusCode());
         $body = $this->decode($resp);
-        self::assertSame('not_implemented', $body['error']);
+        self::assertSame('ipinfo', $body['provider']);
+        self::assertSame('IPINFO_TOKEN', $body['missing']);
+    }
+
+    public function testEnrichPendingNoOpsCleanlyWithoutDbs(): void
+    {
+        $resp = $this->internalRequest(
+            'POST',
+            '/internal/jobs/enrich-pending',
+            headers: ['Authorization' => 'Bearer ' . self::TOKEN],
+        );
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame('enrich-pending', $body['job']);
+        self::assertSame('success', $body['status']);
+        self::assertSame(0, $body['items_processed']);
     }
 
     public function testStatusListsAllRegisteredJobs(): void
@@ -149,9 +199,25 @@ final class JobsEndpointsTest extends AppTestCase
         self::assertArrayHasKey('recompute-scores', $body['jobs']);
         self::assertArrayHasKey('cleanup-audit', $body['jobs']);
         self::assertArrayHasKey('enrich-pending', $body['jobs']);
+        self::assertArrayHasKey('refresh-geoip', $body['jobs']);
         self::assertArrayHasKey('tick', $body['jobs']);
     }
 
+    private function swapDownloader(\App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader $downloader): void
+    {
+        if (!method_exists($this->container, 'set')) {
+            return;
+        }
+        /** @var \DI\Container $container */
+        $container = $this->container;
+        $container->set(\App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader::class, $downloader);
+        // PHP-DI caches singletons, so JobsController already holds the
+        // previous downloader. Replace it with a freshly built one
+        // (via container->make) so it picks up the new binding.
+        $container->set(\App\Application\Internal\JobsController::class, $container->make(\App\Application\Internal\JobsController::class));
+        $this->app = \App\App\AppFactory::build($this->container);
+    }
+
     public function testCleanupAuditSucceedsAndRecordsRun(): void
     {
         $resp = $this->internalRequest(

+ 9 - 0
api/tests/Integration/Support/AppTestCase.php

@@ -89,6 +89,15 @@ abstract class AppTestCase extends TestCase
             'rate_limit_per_second' => 1000,
             'cidr_evaluator_ttl_seconds' => 0,
             'blocklist_cache_ttl_seconds' => 0,
+            'geoip' => [
+                'enabled' => true,
+                'provider' => 'dbip',
+                'country_db' => sys_get_temp_dir() . '/irdb-geoip-missing-country.mmdb',
+                'asn_db' => sys_get_temp_dir() . '/irdb-geoip-missing-asn.mmdb',
+                'maxmind_license_key' => '',
+                'ipinfo_token' => '',
+                'refresh_interval_days' => 7,
+            ],
         ];
 
         $this->container = Container::build($settings);

+ 37 - 0
api/tests/Unit/Enrichment/IpinfoRecordAdapterTest.php

@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Enrichment;
+
+use App\Infrastructure\Enrichment\IpinfoRecordAdapter;
+use PHPUnit\Framework\TestCase;
+
+final class IpinfoRecordAdapterTest extends TestCase
+{
+    public function testCountryFlat(): void
+    {
+        $adapter = new IpinfoRecordAdapter();
+        self::assertSame('GB', $adapter->extractCountryCode(['country_code' => 'GB']));
+        self::assertSame('GB', $adapter->extractCountryCode(['country_code' => 'gb']));
+        self::assertNull($adapter->extractCountryCode([]));
+        self::assertNull($adapter->extractCountryCode(['country_code' => '']));
+    }
+
+    public function testAsnStripsAsPrefix(): void
+    {
+        $adapter = new IpinfoRecordAdapter();
+        self::assertSame(13335, $adapter->extractAsn(['asn' => 'AS13335']));
+        self::assertSame(13335, $adapter->extractAsn(['asn' => 'as13335']));
+        self::assertSame(13335, $adapter->extractAsn(['asn' => '13335']));
+        self::assertNull($adapter->extractAsn([]));
+        self::assertNull($adapter->extractAsn(['asn' => 'NOTANUMBER']));
+    }
+
+    public function testAsName(): void
+    {
+        $adapter = new IpinfoRecordAdapter();
+        self::assertSame('Cloudflare', $adapter->extractAsOrg(['as_name' => 'Cloudflare']));
+        self::assertNull($adapter->extractAsOrg([]));
+    }
+}

+ 46 - 0
api/tests/Unit/Enrichment/MaxMindRecordAdapterTest.php

@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Enrichment;
+
+use App\Infrastructure\Enrichment\MaxMindRecordAdapter;
+use PHPUnit\Framework\TestCase;
+
+final class MaxMindRecordAdapterTest extends TestCase
+{
+    public function testCountryNested(): void
+    {
+        $adapter = new MaxMindRecordAdapter();
+        $record = ['country' => ['iso_code' => 'gb', 'names' => ['en' => 'United Kingdom']]];
+        self::assertSame('GB', $adapter->extractCountryCode($record));
+    }
+
+    public function testCountryMissingReturnsNull(): void
+    {
+        $adapter = new MaxMindRecordAdapter();
+        self::assertNull($adapter->extractCountryCode([]));
+        self::assertNull($adapter->extractCountryCode(['country' => null]));
+        self::assertNull($adapter->extractCountryCode(['country' => ['names' => ['en' => 'X']]]));
+    }
+
+    public function testAsn(): void
+    {
+        $adapter = new MaxMindRecordAdapter();
+        self::assertSame(13335, $adapter->extractAsn(['autonomous_system_number' => 13335]));
+        self::assertSame(13335, $adapter->extractAsn(['autonomous_system_number' => '13335']));
+        self::assertNull($adapter->extractAsn([]));
+        self::assertNull($adapter->extractAsn(['autonomous_system_number' => 'AS13335']));
+    }
+
+    public function testAsOrg(): void
+    {
+        $adapter = new MaxMindRecordAdapter();
+        self::assertSame(
+            'Cloudflare, Inc.',
+            $adapter->extractAsOrg(['autonomous_system_organization' => 'Cloudflare, Inc.']),
+        );
+        self::assertNull($adapter->extractAsOrg([]));
+        self::assertNull($adapter->extractAsOrg(['autonomous_system_organization' => '']));
+    }
+}

+ 101 - 0
api/tests/Unit/Enrichment/MmdbEnrichmentServiceTest.php

@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Enrichment;
+
+use App\Domain\Ip\IpAddress;
+use App\Domain\Time\Clock;
+use App\Domain\Time\SystemClock;
+use App\Infrastructure\Enrichment\MaxMindRecordAdapter;
+use App\Infrastructure\Enrichment\MmdbEnrichmentService;
+use Monolog\Handler\TestHandler;
+use Monolog\Logger;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Drives MmdbEnrichmentService against the vendored MaxMind test
+ * fixtures (Apache-2.0). Covers the load → lookup → result → reload
+ * lifecycle plus the warn-once-on-missing-DB invariant.
+ */
+final class MmdbEnrichmentServiceTest extends TestCase
+{
+    private const COUNTRY_DB = __DIR__ . '/../../Fixtures/geoip/country.mmdb';
+    private const ASN_DB = __DIR__ . '/../../Fixtures/geoip/asn.mmdb';
+
+    public function testLooksUpKnownIpv4Fixture(): void
+    {
+        $service = $this->makeService(self::COUNTRY_DB, self::ASN_DB);
+        $result = $service->enrich(IpAddress::fromString('81.2.69.142'));
+        self::assertSame('GB', $result->countryCode);
+        // The fixture's ASN db doesn't carry every test IP — country alone is the load-bearing assertion.
+    }
+
+    public function testIpv6Lookup(): void
+    {
+        $service = $this->makeService(self::COUNTRY_DB, self::ASN_DB);
+        // The MaxMind fixture covers v4-mapped v6 of 81.2.69.142.
+        $result = $service->enrich(IpAddress::fromString('::ffff:81.2.69.142'));
+        self::assertSame('GB', $result->countryCode);
+    }
+
+    public function testUnknownIpReturnsEmpty(): void
+    {
+        $service = $this->makeService(self::COUNTRY_DB, self::ASN_DB);
+        // A reserved-but-not-in-fixture IP.
+        $result = $service->enrich(IpAddress::fromString('192.0.2.99'));
+        self::assertNull($result->countryCode);
+        self::assertNull($result->asn);
+        self::assertTrue($result->isEmpty());
+    }
+
+    public function testMissingDbWarnsOnceAndReturnsEmpty(): void
+    {
+        $logger = new Logger('t');
+        $handler = new TestHandler();
+        $logger->pushHandler($handler);
+
+        $service = new MmdbEnrichmentService(
+            countryDbPath: '/nonexistent/country.mmdb',
+            asnDbPath: '/nonexistent/asn.mmdb',
+            adapter: new MaxMindRecordAdapter(),
+            clock: new SystemClock(),
+            logger: $logger,
+        );
+
+        // Two lookups should still produce only one warning.
+        $a = $service->enrich(IpAddress::fromString('1.2.3.4'));
+        $b = $service->enrich(IpAddress::fromString('5.6.7.8'));
+        self::assertTrue($a->isEmpty());
+        self::assertTrue($b->isEmpty());
+        self::assertFalse($service->isReady());
+
+        $messages = array_map(static fn ($r): string => $r->message, $handler->getRecords());
+        $matches = array_filter($messages, static fn (string $m): bool => $m === 'geoip_db_unavailable');
+        self::assertCount(1, $matches);
+    }
+
+    public function testReloadReadersClearsState(): void
+    {
+        $service = $this->makeService(self::COUNTRY_DB, self::ASN_DB);
+        $service->enrich(IpAddress::fromString('81.2.69.142'));
+        $service->reloadReaders();
+        // Should still work after reload.
+        $result = $service->enrich(IpAddress::fromString('81.2.69.142'));
+        self::assertSame('GB', $result->countryCode);
+    }
+
+    private function makeService(string $country, string $asn, ?Clock $clock = null): MmdbEnrichmentService
+    {
+        $logger = new Logger('t');
+        $logger->pushHandler(new TestHandler());
+
+        return new MmdbEnrichmentService(
+            countryDbPath: $country,
+            asnDbPath: $asn,
+            adapter: new MaxMindRecordAdapter(),
+            clock: $clock ?? new SystemClock(),
+            logger: $logger,
+        );
+    }
+}

+ 175 - 54
files/M11-enrichment.md

@@ -5,7 +5,9 @@
 
 ## Mission
 
-Wire up MaxMind GeoLite2 enrichment: a wrapper service, a working `enrich-pending` job (replacing the M05 skeleton), the `refresh-geoip` job (replacing the M05 stub that returned 412), and UI display of country flag and ASN on the IP detail page.
+Wire up MMDB-based GeoIP/ASN enrichment with three pluggable providers — **DB-IP Lite (default, no auth required)**, **MaxMind GeoLite2 (opt-in, license key)**, **IPinfo Lite (opt-in, token)**. Build a single lookup wrapper, a working `enrich-pending` job (replacing the M05 skeleton), the `refresh-geoip` job (replacing the M05 stub that returned 412), and UI display of country flag and ASN on the IP detail page.
+
+The provider abstraction is intentionally narrow: only the **download** path forks per provider. The on-disk format (MMDB) and the lookup path are common.
 
 ## Before you start
 
@@ -15,11 +17,19 @@ Wire up MaxMind GeoLite2 enrichment: a wrapper service, a working `enrich-pendin
    cd api && composer test && cd ..
    ```
 2. Read `SPEC.md` §2 (GeoIP/ASN section), §4 (`ip_enrichment` table), §6 (`refresh-geoip` and `enrich-pending` job endpoints), §10 (where the DBs live; `/data/geoip/`), §15 (note out-of-scope items).
-3. Decide whether to test with a real MaxMind license. If not, the agent uses small fixture `.mmdb` files committed to the repo for tests. The `php-maxmind/MaxMind-DB-Reader-php` library can read fixtures.
+3. **Pick a provider for development.** All three speak MMDB; the lookup code does not care which is on disk. The default for fresh installs is DB-IP because it needs no credentials.
+
+   | Provider | Auth | License | Update cadence | Compression | Integrity check published | Attribution required |
+   |---|---|---|---|---|---|---|
+   | **DB-IP Lite** (default) | none | CC BY 4.0 | monthly (1st) | `.mmdb.gz` (single file) | no | yes — "IP Geolocation by DB-IP" |
+   | MaxMind GeoLite2 (opt-in) | license key | MaxMind EULA, free tier | twice weekly | `.tar.gz` (directory) | yes — `.sha256` companion | no |
+   | IPinfo Lite (opt-in) | token | IPinfo TOS, free tier | weekly | `.mmdb` (uncompressed) | no | yes — "powered by IPinfo" |
+
+4. Test fixtures live in `api/tests/Fixtures/geoip/` and are committed to the repo. They use the public `GeoLite2-City-Test.mmdb` / `GeoLite2-ASN-Test.mmdb` style fixtures from the `maxmind/MaxMind-DB` repo (Apache-2.0, vendorable). They cover IP `81.2.69.142` (GB) and a small IPv6 set. Acceptance does not depend on a real provider being reachable.
 
 ## Tasks
 
-### 1. MaxMind wrapper
+### 1. MMDB wrapper
 
 In `api/src/Domain/Enrichment/`:
 
@@ -28,12 +38,17 @@ In `api/src/Domain/Enrichment/`:
 
 In `api/src/Infrastructure/Enrichment/`:
 
-- `MaxMindEnrichmentService.php` — implements the interface using `geoip2/geoip2`. Accepts paths to two `.mmdb` files (Country and ASN). Lazy-loads the readers; if a file is missing, log a warning once and return a result with all-null fields. Add `geoip2/geoip2` to `api/composer.json` if it isn't already (allowed; SPEC §2 names MaxMind).
-- `EnrichmentRepository.php`:
-  - `find(string $ipBin): ?EnrichmentRow`
-  - `upsert(string $ipBin, EnrichmentResult)`
-  - `findPending(int $limit): array<string>` — returns `ip_bin` values that exist in `reports` or `manual_blocks` but not in `ip_enrichment`. Order by `MIN(received_at)` so older entries get caught up first.
-  - Used by the job and by the admin endpoint `GET /api/v1/admin/ips/{ip}` (already returning the field, was null until now).
+- `MmdbEnrichmentService.php` — implements `EnrichmentService` against any MMDB file. Accepts paths to two `.mmdb` files (Country and ASN) plus a `RecordAdapter` keyed on the configured provider. Lazy-loads readers; if a file is missing or unreadable, log a warning **once per process lifetime** and return an all-null result.
+  - Use `MaxMind\Db\Reader::get($ip)` directly (the lower-level open-format reader; ships as a transitive dep of `geoip2/geoip2`). Avoid the higher-level `Geoip2\Database\Reader::country()` accessor — it's MaxMind-shape-specific and breaks on IPinfo's flat record schema.
+  - Add `geoip2/geoip2` to `api/composer.json` (allowed; SPEC §2 names MaxMind, and the package is the canonical PHP MMDB reader).
+- `RecordAdapter.php` — small interface with `extractCountryCode(array $record): ?string`, `extractAsn(array $record): ?int`, `extractAsOrg(array $record): ?string`. Three implementations:
+  - `MaxMindRecordAdapter` — country: `$record['country']['iso_code']`; ASN: `$record['autonomous_system_number']`, `$record['autonomous_system_organization']`. (DB-IP shares this schema.)
+  - `IpinfoRecordAdapter` — country: `$record['country_code']` (uppercase ISO-3166); ASN: `$record['asn']` (string like `"AS13335"` — strip prefix, cast to int), `$record['as_name']`.
+- `EnrichmentRepository.php` (new file under `api/src/Infrastructure/Reputation/` to live next to `IpEnrichmentRepository`, OR replace the existing read-only `IpEnrichmentRepository` — pick the latter; keep one class):
+  - `find(string $ipBin): ?array` — keep the existing M09 shape.
+  - `upsert(string $ipBin, string $ipText, EnrichmentResult $result): void` — driver-aware UPSERT (mirrors `IpScoreRepository::upsert` for SQLite/MySQL split).
+  - `findPending(int $limit): array<string>` — `ip_bin` values that exist in `reports` or `manual_blocks` but not in `ip_enrichment`. Order by `MIN(received_at)` so older entries get caught up first. Use `UNION` over the two source tables, GROUP BY ip_bin, LEFT JOIN `ip_enrichment` filtering nulls.
+  - `clearAllEnrichedAt(): int` — used only by the `?reenrich=true` flag on `refresh-geoip`. Sets `enriched_at = NULL` so `findPending` re-picks rows up. Returns affected row count for the job's `items_processed`.
 
 ### 2. `enrich-pending` job — full implementation
 
@@ -41,8 +56,9 @@ Replace the skeleton in `api/src/Application/Jobs/EnrichPendingJob.php`:
 
 - Pulls a batch from `EnrichmentRepository::findPending(limit=200)`.
 - For each ip: calls `EnrichmentService::enrich`, upserts the result.
-- If the MaxMind DBs aren't present (e.g. `MAXMIND_LICENSE_KEY` never set, no fallback `.mmdb`s):
-  - The service returns all-null results. Don't store them — that would create poison rows. Instead, log a single warning per job run and exit cleanly with `items_processed=0`.
+- If the configured MMDBs aren't present (e.g. opt-in provider whose credential was never set, or `refresh-geoip` hasn't run yet, or the fixtures weren't mounted):
+  - The service returns all-null results. **Don't store them** — that would create poison rows. Detect by `countryCode === null && asn === null` and skip.
+  - Log a single warning per job run (not per IP) and exit cleanly with `items_processed=0`.
 - Default interval: 300s. Max runtime: 60s.
 - Idempotent: if an IP is already enriched, skip it (the `findPending` query already excludes them).
 
@@ -50,15 +66,47 @@ Replace the skeleton in `api/src/Application/Jobs/EnrichPendingJob.php`:
 
 Replace the stub in `api/src/Application/Jobs/RefreshGeoipJob.php`:
 
-- If `MAXMIND_LICENSE_KEY` is empty: return `412 Precondition Failed` from the HTTP handler with `{"error":"no_license_key"}`. The job itself shouldn't be invoked — the controller short-circuits.
-- Otherwise:
-  - Download `GeoLite2-Country.tar.gz` and `GeoLite2-ASN.tar.gz` from MaxMind's permalink endpoint using HTTPS + license key.
-  - Verify the tarball's SHA-256 against the matching `.sha256` URL.
-  - Extract to a temp dir.
-  - Atomic-replace the existing `.mmdb` files at `GEOIP_COUNTRY_DB` and `GEOIP_ASN_DB`. Use rename within the same filesystem.
-  - Reload the in-process readers (clear any cached singleton).
-- Default interval: 7 days (`JOB_GEOIP_REFRESH_INTERVAL_DAYS`). Max runtime: 5 minutes.
-- On HTTP/network failure: write a failure run entry, log clearly, don't leave partial files.
+- The job is provider-agnostic. Provider-specific logic sits behind a `GeoIpDownloader` interface in `api/src/Infrastructure/Enrichment/Downloaders/`:
+
+  ```php
+  interface GeoIpDownloader {
+      public function name(): string;          // "dbip" | "maxmind" | "ipinfo"
+      public function requiresCredential(): bool;
+      public function hasCredential(): bool;   // false ⇒ controller short-circuits 412
+      /** @return array{country: string, asn: string} paths to verified .mmdb files in $tempDir */
+      public function download(string $tempDir): array;
+  }
+  ```
+
+- Three implementations:
+
+  - **`DbipDownloader`** (default)
+    - URLs: `https://download.db-ip.com/free/dbip-country-lite-YYYY-MM.mmdb.gz` and `…asn-lite…`.
+    - On 404 (early-month rollover edge: monthly cuts publish on/around the 1st), fall back to previous month. Cap at one fallback step.
+    - Verify each file by: (a) gzip-integrity (`gzdecode` round-trip), (b) opening the decoded MMDB with `MaxMind\Db\Reader` and reading metadata (fails fast on truncation/corruption), (c) sane row count: `metadata.nodeCount > 100_000` for country, `> 50_000` for ASN. No SHA-256 published; this stack is the substitute.
+    - `requiresCredential()` returns false; `hasCredential()` always true.
+
+  - **`MaxMindDownloader`** (opt-in)
+    - URLs: MaxMind's permalink endpoint `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=…&suffix=tar.gz` (and `GeoLite2-ASN`).
+    - Verify the tarball's SHA-256 against the matching `…&suffix=tar.gz.sha256` URL.
+    - Extract the `.tar.gz`, walk the resulting directory for the `.mmdb` file (MaxMind's tarball nests one).
+    - `requiresCredential()` true; `hasCredential()` checks `MAXMIND_LICENSE_KEY !== ''`.
+
+  - **`IPinfoDownloader`** (opt-in)
+    - URLs: `https://ipinfo.io/data/free/country.mmdb?token=…` and `…/free/asn.mmdb?token=…`. Direct MMDB, no compression.
+    - Verify identically to DB-IP (no integrity file published; metadata + node-count sanity check).
+    - `requiresCredential()` true; `hasCredential()` checks `IPINFO_TOKEN !== ''`.
+
+- Job flow (provider-independent):
+  - At the HTTP-handler level: if the selected downloader has `requiresCredential() && !hasCredential()`, return `412 Precondition Failed` with `{"error":"no_credential","provider":"<name>","missing":"MAXMIND_LICENSE_KEY"}` (or `IPINFO_TOKEN`). Don't even start the job. **For provider=dbip this 412 path is unreachable**, since DB-IP needs no credential.
+  - Otherwise the job:
+    - Acquires its lock (default interval 7 days, `JOB_GEOIP_REFRESH_INTERVAL_DAYS`; max runtime 5 minutes).
+    - Calls `$downloader->download($tempDir)`.
+    - Atomic-replaces the existing files at `GEOIP_COUNTRY_DB` and `GEOIP_ASN_DB`. `tempnam()` in the same filesystem as the target, write, `rename()` to the target. Avoid leaving partials if the process crashes.
+    - Reloads in-process readers (`MmdbEnrichmentService::reloadReaders()` clears its cached `MaxMind\Db\Reader` instances).
+    - On success: `items_processed` = sum of `metadata.nodeCount` from both files (rough indicator).
+    - Optional `?reenrich=true` query flag: after a successful refresh, also call `EnrichmentRepository::clearAllEnrichedAt()`. Reflect the count in the response. Default off.
+- On HTTP/network failure: write a failure run entry, log clearly with provider name (no credential in any log line), don't leave partial files.
 - Use Guzzle (already in api deps).
 
 ### 4. UI: IP detail enrichment panel
@@ -70,10 +118,14 @@ Update `ui/resources/views/pages/ips/detail.twig`:
 - Otherwise show the country flag (Unicode regional indicator) + country name (use a small mapping or a JSON lookup table).
 - ASN: show as `AS{asn} {as_org}`, link to bgp.he.net or similar (target=_blank, rel=noopener) — optional but nice.
 - Add `enriched_at` as a small timestamp footer ("Enriched 4 hours ago").
+- **Attribution footer** under the panel: read the configured provider from the dashboard config endpoint (or expose via `GET /api/v1/admin/config` if not already; or pass through Twig globals) and render:
+  - `dbip` → `IP Geolocation by <a href="https://db-ip.com">DB-IP</a>` (CC BY 4.0).
+  - `ipinfo` → `IP data powered by <a href="https://ipinfo.io">IPinfo</a>`.
+  - `maxmind` → no attribution required; render nothing.
 
 ### 5. Search filters
 
-The IPs list page already accepts `country` and `asn` filters from M09. They should now actually filter results — the api joins `ip_enrichment` on the search query. Add a simple country dropdown using the populated set of countries seen so far (one extra endpoint or just compute on the fly).
+The IPs list page already accepts `country` and `asn` filters from M09. They should now actually filter results — the api joins `ip_enrichment` on the search query (already wired in `IpScoreRepository::searchIps`). Add a simple country dropdown using the populated set of countries seen so far via a new `GET /api/v1/admin/ips/countries` endpoint (returns `[{code, count}]` from `SELECT country_code, COUNT(*) FROM ip_enrichment WHERE country_code IS NOT NULL GROUP BY country_code ORDER BY country_code`).
 
 ### 6. Update healthz
 
@@ -83,6 +135,8 @@ The IPs list page already accepts `country` and `asn` filters from M09. They sho
   "status": "ok",
   "db": {"connected": true, "driver": "sqlite"},
   "geoip": {
+    "provider": "dbip",
+    "provider_configured": true,
     "country_db_present": true,
     "asn_db_present": true,
     "country_db_modified": "2026-04-20T...",
@@ -90,58 +144,74 @@ The IPs list page already accepts `country` and `asn` filters from M09. They sho
   }
 }
 ```
-Missing DBs don't make `/healthz` unhealthy (the system still works without enrichment). Just report the state.
+- `provider_configured` is `true` for `dbip` always, `true` for `maxmind`/`ipinfo` when the credential is set.
+- Missing DBs don't make `/healthz` unhealthy (the system still works without enrichment). Just report the state.
 
 ## Implementation notes
 
-- **Build-time vs runtime DBs**: The Dockerfile may bake DBs in at build time if `MAXMIND_LICENSE_KEY` is set as a build arg; otherwise they're absent until `refresh-geoip` runs. Either way, the runtime path is `/data/geoip/`. The Dockerfile copies build-time DBs into `/data/geoip/` if present.
-- **License key handling**: never log it. Don't include it in error messages or `job_runs.details`. Mask in any echoed config.
-- **Atomic file replace**: `tempnam()` in `/data/geoip/`, write the new file, `rename()` to the target. Avoid leaving partials if the process crashes.
-- **MaxMind library**: use `geoip2/geoip2`. Don't roll your own `.mmdb` parser. Don't use a service that calls back to MaxMind on every lookup — the local DB is the point.
-- **IPv6**: the same DBs cover both families. Verify with a v6 lookup test.
-- **Large batches**: 200 per tick is a safe default. Each lookup is fast; 200 takes well under a second.
-- **Tests**: ship two small fixture `.mmdb` files (the `geoip2/geoip2` test fixtures are publicly licensed and small; you can vendor them in `api/tests/Fixtures/geoip/`). Use them in unit tests.
+### Cross-provider
+
+- **Stable on-disk filenames.** Whatever provider supplied them, the runtime paths are `GEOIP_COUNTRY_DB=/data/geoip/country.mmdb` and `GEOIP_ASN_DB=/data/geoip/asn.mmdb` (generalize the SPEC §9 defaults — see "Deviations from SPEC" in the handoff). Downloaders write to a temp dir and the job atomic-renames to these stable paths. The lookup service never sees provider details.
+- **Atomic file replace.** `tempnam()` in `/data/geoip/`, write the new file, `rename()` to the target. Avoid leaving partials if the process crashes.
+- **MMDB library.** Use `geoip2/geoip2` for the package; use the underlying `MaxMind\Db\Reader` class directly so the same code reads MaxMind, DB-IP, and IPinfo files. Don't roll your own `.mmdb` parser. Don't use a service that calls back to a remote API on every lookup — the local DB is the point.
+- **IPv6.** All three providers' DBs cover both families. Verify with a v6 lookup test against the fixtures.
+- **Large batches.** 200 per tick is a safe default. Each lookup is microseconds; 200 takes well under a second.
+- **Tests.** The fixture path is provider-independent: ship two small `.mmdb` files in `api/tests/Fixtures/geoip/` and have the test harness point `GEOIP_COUNTRY_DB`/`GEOIP_ASN_DB` at them. Use the `MaxMindRecordAdapter` for fixture-based tests since the public test MMDBs use MaxMind's schema.
+
+### Provider-specific
+
+- **DB-IP**: monthly cadence — flag if `country_db_modified` is older than 45 days in healthz (warning, not error). License is CC BY 4.0; the UI footer + README must credit DB-IP. URL pattern is date-stamped; downloader composes from `now()` and falls back one month on 404.
+- **MaxMind**: never log the license key. Don't include it in error messages, `job_runs.details`, or any echoed config. Mask in the masked-config endpoint.
+- **IPinfo**: same — never log the token. Same masking treatment.
+- **Build-time vs runtime DBs**. The Dockerfile may bake DBs in at build time when an opt-in provider's credential is set as a build arg; otherwise they're absent until `refresh-geoip` runs. With DB-IP default, the entrypoint can optionally trigger an initial `refresh-geoip` on first boot if the files are missing — out of scope for this milestone; leave for M14 hardening.
 
 ## Out of scope (DO NOT)
 
-- Other enrichment sources (Spamhaus, IPInfo, AbuseIPDB). MaxMind only.
+- Other enrichment sources (Spamhaus, AbuseIPDB, internal corporate feeds). Three providers is the cap; the abstraction is enough.
 - Per-request enrichment lookups in the report endpoint. Enrichment is a background concern.
 - Reverse-DNS / WHOIS enrichment.
 - Auditing the enrichment job (M12 owns audit emission generally; this job logs to its `job_runs` row).
-- New API endpoints beyond what's listed.
-- Mass re-enrichment of all IPs on every refresh-geoip run. New DB ⇒ existing rows stay. Add a `?reenrich=true` flag to refresh-geoip that, if true, also nulls the `enriched_at` so `findPending` re-picks them up — but only run that on explicit request.
+- New API endpoints beyond what's listed (the `/admin/ips/countries` endpoint is the only addition).
+- Mass re-enrichment of all IPs on every refresh-geoip run. New DB ⇒ existing rows stay. The `?reenrich=true` flag opts into clearing `enriched_at` so `findPending` re-picks them up — only on explicit request.
+- A fourth provider. Pick from the three above.
+- Auto-bootstrapping the DB on first container start. The job runs on schedule; first-run will populate.
 
 ## Acceptance
 
+The acceptance script is structured into three blocks: default provider (DB-IP, no credentials), then opt-ins (MaxMind, IPinfo). The fixture-based assertions are provider-independent and are the load-bearing checks for correctness.
+
 ```bash
 cd api && composer cs && composer stan && composer test && cd ..
 
 docker compose down -v
 cp .env.example .env
-# DO NOT set MAXMIND_LICENSE_KEY for the first part of the test
+# Default config: GEOIP_PROVIDER=dbip, no MAXMIND_LICENSE_KEY, no IPINFO_TOKEN
 docker compose up -d
 sleep 15
 
 ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
 INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
 
-# Without DBs / license key: refresh-geoip returns 412
+# --- Block A: default provider (DB-IP) ---
+
+# DB-IP needs no credential — refresh-geoip does NOT 412.
+# (Skip the live download in CI; assert the controller doesn't short-circuit.)
 test "$(curl -s -o /dev/null -w '%{http_code}' \
   -H "Authorization: Bearer $INTERNAL_TOKEN" \
-  -X POST http://localhost:8081/internal/jobs/refresh-geoip)" = "412"
+  -X POST 'http://localhost:8081/internal/jobs/refresh-geoip?dry_run=1')" != "412"
 
-# enrich-pending no-ops cleanly when DBs are missing
+# enrich-pending no-ops cleanly when DBs are missing (regardless of provider)
 RESP=$(curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
   http://localhost:8081/internal/jobs/enrich-pending)
 echo "$RESP" | grep -q '"status":"success"'
 echo "$RESP" | grep -q '"items_processed":0'
 
-# /healthz reports geoip status
+# /healthz reports geoip status with provider name
+curl -s http://localhost:8081/healthz | grep -q '"provider":"dbip"'
 curl -s http://localhost:8081/healthz | grep -q '"country_db_present":false'
 
-# With fixture DBs present (copy them into the volume)
+# Fixture-based functional check (provider-independent path)
 docker compose cp api/tests/Fixtures/geoip/. api:/data/geoip/
-# Submit a report for an IP that's in the fixture
 RID=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
   -d '{"name":"test","trust_weight":1.0}' \
   http://localhost:8081/api/v1/admin/reporters | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
@@ -152,17 +222,48 @@ curl -s -X POST -H "Authorization: Bearer $RT" -H "Content-Type: application/jso
   -d '{"ip":"81.2.69.142","category":"brute_force"}' \
   http://localhost:8081/api/v1/report > /dev/null
 
-# Run enrichment
 curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
   http://localhost:8081/internal/jobs/enrich-pending | grep -q '"items_processed":1'
 
-# IP detail returns enrichment fields populated
 curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
   http://localhost:8081/api/v1/admin/ips/81.2.69.142 | grep -qE '"country_code":"(GB|US)"'
 
-# /healthz reflects DB presence
 curl -s http://localhost:8081/healthz | grep -q '"country_db_present":true'
 
+docker compose down -v
+
+# --- Block B: MaxMind opt-in ---
+
+cp .env.example .env
+echo 'GEOIP_PROVIDER=maxmind' >> .env
+# Leave MAXMIND_LICENSE_KEY empty
+docker compose up -d
+sleep 15
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+# Missing license key now triggers 412 (not under DB-IP default)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -X POST http://localhost:8081/internal/jobs/refresh-geoip)" = "412"
+curl -s http://localhost:8081/healthz | grep -q '"provider":"maxmind"'
+curl -s http://localhost:8081/healthz | grep -q '"provider_configured":false'
+
+docker compose down -v
+
+# --- Block C: IPinfo opt-in ---
+
+cp .env.example .env
+echo 'GEOIP_PROVIDER=ipinfo' >> .env
+# Leave IPINFO_TOKEN empty
+docker compose up -d
+sleep 15
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -X POST http://localhost:8081/internal/jobs/refresh-geoip)" = "412"
+curl -s http://localhost:8081/healthz | grep -q '"provider":"ipinfo"'
+
 docker compose down -v
 ```
 
@@ -170,29 +271,49 @@ docker compose down -v
 
 1. Commit:
    ```
-   feat(M11): MaxMind GeoLite2 enrichment
+   feat(M11): MMDB enrichment with DB-IP / MaxMind / IPinfo providers
 
-   - EnrichmentService backed by geoip2/geoip2
+   - EnrichmentService backed by MaxMind\Db\Reader (open MMDB format)
+   - GeoIpDownloader abstraction; DB-IP default, MaxMind & IPinfo opt-in
    - enrich-pending job (replaces M05 skeleton): 200 per tick, no-ops cleanly without DBs
-   - refresh-geoip job: download + verify + atomic replace, 412 without license key
-   - IP detail UI shows country flag + ASN (graceful when null)
-   - /healthz reports geoip db status
-   - country/asn filters on IPs list now functional
+   - refresh-geoip job: provider-aware download + verify + atomic replace
+     - 412 only when an opt-in provider's credential is unset
+   - IP detail UI shows country flag + ASN with provider attribution (graceful when null)
+   - /healthz reports provider, configured state, DB presence + mtimes
+   - country/asn filters on IPs list now functional; /admin/ips/countries dropdown source
    ```
 
 2. Append to `PROGRESS.md`:
    ```markdown
    ## M11 — Enrichment (done)
 
-   **Built:** GeoIP wrapper, both jobs, UI display, healthz fields.
+   **Built:** MMDB wrapper, three pluggable downloaders (DB-IP / MaxMind / IPinfo),
+   both jobs, UI display + attribution, healthz fields, country dropdown source.
 
    **Notes for next milestone:**
-   - DBs live at /data/geoip/. Without MAXMIND_LICENSE_KEY they must be present before the container starts (mount or copy in).
-   - License key never logged.
+   - DBs live at /data/geoip/{country,asn}.mmdb (renamed from SPEC §9 defaults to be
+     provider-agnostic; see "Deviations" below).
+   - Default provider is DB-IP — no credential required, never returns 412.
+   - MaxMind and IPinfo paths return 412 when their credential is empty.
+   - License key / IPinfo token never logged.
    - Re-enrichment is opt-in via ?reenrich=true on refresh-geoip.
-
-   **Deviations from SPEC:** none.
-   **Added dependencies:** geoip2/geoip2 (mentioned in SPEC §2 as the planned library).
+   - DB-IP and IPinfo: no upstream integrity file; verification is gzip-decode
+     (DB-IP only) + MMDB metadata + node-count sanity. MaxMind keeps SHA-256.
+   - Attribution rendered in UI for DB-IP and IPinfo per their license terms.
+
+   **Deviations from SPEC:**
+   - SPEC §9 named GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb. Renamed
+     to /data/geoip/country.mmdb so the path is provider-agnostic. Documented
+     in .env.example.
+   - SPEC §2 names MaxMind GeoLite2 specifically; we keep MaxMind as a first-class
+     provider but default to DB-IP (also MMDB) for friction-free self-hosting.
+
+   **Added dependencies:** geoip2/geoip2 (mentioned in SPEC §2 as the planned
+   library; we use its underlying MaxMind\Db\Reader for cross-provider support).
+
+   **Added env vars:** GEOIP_PROVIDER (default `dbip`; values `dbip|maxmind|ipinfo`),
+   IPINFO_TOKEN (used only when provider=ipinfo). MAXMIND_LICENSE_KEY was already
+   in .env.example.
    ```
 
 3. **Stop.** Do not start M12.

+ 5 - 0
ui/config/settings.php

@@ -56,4 +56,9 @@ return [
     // Session: 8h inactivity, 24h absolute
     'session_idle_seconds' => (int) (getenv('SESSION_IDLE_SECONDS') ?: 28800),
     'session_absolute_seconds' => (int) (getenv('SESSION_ABSOLUTE_SECONDS') ?: 86400),
+
+    // GeoIP — only the provider name. The UI uses it to pick the right
+    // attribution string for the IP-detail enrichment panel. The api
+    // owns the actual provider config; this is a display-only mirror.
+    'geoip_provider' => strtolower((string) (getenv('GEOIP_PROVIDER') ?: 'dbip')),
 ];

+ 18 - 3
ui/resources/views/pages/ips/detail.twig

@@ -96,14 +96,29 @@
             {% if detail.enrichment.country_code or detail.enrichment.asn %}
                 <dl class="mt-3 grid grid-cols-3 gap-y-2 text-sm">
                     <dt class="text-slate-500 dark:text-slate-400">Country</dt>
-                    <dd class="col-span-2 font-mono">{{ h.flag(detail.enrichment.country_code) }} {{ detail.enrichment.country_code|default('—') }}</dd>
+                    <dd class="col-span-2 font-mono">{{ h.flag(detail.enrichment.country_code) }} <span>{{ detail.enrichment.country_code|default('—') }}</span></dd>
                     <dt class="text-slate-500 dark:text-slate-400">ASN</dt>
-                    <dd class="col-span-2 font-mono">{{ detail.enrichment.asn|default('—') }}</dd>
+                    <dd class="col-span-2 font-mono">
+                        {% if detail.enrichment.asn %}
+                            <a href="https://bgp.he.net/AS{{ detail.enrichment.asn }}" target="_blank" rel="noopener" class="text-indigo-600 hover:underline dark:text-indigo-400">AS{{ detail.enrichment.asn }}</a>
+                        {% else %}—{% endif %}
+                    </dd>
                     <dt class="text-slate-500 dark:text-slate-400">AS org</dt>
                     <dd class="col-span-2">{{ detail.enrichment.as_org|default('—') }}</dd>
                 </dl>
+                {% if detail.enrichment.enriched_at %}
+                    <p class="mt-3 text-xs text-slate-400">Enriched <time datetime="{{ detail.enrichment.enriched_at }}">{{ detail.enrichment.enriched_at }}</time> UTC</p>
+                {% endif %}
             {% else %}
-                <p class="mt-3 text-sm text-slate-400">Not yet enriched (data lands once the GeoIP job runs in M11).</p>
+                <p class="mt-3 text-sm text-slate-400">
+                    <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-500 dark:bg-slate-800">Unknown</span>
+                    not yet enriched.
+                </p>
+            {% endif %}
+            {% if geoip_provider == 'dbip' %}
+                <p class="mt-4 border-t border-slate-100 pt-3 text-[0.65rem] text-slate-400 dark:border-slate-800">IP Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener" class="hover:underline">DB-IP</a> (CC BY 4.0)</p>
+            {% elseif geoip_provider == 'ipinfo' %}
+                <p class="mt-4 border-t border-slate-100 pt-3 text-[0.65rem] text-slate-400 dark:border-slate-800">IP data powered by <a href="https://ipinfo.io" target="_blank" rel="noopener" class="hover:underline">IPinfo</a></p>
             {% endif %}
         </div>
 

+ 11 - 2
ui/resources/views/pages/ips/index.twig

@@ -65,8 +65,17 @@
         </div>
         <div>
             <label for="f-country" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Country</label>
-            <input type="text" id="f-country" name="country" maxlength="2" value="{{ filters.country|default('') }}"
-                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono text-sm uppercase dark:border-slate-700 dark:bg-slate-950">
+            {% if countries|default([])|length > 0 %}
+                <select id="f-country" name="country" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono text-sm uppercase dark:border-slate-700 dark:bg-slate-950">
+                    <option value="">— any —</option>
+                    {% for c in countries %}
+                        <option value="{{ c.code }}" {% if filters.country|upper == c.code|upper %}selected{% endif %}>{{ c.code }} ({{ c.count }})</option>
+                    {% endfor %}
+                </select>
+            {% else %}
+                <input type="text" id="f-country" name="country" maxlength="2" value="{{ filters.country|default('') }}"
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono text-sm uppercase dark:border-slate-700 dark:bg-slate-950">
+            {% endif %}
         </div>
         <div>
             <label for="f-asn" class="block text-xs font-medium text-slate-600 dark:text-slate-400">ASN</label>

+ 24 - 0
ui/src/ApiClient/AdminClient.php

@@ -72,6 +72,30 @@ final class AdminClient
         return DashboardStatsDto::fromArray($payload);
     }
 
+    /**
+     * @return list<array{code: string, count: int}>
+     */
+    public function listCountries(int $actingUserId): array
+    {
+        $payload = $this->api->request('GET', '/api/v1/admin/ips/countries', [], $actingUserId);
+        $items = $payload['items'] ?? [];
+        if (!is_array($items)) {
+            return [];
+        }
+        $out = [];
+        foreach ($items as $item) {
+            if (!is_array($item)) {
+                continue;
+            }
+            $out[] = [
+                'code' => (string) ($item['code'] ?? ''),
+                'count' => (int) ($item['count'] ?? 0),
+            ];
+        }
+
+        return $out;
+    }
+
     // ---- manual blocks (M10) ----
 
     /**

+ 2 - 0
ui/src/App/Container.php

@@ -81,6 +81,7 @@ final class Container
             'settings.local_admin_password_hash' => (string) ($settings['local_admin_password_hash'] ?? ''),
             'settings.session_idle' => (int) ($settings['session_idle_seconds'] ?? 28800),
             'settings.session_absolute' => (int) ($settings['session_absolute_seconds'] ?? 86400),
+            'settings.geoip_provider' => strtolower((string) ($settings['geoip_provider'] ?? 'dbip')),
 
             LoggerInterface::class => factory(static function (ContainerInterface $c): LoggerInterface {
                 $logger = new Logger('ui');
@@ -179,6 +180,7 @@ final class Container
                     'oidc_enabled' => (bool) $c->get('settings.oidc_enabled'),
                     'local_admin_enabled' => (bool) $c->get('settings.local_admin_enabled'),
                     'app_env' => (string) $c->get('settings.app_env'),
+                    'geoip_provider' => (string) $c->get('settings.geoip_provider'),
                 ]);
             }),
 

+ 3 - 0
ui/src/Controllers/IpsController.php

@@ -59,9 +59,11 @@ final class IpsController
         $pageSize = 25;
 
         $list = null;
+        $countries = [];
         $error = null;
         try {
             $list = $this->admin->searchIps($user->userId, $filters, $page, $pageSize);
+            $countries = $this->admin->listCountries($user->userId);
         } catch (ApiValidationException $e) {
             $error = 'invalid filter: ' . implode(', ', array_keys($e->details));
         } catch (ApiException) {
@@ -75,6 +77,7 @@ final class IpsController
             'page' => $page,
             'categories' => self::CATEGORIES,
             'statuses' => self::STATUSES,
+            'countries' => $countries,
             'error' => $error,
         ]);
     }

+ 4 - 0
ui/tests/Integration/App/IpsPageTest.php

@@ -46,6 +46,8 @@ final class IpsPageTest extends AppTestCase
                 ],
             ],
         ]);
+        // listCountries() — issued after searchIps() by the controller.
+        $this->enqueueApiResponse(200, ['items' => []]);
 
         $response = $this->request('GET', '/app/ips');
 
@@ -65,6 +67,7 @@ final class IpsPageTest extends AppTestCase
             'total' => 0,
             'items' => [],
         ]);
+        $this->enqueueApiResponse(200, ['items' => []]);
 
         $response = $this->request('GET', '/app/ips');
 
@@ -75,6 +78,7 @@ final class IpsPageTest extends AppTestCase
     public function testListPagePassesFiltersThrough(): void
     {
         $this->enqueueApiResponse(200, ['page' => 1, 'page_size' => 25, 'total' => 0, 'items' => []]);
+        $this->enqueueApiResponse(200, ['items' => []]);
 
         $response = $this->request('GET', '/app/ips?q=2001&category=spam');
         $body = (string) $response->getBody();