IP location
An IP address's country, city and ASN from MaxMind-format .mmdb files in pure Rust: lookups through a kept reader that notices a replaced file, names in a chosen language, address kinds told apart, and metadata. Bring your own database: the samples are made up.
Version 1.0.0 · 7 functions · 2 crates
Functions
What use ip_location.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use ip_location_kind, ip_location_raw from ip_location (Quillo 0.6).
ip_location_kind(ip: str) → anyor an errorWhat kind of address this is: { ip, version, kind, public }, kind being public, private, loopback, link-local, multicast, carrier-grade nat, documentation and so on. Private ones are in no database, so check before you look.
ip_location_raw(db: str, ip: str) → anyor an errorThe record at an address, as the file has it, or null, with its network: for fields this plugin does not lift out.
ip_location_city(db: str, ip: str, lang: str) → anyor an errorWhere an address is, from a City or Country database: { found, ip, network, country, country_name, continent, region, city, postal, latitude, longitude, time_zone, accuracy_km }. Names come in lang, English when the file lacks it. An address the file has nothing on gives found: false.
ip_location_asn(db: str, ip: str) → anyor an errorWho an address belongs to, from an ASN database: { found, ip, network, asn, organization }.
ip_location_lookup(ip: str, lang: str) → anyor an errorCity and ASN at once, from IP_LOCATION_CITY_DB and IP_LOCATION_ASN_DB, and the kind of address: what a log line or a sign-in check wants. A private address looks up nothing.
ip_location_info(db: str) → anyor an errorWhat a file is: { type, description, built, ip_version, languages, nodes, record_size, format }, built in seconds since 1970. Check its age: stale data gives wrong answers.
ip_location_verify(db: str) → str?or an errorChecks a whole file against the format: search tree, data and metadata. Null when sound, or the reason.
Get it
$ quillo install ip_location # ip_location.qwer into this folder$ quillo install ip_location --example # and its example, ip_location.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/ip_location.qwer$ curl -O https://quillolang.org/plugins/files/ip_location.qwe$ curl -O https://quillolang.org/plugins/files/ip_location_city.mmdb$ curl -O https://quillolang.org/plugins/files/ip_location_asn.mmdbThen, in your program, all of it or only what you call:
use ip_location.qweruse ip_location_kind, ip_location_raw from ip_location // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }maxminddb = "=0.32.0"Example
// Where a visitor is: country, city and network from MaxMind-format .mmdb files. The two samples// are tiny, made by a script, and hold only documentation addresses and made-up places: GeoLite2// data has a licence of its own, and comes from MaxMind with an account.//// IP_LOCATION_CITY_DB=ip_location_city.mmdb IP_LOCATION_ASN_DB=ip_location_asn.mmdb quillo run ip_location.qwe// then open http://localhost:8080/where/192.0.2.10, /lookup, /kinds, /info and /refused//// With a real file: point the variables at GeoLite2-City.mmdb and GeoLite2-ASN.mmdb, or a paid// GeoIP2 file. A replaced file is noticed by its date and read again.use "ip_location.qwer"let CITY = "ip_location_city.mmdb"let ASN = "ip_location_asn.mmdb"route GET /where/:ip = ip_location_city(CITY, ip, "en")?route GET /lookup = { berlin: ip_location_lookup("192.0.2.10", "de")?, lyon: ip_location_city(CITY, "192.0.2.200", "fr")?, oakland: ip_location_asn(ASN, "198.51.100.77")?, ipv6: ip_location_city(CITY, "2001:db8:1::42", "en")?, asn32: ip_location_asn(ASN, "2001:db8:1:2::1")?, nowhere: ip_location_city(CITY, "203.0.113.5", "en")?, raw: ip_location_raw(CITY, "198.51.100.1")?.record.subdivisions,}route GET /kinds = ["8.8.8.8", "10.1.2.3", "127.0.0.1", "169.254.1.1", "100.64.0.1", "192.0.2.1", "224.0.0.1", "::1", "fd00::1", "fe80::1", "2606:4700::1111", "::ffff:10.0.0.1", "2001:db8::1"] | map((a) => ip_location_kind(a)?.kind)route GET /info = { city: ip_location_info(CITY)?, asn_type: ip_location_info(ASN)?.type, sound: ip_location_verify(CITY)?,}route GET /refused = { not_ip: ip_location_city(CITY, "not-an-ip", "en").error, missing: ip_location_city("no-such.mmdb", "192.0.2.1", "en").error != null, not_mmdb: ip_location_info("ip_location.qwe").error != null,}$ quillo run ip_location.qwe$ curl 'localhost:8080/where/192.0.2.10'$ curl 'localhost:8080/lookup'$ curl 'localhost:8080/kinds'$ curl 'localhost:8080/info'$ curl 'localhost:8080/refused'Source
//! An IP address's country, city and ASN from MaxMind-format .mmdb files, in pure Rust: lookups through a kept reader, names in a chosen language, address kinds told apart, and metadata.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }//! maxminddb = "=0.32.0"use maxminddb::Reader;use serde_json::{json, Value};use std::collections::HashMap;use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};use std::sync::{Arc, Mutex};use std::time::SystemTime;type Db = Arc<Reader<Vec<u8>>>;/// Files by path, read once, and read again when they change on disk: the databases are replaced each week.static DBS: Mutex<Option<HashMap<String, (SystemTime, Db)>>> = Mutex::new(None);fn open(path: &str) -> Result<Db, String> { let stamp = std::fs::metadata(path).and_then(|m| m.modified()).map_err(|e| format!("{}: {}", path, e))?; if let Some((t, d)) = DBS.lock().unwrap().as_ref().and_then(|m| m.get(path)) { if *t == stamp { return Ok(d.clone()); } } let r: Db = Arc::new(Reader::open_readfile(path).map_err(|e| format!("{}: {}", path, e))?); DBS.lock().unwrap().get_or_insert_with(HashMap::new).insert(path.to_string(), (stamp, r.clone())); Ok(r)}fn parse_ip(ip: &str) -> Result<IpAddr, String> { ip.trim().parse().map_err(|_| format!("`{}` is not an IP address", ip))}fn kind_v4(a: Ipv4Addr) -> &'static str { let o = a.octets(); if a.is_private() { "private" } else if a.is_loopback() { "loopback" } else if a.is_link_local() { "link-local" } else if a.is_multicast() { "multicast" } else if a.is_broadcast() { "broadcast" } else if a.is_unspecified() { "unspecified" } else if o[0] == 100 && (64..128).contains(&o[1]) { "carrier-grade nat" } else if matches!(o, [192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _]) { "documentation" } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) { "benchmarking" } else if o[0] >= 240 { "reserved" } else { "public" }}fn kind_v6(a: Ipv6Addr) -> &'static str { let s = a.segments(); if let Some(v4) = a.to_ipv4_mapped() { return kind_v4(v4); } if a.is_loopback() { "loopback" } else if a.is_unspecified() { "unspecified" } else if a.is_multicast() { "multicast" } else if s[0] & 0xfe00 == 0xfc00 { "private" } else if s[0] & 0xffc0 == 0xfe80 { "link-local" } else if s[0] == 0x2001 && s[1] == 0x0db8 { "documentation" } else { "public" }}/// What kind of address this is: `{ ip, version, kind, public }`, `kind` being public, private, loopback, link-local, multicast, carrier-grade nat, documentation and so on. Private ones are in no database, so check before you look.pub fn ip_location_kind(ip: &str) -> Result<Value, String> { let a = parse_ip(ip)?; let (version, kind) = match a { IpAddr::V4(x) => (4, kind_v4(x)), IpAddr::V6(x) => (6, kind_v6(x)), }; Ok(json!({ "ip": a.to_string(), "version": version, "kind": kind, "public": kind == "public" }))}/// The record at an address, as the file has it, or null, with its `network`: for fields this plugin does not lift out.pub fn ip_location_raw(db: &str, ip: &str) -> Result<Value, String> { let (rec, net) = find(db, ip)?; Ok(json!({ "record": rec, "network": net }))}fn find(db: &str, ip: &str) -> Result<(Value, Value), String> { let r = open(db)?; let a = parse_ip(ip)?; let hit = r.lookup(a).map_err(|e| format!("{}: {}", db, e))?; if !hit.has_data() { return Ok((Value::Null, Value::Null)); } let net = hit.network().map(|n| json!(n.to_string())).unwrap_or(Value::Null); let rec: Option<Value> = hit.decode().map_err(|e| format!("{}: {}", db, e))?; Ok((rec.unwrap_or(Value::Null), net))}fn name(v: &Value, lang: &str) -> Value { let n = &v["names"]; if n[lang].is_string() { n[lang].clone() } else { n["en"].clone() }}fn lift(rec: &Value, lang: &str) -> Value { let region: Vec<Value> = rec["subdivisions"].as_array().into_iter().flatten().map(|s| json!({ "code": s["iso_code"], "name": name(s, lang) })).collect(); json!({ "country": rec["country"]["iso_code"], "country_name": name(&rec["country"], lang), "continent": rec["continent"]["code"], "continent_name": name(&rec["continent"], lang), "region": region.first().cloned().unwrap_or(Value::Null), "regions": region, "city": name(&rec["city"], lang), "postal": rec["postal"]["code"], "latitude": rec["location"]["latitude"], "longitude": rec["location"]["longitude"], "time_zone": rec["location"]["time_zone"], "accuracy_km": rec["location"]["accuracy_radius"], "eu": rec["country"]["is_in_european_union"], "registered_country": rec["registered_country"]["iso_code"], })}/// Where an address is, from a City or Country database: `{ found, ip, network, country, country_name, continent, region, city, postal, latitude, longitude, time_zone, accuracy_km }`. Names come in `lang`, English when the file lacks it. An address the file has nothing on gives `found: false`.pub fn ip_location_city(db: &str, ip: &str, lang: &str) -> Result<Value, String> { let (rec, net) = find(db, ip)?; if rec.is_null() { return Ok(json!({ "found": false, "ip": ip.trim() })); } let mut v = lift(&rec, if lang.is_empty() { "en" } else { lang }); v["found"] = json!(true); v["ip"] = json!(ip.trim()); v["network"] = net; Ok(v)}/// Who an address belongs to, from an ASN database: `{ found, ip, network, asn, organization }`.pub fn ip_location_asn(db: &str, ip: &str) -> Result<Value, String> { let (rec, net) = find(db, ip)?; if rec.is_null() { return Ok(json!({ "found": false, "ip": ip.trim() })); } Ok(json!({ "found": true, "ip": ip.trim(), "network": net, "asn": rec["autonomous_system_number"], "organization": rec["autonomous_system_organization"] }))}/// City and ASN at once, from `IP_LOCATION_CITY_DB` and `IP_LOCATION_ASN_DB`, and the kind of address: what a log line or a sign-in check wants. A private address looks up nothing.pub fn ip_location_lookup(ip: &str, lang: &str) -> Result<Value, String> { let kind = ip_location_kind(ip)?; let var = |k: &str| std::env::var(k).ok().filter(|x| !x.is_empty()); let city = match var("IP_LOCATION_CITY_DB") { Some(p) => ip_location_city(&p, ip, lang)?, None => return Err("IP_LOCATION_CITY_DB is not set".into()), }; let asn = match var("IP_LOCATION_ASN_DB") { Some(p) => ip_location_asn(&p, ip)?, None => Value::Null, }; Ok(json!({ "ip": kind["ip"], "kind": kind["kind"], "found": city["found"], "country": city["country"], "city": city["city"], "time_zone": city["time_zone"], "asn": asn["asn"], "organization": asn["organization"] }))}/// What a file is: `{ type, description, built, ip_version, languages, nodes, record_size, format }`, `built` in seconds since 1970. Check its age: stale data gives wrong answers.pub fn ip_location_info(db: &str) -> Result<Value, String> { let r = open(db)?; let m = r.metadata(); Ok(json!({ "type": m.database_type, "description": m.description.get("en"), "built": m.build_epoch, "ip_version": m.ip_version, "languages": m.languages, "nodes": m.node_count, "record_size": m.record_size, "format": format!("{}.{}", m.binary_format_major_version, m.binary_format_minor_version), }))}/// Checks a whole file against the format: search tree, data and metadata. Null when sound, or the reason.pub fn ip_location_verify(db: &str) -> Result<Option<String>, String> { let r = open(db)?; Ok(r.verify().err().map(|e| e.to_string()))}Thread 0 posts
On IP location: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.