Skip to content

geoip-country-lists-package

Packages

Package the ipverse/rir-ip per-country IP range lists as a pinned, reproducible Nix derivation, so firewall / fail2ban / nginx-geo rules can read country IPv4/IPv6 ranges from the Nix store instead of fetching them live at runtime.

The problem

You want to allow or block traffic by country at the packet filter (or feed per-country CIDR sets into fail2ban, nftables sets, an nginx geo map, etc.). The RIRs (ARIN, RIPE, APNIC, LACNIC, AFRINIC) publish delegation statistics that map IP allocations to countries; ipverse/rir-ip aggregates those into per-ISO-3166 files.

The naive approach is to curl those lists at boot or on a timer. That has three failure modes:

  1. Silent drift. Your firewall's behaviour changes whenever upstream (or a mirror, or a DNS answer) changes. A country's ranges shift and you only find out when something is wrongly (un)blocked. Nothing in your config history records what the rules were.
  2. Boot / network race. Rules meant to police the network depend on the network being up to fetch them. On a cold boot or an offline host the fetch fails and you either fail open (no rules) or fail closed (locked out).
  3. Not auditable. There's no diff to review when the rule set changes.

The insight

Country IP ranges are data, and data can be pinned like source. Wrapping the list repo in a derivation with fetchFromGitHub { rev; sha256; } gives you:

  • Reproducibility — a rebuild produces byte-identical rules; the ranges only change when you deliberately bump rev, which is a reviewable diff.
  • Boot-safety — the store path exists before any interface comes up; no fetch races the firewall it configures.
  • Auditability — updating the range set is a rev+hash change in version control, not invisible runtime state.

Store layout

After building, the derivation exposes the upstream country/ tree under a stable prefix:

<out>/share/geoip-country-lists/<cc>/ipv4.txt
<out>/share/geoip-country-lists/<cc>/ipv6.txt
<out>/share/geoip-country-lists/<cc>/ipv4-aggregated.txt
<out>/share/geoip-country-lists/<cc>/ipv6-aggregated.txt

<cc> is the lowercase ISO-3166 alpha-2 code (de, us, cn, …). Prefer the *-aggregated.txt files — they merge adjacent CIDRs into the smallest set, which means fewer rules / smaller nftables sets. Lines beginning with # are comments and must be stripped before feeding the ranges to a rule engine.

Usage

As an overlay

# overlays/geoip-country-lists.nix
final: _: {
  geoip-country-lists = final.callPackage ./geoip-country-lists-package { };
}

Consuming it (example: build an nftables/iptables allow set)

{ pkgs, lib, ... }:
let
  lists = pkgs.geoip-country-lists;
  countries = [ "de" "us" ];           # lowercase ISO codes
  rangesFor = cc:
    "${lists}/share/geoip-country-lists/${cc}/ipv4-aggregated.txt";
in
{
  # e.g. in a service ExecStart, read + strip comments:
  #   grep -v '^#' ${rangesFor "de"} | while read cidr; do
  #     nft add element inet filter allowed_v4 "{ $cidr }"
  #   done
  environment.etc."geoip/de-v4.txt".source = rangesFor "de";
}

The key move in any consumer: interpolate the store path, grep -v '^#' to drop comment lines, then load each CIDR into your firewall's set. Because the path is a store path, the ranges are fixed at build time.

Updating the pinned data

  1. Pick a new commit from ipverse/rir-ip.
  2. Set rev to it and sha256 = lib.fakeSha256;.
  3. Build once; Nix reports the real hash in the got: line — paste it back into sha256.
  4. Nothing else to touch: version is finalAttrs.src.rev, so it follows the pin automatically.
  5. Commit. The diff is your audit record of what the range set changed to.

Caveats

  • Coarse geolocation. This is allocation-level, country-granularity data. It is fine for "block/allow an entire country at layer 3". It is not city- or ASN-level and should not be used for analytics-grade geolocation — use a MaxMind GeoLite2 .mmdb for that.
  • Country geolocation is inherently imprecise. VPNs, CDNs, cloud providers, and re-allocated blocks mean a per-country filter will have false positives and negatives. Treat it as a blunt instrument, not an identity check.
  • You are responsible for freshness. Pinning is the whole point — it will not auto-update. Bump the rev on whatever cadence your threat model needs.
  • License. The upstream repo is MIT-licensed; the underlying RIR delegation data has its own terms. Review both before redistributing.

Files

  • default.nix — the derivation (call with callPackage).

Source

packages/geoip-country-lists-package/default.nix
{
  lib,
  stdenv,
  fetchFromGitHub,
}:

# Package the ipverse/rir-ip country IP-range lists as a pinned, reproducible
# Nix derivation. Firewall / fail2ban / nginx geo rules can then reference
#   <out>/share/geoip-country-lists/<cc>/ipv4-aggregated.txt
#   <out>/share/geoip-country-lists/<cc>/ipv6-aggregated.txt
# from the store instead of fetching the lists live at runtime.
#
# Why a derivation and not a runtime curl:
#   - Reproducible: the ranges are pinned by `rev` + `sha256`, so a rebuild
#     yields byte-identical rules. A live fetch means your firewall silently
#     changes whenever upstream (or a mirror, or DNS) changes — untracked drift
#     that is invisible until it wrongly (un)blocks traffic.
#   - Offline / boot-safe: firewall rules that depend on a network fetch race
#     the very network they are meant to police. Store paths are present before
#     the interface comes up.
#   - Auditable: bumping the range set becomes a reviewable rev+hash change.
#
# The RIRs (ARIN, RIPE, APNIC, LACNIC, AFRINIC) publish delegation stats; the
# ipverse/rir-ip repo aggregates them per ISO-3166 alpha-2 country code. This
# is coarse, allocation-level geolocation — fine for "block/allow a whole
# country at the packet filter", NOT for city/ASN lookup (use a MaxMind mmdb
# for that).

stdenv.mkDerivation (finalAttrs: {
  pname = "geoip-country-lists";
  # Derived from the pinned `rev` via `finalAttrs`, so it can never drift out of
  # sync with the data: bumping the rev below bumps the version for free.
  version = finalAttrs.src.rev;

  src = fetchFromGitHub {
    owner = "ipverse";
    repo = "rir-ip";
    # Pin to a specific commit, not a branch/tag. To bump: pick a new rev, set
    # sha256 = lib.fakeSha256, build once, paste the "got:" hash back here.
    rev = "7c8ed361db346baac03fcaa0d2965c1a12050d8e";
    sha256 = "sha256-jG9FVzTGgo7WSq/Dk+pqQiwu5c2UttS6TBrovTF56bU=";
  };

  # Pure data — nothing to compile.
  dontBuild = true;
  dontConfigure = true;

  # The repo lays the lists out as country/<cc>/{ipv4,ipv6,ipv4-aggregated,
  # ipv6-aggregated}.txt. We expose the whole `country/` tree under a stable
  # share/ prefix so consumers can build the path from a lowercased ISO code.
  installPhase = ''
    runHook preInstall
    mkdir -p "$out/share/geoip-country-lists"
    cp -r country/* "$out/share/geoip-country-lists/"
    runHook postInstall
  '';

  meta = with lib; {
    description = "Per-country IPv4/IPv6 allocation lists (ipverse/rir-ip) for firewall rules";
    homepage = "https://github.com/ipverse/rir-ip";
    license = licenses.mit;
    platforms = platforms.all;
    maintainers = [ ];
  };
})