Skip to content

GeoIP enrichment

Optional, purely local geolocation and autonomous-system enrichment of flow records, from MaxMind .mmdb files on disk. No lookup ever touches the network: a lookup is a radix-tree walk costing well under a microsecond.

It exists to close an asymmetry. Zenarmor's conn documents carry geo from Zenarmor's own database; NetFlow-derived flows carry bare addresses and no geo at all. Because the correlator merges the two into one flow log, whether a given connection got a country depended on whether Zenarmor happened to see it - an interface Zenarmor is not inspecting, or any traffic on a box without the plugin, produced flow logs that were structurally identical except that the geo fields were silently absent. On the dashboard that looks like less traffic, not like missing data.

With this on, every external address gets a country, continent and ASN from the exporter's own database, whether or not Zenarmor exists.

This is not the opnsense_firewall_geoip_* metrics

Those four metrics report the status of OPNsense's own GeoIP alias feature - how many addresses its database loaded, when the firewall last downloaded it. They are unrelated to this page, and the MaxMind license key in their descriptions is one configured on the firewall, not here.

Enabling it

Off by default, because it needs a database the exporter does not ship.

--geoip.enabled \
--geoip.country-database=/var/lib/geoip/GeoLite2-Country.mmdb \
--geoip.asn-database=/var/lib/geoip/GeoLite2-ASN.mmdb

Two supply paths, and both are supported:

  • Operator-managed files (the default): a geoipupdate cron, a sidecar, or a mounted volume writes the files, and --geoip.reload-interval (15m) notices when one changes and hot-swaps it under the running exporter.
  • The built-in downloader (--geoip.download.enabled): the exporter fetches from MaxMind directly, so no sidecar is needed. Requires --geoip.download.account-id and a license key. Conditional requests mean an unchanged database costs a 304 and no download quota. Leave --geoip.country-database / --geoip.asn-database unset and they default to what the downloader installs.

The two schedules are independent because they answer different questions. --geoip.download.interval is "has MaxMind published a newer build?"; a network question, worth asking daily since GeoLite2 rebuilds twice a week. --geoip.reload-interval is "has the file on disk changed?"; a local stat, and the only thing that makes the operator-managed path work at all.

Which editions to ship

Default is Country + ASN. The resident cost is real and is the reason City is not default:

edition resident what it adds
GeoLite2-Country ~9 MB country, continent
GeoLite2-ASN ~12 MB ASN and organization
GeoLite2-City ~60 MB city and region, plus everything Country gives

GeoLite2-ASN is included by default because it is the one thing Zenarmor cannot supply on any box: Zenarmor ships no ASN database at all, and its asn field is the string "0" on every record - which is its empty value, not AS0.

City is supported and is the right choice if you want locality without Zenarmor, which is most deployments. A City database is a strict superset of a Country one, so the same --geoip.country-database path accepts either:

--geoip.download.editions=GeoLite2-City,GeoLite2-ASN

City and region reach log records only, never a metric label, at any setting.

Precedence: ours vs Zenarmor's

Zenarmor is an optional enhancement, never a dependency. A record must never be missing geo because a plugin is absent - that is the whole asymmetry this feature closes. But where Zenarmor is installed, two databases answer, and the better source differs per field:

field source why
country / continent ours wins, Zenarmor's kept alongside the dashboards read this one, so its meaning must not vary by which receiver saw the flow
city / region Zenarmor when present, ours as fallback theirs is a commercial GeoIP2-City build; ours is the only source on the vast majority of boxes, which have no Zenarmor
ASN ours only Zenarmor ships no ASN database on any box

Ours-wins on country is not free. Read off a live firewall, Zenarmor's file reports database_type: GeoIP2-City at 126 MB, against a stock free GeoLite2-City's 63 MB - a commercial or custom build, under a GeoLite2 filename. So on a connection Zenarmor did see, overwriting its answer with a free GeoLite2 lookup can genuinely replace a better attribution with a worse one. That is a deliberate trade of accuracy for consistency, and it is measured rather than assumed:

  • Zenarmor's value is retained, never discarded. Where it disagrees with ours it rides on the log record as <src|dst>.geo.zen_country.
  • opnsense_flow_geoip_country_comparisons_total{result="agree"|"disagree"} counts every endpoint where both answered, so the cost has a rate and a denominator.

Provenance is mandatory

Because city's source legitimately varies by deployment, every enriched endpoint carries an attribute naming which database answered: <src|dst>.geo.country_source and <src|dst>.geo.city_source, each maxmind or zenarmor. A field that quietly means two different things depending on plugin state is exactly the class of defect worth spending an attribute to prevent.

It matters most for region, where the two sources disagree on format as well as value: MaxMind returns the ISO 3166-2 code (ENG), Zenarmor returns a name (England). Neither is normalised into the other - inventing that mapping would be fabricating data. Zenarmor's city_name is also not reliably a city: a live record for a Grafana endpoint carried dst_geoip_city_name: "England", which is a subdivision. The provenance attribute is how you tell.

What lands where

Flow log records carry geo unconditionally whenever --geoip.enabled is set:

attribute notes
<src\|dst>.geo.country ISO 3166-1 alpha-2
<src\|dst>.geo.continent two-letter code
<src\|dst>.geo.city City database, or Zenarmor
<src\|dst>.geo.region ISO 3166-2 code (ours) or a name (Zenarmor) - see provenance above
<src\|dst>.geo.asn e.g. AS13335
<src\|dst>.geo.as_org e.g. Cloudflare
<src\|dst>.geo.country_source maxmind or zenarmor
<src\|dst>.geo.city_source maxmind or zenarmor
<src\|dst>.geo.zen_country only when Zenarmor's answer differs from the exported one

All of these are Loki structured metadata, never index labels - the same rule every other high-cardinality flow attribute follows. A key is omitted entirely when it carries nothing.

Flow metrics carry geo only behind an explicit opt-in:

--flow.geoip.metric-dims

which adds a single country label to opnsense_flow_bytes_total, _packets_total and _records_total, naming the remote end of the flow. Off by default and it should usually stay off: country is a ~250-value dimension multiplied against every existing flow label, so this can multiply the family's cardinality roughly 250-fold. --flow.top-n and --flow.max-keys still bound the result, which means the practical effect on a busy box is that real series start folding into __other__ rather than that the family grows without limit.

ASN and city never become labels at any setting. With the opt-in off, every series carries country="", which Prometheus treats as an absent label - so the family reads exactly as it did before this feature existed.

Fail-open, always

A missing, unreadable or stale database means the geo attributes are simply absent. It is never a startup failure and never blocks a lookup. A file that fails to parse on reload leaves the previously loaded database serving: a bad update costs freshness, never availability.

That is also the trap. A database that failed to load looks exactly like a quiet network - every attribute absent, every counter at zero, nothing failing - which is why these exist:

metric answers
opnsense_flow_geoip_lookups_total{database,result} is either database answering? database="skipped" is addresses that are not globally routable, not a failure
opnsense_flow_geoip_enriched_records_total is this feature doing anything at all?
opnsense_flow_geoip_database_build_timestamp_seconds{database} how old is the data? Absent, never zero, when a database is not loaded
opnsense_flow_geoip_reloads_total{database,result} did a hot-swap happen, or fail?
opnsense_flow_geoip_downloads_total{result} unmodified is the healthy steady state
opnsense_flow_geoip_country_comparisons_total{result} what is ours-wins costing?

The build timestamp is MaxMind's build date, not the download time, so it is the right staleness signal - and OPNsenseFlowGeoIPDatabaseStale fires on it at 14 days, which is four missed GeoLite2 builds.

Notes

Databases are read into the heap, not memory-mapped. mmap is faster to load and cheaper in RSS, and it is also a way to crash the process: anything that truncates the file while a lookup is walking the mapping - a plain curl -o db.mmdb, an editor, an rsync without --inplace - faults every in-flight read with SIGBUS and takes the exporter down. The resident costs in the table above are the price of that safety.

There is no result cache, deliberately. A lookup is a tree walk that allocates nothing; an LRU in front would add locking, memory and a TTL surface for no gain.

Non-global addresses are never geolocated. Loopback, RFC 1918, link-local, multicast and carrier-grade NAT (100.64.0.0/10) are skipped before any database is consulted, and counted under database="skipped". CGNAT is skipped because MaxMind publishes no records for it, so a lookup could only ever miss - note that the exporter's WAN-detection heuristic deliberately treats CGNAT the other way, since it is what many ISPs hand a WAN.

The firewall's own databases are deliberately not used. A box running Zenarmor or CrowdSec already has .mmdb files under /usr/local/zenarmor/db/GeoIP and /var/db/crowdsec/data. The exporter talks to the firewall over the REST API and normally runs on a different host entirely, so those paths do not exist where this code runs. It ships its own database or it has none.

No database is committed to this repository. They are tens of megabytes each, rebuilt twice a week, and covered by the GeoLite End User License Agreement. The only .mmdb files in the tree are MaxMind's own synthetic test fixtures, used by the unit tests.