geoip-database-provider¶
Modules
A single, credential-free NixOS provider for the MaxMind GeoLite2 databases
(City, Country, ASN). One host-wide oneshot fetches the .mmdb files into a
shared directory; every consumer (log analyzers like GoAccess, firewall
geo-blocking, geoip-aware apps) reads that directory directly and never
downloads its own copy.
The problem¶
- MaxMind gates the official downloads. Fetching GeoLite2 from MaxMind requires an account and a license key. That means a credential to store, scope, and rotate on every host that wants a geo database — annoying for a fleet and overkill when several services on one host all want the same file.
- Consumers race the download. A service that needs the database at start-up will find an empty directory if it boots before anything has fetched it.
The approach and its traps¶
-
P3TERX mirror instead of MaxMind. The default
mirrorBaseUrlpoints at theP3TERX/GeoLite.mmdbGitHub mirror, which republishes the same GeoLite2 files under the same license with no auth wall — so there are no credentials to store or rotate. The trade-off: you trust a third party for freshness and integrity, and there is no checksum verification of the downloaded files. If that trade-off is unacceptable, pointmirrorBaseUrlat your own mirror or a MaxMind-authenticated endpoint. -
RemainAfterExitgates consumers. The updater is aoneshotwithRemainAfterExit = true, so after a successful run the unit stays reported as active. A consumer service that declaresafter = [ "geoip-updater.service" ]; wants = [ "geoip-updater.service" ];therefore won't start until the databases have been fetched at least once. This is the whole point of the pattern — ordering, not just a cron job. -
Activation-time initial download. The systemd timer's
OnBootSecfires a few minutes after boot, but a consumer deployed alongside this module in the same activation would find an empty directory in the meantime. Thesystem.activationScriptsblock does a one-time synchronous fetch when the first database file is missing, closing that gap. It ends with|| trueso a failed download never aborts system activation. -
Jitter avoids a thundering herd.
RandomizedDelaySec(default1h) spreads the scheduled refresh across a fleet so many hosts don't all hit the mirror in the same minute.
Usage¶
{
imports = [ ./modules/geoip-database-provider ];
modules.services.geoip-databases.enable = true;
}
A consumer service gates on the provider like this:
systemd.services.my-geoip-consumer = {
after = [ "geoip-updater.service" ];
wants = [ "geoip-updater.service" ];
# reads /var/lib/geoip-databases/GeoLite2-City.mmdb etc.
};
Options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
Turn the provider on. |
dataDir |
/var/lib/geoip-databases |
Where the .mmdb files land; consumers read here. |
user / group |
geoip |
System user/group owning the dir and running the updater. |
mirrorBaseUrl |
P3TERX GitHub mirror | Base URL each database filename is appended to. |
databases |
City, Country, ASN .mmdb |
Filenames to fetch; the first is the presence probe. |
updateInterval |
weekly |
Refresh cadence (OnCalendar format). |
randomizedDelaySec |
1h |
Jitter on the scheduled refresh. |
Caveats¶
- No integrity/checksum verification of the downloaded databases with the
default mirror. Transport is TLS-verified, but the content is trusted on the
word of a third-party GitHub account; a fixed hash can't be pinned because the
databases are mutable (refreshed weekly). If that mirror or account were
compromised, poisoned mappings would land silently — so treat this data as
advisory, and if you feed it into firewall geo-blocking or other
security-sensitive decisions, host your own mirror or a MaxMind-authenticated
endpoint via
mirrorBaseUrlinstead. - The updater hard-fails (
curl -fL) on an HTTP error so a bad fetch doesn't silently overwrite a good database with an error page. An already-present old copy is left in place if a later refresh fails. - GeoLite2 accuracy and the mirror's update lag are inherited from upstream; this module only handles distribution and ordering.
Source¶
modules/geoip-database-provider/default.nix
# GeoIP database provider — one shared, credential-free GeoLite2 mirror.
#
# A single oneshot service downloads the GeoLite2 City/Country/ASN databases
# into a shared directory. Its `RemainAfterExit = true` keeps the unit "active"
# after a successful run, so consumer services can order themselves After/Wants
# geoip-updater.service and be guaranteed the .mmdb files exist before they start.
#
# See README.md for the why/traps (P3TERX mirror vs MaxMind account wall,
# RemainAfterExit gating, activation-time initial download, jitter).
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.modules.services.geoip-databases;
# Each entry: filename written into dataDir. The mirror serves them all
# under the same path prefix (cfg.mirrorBaseUrl).
databases = cfg.databases;
geoipUpdater = pkgs.writeShellScriptBin "geoip-updater" ''
set -eu
GEOIP_DIR="${cfg.dataDir}"
mkdir -p "$GEOIP_DIR"
for db in ${escapeShellArgs databases}; do
echo "Downloading $db ..."
${pkgs.curl}/bin/curl -fL -o "$GEOIP_DIR/$db" \
"${cfg.mirrorBaseUrl}/$db"
done
echo "GeoIP databases updated successfully!"
chmod 644 "$GEOIP_DIR"/*.mmdb
'';
in
{
options.modules.services.geoip-databases = {
enable = mkEnableOption "shared GeoIP (GeoLite2) database provider";
dataDir = mkOption {
type = types.str;
default = "/var/lib/geoip-databases";
description = ''
Directory the .mmdb files are written to. Consumers read this path
directly (they should never download their own copy).
'';
};
user = mkOption {
type = types.str;
default = "geoip";
description = "System user that owns the data directory and runs the updater.";
};
group = mkOption {
type = types.str;
default = "geoip";
description = "System group that owns the data directory.";
};
mirrorBaseUrl = mkOption {
type = types.str;
default = "https://github.com/P3TERX/GeoLite.mmdb/raw/download";
description = ''
Base URL each database filename is appended to. The default is a
no-auth GitHub mirror of MaxMind's GeoLite2 files (sidesteps the
MaxMind account + license-key wall — see README). Point this at your
own mirror or a MaxMind-authenticated endpoint if you prefer.
'';
};
databases = mkOption {
type = types.listOf types.str;
default = [
"GeoLite2-City.mmdb"
"GeoLite2-Country.mmdb"
"GeoLite2-ASN.mmdb"
];
description = ''
Database filenames to fetch from mirrorBaseUrl. The first entry is
also used as the presence probe by the activation script.
'';
};
updateInterval = mkOption {
type = types.str;
default = "weekly";
description = "How often to refresh the databases (systemd OnCalendar format).";
};
randomizedDelaySec = mkOption {
type = types.str;
default = "1h";
description = ''
Jitter added to the scheduled refresh. Spreads the fetch across a
fleet so many hosts don't hit the mirror in the same minute.
'';
};
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
description = "GeoIP database updater";
};
users.groups.${cfg.group} = { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0755 ${cfg.user} ${cfg.group} - -"
];
systemd.services.geoip-updater = {
description = "Update GeoIP databases";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
# Stay "active" after a successful run so consumers ordered
# After=/Wants=geoip-updater.service only start once the DBs exist.
RemainAfterExit = true;
ExecStart = "${geoipUpdater}/bin/geoip-updater";
User = cfg.user;
Group = cfg.group;
StandardOutput = "journal";
StandardError = "journal";
PrivateTmp = true;
ProtectHome = true;
NoNewPrivileges = true;
ReadWritePaths = [ cfg.dataDir ];
};
};
systemd.timers.geoip-updater = {
description = "Update GeoIP databases periodically";
wantedBy = [ "timers.target" ];
partOf = [ "geoip-updater.service" ];
timerConfig = {
OnCalendar = cfg.updateInterval;
OnBootSec = "5min";
Persistent = true;
RandomizedDelaySec = cfg.randomizedDelaySec;
};
};
# First-boot / first-deploy: pull the databases immediately so a consumer
# deployed alongside this module doesn't find an empty directory before the
# timer's OnBootSec fires. `|| true` keeps a failed download from aborting
# activation.
system.activationScripts.geoip-databases = ''
if [ ! -f ${cfg.dataDir}/${builtins.head cfg.databases} ]; then
echo "GeoIP databases not found. Starting initial download..."
${pkgs.systemd}/bin/systemctl start geoip-updater.service || true
fi
'';
};
}