A small, local GeoIP lookup daemon for Linux. It answers GET /lookup?ip=<address> over a UNIX domain socket (and,optionally, TCP)
See TCP listener) using MaxMind MMDB databases (via libmaxminddb), so NGINX (or any other local process)
can annotate a request with country/ASN/city information via an auth_request subrequest.
nshgeoip performs annotation only. It never decides whether an IP address should be allowed or blocked.
IP address -> nshgeoip -> local MMDB lookup -> GeoIP annotation
- Architecture
- Dependencies
- Compilation
- Configuration
- Installation
- Management script
- systemd setup
- UNIX socket permissions
- TCP listener
- HTTP API
- Health and metrics
- curl examples
- NGINX auth_request example
- IPv6 support
- Concurrency model
- Signals
- Security considerations
- Testing
- Attribution
GeoLite2 MMDB
|
libmaxminddb
|
+------------+
| nshgeoip |
+------------+
|
UNIX socket
|
+------------+------------+
| | |
NGINX local app curl/test
(An optional TCP listener can stand in for the UNIX socket above -- see TCP listener -- for a client that can't reach a UNIX socket path at all, e.g. running in a different container/network namespace.)
The MMDB databases are opened once at startup with libmaxminddb's normal memory-mapped access and kept open for the
daemon's lifetime -- there is no per-request reopening and no application-level cache on top; the mmap'd lookup is
already fast. SIGHUP re-opens the configured databases and atomically swaps them in (see Signals) without
restarting the process or interrupting in-flight requests.
GeoLite2-City.mmdb is supported alongside Country and ASN (city_db in the config) -- city name, postal code,
latitude, longitude, and accuracy radius. City fields are always sourced from city_db specifically, never inferred
from country_db, even though a real GeoLite2-City.mmdb also carries country/continent data too: whenever
country_db is configured it stays authoritative for those fields, so two independently-updated databases can't
silently disagree. But if country_db isn't configured at all, country/continent fall back to city_db's own
fields instead, since MaxMind's City schema already includes them -- so a city_db + asn_db setup (no
country_db needed) still gets full country/continent/city/ASN annotation. That's enough for consumers like
CrowdSec, which only care about city and ASN.
- A C++17 compiler (
g++) libmaxminddb(library + development headers)- POSIX threads (
pthread) - Linux (uses
signalfd,accept4; not portable to other platforms by design)
No Boost, no HTTP framework, no JSON library, no Prometheus client library -- nshgeoip implements just enough HTTP/1.1
parsing/serialization, JSON output, and Prometheus text exposition format for its three endpoints (/lookup, /health,
/metrics).
On Debian/Ubuntu:
sudo apt-get install build-essential libmaxminddb-devOn RHEL/Fedora:
sudo dnf install gcc-c++ libmaxminddb-develmakeProduces the nshgeoip binary in the project root. make test builds and runs the unit-test binary (tests/test_nshgeoip, see Testing).
make clean./build.sh # build the image, extract the binary to ./nshgeoip
./build.sh ./out/nshgeoip # ...to a specific path instead
IMAGE_TAG=myregistry/nshgeoip:1.0 ./build.sh # override the image tagBuilds a fully static (musl, no shared libmaxminddb) nshgeoip binary using Alpine's own libmaxminddb-static
package, inside a multi-stage Dockerfile. build.sh runs docker build, then copies the resulting binary out of
the image to disk -- the image itself is also usable directly as a container without extracting anything. The build
stage is Alpine (needs g++/make/the dev packages); the runtime stage is FROM scratch -- no base OS at all, just
the static binary at /nshgeoip, running as a non-root numeric UID (1000:1000 -- scratch has no /etc/passwd to
hold a named user). The actual compile/link steps live in
docker/compile_alpine_static.sh (a plain sh script, run inside the build stage),
not inline in the Dockerfile.
A static musl binary has no runtime dependency on libmaxminddb (or glibc) being installed at all, which is what
makes a scratch runtime possible in the first place.
A bare scratch image has no /tmp or /run at all until something creates them, and no shell in the runtime stage
to run mkdir -- so both are pre-created empty in the Alpine build stage and COPY'd in with explicit permissions
(--chmod/--chown set at copy time, not relied on from the source directory, since plain COPY doesn't reliably
preserve a source directory's own mode): /tmp world-writable (1777), /run owned by the same 1000:1000 the
container runs as. That means the default socket path (/run/nshgeoip/nshgeoip.sock) just works with no volume or
tmpfs required at all -- docker run -v /var/lib/GeoIP:/var/lib/GeoIP:ro nshgeoip:latest is enough on its own.
The image ships no CMD, so it runs with zero arguments -- config file is optional at its default path (see
Configuration), falling back entirely to NSHGEOIP_* environment variables and auto-detected
databases. Mount a real config file and pass --config PATH yourself if you want one instead.
There's also no curl/wget in the image for a HEALTHCHECK to shell out to -- that's exactly what --health-check
(see Configuration) exists for; the Dockerfile already wires it in as
HEALTHCHECK CMD ["/nshgeoip", "--health-check"].
See docker-compose.yml for a ready-to-run example (GeoIP volume, optional TCP via
NSHGEOIP_TCP_PORT, healthcheck).
nshgeoip reads a simple key=value file, by default /etc/nshgeoip/nshgeoip.conf (override with --config PATH). See
etc/nshgeoip.conf.example:
country_db=/var/lib/GeoIP/GeoLite2-Country.mmdb
asn_db=/var/lib/GeoIP/GeoLite2-ASN.mmdb
city_db=/var/lib/GeoIP/GeoLite2-City.mmdb
socket=/run/nshgeoip/nshgeoip.sock
socket_mode=0660
#threads=8 # default: CPU core count, clamped to 4-20
max_request_bytes=8192
#debug_log=false- Blank lines and lines starting with
#are ignored. - At least one of
country_db/asn_db/city_dbmust be set; any of them can be omitted (or commented out) to disable that lookup type. - Unknown keys are ignored but logged as a warning at startup, so the format can grow without breaking old config files.
socket_modeis octal (matcheschmodnotation, e.g.0660).
Command-line options:
nshgeoip --config /etc/nshgeoip/nshgeoip.conf # use a specific config file
nshgeoip --check-db /var/lib/GeoIP/GeoLite2-City.mmdb # print an .mmdb file's own metadata and exit
nshgeoip --check-db PATH --format json # ...as one JSON object instead
nshgeoip --check-db PATH --format ini # ...as "key=value" lines instead
nshgeoip --version # print version and exit
nshgeoip --help # print usage and exit
The config file is optional at its default path (/etc/nshgeoip/nshgeoip.conf) -- run with no config file at all and
nshgeoip falls back to built-in defaults plus whatever NSHGEOIP_* environment variables are set, which is the
intended way to configure it in a container. A --config PATH that's explicitly given and missing is still an error.
nshgeoip --help prints every config key alongside its environment variable, description, and default.
--check-db PATH is a standalone diagnostic mode, independent of any configured country_db/asn_db/city_db (or
even a config file at all): it opens the given .mmdb file directly and prints everything libmaxminddb's own metadata
exposes -- database type, IP version, binary format, node count, record size, build date, age in days, languages, and
description -- then exits. --format table (the default) is an aligned table for a human at a terminal; --format json/--format ini are for scripting, and use libmaxminddb's own MMDB_metadata_s field names (database_type,
ip_version, binary_format_major_version, node_count, record_size, build_epoch, languages, description)
plus build_date/age_days, which the library doesn't itself provide (derived from build_epoch):
{"path":"...","database_type":"GeoLite2-City","ip_version":6,"binary_format_major_version":2,
"binary_format_minor_version":0,"node_count":6095709,"record_size":28,"build_epoch":1787323152,
"build_date":"2026-08-21T14:39:12Z","age_days":3.8,"age_ms":330720000,"age_ns":330720000000000,
"languages":["de","en","es","fr","ja","pt-BR","ru","zh-CN"],
"description":"GeoLite2 City database",
"description_list":[{"language":"en","description":"GeoLite2 City database"}]}age_days/age_ms/age_ns are the same age in different units (days to one decimal place; whole milliseconds/
nanoseconds) -- build_epoch only has whole-second resolution to begin with, so age_ms/age_ns are exact unit
conversions of that same whole-second difference, not independently more precise measurements.
description is a convenience field: just the English text, which is what's wanted most of the time (empty string if
there's no en entry). description_list is the full list, as an array of {language, description} objects
rather than a {lang: text} map -- MMDB_description_s itself has exactly those two fields per entry, so this mirrors
the underlying struct shape directly. The ini format gets the same treatment: a plain description= line alongside
description_<lang>= for each language. languages stays a flat array, matching the struct's plain const char **
there.
The same build-date/age reporting also happens automatically for whichever databases are actually configured, logged
once at startup and again on every SIGHUP reload:
opened city database: /var/lib/GeoIP/GeoLite2-City.mmdb (GeoLite2-City, built 2026-08-21T14:39:12Z, 3.8 days old)
Download GeoLite2-Country.mmdb, GeoLite2-ASN.mmdb, and (optionally) GeoLite2-City.mmdb from your MaxMind account
(a free GeoLite2 account is enough) and point country_db / asn_db / city_db at wherever you keep them, e.g.
/var/lib/GeoIP/. The nshgeoip process user needs read access to those files, nothing more -- nshgeoip never writes to
them. nshgeoipctl.sh download-db automates this download.
Running more than one host that needs these databases? geoip-update/ and geoip-sync/ are
two small standalone Compose stacks for that: geoip-update is a single host with real MaxMind credentials that
downloads the databases (via the official geoipupdate image) and serves them over an internal NGINX mirror with
MD5/SHA-256/metadata sidecars; geoip-sync runs on every other host instead, syncing from that mirror on an
interval with full checksum verification and no MaxMind account needed at all. Both publish to /var/lib/GeoIP by
default, so nshgeoip picks the result up automatically via the auto-detection below -- no explicit country_db/
asn_db/city_db configuration needed on the hosts running geoip-sync.
Any of the three left unset falls back, at startup, to GeoLite2-{Country,ASN,City}.mmdb under the first of
/var/lib/GeoIP (geoipupdate's own default location) or /var/lib/crowdsec/data (CrowdSec's bundled copy -- ASN/City
only, no Country) where that file actually exists -- see nshgeoip --help for the exact search order. Explicit
configuration (config file, environment variable) always wins; auto-detection only ever fills in a field left
completely unset, and each substitution it makes is logged at startup so it's never silent. country_db's
auto-detection is skipped entirely once city_db ends up set (by config or by this same auto-detection), even if a
GeoLite2-Country.mmdb also exists at one of those locations -- a real city_db already carries the
country_code/country_name/continent_code fields (see HTTP API), so opening a whole separate Country
database on top would add nothing.
./nshgeoipctl.sh installThis builds nshgeoip, installs the binary to /usr/local/sbin (override with PREFIX=/usr ./nshgeoipctl.sh install),
creates the nshgeoip system user/group if missing, generates and installs the systemd unit, creates
/etc/nshgeoip/nshgeoip.conf from etc/nshgeoip.conf.example if one doesn't already exist,
and enables + starts the service -- then prints the resulting config so you can see what to edit. Existing config files
and users/groups are left untouched on a re-run. See Management script below for the rest of its
commands (download-db, systemd <cmd>, status, ...).
To install just the binary + an example config without any of that (e.g. for scripting your own setup), make install
still works on its own:
make install # installs to /usr/local/sbin by default
# or: make install PREFIX=/usrnshgeoipctl.sh is the single entry point for installing, configuring, and operating nshgeoip --
run it from this checked-out source tree (it shells out to make for the actual build):
./nshgeoipctl.sh install # build, install, create user/config, enable + start the service
./nshgeoipctl.sh config # show the current config file
./nshgeoipctl.sh download-db # fetch/update GeoLite2-Country/-ASN/-City from MaxMind
./nshgeoipctl.sh download-db city # fetch/update just GeoLite2-City
./nshgeoipctl.sh systemd restart # start|stop|restart|enable|disable|status
./nshgeoipctl.sh status # same as: systemd status
./nshgeoipctl.sh version
download-db talks directly to the same updates.maxmind.com API geoipupdate
itself uses (curl/tar, Basic Auth with an account ID + license key) -- not the underlying geoipupdate binary, which
isn't packaged for Alpine and so isn't a dependency this project can lean on for its container image. It's safe (and
intended) to run repeatedly, e.g. from a cron job or timer: for each requested edition it checks whether a copy already
exists somewhere nshgeoip would find it that download-db doesn't itself manage (e.g. CrowdSec's bundled copy at
/var/lib/crowdsec/data) and leaves that alone entirely; otherwise, if it already owns a copy at its own managed
location, it MD5-checks that file against MaxMind's update endpoint and only replaces it if MaxMind reports something
newer (an HTTP 304 back means "already current," the same thing geoipupdate -v itself reports as "No new updates
available") -- so a database that's already up to date costs one small request, not a re-download. Only a
completely-missing edition triggers a full fresh download. Any archive actually received is SHA256-verified against
the checksum MaxMind publishes at the same download URL (suffix=tar.gz.sha256 instead of suffix=tar.gz) before it's
extracted or installed -- a download that fails this check is rejected outright, not installed.
If /etc/GeoIP.conf (geoipupdate's own config file) exists, its AccountID/LicenseKey/EditionIDs/DatabaseDirectory
are read and reused automatically, so a system already set up for geoipupdate doesn't need anything entered twice.
Otherwise the account ID and license key come from NSHGEOIP_MAXMIND_ACCOUNT_ID/NSHGEOIP_MAXMIND_LICENSE_KEY, or an
interactive prompt (the license key is never echoed, logged, or printed) -- none of that is needed at all if every
requested edition turns out to already be available. Each .mmdb is installed at (or updated at) the path the nshgeoip
config file's country_db/asn_db/city_db already points at, or else /etc/GeoIP.conf's DatabaseDirectory, or else
the documented /var/lib/GeoIP/ default -- matching geoipupdate's own default database directory.
nshgeoipctl.sh install generates the unit (see generate_systemd_unit() in the script) and runs nshgeoip as the
unprivileged nshgeoip user, with RuntimeDirectory=nshgeoip (so /run/nshgeoip is created/removed automatically) and
a fairly aggressive sandbox: NoNewPrivileges, ProtectSystem=strict, ProtectHome, RestrictAddressFamilies=AF_UNIX,
an empty CapabilityBoundingSet, and several more Protect*/Restrict* options. RestrictAddressFamilies=AF_UNIX
matches the default (TCP listener disabled) -- if you enable tcp_port= (see TCP listener), add
AF_INET AF_INET6 to that line too, or the sandbox will kill nshgeoip the moment it tries to bind a TCP socket.
Adjust SupplementaryGroups=nginx to whatever group your local client(s) run as.
sudo systemctl status nshgeoip
sudo journalctl -u nshgeoip -f
sudo systemctl reload nshgeoip # sends SIGHUP: reopens the MMDB filesnshgeoip binds socket (default /run/nshgeoip/nshgeoip.sock) and chmods it to socket_mode (default 0660) right
after binding. Combined with the systemd unit's User=nshgeoip and the client's group membership (e.g. nginx), this
gives a working nshgeoip:nginx 0660 setup out of the box. On startup, a leftover socket file from a previous run is
removed automatically -- but only if it actually is a socket (S_ISSOCK); if some other kind of file occupies that
path, nshgeoip refuses to start rather than deleting it.
On clean shutdown (SIGTERM/SIGINT), nshgeoip stops accepting new connections, lets in-flight requests finish, closes
the listening socket, and removes the socket file.
The UNIX socket is always available; TCP is an off-by-default option for consumers that can't reach a UNIX socket path
(e.g. running in a different container/network namespace than nshgeoip). Enable it with tcp_port (config file,
NSHGEOIP_TCP_PORT, or --tcp-port... see below):
tcp_port=8080 # 0 or unset = disabled (default)
#tcp_address=127.0.0.1 # unset = bind loopback on both IPv4 (127.0.0.1) and IPv6 (::1)With tcp_address unset, nshgeoip binds loopback on both address families (IPV6_V6ONLY is set on the IPv6 socket so
it doesn't also grab IPv4 as a side effect); setting tcp_address binds that one address only, instead of the loopback
default. Every config key (including tcp_port/tcp_address) has a matching NSHGEOIP_<KEY> environment variable that
overrides it -- see nshgeoip --help for the full list and precedence order (environment > config file > default).
Remember to update RestrictAddressFamilies in the systemd unit (see systemd setup) if you enable
this -- it's AF_UNIX only by default, matching TCP being off.
GET /lookup?ip=<address>
Successful response:
{
"ip": "8.8.8.8",
"country_code": "US",
"country_name": "United States",
"continent_code": "NA",
"asn": 15169,
"as_org": "GOOGLE",
"city_name": "Mountain View",
"postal_code": "94043",
"latitude": 37.4056,
"longitude": -122.0775,
"accuracy_radius": 1000
}The city_name/postal_code/latitude/longitude/accuracy_radius fields come from city_db (see
Configuration); if it isn't configured, they're always null.
Send Accept: text/plain to get the same data as key=value lines instead of JSON -- handy for a shell script that
would rather grep/cut than parse JSON:
$ curl -sS -H 'Accept: text/plain' --unix-socket /run/nshgeoip/nshgeoip.sock \
'http://localhost/lookup?ip=8.8.8.8'
ip=8.8.8.8
country_code=US
country_name=United States
continent_code=NA
asn=15169
as_org=GOOGLE
A missing field is simply an omitted line here (not a JSON-style null -- INI-style output has no native "null" to
write), and every value goes through the same CR/LF-stripping sanitization as the X-GeoIP-* headers, so an MMDB value
can't forge an extra key=value line. Error responses (400/404/405/500) become a single error=... line in this format
too. Content-Type is application/json or text/plain; charset=utf-8 accordingly. No Accept header, or anything
other than text/plain (including */*), defaults to JSON.
A field whose value is not available in the configured databases is null in the JSON body (the key is always present);
nshgeoip never invents a value. Response headers work the other way: a header is omitted entirely rather than sent
empty when there is nothing to put in it.
| Header | Present when |
|---|---|
X-GeoIP-Country |
country_code is available |
X-GeoIP-Continent |
continent_code is available |
X-GeoIP-ASN |
asn is available |
X-GeoIP-AS-Org |
as_org is available |
X-GeoIP-City |
city_name is available |
X-GeoIP-Postal-Code |
postal_code is available |
X-GeoIP-Latitude |
latitude is available |
X-GeoIP-Longitude |
longitude is available |
X-GeoIP-Accuracy-Radius |
accuracy_radius is available |
All header values are stripped of CR/LF and other control characters before being written, so MMDB data (a downloaded database file, still treated as untrusted input here) can never inject a header or split the response.
| Code | Meaning |
|---|---|
| 200 | Successful lookup (may still have null fields) |
| 400 | Missing or syntactically invalid ip parameter, or a malformed HTTP request |
| 404 | Syntactically valid IP, but no GeoIP data for it, or an unknown path |
| 405 | Method other than GET |
| 500 | Internal/database error |
GET and HEAD are implemented (anything else gets 405). HEAD returns the same status, X-GeoIP-*/Allow headers,
and Content-Length a GET for the same URL would, but without a body -- and without Content-Type, since there's no
body left to describe. This lets a client that only wants the X-GeoIP-* headers (e.g. curl -I) skip paying for or
parsing the JSON. Every response includes Connection: close (see Concurrency model for why).
GET /health and GET /metrics are always available -- no config flag turns them off; restrict access with NGINX (or
a firewall) if that's needed for your deployment. /metrics is never negotiated: always Prometheus text exposition
format, regardless of Accept.
/health is different on purpose: most container health checks (Docker HEALTHCHECK, Kubernetes
liveness/readiness probes) only ever look at the HTTP status code and rarely send an Accept header at all, so
/health defaults to a minimal text body -- unlike /lookup, where no Accept header means JSON. The full
per-database JSON detail is opt-in, for a human or a dashboard that explicitly asks for it:
$ curl --unix-socket /run/nshgeoip/nshgeoip.sock 'http://localhost/health'
status=ok
$ curl -H 'Accept: application/json' --unix-socket /run/nshgeoip/nshgeoip.sock 'http://localhost/health'
{"status":"ok","version":"0.9.2","libmaxminddb_version":"1.9.1","uptime_seconds":12345,"databases":{
"country":{"open":false},
"asn":{"open":true,"database_type":"GeoLite2-ASN","build_epoch":1787559317,
"build_date":"2026-08-24T08:15:17Z","age_days":1.1,"age_ms":97314000,"age_ns":97314000000000},
"city":{"open":true,"database_type":"GeoLite2-City","build_epoch":1787323152,
"build_date":"2026-08-21T14:39:12Z","age_days":3.9,"age_ms":330720000,"age_ns":330720000000000}}}
status is always "ok" -- nshgeoip has no other status to report today; if something were actually broken at startup
it wouldn't be running to answer this at all. The per-database open/build_epoch/build_date/age_days/age_ms/
age_ns fields are the useful part of the JSON body -- the same data --check-db and the startup log already show
(see MaxMind database configuration) -- so a dashboard or alert can watch age_days
directly instead of cross-referencing logs.
$ curl --unix-socket /run/nshgeoip/nshgeoip.sock 'http://localhost/metrics'
# HELP nshgeoip_build_info nshgeoip build information.
# TYPE nshgeoip_build_info gauge
nshgeoip_build_info{version="0.9.2",libmaxminddb_version="1.9.1"} 1
# HELP nshgeoip_db_age_seconds Seconds since the database was built.
# TYPE nshgeoip_db_age_seconds gauge
nshgeoip_db_age_seconds{db="asn"} 97314
nshgeoip_db_age_seconds{db="city"} 330720
# HELP nshgeoip_requests_total Total HTTP requests received, by path.
# TYPE nshgeoip_requests_total counter
nshgeoip_requests_total{path="lookup"} 30
nshgeoip_requests_total{path="health"} 8
nshgeoip_requests_total{path="metrics"} 4
nshgeoip_requests_total{path="other"} 0
# HELP nshgeoip_http_responses_total HTTP responses sent, by status code.
# TYPE nshgeoip_http_responses_total counter
nshgeoip_http_responses_total{code="200"} 30
...
# HELP nshgeoip_lookup_results_total /lookup outcomes, by result.
# TYPE nshgeoip_lookup_results_total counter
nshgeoip_lookup_results_total{result="found"} 25
nshgeoip_lookup_results_total{result="not_found"} 5
nshgeoip_requests_total is split by path (lookup/health/metrics/other) so self-monitoring traffic -- health
checks, Prometheus scraping /metrics itself -- never gets conflated with real GeoIP lookup traffic; sum across labels
for a plain "total HTTP requests" figure. other covers an unknown path and a request whose path couldn't even be
determined (malformed, too large).
Request/response counters (nshgeoip_requests_total{path=...}, nshgeoip_http_responses_total{code=...},
nshgeoip_lookup_results_total{result=...}) are plain std::atomic<uint64_t> with relaxed memory ordering,
incremented from whichever worker thread handles each connection (see Concurrency model) --
they're independent counters with no ordering relationship to any other memory a reader needs to observe alongside
them, so relaxed is sufficient and cheapest.
metrics_file is a separate, optional setting: a path to periodically write the exact same content /metrics serves,
independent of the HTTP endpoint (which stays available regardless). This is for delivery that doesn't involve
scraping nshgeoip directly -- e.g. node_exporter's textfile
collector reading .prom files off disk.
metrics_file=/var/lib/node_exporter/textfile_collector/nshgeoip.prom
metrics_interval_seconds=60 # default; 1-86400Written once immediately at startup and then every metrics_interval_seconds, atomically (a temp file in the same
directory, then rename(), which is atomic on POSIX within one filesystem) so a reader never sees a partial write
mid-update. A metrics_file problem (bad permissions, missing directory) is logged as a warning, never fatal -- it
should never be able to take the daemon down. Empty/unset (the default) disables the feature entirely, with no
periodic wake-up cost: nshgeoip's main loop only switches from an indefinite poll() wait to a timed one when
metrics_file is actually configured.
curl --unix-socket /run/nshgeoip/nshgeoip.sock 'http://localhost/lookup?ip=8.8.8.8'
curl --unix-socket /run/nshgeoip/nshgeoip.sock 'http://localhost/lookup?ip=2001:4860:4860::8888'examples/curl-example.sh runs a handful of these (including the 400/405 error cases) against a running daemon.
examples/nginx-geoip.conf has a full working example. The core of it:
location = /_geoip {
internal;
proxy_pass http://unix:/run/nshgeoip/nshgeoip.sock:/lookup?ip=$remote_addr;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# nginx's auth_request module treats any non-2xx/401/403 subrequest
# response as a hard error for the real request. Since nshgeoip's 400/404
# ("no data for this address") must never block or break the request,
# coerce them (and a 500 from nshgeoip) into a plain 200 with no
# X-GeoIP-* headers instead:
proxy_intercept_errors on;
error_page 400 404 405 500 502 503 504 = @geoip_unavailable;
}
location @geoip_unavailable {
internal;
return 200;
}
location / {
auth_request /_geoip;
auth_request_set $geoip_country $upstream_http_x_geoip_country;
auth_request_set $geoip_continent $upstream_http_x_geoip_continent;
auth_request_set $geoip_asn $upstream_http_x_geoip_asn;
auth_request_set $geoip_as_org $upstream_http_x_geoip_as_org;
# ... use $geoip_country etc., e.g. proxy_set_header to your app,
# or `if ($geoip_country = XX) { return 403; }` for actual policy.
}That last point matters: policy decisions belong in this NGINX config (or your application), not in nshgeoip. nshgeoip only ever answers "what do you know about this address," never "should this be allowed."
ip accepts both IPv4 and IPv6 literals (e.g. 2001:4860:4860::8888). Parsing uses inet_pton() only -- never
getaddrinfo() or any other resolver path -- so a client-supplied address can never trigger a DNS lookup or any other
outbound network activity; nshgeoip never makes an outbound network connection of any kind.
A fixed-size worker thread pool (threads in the config, default the host's CPU core count clamped to 4-20 -- see
Configuration): the accept loop hands each accepted connection off to the pool, and idle workers pick
connections up from a queue.
This was chosen over the alternatives because of what nshgeoip actually serves: short-lived, low-QPS, synchronous local
subrequests (an NGINX auth_request blocks the real request on this call, so there's never a large number of them in
flight at once). Thread-per-connection would be simpler still but leaves thread count unbounded under a burst; a
single-threaded epoll reactor would need an asynchronous HTTP parser for no real benefit at this request volume. A
small bounded pool gives predictable resource use with a plain, easy-to-audit blocking-I/O implementation per
connection.
libmaxminddb lookups are read-only against the memory-mapped database and safe to call concurrently from multiple
threads; a std::shared_mutex in GeoIpDatabases lets lookups run fully in parallel (shared/read lock) while SIGHUP
briefly takes an exclusive lock only to swap in newly reloaded database handles.
Each connection is handled synchronously end-to-end (read request, look up, write response, close) with no
persistent/keep-alive connections -- every response is Connection: close. This avoids needing to implement pipelining,
chunked transfer, or partial-body edge cases for a backend that only ever needs to answer one small request at a time.
examples/load_test.sh starts a throwaway nshgeoip instance and hits /lookup with
ab (Apache Bench) and/or wrk,
whichever is installed. Both tools are TCP-only -- neither can talk to a UNIX domain socket -- so the script enables
tcp_port/tcp_address for the duration of the run rather than testing over the socket NGINX actually uses; combined
with nshgeoip never keeping a connection alive (see Concurrency model above), this measures
accept+dispatch+lookup overhead under fresh connections each time, not the exact production request path.
make # build nshgeoip first
./examples/load_test.sh--- ab: 20000 requests, concurrency 50 ---
Requests per second: 19563.73 [#/sec] (mean)
Time per request: 1.022 [ms] (mean)
--- wrk: 4 threads, concurrency 50, 10s ---
Requests/sec: 24959.00
Everything is tunable via environment variables, e.g. a higher-concurrency run against a specific address:
LOAD_TEST_IP=1.1.1.1 LOAD_TEST_CONCURRENCY=200 LOAD_TEST_REQUESTS=100000 ./examples/load_test.shNeeds at least one real or fixture .mmdb to start nshgeoip at all, same requirement as
tests/integration_test.sh -- auto-detected at a standard location or via
TEST_COUNTRY_DB/TEST_ASN_DB/TEST_CITY_DB.
| Signal | Effect |
|---|---|
SIGTERM / SIGINT |
Clean shutdown: stop accepting, finish in-flight requests, close the listening socket, remove the socket file, close the MMDB databases |
SIGHUP |
Re-open the configured MMDB files and atomically swap them in, without dropping the listening socket or interrupting in-flight requests |
Signals are delivered via signalfd, polled alongside the listening socket in the same loop -- no async-signal-unsafe
code runs in a signal handler.
- All request data is treated as untrusted, including on the UNIX socket.
- IPv4/IPv6 addresses are validated with
inet_pton()before ever being passed tolibmaxminddb; invalid input never reaches the lookup path. - Request header size is bounded (
max_request_bytes, default 8 KiB) and reading a request has a receive timeout, so a slow or oversized request can't tie up a worker thread indefinitely. - The HTTP parser only implements what's needed for
GET /lookup?ip=...-- no chunked transfer, no arbitrary methods, nothing decoding a client-supplied filesystem path. - No shell execution anywhere in the request path.
- Response headers built from MMDB data are sanitized (CR/LF and other control characters stripped) so a database value can never inject a header or split the response.
nshgeoipnever makes an outbound network connection -- no DNS, no HTTP client, nothing. It only ever accepts connections: its UNIX socket (always) and only if explicitly enabled, an optional TCP listener (see TCP listener) -- off by default.- RAII wrappers manage the listening socket, each connection's file descriptor, and the
MMDB_sdatabase handles, so error paths and shutdown can't leak them. - The systemd unit runs as an unprivileged user with
NoNewPrivileges,ProtectSystem=strict, an empty capability set and, by default (matching TCP being off),RestrictAddressFamilies=AF_UNIX-- see TCP listener for what to add if you enable it.
make test # unit tests: IP parsing, JSON/header sanitization, config
# parsing, Prometheus/health rendering (tests/test_nshgeoip.cpp)
make # build nshgeoip itself first
bash tests/integration_test.sh
bash tests/test_nshgeoipctl.shtests/integration_test.sh runs a real nshgeoip process and covers what the unit tests can't: concurrent requests, a
malformed raw HTTP request on the wire, a stale/leftover socket file left by a crashed run, refusing to clobber a
non-socket file at the socket path, a clean shutdown on SIGTERM, a SIGHUP reload (and that the daemon keeps serving
afterward), the /health//metrics endpoints, metrics_file's periodic write, --check-db (both a real file and a
clean failure on a nonexistent one), and the TCP listener -- default dual-stack IPv4+IPv6, an explicit tcp_address
restricting to one family, and that the UNIX socket keeps working with TCP also enabled.
It needs at least one real .mmdb file to start nshgeoip at all, but that's often already true with zero setup: if a
database is auto-detectable at a standard location (/var/lib/GeoIP, /var/lib/crowdsec/data -- see MaxMind database
configuration), the command above just works, using that real data. Content-specific
assertions (an exact country_code, city name, etc.) are the exception -- those only run against
TEST_COUNTRY_DB/TEST_ASN_DB/TEST_CITY_DB, since real production data won't contain MaxMind's synthetic test
values. Point those at MaxMind's small redistributable test fixtures (used by libmaxminddb's own test suite) to
exercise them too:
TEST_COUNTRY_DB=/path/to/GeoLite2-Country-Test.mmdb \
TEST_ASN_DB=/path/to/GeoLite2-ASN-Test.mmdb \
TEST_CITY_DB=/path/to/GeoLite2-City-Test.mmdb \
bash tests/integration_test.shhttps://github.com/maxmind/MaxMind-DB/tree/main/test-data has those fixtures. TEST_KNOWN_IP/TEST_KNOWN_COUNTRY
asserts on a real address instead, against whatever database is actually active -- real or fixture.
tests/test_nshgeoipctl.sh covers nshgeoipctl.sh itself, offline: edition/config-key mapping,
parsing /etc/GeoIP.conf, resolving owned vs. externally-available database paths, and download_db's skip logic
(nothing to do when every edition is already available externally, skipping country when city covers it on an
auto-defaulted run but never on an explicit request). It doesn't touch the network or need credentials.
examples/curl-example.sh is the quick manual smoke test.
This product uses GeoLite2 data created by MaxMind, available from https://www.maxmind.com. nshgeoip doesn't
ship or bundle any GeoLite2 database itself -- you download it separately (directly, via nshgeoipctl.sh download-db, or via geoipupdate; see MaxMind database configuration) and
point nshgeoip at it.
Don't have a MaxMind account yet? Sign up for GeoLite2 (free) at https://www.maxmind.com/en/geolite2/signup. If
your use case needs more accuracy or update frequency than GeoLite2 offers, MaxMind's paid
GeoIP2 databases are drop-in compatible -- same .mmdb format,
same libmaxminddb reader, just point nshgeoip at the GeoIP2 file instead.
nshgeoip links against libmaxminddb (Copyright MaxMind, Inc.,
Apache License 2.0) -- statically in the static build's release binaries,
dynamically otherwise. See NOTICE for the reproduced copyright notice.