perhaps is_vpn, is_tor, etc. we can find the public IP lists of major public vpn providers like nord, proton, mullvad, etc. we should also include our best-effort geolocation data.
we should also bundle/vendor some good public geoip database as a fallback if the geojs lookup times out.
the geolocation cache should be kept in memory but also needs to persist to disk and be read on restart/startup. perhaps sqlite?
perhaps is_vpn, is_tor, etc. we can find the public IP lists of major public vpn providers like nord, proton, mullvad, etc. we should also include our best-effort geolocation data.
we can use https://www.geojs.io/ to lookup and cache geolocations.
https://www.geojs.io/docs/v1/endpoints/geo/
we should also bundle/vendor some good public geoip database as a fallback if the geojs lookup times out.
the geolocation cache should be kept in memory but also needs to persist to disk and be read on restart/startup. perhaps sqlite?
oh, hostname/PTR is an obvious one. we should fetch using DoH from Google, Cloudflare, other public reliable resolvers, and cache locally based on record TTL. cache lives in ram, persists to disk, and is loaded on startup. sqlite on disk, probably.
oh, `hostname`/`PTR` is an obvious one. we should fetch using DoH from Google, Cloudflare, other public reliable resolvers, and cache locally based on record TTL. cache lives in ram, persists to disk, and is loaded on startup. sqlite on disk, probably.
Proposal for the IP lookup answer (/ip/{addr}, /api/v1/ip/{ip}), one item per addition. Every value follows API_POLICIES.md: native types, the unit in the field name. ipinfo.io's loc ("52.52,13.41") and org ("AS15169 Google LLC") strings are not copied.
2. is_vpn (boolean) and vpn_providers (list of strings)
True when the address is a server of a commercial VPN; vpn_providers names which, empty when none (one address can be on two lists). Data, each checked today, downloadable whole with no key or account, fetched once a day with the blocklist downloader (new JSON parsers, otherwise the same code, on-disk copies and validators):
Not usable: Proton (https://api.protonmail.ch/vpn/logicals answers 400 without a Proton client app's x-pm-appversion header, and 401 or 422 with every value I tried; the header is a client identity), Surfshark (https://api.surfshark.com/v4/server/clusters lists 141 host names, no addresses), ExpressVPN (publishes no list). https://github.com/X4BNet/lists_vpn (MIT, 10,947 IPv4 ranges) is a broad community list that names no provider; it could be a fifth source labelled x4bnet if coverage matters more than attribution.
True when the address is in reserved space (private, loopback, documentation, multicast), by the rule the /bogons page already applies to prefixes. Tells apart "never routable" from "no route right now", which both show as netblock: null today. No data source.
Best-effort location of the address. Nested, because country_code at the top level is already the AS organisation's country. Fields: country_code (string), country (string), continent_code (string), region (string), city (string), latitude (number), longitude (number), accuracy_radius_km (number), time_zone (string), source (string: geojs or dbip), updated (time the value was fetched). A part the source does not know is null; geo is null only when neither source answers.
Data: geojs.io's geo endpoint, https://get.geojs.io/v1/ip/geo.json?ip=a,b,c, one batch request for all addresses of a lookup, 1 second budget (https://www.geojs.io/docs/v1/endpoints/geo/). It is MaxMind GeoLite2 data, says "No rate limits (yet)" (https://www.geojs.io/), and its terms forbid "excessive" use at geojs's discretion (https://www.geojs.io/tos/), which the cache keeps us clear of. Quirks seen today: latitude/longitude come as strings and are the string "nil" when unknown, and an IPv6 address answered with no country at all; accuracy is a radius in km.
Fallback when geojs times out or errors: DB-IP's free database, read with the standard pure-Go MMDB reader. Two sizes to choose from:
City Lite (https://db-ip.com/db/download/ip-to-city-lite): 121 MB MMDB, region, city and coordinates like geojs (no time zone or accuracy radius); needs the 16 MiB download cap raised and about 130 MB of mapped file in memory.
Both are CC BY 4.0, published monthly at a dated URL (dbip-country-lite-YYYY-MM.mmdb.gz), no account, and require "IP Geolocation by DB-IP" with a link on pages that show results, so the home page footer would carry one. Fetched monthly by the downloader into the state directory, not committed to git; the image build can pre-fetch one so a fresh container has it. Rejected: MaxMind GeoLite2 needs an account and licence key and its EULA requires deleting old copies within 30 days (https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/, https://www.maxmind.com/en/geolite2/eula); the keyless mirror https://github.com/sapics/ip-location-db is under the same EULA; IPFire's database (https://www.ipfire.org/location, CC BY-SA 4.0, 6.4 MB, daily) has no Go reader.
before:(nothing)after:"geo":{"country_code":"DE","country":"Germany","continent_code":"EU","region":"Brandenburg","city":"Brandenburg an der Havel","latitude":52.6171,"longitude":13.1207,"accuracy_radius_km":20,"time_zone":"Europe/Berlin","source":"geojs","updated":"2026-10-27T20:37:28Z"}
6. ptr (list of strings, exists today) over DNS-over-HTTPS with a TTL cache
ptr already exists: the host's resolver via net.DefaultResolver.LookupAddr, 2 seconds per address, no cache. Same field, same shape; the change is the source and the cache. Resolvers, each checked today with a PTR query:
Suggest the RFC 8484 wire format (golang.org/x/net/dns/dnsmessage packs and parses it), which every resolver above accepts; the JSON format has no RFC and differs per resolver. Try the resolvers in turn, 2 seconds in all, as today. Every answer carries the record's TTL; the Authority SOA carries the negative TTL for a name without a PTR.
Both in one SQLite file, cache.db in the state directory (the driver is already a dependency), WAL mode, separate from the routes database so they never queue behind its writer. Loaded into memory at startup (the geolocation cache up to its cap, newest first), written through as entries arrive (batched every few seconds). A sweep every 10 minutes deletes expired rows; memory misses read SQLite before asking outside. Assumed load: 100,000 distinct addresses looked up per 30 days (the home page's own-address card makes one per visitor); 1,000,000 as the high case.
Geolocation cache: key address, value the geo object and its fetch time. Only geojs answers are cached (the fallback is local). An entry expires 7 days after its fetch, since GeoLite2 data changes twice a week. About 300 bytes per entry in memory (five strings, two numbers, a time, map overhead): 30 MB at 100,000, 300 MB at 1,000,000. On disk about 200 bytes per row: 20 MB and 200 MB. Memory capped at 250,000 entries (about 75 MB), least recently used dropped first; disk holds everything until expiry.
Hostname cache: key address, value the names, the expiry and a negative flag. Expiry is the record TTL clamped to 60 seconds minimum and 24 hours maximum; a negative answer expires at the SOA minimum, at most 1 hour; a failed query is not cached. Bounded by distinct addresses per 24 hours: at 100,000 a day, about 200 bytes per entry in memory, 20 MB; on disk about 150 bytes per row, 15 MB.
Privacy note for your judgement: a geojs lookup sends the visitor's address to geojs, once per 7 days per address; the DoH lookup sends it to Google or Cloudflare, where today it goes to the host's resolver.
Full example: today and with everything above
Address 185.220.101.1, a Tor exit. ptr, geo and the Tor listing are today's real answers; the route and AS figures are illustrative.
Today:
{"status":"ok","data":{"query":"185.220.101.1","results":[{"netblock":"185.220.101.0/24","mask_length":24,"ip_version":4,"num_peers":41,"asn":60729,"num_origins":1,"origin_asns":[60729],"as_name":"ZWIEBELFREUNDE","handle":"ZWIEBELFREUNDE","description":"Stiftung Erneuerbare Freiheit","org_name":"Stiftung Erneuerbare Freiheit","org_id":"ORG-SEF2-RIPE","address":"Berlin, Germany","country_code":"DE","abuse_email":"abuse@artikel10.org","rir":"RIPE","first_seen":"2026-09-21T10:14:02Z","last_seen":"2026-10-27T18:02:40Z","ip":"185.220.101.1","ptr":["berlin01.tor-exit.artikel10.org"],"blocklists":[{"name":"tor_exits","description":"A Tor exit node, where traffic from the Tor network leaves","updated":"2026-10-27T20:00:11Z"}]}]},"@meta":{"time_zone":"UTC","api_version":1,"execution_time_ms":41}}
With every addition:
{"status":"ok","data":{"query":"185.220.101.1","results":[{"netblock":"185.220.101.0/24","mask_length":24,"ip_version":4,"num_peers":41,"asn":60729,"num_origins":1,"origin_asns":[60729],"as_name":"ZWIEBELFREUNDE","handle":"ZWIEBELFREUNDE","description":"Stiftung Erneuerbare Freiheit","org_name":"Stiftung Erneuerbare Freiheit","org_id":"ORG-SEF2-RIPE","address":"Berlin, Germany","country_code":"DE","abuse_email":"abuse@artikel10.org","rir":"RIPE","first_seen":"2026-09-21T10:14:02Z","last_seen":"2026-10-27T18:02:40Z","ip":"185.220.101.1","ptr":["berlin01.tor-exit.artikel10.org"],"blocklists":[{"name":"tor_exits","description":"A Tor exit node, where traffic from the Tor network leaves","updated":"2026-10-27T20:00:11Z"}],"is_bogon":false,"is_tor":true,"is_vpn":false,"vpn_providers":[],"is_relay":false,"geo":{"country_code":"DE","country":"Germany","continent_code":"EU","region":"Brandenburg","city":"Brandenburg an der Havel","latitude":52.6171,"longitude":13.1207,"accuracy_radius_km":20,"time_zone":"Europe/Berlin","source":"geojs","updated":"2026-10-27T20:37:28Z"}}]},"@meta":{"time_zone":"UTC","api_version":1,"execution_time_ms":44}}
Left out, and why
hostname (ipinfo): it is ptr[0]; a second field saying the same thing.
is_proxy: the only keyless list, FireHOL proxies (41 MB, 3.3 million addresses), is too broad to mean much.
is_anycast: the only keyless list, https://github.com/bgptools/anycast-prefixes (4,605 IPv4 and 1,819 IPv6 prefixes), carries no licence; IPFire's anycast flag needs its C reader. Doable later if bgp.tools grants one.
is_hosting / as_type (ipinfo): no keyless source names the type of an AS. X4BNet's MIT datacenter list (44,612 IPv4 ranges) could feed an is_datacenter later.
postal: neither geojs nor DB-IP Lite has it.
is_eu: DB-IP has it, geojs does not; a client derives it from geo.country_code.
geojs's asn and organization_name: the routing table already gives the AS, from live BGP.
company, carrier, domains, the abuse object (ipinfo): proprietary data with no public source; abuse_email from WHOIS is already there.
Not checked: how many addresses the public instance actually looks up per day, so the cache figures rest on the stated assumption; whether geojs's terms allow caching (they do not mention it).
Which to build?
My recommendation: all six, with the Country Lite fallback for geo. Order of value: ptr over DoH with the cache (fixes a real cost today: an uncached 2-second lookup per address), geo, is_tor and is_vpn with vpn_providers, is_relay, is_bogon. Decisions that are yours: the fallback size (Country Lite at 8 MB, or City Lite at 121 MB), whether x4bnet joins the VPN sources, whether to switch tor_exits to Onionoo for IPv6, and whether the home page carrying a DB-IP attribution link is acceptable.
Model: fable-5-1
Proposal for the IP lookup answer (`/ip/{addr}`, `/api/v1/ip/{ip}`), one item per addition. Every value follows `API_POLICIES.md`: native types, the unit in the field name. ipinfo.io's `loc` ("52.52,13.41") and `org` ("AS15169 Google LLC") strings are not copied.
For reference, ipinfo.io returns without a token `ip`, `hostname`, `city`, `region`, `country`, `loc`, `org`, `postal`, `timezone`, `anycast`, `bogon` (https://ipinfo.io/8.8.8.8/json, https://ipinfo.io/developers/data-types); on paid plans `privacy` with `vpn`, `proxy`, `tor`, `relay`, `hosting`, `service` (https://www.ipinfo.io/developers/privacy-detection-extended), plus `asn.type`, `company`, `carrier`, `abuse`, `domains`; its newer Core shape has `is_anycast`, `is_mobile`, `is_satellite`, `is_anonymous`, `is_hosting` (https://community.ipinfo.io/t/ipinfos-core-api-service-makes-accessing-privacy-data-more-affordable/6769). The items below are the ones a public source without a key or an account can feed.
### 1. `is_tor` (boolean)
True when the address is a Tor exit. Data: the `tor_exits` list RouteWatch already downloads every hour (https://check.torproject.org/torbulkexitlist, about 1,300 addresses). No new download. That list is IPv4 only; Onionoo (https://onionoo.torproject.org/details?flag=Exit&running=true&fields=exit_addresses,or_addresses) would add the IPv6 exits.
```json
before: "blocklists": [{"name": "tor_exits", "description": "...", "updated": "..."}]
after: "blocklists": [...], "is_tor": true
```
### 2. `is_vpn` (boolean) and `vpn_providers` (list of strings)
True when the address is a server of a commercial VPN; `vpn_providers` names which, empty when none (one address can be on two lists). Data, each checked today, downloadable whole with no key or account, fetched once a day with the blocklist downloader (new JSON parsers, otherwise the same code, on-disk copies and validators):
- `mullvad`: https://api.mullvad.net/www/relays/all/ (566 IPv4, 556 IPv6, 294 KB)
- `nordvpn`: https://api.nordvpn.com/v1/servers?limit=0&fields[servers.station]&fields[servers.ipv6_station]&fields[servers.hostname] (7,233 IPv4, no IPv6, 565 KB; without the field filter it is 27 MB, over the downloader's 16 MiB cap)
- `pia`: https://serverlist.piaservers.net/vpninfo/servers/v6 (1,520 addresses, 174 KB; JSON followed by a signature block after a blank line)
- `ivpn`: https://api.ivpn.net/v5/servers.json (59 gateways with their host addresses, 75 KB)
Not usable: Proton (https://api.protonmail.ch/vpn/logicals answers 400 without a Proton client app's `x-pm-appversion` header, and 401 or 422 with every value I tried; the header is a client identity), Surfshark (https://api.surfshark.com/v4/server/clusters lists 141 host names, no addresses), ExpressVPN (publishes no list). https://github.com/X4BNet/lists_vpn (MIT, 10,947 IPv4 ranges) is a broad community list that names no provider; it could be a fifth source labelled `x4bnet` if coverage matters more than attribution.
```json
before: (nothing)
after: "is_vpn": true, "vpn_providers": ["mullvad"]
```
### 3. `is_relay` (boolean)
True when the address is an iCloud Private Relay egress address (ipinfo's `privacy.relay`). Data: Apple's own feed, https://mask-api.icloud.com/egress-ip-ranges.csv (285,128 ranges with country, region and city; 12 MB; linked from https://developer.apple.com/support/prepare-your-network-for-icloud-private-relay/), fetched daily by the blocklist downloader. No key.
```json
before: (nothing)
after: "is_relay": false
```
### 4. `is_bogon` (boolean)
True when the address is in reserved space (private, loopback, documentation, multicast), by the rule the `/bogons` page already applies to prefixes. Tells apart "never routable" from "no route right now", which both show as `netblock: null` today. No data source.
```json
before: "netblock": null
after: "netblock": null, "is_bogon": true
```
### 5. `geo` (object, or null)
Best-effort location of the address. Nested, because `country_code` at the top level is already the AS organisation's country. Fields: `country_code` (string), `country` (string), `continent_code` (string), `region` (string), `city` (string), `latitude` (number), `longitude` (number), `accuracy_radius_km` (number), `time_zone` (string), `source` (string: `geojs` or `dbip`), `updated` (time the value was fetched). A part the source does not know is null; `geo` is null only when neither source answers.
Data: geojs.io's geo endpoint, https://get.geojs.io/v1/ip/geo.json?ip=a,b,c, one batch request for all addresses of a lookup, 1 second budget (https://www.geojs.io/docs/v1/endpoints/geo/). It is MaxMind GeoLite2 data, says "No rate limits (yet)" (https://www.geojs.io/), and its terms forbid "excessive" use at geojs's discretion (https://www.geojs.io/tos/), which the cache keeps us clear of. Quirks seen today: `latitude`/`longitude` come as strings and are the string `"nil"` when unknown, and an IPv6 address answered with no country at all; `accuracy` is a radius in km.
Fallback when geojs times out or errors: DB-IP's free database, read with the standard pure-Go MMDB reader. Two sizes to choose from:
- Country Lite (https://db-ip.com/db/download/ip-to-country-lite): 8 MB MMDB, country and continent only, fits the downloader as is. Recommended.
- City Lite (https://db-ip.com/db/download/ip-to-city-lite): 121 MB MMDB, region, city and coordinates like geojs (no time zone or accuracy radius); needs the 16 MiB download cap raised and about 130 MB of mapped file in memory.
Both are CC BY 4.0, published monthly at a dated URL (`dbip-country-lite-YYYY-MM.mmdb.gz`), no account, and require "IP Geolocation by DB-IP" with a link on pages that show results, so the home page footer would carry one. Fetched monthly by the downloader into the state directory, not committed to git; the image build can pre-fetch one so a fresh container has it. Rejected: MaxMind GeoLite2 needs an account and licence key and its EULA requires deleting old copies within 30 days (https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/, https://www.maxmind.com/en/geolite2/eula); the keyless mirror https://github.com/sapics/ip-location-db is under the same EULA; IPFire's database (https://www.ipfire.org/location, CC BY-SA 4.0, 6.4 MB, daily) has no Go reader.
```json
before: (nothing)
after: "geo": {"country_code": "DE", "country": "Germany", "continent_code": "EU", "region": "Brandenburg", "city": "Brandenburg an der Havel", "latitude": 52.6171, "longitude": 13.1207, "accuracy_radius_km": 20, "time_zone": "Europe/Berlin", "source": "geojs", "updated": "2026-10-27T20:37:28Z"}
```
### 6. `ptr` (list of strings, exists today) over DNS-over-HTTPS with a TTL cache
`ptr` already exists: the host's resolver via `net.DefaultResolver.LookupAddr`, 2 seconds per address, no cache. Same field, same shape; the change is the source and the cache. Resolvers, each checked today with a PTR query:
- Google https://dns.google/resolve (JSON, https://developers.google.com/speed/public-dns/docs/doh/json; default limit 1,500 queries per second per client address, https://developers.google.com/speed/public-dns/docs/isp)
- Cloudflare https://cloudflare-dns.com/dns-query with `accept: application/dns-json` (https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/; no limit documented)
- AdGuard https://unfiltered.adguard-dns.com/resolve (JSON works; https://adguard-dns.io/kb/general/dns-providers/)
- Quad9 https://dns.quad9.net/dns-query (RFC 8484 wire format only, https://www.quad9.net/service/service-addresses-and-features/)
Suggest the RFC 8484 wire format (`golang.org/x/net/dns/dnsmessage` packs and parses it), which every resolver above accepts; the JSON format has no RFC and differs per resolver. Try the resolvers in turn, 2 seconds in all, as today. Every answer carries the record's TTL; the Authority SOA carries the negative TTL for a name without a PTR.
```json
before: "ptr": ["berlin01.tor-exit.artikel10.org"]
after: "ptr": ["berlin01.tor-exit.artikel10.org"] (same; cached, served from memory until the TTL runs out)
```
### The two caches
Both in one SQLite file, `cache.db` in the state directory (the driver is already a dependency), WAL mode, separate from the routes database so they never queue behind its writer. Loaded into memory at startup (the geolocation cache up to its cap, newest first), written through as entries arrive (batched every few seconds). A sweep every 10 minutes deletes expired rows; memory misses read SQLite before asking outside. Assumed load: 100,000 distinct addresses looked up per 30 days (the home page's own-address card makes one per visitor); 1,000,000 as the high case.
- Geolocation cache: key address, value the `geo` object and its fetch time. Only geojs answers are cached (the fallback is local). An entry expires 7 days after its fetch, since GeoLite2 data changes twice a week. About 300 bytes per entry in memory (five strings, two numbers, a time, map overhead): 30 MB at 100,000, 300 MB at 1,000,000. On disk about 200 bytes per row: 20 MB and 200 MB. Memory capped at 250,000 entries (about 75 MB), least recently used dropped first; disk holds everything until expiry.
- Hostname cache: key address, value the names, the expiry and a negative flag. Expiry is the record TTL clamped to 60 seconds minimum and 24 hours maximum; a negative answer expires at the SOA minimum, at most 1 hour; a failed query is not cached. Bounded by distinct addresses per 24 hours: at 100,000 a day, about 200 bytes per entry in memory, 20 MB; on disk about 150 bytes per row, 15 MB.
Privacy note for your judgement: a geojs lookup sends the visitor's address to geojs, once per 7 days per address; the DoH lookup sends it to Google or Cloudflare, where today it goes to the host's resolver.
### Full example: today and with everything above
Address 185.220.101.1, a Tor exit. `ptr`, `geo` and the Tor listing are today's real answers; the route and AS figures are illustrative.
Today:
```json
{
"status": "ok",
"data": {
"query": "185.220.101.1",
"results": [
{
"netblock": "185.220.101.0/24",
"mask_length": 24,
"ip_version": 4,
"num_peers": 41,
"asn": 60729,
"num_origins": 1,
"origin_asns": [60729],
"as_name": "ZWIEBELFREUNDE",
"handle": "ZWIEBELFREUNDE",
"description": "Stiftung Erneuerbare Freiheit",
"org_name": "Stiftung Erneuerbare Freiheit",
"org_id": "ORG-SEF2-RIPE",
"address": "Berlin, Germany",
"country_code": "DE",
"abuse_email": "abuse@artikel10.org",
"rir": "RIPE",
"first_seen": "2026-09-21T10:14:02Z",
"last_seen": "2026-10-27T18:02:40Z",
"ip": "185.220.101.1",
"ptr": ["berlin01.tor-exit.artikel10.org"],
"blocklists": [
{
"name": "tor_exits",
"description": "A Tor exit node, where traffic from the Tor network leaves",
"updated": "2026-10-27T20:00:11Z"
}
]
}
]
},
"@meta": {"time_zone": "UTC", "api_version": 1, "execution_time_ms": 41}
}
```
With every addition:
```json
{
"status": "ok",
"data": {
"query": "185.220.101.1",
"results": [
{
"netblock": "185.220.101.0/24",
"mask_length": 24,
"ip_version": 4,
"num_peers": 41,
"asn": 60729,
"num_origins": 1,
"origin_asns": [60729],
"as_name": "ZWIEBELFREUNDE",
"handle": "ZWIEBELFREUNDE",
"description": "Stiftung Erneuerbare Freiheit",
"org_name": "Stiftung Erneuerbare Freiheit",
"org_id": "ORG-SEF2-RIPE",
"address": "Berlin, Germany",
"country_code": "DE",
"abuse_email": "abuse@artikel10.org",
"rir": "RIPE",
"first_seen": "2026-09-21T10:14:02Z",
"last_seen": "2026-10-27T18:02:40Z",
"ip": "185.220.101.1",
"ptr": ["berlin01.tor-exit.artikel10.org"],
"blocklists": [
{
"name": "tor_exits",
"description": "A Tor exit node, where traffic from the Tor network leaves",
"updated": "2026-10-27T20:00:11Z"
}
],
"is_bogon": false,
"is_tor": true,
"is_vpn": false,
"vpn_providers": [],
"is_relay": false,
"geo": {
"country_code": "DE",
"country": "Germany",
"continent_code": "EU",
"region": "Brandenburg",
"city": "Brandenburg an der Havel",
"latitude": 52.6171,
"longitude": 13.1207,
"accuracy_radius_km": 20,
"time_zone": "Europe/Berlin",
"source": "geojs",
"updated": "2026-10-27T20:37:28Z"
}
}
]
},
"@meta": {"time_zone": "UTC", "api_version": 1, "execution_time_ms": 44}
}
```
### Left out, and why
- `hostname` (ipinfo): it is `ptr[0]`; a second field saying the same thing.
- `is_proxy`: the only keyless list, FireHOL proxies (41 MB, 3.3 million addresses), is too broad to mean much.
- `is_anycast`: the only keyless list, https://github.com/bgptools/anycast-prefixes (4,605 IPv4 and 1,819 IPv6 prefixes), carries no licence; IPFire's anycast flag needs its C reader. Doable later if bgp.tools grants one.
- `is_hosting` / `as_type` (ipinfo): no keyless source names the type of an AS. X4BNet's MIT datacenter list (44,612 IPv4 ranges) could feed an `is_datacenter` later.
- `postal`: neither geojs nor DB-IP Lite has it.
- `is_eu`: DB-IP has it, geojs does not; a client derives it from `geo.country_code`.
- geojs's `asn` and `organization_name`: the routing table already gives the AS, from live BGP.
- geojs's PTR endpoint (https://www.geojs.io/docs/v1/endpoints/ptr/): one address per request and no TTL; DoH gives both.
- `company`, `carrier`, `domains`, the `abuse` object (ipinfo): proprietary data with no public source; `abuse_email` from WHOIS is already there.
Not checked: how many addresses the public instance actually looks up per day, so the cache figures rest on the stated assumption; whether geojs's terms allow caching (they do not mention it).
### Which to build?
My recommendation: all six, with the Country Lite fallback for `geo`. Order of value: `ptr` over DoH with the cache (fixes a real cost today: an uncached 2-second lookup per address), `geo`, `is_tor` and `is_vpn` with `vpn_providers`, `is_relay`, `is_bogon`. Decisions that are yours: the fallback size (Country Lite at 8 MB, or City Lite at 121 MB), whether `x4bnet` joins the VPN sources, whether to switch `tor_exits` to Onionoo for IPv6, and whether the home page carrying a DB-IP attribution link is acceptable.
Model: fable-5-1
clawbot
removed their assignment 2026-10-27 21:45:36 +01:00
sneak
was assigned by clawbot2026-10-27 21:45:36 +01:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
perhaps is_vpn, is_tor, etc. we can find the public IP lists of major public vpn providers like nord, proton, mullvad, etc. we should also include our best-effort geolocation data.
we can use https://www.geojs.io/ to lookup and cache geolocations.
https://www.geojs.io/docs/v1/endpoints/geo/
we should also bundle/vendor some good public geoip database as a fallback if the geojs lookup times out.
the geolocation cache should be kept in memory but also needs to persist to disk and be read on restart/startup. perhaps sqlite?
I'd like a list of proposed additions to our api responses, including examples of what they look like now and what they would look like after.
review https://ipinfo.io response formats to identify potential additions.
oh,
hostname/PTRis an obvious one. we should fetch using DoH from Google, Cloudflare, other public reliable resolvers, and cache locally based on record TTL. cache lives in ram, persists to disk, and is loaded on startup. sqlite on disk, probably.Proposal for the IP lookup answer (
/ip/{addr},/api/v1/ip/{ip}), one item per addition. Every value followsAPI_POLICIES.md: native types, the unit in the field name. ipinfo.io'sloc("52.52,13.41") andorg("AS15169 Google LLC") strings are not copied.For reference, ipinfo.io returns without a token
ip,hostname,city,region,country,loc,org,postal,timezone,anycast,bogon(https://ipinfo.io/8.8.8.8/json, https://ipinfo.io/developers/data-types); on paid plansprivacywithvpn,proxy,tor,relay,hosting,service(https://www.ipinfo.io/developers/privacy-detection-extended), plusasn.type,company,carrier,abuse,domains; its newer Core shape hasis_anycast,is_mobile,is_satellite,is_anonymous,is_hosting(https://community.ipinfo.io/t/ipinfos-core-api-service-makes-accessing-privacy-data-more-affordable/6769). The items below are the ones a public source without a key or an account can feed.1.
is_tor(boolean)True when the address is a Tor exit. Data: the
tor_exitslist RouteWatch already downloads every hour (https://check.torproject.org/torbulkexitlist, about 1,300 addresses). No new download. That list is IPv4 only; Onionoo (https://onionoo.torproject.org/details?flag=Exit&running=true&fields=exit_addresses,or_addresses) would add the IPv6 exits.2.
is_vpn(boolean) andvpn_providers(list of strings)True when the address is a server of a commercial VPN;
vpn_providersnames which, empty when none (one address can be on two lists). Data, each checked today, downloadable whole with no key or account, fetched once a day with the blocklist downloader (new JSON parsers, otherwise the same code, on-disk copies and validators):mullvad: https://api.mullvad.net/www/relays/all/ (566 IPv4, 556 IPv6, 294 KB)nordvpn: https://api.nordvpn.com/v1/servers?limit=0&fields[servers.station]&fields[servers.ipv6_station]&fields[servers.hostname] (7,233 IPv4, no IPv6, 565 KB; without the field filter it is 27 MB, over the downloader's 16 MiB cap)pia: https://serverlist.piaservers.net/vpninfo/servers/v6 (1,520 addresses, 174 KB; JSON followed by a signature block after a blank line)ivpn: https://api.ivpn.net/v5/servers.json (59 gateways with their host addresses, 75 KB)Not usable: Proton (https://api.protonmail.ch/vpn/logicals answers 400 without a Proton client app's
x-pm-appversionheader, and 401 or 422 with every value I tried; the header is a client identity), Surfshark (https://api.surfshark.com/v4/server/clusters lists 141 host names, no addresses), ExpressVPN (publishes no list). https://github.com/X4BNet/lists_vpn (MIT, 10,947 IPv4 ranges) is a broad community list that names no provider; it could be a fifth source labelledx4bnetif coverage matters more than attribution.3.
is_relay(boolean)True when the address is an iCloud Private Relay egress address (ipinfo's
privacy.relay). Data: Apple's own feed, https://mask-api.icloud.com/egress-ip-ranges.csv (285,128 ranges with country, region and city; 12 MB; linked from https://developer.apple.com/support/prepare-your-network-for-icloud-private-relay/), fetched daily by the blocklist downloader. No key.4.
is_bogon(boolean)True when the address is in reserved space (private, loopback, documentation, multicast), by the rule the
/bogonspage already applies to prefixes. Tells apart "never routable" from "no route right now", which both show asnetblock: nulltoday. No data source.5.
geo(object, or null)Best-effort location of the address. Nested, because
country_codeat the top level is already the AS organisation's country. Fields:country_code(string),country(string),continent_code(string),region(string),city(string),latitude(number),longitude(number),accuracy_radius_km(number),time_zone(string),source(string:geojsordbip),updated(time the value was fetched). A part the source does not know is null;geois null only when neither source answers.Data: geojs.io's geo endpoint, https://get.geojs.io/v1/ip/geo.json?ip=a,b,c, one batch request for all addresses of a lookup, 1 second budget (https://www.geojs.io/docs/v1/endpoints/geo/). It is MaxMind GeoLite2 data, says "No rate limits (yet)" (https://www.geojs.io/), and its terms forbid "excessive" use at geojs's discretion (https://www.geojs.io/tos/), which the cache keeps us clear of. Quirks seen today:
latitude/longitudecome as strings and are the string"nil"when unknown, and an IPv6 address answered with no country at all;accuracyis a radius in km.Fallback when geojs times out or errors: DB-IP's free database, read with the standard pure-Go MMDB reader. Two sizes to choose from:
Both are CC BY 4.0, published monthly at a dated URL (
dbip-country-lite-YYYY-MM.mmdb.gz), no account, and require "IP Geolocation by DB-IP" with a link on pages that show results, so the home page footer would carry one. Fetched monthly by the downloader into the state directory, not committed to git; the image build can pre-fetch one so a fresh container has it. Rejected: MaxMind GeoLite2 needs an account and licence key and its EULA requires deleting old copies within 30 days (https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/, https://www.maxmind.com/en/geolite2/eula); the keyless mirror https://github.com/sapics/ip-location-db is under the same EULA; IPFire's database (https://www.ipfire.org/location, CC BY-SA 4.0, 6.4 MB, daily) has no Go reader.6.
ptr(list of strings, exists today) over DNS-over-HTTPS with a TTL cacheptralready exists: the host's resolver vianet.DefaultResolver.LookupAddr, 2 seconds per address, no cache. Same field, same shape; the change is the source and the cache. Resolvers, each checked today with a PTR query:accept: application/dns-json(https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/; no limit documented)Suggest the RFC 8484 wire format (
golang.org/x/net/dns/dnsmessagepacks and parses it), which every resolver above accepts; the JSON format has no RFC and differs per resolver. Try the resolvers in turn, 2 seconds in all, as today. Every answer carries the record's TTL; the Authority SOA carries the negative TTL for a name without a PTR.The two caches
Both in one SQLite file,
cache.dbin the state directory (the driver is already a dependency), WAL mode, separate from the routes database so they never queue behind its writer. Loaded into memory at startup (the geolocation cache up to its cap, newest first), written through as entries arrive (batched every few seconds). A sweep every 10 minutes deletes expired rows; memory misses read SQLite before asking outside. Assumed load: 100,000 distinct addresses looked up per 30 days (the home page's own-address card makes one per visitor); 1,000,000 as the high case.geoobject and its fetch time. Only geojs answers are cached (the fallback is local). An entry expires 7 days after its fetch, since GeoLite2 data changes twice a week. About 300 bytes per entry in memory (five strings, two numbers, a time, map overhead): 30 MB at 100,000, 300 MB at 1,000,000. On disk about 200 bytes per row: 20 MB and 200 MB. Memory capped at 250,000 entries (about 75 MB), least recently used dropped first; disk holds everything until expiry.Privacy note for your judgement: a geojs lookup sends the visitor's address to geojs, once per 7 days per address; the DoH lookup sends it to Google or Cloudflare, where today it goes to the host's resolver.
Full example: today and with everything above
Address 185.220.101.1, a Tor exit.
ptr,geoand the Tor listing are today's real answers; the route and AS figures are illustrative.Today:
With every addition:
Left out, and why
hostname(ipinfo): it isptr[0]; a second field saying the same thing.is_proxy: the only keyless list, FireHOL proxies (41 MB, 3.3 million addresses), is too broad to mean much.is_anycast: the only keyless list, https://github.com/bgptools/anycast-prefixes (4,605 IPv4 and 1,819 IPv6 prefixes), carries no licence; IPFire's anycast flag needs its C reader. Doable later if bgp.tools grants one.is_hosting/as_type(ipinfo): no keyless source names the type of an AS. X4BNet's MIT datacenter list (44,612 IPv4 ranges) could feed anis_datacenterlater.postal: neither geojs nor DB-IP Lite has it.is_eu: DB-IP has it, geojs does not; a client derives it fromgeo.country_code.asnandorganization_name: the routing table already gives the AS, from live BGP.company,carrier,domains, theabuseobject (ipinfo): proprietary data with no public source;abuse_emailfrom WHOIS is already there.Not checked: how many addresses the public instance actually looks up per day, so the cache figures rest on the stated assumption; whether geojs's terms allow caching (they do not mention it).
Which to build?
My recommendation: all six, with the Country Lite fallback for
geo. Order of value:ptrover DoH with the cache (fixes a real cost today: an uncached 2-second lookup per address),geo,is_torandis_vpnwithvpn_providers,is_relay,is_bogon. Decisions that are yours: the fallback size (Country Lite at 8 MB, or City Lite at 121 MB), whetherx4bnetjoins the VPN sources, whether to switchtor_exitsto Onionoo for IPv6, and whether the home page carrying a DB-IP attribution link is acceptable.Model: fable-5-1