Skip to content

GeoIP enrichment

Purely local geolocation and autonomous-system enrichment of flow records and of the filterlog/sshd/Suricata log lines described below, from .mmdb databases on disk. No lookup ever touches the network: a lookup is a radix-tree walk costing well under a microsecond.

On by default since v4, and it works out of the box: the container image bundles the DB-IP Lite Country and ASN databases, so a stock deployment gets countries and autonomous systems with no database to source and no account to create. MaxMind is still fully supported and still more accurate - see Bundled databases and Switching to MaxMind.

Attribution

IP geolocation data by DB-IP, licensed under CC BY 4.0. The same credit ships in the image at /licenses/GEOIP-DB-IP-ATTRIBUTION.txt and in the operator console footer.

Behaviour change on upgrade

--geoip.enabled now defaults to true. With databases in the image there is nothing left to source first, so geo is on unless you turn it off. Combined with the change below, an upgraded container deployment that had never configured GeoIP at all now emits geo attributes on flow records and on filterlog lines, which costs real per-line bytes on the highest-volume log stream on the box - see the measured cost. Set --geoip.enabled=false to opt out entirely, or --logs.syslog.geoip=false to keep geo on flow records only.

--geoip.enabled also covers both flow records and the filterlog/sshd/auth/ Suricata log lines (see Logs below) - one switch means geo, everywhere it can reach.

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.

Bundled databases (DB-IP Lite)

The container image ships two databases, fetched unmodified from DB-IP when the image is built and installed read-only:

path contents size
/usr/share/opnsense2otel/geoip/dbip-country-lite.mmdb country + continent ~8.2 MB
/usr/share/opnsense2otel/geoip/dbip-asn-lite.mmdb ASN + organization ~9.6 MB

Those two paths are the default values of --geoip.country-database and --geoip.asn-database, so nothing needs configuring to use them.

Why DB-IP and not GeoLite2. GeoLite2 cannot legally be bundled: redistributing it inside a product needs MaxMind's paid, signed Commercial Redistribution License and obliges the redistributor to bind every downstream user to a restrictive EULA, which is impossible for an Apache-2.0 public image. DB-IP Lite is plain CC BY 4.0 - redistributable as long as DB-IP is credited.

They are not in the git repository. They are ~17.8 MB of binary republished monthly; committing them would grow this repository's history by that much every month, permanently. So they are fetched at image build time, which has one consequence worth stating plainly:

A non-container build has no bundled database

make, go build, go run . and the release binary archives never run the image build, so nothing exists at those paths. With --geoip.enabled on by default, that is the default configuration of every such build - and it is fail-open: the exporter starts normally, every geo attribute is simply absent, and no error is raised. Point --geoip.country-database / --geoip.asn-database at your own files, or use the container image.

Refresh. DB-IP republishes on the 1st of each month. A scheduled workflow rebuilds and republishes the :main image on the 3rd once the new files are published, and every tagged release build fetches current data as a matter of course. The database's own build date is exported as opnsense_flow_geoip_database_build_timestamp_seconds{database}, so freshness is a query rather than a claim - and OPNsenseFlowGeoIPDatabaseStale fires on it.

Accuracy: read this before trusting a city

DB-IP publishes Lite as an explicitly reduced-coverage, reduced-accuracy subset of their commercial data, and the accuracy is not uniform across fields:

field free-tier accuracy, broadly
country >95% everywhere, across every free provider tested independently
city ~70-78% in North America and Western Europe, and worse elsewhere

Country is the only field that may ever become a metric label, and it is the field that holds up. City and region are log attributes only. Treat a city as a hint worth having, not as a fact worth alerting on, and use MaxMind's commercial tiers if you need better.

City is supported, but is not bundled

DB-IP City Lite is 125 MB uncompressed against Country Lite's 8.2 MB, and it would replace the country database rather than add to it (a City database is a strict superset, and one flag accepts either). That is roughly +117 MB on every image pull, forever, for data that reaches no metric - so it is not bundled.

To use it, download it yourself and point the same flag at it:

curl -fSL -o /var/lib/geoip/dbip-city-lite.mmdb.gz \
  https://download.db-ip.com/free/dbip-city-lite-$(date -u +%Y-%m).mmdb.gz
gunzip /var/lib/geoip/dbip-city-lite.mmdb.gz
--geoip.country-database=/var/lib/geoip/dbip-city-lite.mmdb

region is always empty with DB-IP City data

DB-IP City Lite's subdivisions[] entries carry only names - there is no iso_code key in the object at all. The exporter's <src|dst>.geo.region attribute is an ISO 3166-2 code, so it decodes empty on every record against a DB-IP City database even where the city name resolved fine. This is a property of DB-IP's Lite tier, verified against the real file, not a bug.

Reading the subdivision name into that field instead is deliberately not done: it would silently change region from a code to free text and break every MaxMind consumer of it. A MaxMind City database populates it correctly.

Switching to MaxMind

MaxMind is more accurate, fully supported, and wins outright over the bundled copies. Two supply paths:

  • Operator-managed files: point the flags at your own files. A geoipupdate cron, a sidecar, or a mounted volume writes them, and --geoip.reload-interval (15m) notices when one changes and hot-swaps it under the running exporter.
--geoip.country-database=/var/lib/geoip/GeoLite2-Country.mmdb \
--geoip.asn-database=/var/lib/geoip/GeoLite2-ASN.mmdb
  • 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 at their defaults and they switch to what the downloader installs - the bundled paths are treated as unclaimed precisely so this works without extra flags.

Precedence

Resolved in this order, first match wins:

  1. MaxMind via --geoip.download.enabled - configuring the downloader repoints both database paths at what it installs, overriding the bundled copies.
  2. An explicit --geoip.country-database / --geoip.asn-database - your path is never overridden by anything.
  3. The bundled DB-IP Lite copies - the flag defaults, used when you set neither of the above.

Turning it off

--geoip.enabled=false

Every geo attribute disappears from flow records and log lines, and the country metric label resolves to off with it (see --flow.geoip.metric-dims below). The ~18 MB of databases are simply never read.

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 MaxMind editions to download

Applies to --geoip.download.enabled only; the bundled DB-IP copies are covered above. 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. On by default since #537, with --flow.top-n and --flow.max-keys raised 10x in the same change to hold it: in practice a box talks to a few dozen countries, not the ~250 the dimension can hold, but at the old bounds even that would have folded real series into __other__. Set it to false to drop the label; the flow families then carry the dimensions they did before the label existed.

With --geoip.enabled=false the label resolves to off rather than refusing to start, so turning geo off never costs you a startup.

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

Logs (filterlog, sshd/auth, Suricata)

--geoip.enabled also adds geo to three log lanes, and only these three: the ones where a remote peer is the subject of the line, not an incidental mention.

  • filterlog - every blocked/passed packet, both src.ip and dst.ip. This is what answers "which countries are hitting my WAN and getting dropped", and it works identically with or without Zenarmor, because filterlog never had any geo source at all.
  • sshd / auth - a brute-force or login attempt's source address.
  • Suricata (the EVE-over-syslog alert path) - an alert's source and destination.

Deliberately excluded: unbound, DHCP, and every other lane where an address appears incidentally. Geo is not queried there, and it would be paid for on every line for no operational benefit.

Attributes are a subset of the flow lane's, with the same keys so one Loki query covers both streams:

attribute notes
<src\|dst>.geo.country ISO 3166-1 alpha-2
<src\|dst>.geo.country_source always maxmind - these lanes have no second source to disagree with
<src\|dst>.geo.continent two-letter code
<src\|dst>.geo.asn e.g. AS13335
<src\|dst>.geo.as_org e.g. Cloudflare

No city, no region. They stay a flow/Zenarmor-only field: filterlog's volume makes them a worse trade here than they already were on flow (see the per-line cost below), and nothing on this path needs them.

Non-global addresses (loopback, RFC 1918, link-local, CGNAT) are skipped through the same geoip.Enrichable check flow enrichment uses - never a second classifier, never a lookup that could only ever miss.

Logs-only. Country does not become a log_events metric dimension, on any setting, and there is no flag for it: filterlog's block-line volume is far higher than flow volume, so the cardinality multiplication that justifies --flow.geoip.metric-dims being opt-in on flow is a strictly worse trade on logs.

The per-lane opt-out

--logs.syslog.geoip=false

On by default whenever --geoip.enabled is set - that default is the behaviour change flagged at the top of this page. Set it to false to keep GeoIP on flow records while opting these three log lanes back out. It needs no database of its own: it only gates whether the log lanes consult the same *geoip.DB --geoip.enabled already opened, so flipping it costs nothing beyond the toggle.

Measured per-line cost

Measured against the repository's own filterlog test captures plus a representative sample of real-world autonomous-system organization names (ISPs and cloud/hosting providers commonly seen in inbound block traffic - MaxMind's tiny test fixtures do not have enough variety to size this on their own):

one endpoint both endpoints (worst case: transit traffic between two public addresses)
average ~116 B ~232 B
worst case (longest observed as_org) ~140 B ~280 B

(TestGeoByteCost, internal/logship/syslog/geo_bytecost_test.go - key+value byte length summed over the attributes one geoAttrs call adds, against a representative sample of real-world autonomous-system organization names rather than MaxMind's tiny test fixtures, which do not carry enough org-name variety to size this on their own.)

as_org is the largest single field - about 28% of one endpoint's average added bytes, up to 40% in the worst observed case - but does not dominate the total: country/country_source/continent/asn together are fixed-size overhead of about 57 B regardless of which autonomous system answers. It is shipped per the frozen scope decision (#528) because an ASN number with no organization name needs an external lookup to be readable. Compare against #475, which removed Zenarmor's latitude/longitude at 145-149 B/line for a similar reason - that number lands close to this feature's worst case by coincidence, not because the two are the same shape of cost.

Most filterlog lines add zero bytes: an internal-to-internal line, or a line whose only public-looking address is a miss in the loaded database, gets no .geo. attribute at all (fail-open). The figures above are the cost on a line that actually resolves.

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 the database's own build date, read from its metadata, not the download or image-build time - so it is the right staleness signal for a bundled copy as much as a downloaded one, and it is how you tell what data an image actually carries. OPNsenseFlowGeoIPDatabaseStale fires on it at 45 days, a threshold chosen to cover every database the exporter can load: DB-IP Lite republishes monthly, so anything tighter would fire on a perfectly healthy stock deployment. It does mean a broken MaxMind updater takes 45 days to page rather than 14 - watch opnsense_flow_geoip_downloads_total and opnsense_flow_geoip_reloads_total if you want that sooner, since a failing fetch shows up there immediately.

On a stock deployment there is no updater to fix when this fires: the bundled copy is exactly as old as the image, so the answer is to pull a current one.

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, republished monthly (DB-IP) or twice a week (MaxMind), and GeoLite2 is covered by the GeoLite End User License Agreement besides. The bundled DB-IP files are fetched by the Dockerfile at image build time; the only .mmdb files in the tree are MaxMind's own synthetic test fixtures, used by the unit tests.

The three-way country fallback is inert against DB-IP. The exporter prefers country.iso_code, falling back to registered_country then represented_country - MaxMind omits country entirely for a large slice of the internet (1.1.1.1 is the canonical example) and without the fallback every Cloudflare-fronted address would read as unknown. DB-IP models neither fallback key at all and fills country.iso_code on every record it has, including 1.1.1.1. So with the bundled databases the fallback never fires; it stays because it is load-bearing the moment an operator supplies MaxMind.