Sign inSign up

smeagolworms4/ip-info

By smeagolworms4

•Updated about 2 months ago

Everything an IP address can tell you, over HTTP: country, city, coordinates, timezone and AS,…

Image
0

1.1K

smeagolworms4/ip-info repository overview

⁠ip-info

"Buy Me A Coffee" "Buy Me A Coffee"

Read this in French⁠.

Everything an IP address can tell you, over HTTP. GET / returns the caller's own address, GET /8.8.8.8 returns someone else's: country, city, coordinates, timezone, network and autonomous system. Your own service, on your own machine — the lookup is a local file read, so nothing about your visitors is sent to a third party, and there is no rate limit but yours.

Docker Pulls Image Size arch

Why it exists and how it is used in production: IP Info : géolocaliser une adresse IP⁠.

⁠What it does

  • Answers about any address, IPv4 or IPv6, and about the caller's own.
  • No database server, no external API: MaxMind publishes GeoLite2 as a .mmdb file, and a lookup in it takes microseconds. The file is downloaded once and refreshed in the background — see DATABASES.md⁠.
  • Keeps working without a database: version, category (public, private, loopback, multicast…) and normalised form come from the address itself.
  • One field on its own, as plain text: curl ip.example.com/me/country prints FR.
  • Signs its URLs, if you want it to: with a key set, an address is only served if it carries the right HMAC and a timestamp less than an hour old.
  • Runs as a container or as a single Lambda — same code, same variables, same URLs.
  • Everything is configured through environment variables — no config file, nothing to rebuild.
  • Runs on amd64, arm64 and armv7: a NAS, a VPS or a Raspberry Pi all work.

⁠The response

curl https://ip.example.com/81.2.69.200
{
  "ip": "81.2.69.200",
  "version": 4,
  "type": "public",
  "hostname": null,
  "continent": "EU",
  "continent_name": "Europe",
  "country": "GB",
  "country_name": "United Kingdom",
  "country_is_eu": false,
  "region": "England",
  "region_code": "ENG",
  "city": "London",
  "postal": "OX1",
  "latitude": 51.5142,
  "longitude": -0.0931,
  "accuracy_radius": 10,
  "timezone": "Europe/London",
  "network": "81.2.69.192/26",
  "asn": 12345,
  "as_name": "Test ISP Ltd",
  "as_network": "81.2.69.0/24"
}

Every field is always present, and an unknown value is null rather than a missing key: a typed client has one shape to know, whether the database is loaded or not.

Two fields deserve a word. accuracy_radius is the radius in kilometres within which MaxMind places the address — without it, four decimal places of latitude look like a street address, which they are not. network is the block that actually answered: a /26 is a precise answer, a /8 is a whole country.

type is what the address says about itself, and it is the field to check before trusting the rest: public, private, loopback, link-local, shared (CGNAT), multicast, broadcast, documentation, benchmark, reserved, unspecified. Anything that is not public is in no geolocation database, and the service says so instead of returning an empty object you would have to interpret.

⁠The URL format

URLResult
/the caller's address
/me, /selfthe same thing — easier to read in a signed URL
/8.8.8.8a given address
/2a01:e0c:1::5IPv6, same treatment
/me/countryone field, as text/plain
/8.8.8.8/timezoneidem, for any field
/healthprobe: uptime and state of each database

Two query parameters, on every route:

  • ?fields=ip,country,city — restricts the response. The order of the answer never changes, so two clients asking for the same fields in a different order share a cache entry.
  • ?lang=fr — the language of place names: de, en, es, fr, ja, pt-BR, ru, zh-CN. Missing translations fall back to English.
curl 'https://ip.example.com/81.2.69.200?fields=country,city&lang=fr'
# {"country":"GB","city":"Londres"}

curl https://ip.example.com/me/country
# FR

An absent value returns an empty body, not the string null — which is what makes the plain-text form usable in a shell script.

⁠Getting started

Nothing to clone, nothing to build: the image is published on Docker Hub⁠. You do need a GeoLite2 license key, which is free — MaxMind asks for an account, and that is the price of the data. Sign up here⁠.

Create an empty folder and put this docker-compose.yml in it:

services:
  ip-info:
    image: smeagolworms4/ip-info:latest
    container_name: ip-info
    restart: unless-stopped
    stop_grace_period: 30s
    user: "${PUID:-1000}:${PGID:-1000}"
    environment:
      MAXMIND_LICENSE_KEY: "your-license-key"
      #TRUST_PROXY: "true"      # behind a reverse proxy
      #DEFAULT_LANGUAGE: "fr"
    ports:
      - "${PORT_HOST:-3000}:3000"
    volumes:
      - ./data:/data

Then, next to it:

mkdir data
docker compose up -d
curl localhost:3000/8.8.8.8

The ./data volume matters: the databases are downloaded into it, and without it every restart fetches 70 MB again — which MaxMind counts against your key.

It starts without a key too, and says so in the logs. It then answers with the version, the category and the normalised form of every address, and null everywhere else. That is enough to tell a private address from a public one, and it makes the container's first run easy to check.

⁠The single-command equivalent
docker run -d \
  --name ip-info \
  --restart unless-stopped \
  --user 1000:1000 \
  -p 3000:3000 \
  -e MAXMIND_LICENSE_KEY=your-license-key \
  -v "$(pwd)/data:/data" \
  smeagolworms4/ip-info:latest
⁠Without Docker
npm install
MAXMIND_LICENSE_KEY=your-license-key DB_DIR=./data npm start

Node 20.6 or later, and no native dependency: no compiler, no system library.

⁠On AWS Lambda

The same service also ships as a zip ready to drop onto a single Lambda function — no second function to keep the database up to date, no scheduled job: the cold start downloads it, and each invocation checks its age in passing. LAMBDA.md⁠ covers it.

⁠The databases

There is no database server here. GeoLite2 is a file — an .mmdb, a binary search tree the service walks bit by bit — downloaded once and read in memory. Three ways to get one, and the same variable covers all three:

MAXMIND_LICENSE_KEY=xxx                                   # official download
DB_CITY_URL=https://my-bucket.s3.amazonaws.com/City.tar.gz  # your own mirror
DB_CITY_URL=/srv/geoip/GeoLite2-City.mmdb                 # a file you already have

GeoLite2-City and GeoLite2-ASN are loaded by default. GeoLite2-Country is available for those who only need the country and would rather have a file ten times smaller.

DATABASES.md⁠ covers the refresh cycle, mirroring to S3 (scripts/mirror-databases.sh), and what GeoLite2 is worth in practice.

⁠Configuration

Everything is set through environment variables, and nothing else. They are all declared in the image with their default value, so the settings can be listed without opening the documentation:

docker run --rm smeagolworms4/ip-info:latest env

An empty value is not a missing setting: it hands the decision back to the code (no database configured, computed header, rate limiter off). .env.example lists the same variables with comments, and a test checks that the three lists never drift apart.

⁠Databases
VariableDefaultPurpose
MAXMIND_LICENSE_KEYemptyA free GeoLite2 key. Enough on its own to configure every download
DB_EDITIONScity,asnWhich databases to load: city, country, asn
DB_CITY_URLemptyAn HTTP(S) address, a local path, or file:///…. Wins over the license key
DB_ASN_URLemptySame, for the autonomous-system database
DB_COUNTRY_URLemptySame, for the country-only database
DB_DIR/data in the imageWhere the files are kept. Mount it, or every restart downloads again
DB_REFRESH_INTERVAL86400Age in seconds past which a database is downloaded again
DB_DOWNLOAD_TIMEOUT120000Download timeout, in milliseconds
DB_REQUIREDfalseRefuse to start without a usable database, rather than answering without geolocation for weeks
⁠Network
VariableDefaultPurpose
PORT / HOST3000 / 0.0.0.0Listening socket
BASE_PATHemptyMount prefix, e.g. /ip
HEALTH_PATH/healthHealth probe — never logged, never signed, never cached
TRUST_PROXYemptyExpress trust proxy: true, a hop count, or a list of IPs. Required behind a reverse proxy
⁠Output
VariableDefaultPurpose
DEFAULT_LANGUAGEenLanguage of place names. ?lang= overrides it per request
FIELDSempty (all)The fields published at all. ?fields= can only narrow this list, never reopen it
REVERSE_DNSfalseFill in hostname with a PTR lookup — the only thing here that talks to the network while answering
REVERSE_DNS_TIMEOUT2000Milliseconds before giving up on the PTR lookup
FAKE_IPemptyForces the caller's address, to develop without leaving your machine
⁠Cache headers

They only apply to explicit addresses: the answer about your address is always private, no-store, since it depends on who is asking.

VariableDefaultPurpose
MAX_AGE3600max-age: browsers
S_MAX_AGE86400s-maxage: shared caches
ERROR_MAX_AGE60How long error responses are cached
CACHE_CONTROLemptyReplaces the header computed from the two above
CORS_ORIGIN*Empty removes the CORS headers entirely
⁠Signed URLs
VariableDefaultPurpose
SIGNATURE_KEYemptyThe HMAC secret. Empty disables signing, and the service answers everyone
SIGNATURE_ALGORITHMsha256sha256, sha1 or sha512
SIGNATURE_LENGTH16Hex characters kept, 8 to 128 — within the digest's own length
SIGNATURE_TTL3600How long a signed URL stays valid, in seconds
SIGNATURE_SKEW60Clock tolerance between the machine that signs and the one that verifies
⁠Limits and logs
VariableDefaultPurpose
RATE_LIMIT0Requests per window and per caller. 0 disables it
RATE_LIMIT_WINDOW60Window length, in seconds
SHUTDOWN_TIMEOUT10000Grace period on SIGTERM, in milliseconds
LOG_FORMATtinyA morgan format, or off
LOG_LEVELinfodebug, info, warn, error, silent

⁠Signed URLs

Off by default: with no key, the service answers everyone, which is exactly what you want on a private network or behind an authenticated proxy.

Set SIGNATURE_KEY and every URL then needs two parameters — a timestamp and a HMAC:

/8.8.8.8?d=1770000000&s=cc5849a839193e59

d is part of what is signed, so it cannot be moved, and it bounds the URL's life: one hour by default. That is the difference between an address shared by accident and a stolen key. The rule fits in one line, in any language:

const s = createHmac('sha256', KEY).update(`8.8.8.8?d=${d}`).digest('hex').slice(0, 16);

SIGNATURE.md⁠ has the full specification, reference values to check an implementation against, and the same three lines written out in JavaScript, PHP, Python, C# and Java.

⁠Behind a cache

Not strictly necessary — a lookup costs microseconds, and a single container handles far more requests than a website will send it. But Cache-Control is emitted properly, so a CDN in front works with no configuration: public, max-age=3600, s-maxage=86400 on explicit addresses, private, no-store on / and /me.

That distinction is the one thing to get right if you put your own cache in front: the answer about the caller's address must never be shared, or the second visitor gets the first one's city. The service already says so in its headers; a proxy configured to ignore them will get it wrong.

When URLs are signed, max-age never promises more than the URL's remaining life — a cached answer cannot outlive the address that produced it.

⁠Tests

npm test

Around 90 tests, and no network access needed. The interesting part is how: the test suite carries a small MaxMind DB writer (test/helpers.js), a hundred lines that produce real .mmdb files — search tree, typed data section, metadata block. The service opens them with its own reader, unaware they were made for the occasion.

That is what makes the database tests real rather than mocked: a local HTTP server serves a genuine .tar.gz, and the tests check the download, the extraction, the atomic replacement, the refresh of a two-day-old file, an interrupted download leaving a corrupt file, and an unreachable mirror — which must degrade, not crash.

The rest covers address parsing (reserved ranges, IPv6 normalisation, IPv4-mapped addresses), the HTTP surface (routes, status codes, cache headers, CORS, TRUST_PROXY), signing (expiry, moved timestamps, a signature stolen from another path), and the Lambda entry point on the three AWS event shapes.

The image itself is tested too:

docker build -t ip-info:test .
test/docker-smoke.sh ip-info:test

It checks that the container starts, runs as a non-root user, reads its databases from the volume, answers correctly in IPv4 and IPv6, and honours X-Forwarded-For. It runs on every push through GitHub Actions, on Node 20, 22 and 24 — and on the three published architectures, the two ARM ones under QEMU.

⁠Architecture

src/
  index.js       entry point: configuration, listening socket, clean shutdown
  lambda.js      AWS Lambda entry point: event → HTTP request, response → JSON
  config.js      environment → configuration, and the checks done at startup
  server.js      routing, CORS, cache headers, error handling
  databases.js   download, extraction, atomic replacement, background refresh
  lookup.js      building the response from the MaxMind records
  ip.js          parsing, normalisation and classification of addresses
  signature.js   optional HMAC signing of URLs
  ratelimit.js   fixed-window rate limiter
  logger.js      levelled logs

config.js is the only module that reads process.env: one place to look to know what is adjustable, and tests can build a configuration without touching the environment.

The Lambda entry point does not reimplement anything: it starts the very Express application of server.js once per container, on a loopback socket, and relays each invocation to it. One codebase, two entry points.

⁠Docker Hub image

https://hub.docker.com/r/smeagolworms4/ip-info⁠

Published for linux/amd64, linux/arm64 and linux/arm/v7 from a single multi-arch manifest — the same tag works on a PC, a NAS and a Raspberry Pi.

TagBuilt on
latestevery push to main, and every git tag — the one to use
mainevery push to the main branch
<version> (e.g. 1.0.0)creation of a git tag of that name, to pin a version

→ How the image and the Lambda archive are built and published⁠

⁠Troubleshooting

Every address answers null everywhere — no database is loaded. GET /health says which one and why; the logs say it at startup. Nine times out of ten, MAXMIND_LICENSE_KEY is missing or expired.

The service returns its own address to everyone — it sits behind a reverse proxy and TRUST_PROXY is not set, so it sees the proxy's address. Set TRUST_PROXY=true, and make sure the proxy actually sends X-Forwarded-For. Left unset by default on purpose: with it on, anyone can choose the address they are given by sending the header themselves.

/ answers 127.0.0.1 or ::1 locally — that is correct, it is your address as the service sees it. Use FAKE_IP to develop against a public one.

Every restart downloads the databases again — DB_DIR is not on a volume. In the image it is /data; mount it.

403 Signature invalide on everything — print the string being signed before hashing it. Nine times out of ten it carries a leading slash, BASE_PATH, or the s parameter.

403 URL expirée — the URL is more than SIGNATURE_TTL old, or the two machines' clocks disagree by more than SIGNATURE_SKEW.

The container will not start: /data n'est pas accessible en écriture — the container runs as UID 1000. Set PUID/PGID to match your own, or chown the folder.

⁠Security

The service reads a file and answers. It has no notion of authentication and does not try to: anything private belongs behind the reverse proxy that fronts it, where authentication is that layer's job.

SIGNATURE_KEY binds each URL to the path it asks for and to a moment in time, which is what separates it from a permanent signature: a leaked URL stops working within the hour. It is not access control — a valid URL works for whoever holds it.

Nothing about your visitors leaves the machine. That is the point of a local database rather than a third-party API: the address of the person reading your pages is not sent anywhere, and there is no provider building a profile out of your traffic. REVERSE_DNS is the one exception, and it is off by default: it sends the address to a DNS resolver.

Geolocation is an estimate, and GeoLite2 is the free tier of one. A city is often the ISP's, not the visitor's; a VPN moves the answer to another country entirely. accuracy_radius is there to keep that in view. Deciding anything that matters — a payment, a right, an identity — on this basis is a mistake, whatever the database.

Tag summary

Content type

Image

Digest

sha256:36ff0155c…

Size

56.4 MB

Last updated

about 2 months ago

docker pull smeagolworms4/ip-info