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
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
geoipupdatecron, 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-idand a license key. Conditional requests mean an unchanged database costs a 304 and no download quota. Leave--geoip.country-database/--geoip.asn-databaseat 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:
- MaxMind via
--geoip.download.enabled- configuring the downloader repoints both database paths at what it installs, overriding the bundled copies. - An explicit
--geoip.country-database/--geoip.asn-database- your path is never overridden by anything. - The bundled DB-IP Lite copies - the flag defaults, used when you set neither of the above.
Turning it off¶
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:
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:
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.ipanddst.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¶
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.