feat(check-ip): embedded web UI with IP Location & Status, domain resolution, and richer GeoIP data

- Embed web UI (index.html, app.js, style.css) via go:embed
- Title: 'IP Location & Status' instead of 'check-ip'
- 'Show my IP Info' link for lookups of the visitor's own IP
- Domain resolution with resolved_from field in JSON
- Richer GeoIP fields: region ISO, continent, coordinates, traits, etc.
- /api/raw debug endpoint for raw MaxMind records
- net/geoip: expand Info struct and add LookupRaw method
This commit is contained in:
AJ ONeal 2026-05-27 01:29:34 -06:00
parent ee3c277a00
commit 1bcc160a61
No known key found for this signature in database
9 changed files with 786 additions and 12 deletions

27
cmd/check-ip/HANDOFF.md Normal file
View File

@ -0,0 +1,27 @@
# check-ip — Handoff
## Done
- Embedded web UI at `/` (index.html, app.js, style.css)
- `//go-embed web/**` + `fs.Sub(webFS, "web")` + `http.FileServer`
- `GET /check?host=` — resolves domains, returns JSON verdict
- `GET /check?` (empty host) — returns client's IP (via X-Forwarded-For / RemoteAddr)
- `GET /check?host=<ip>` — pre-fills input and auto-submits on page load
- `GET /healthz` — dataset health
- Deployed to `ip.bnna.net:3080`
- Created `go-webapp-embed` skill
## Remaining
1. **`?ip=` query param removed** — `handle()` only checks `host` now. Existing API consumers using `?ip=` will break. Fix:
```go
query := cmp.Or(r.URL.Query().Get("host"), r.URL.Query().Get("ip"))
```
2. **`105dvh` in CSS** — not universally supported. Use `100dvh` or `min-height: 100vh`.
3. **Domain resolution uses only first result** — `net.LookupHost` can return IPv4 + IPv6. Consider showing all resolved addresses.
4. **No debouncing on client** — fast clicks send multiple requests. Add a simple debounce in app.js.
5. **Error display is raw** — DNS errors show technical messages. Could map to user-friendly text.

View File

@ -10,6 +10,8 @@ require (
github.com/therootcompany/golib/sync/dataset v0.5.0
)
replace github.com/therootcompany/golib/net/geoip => ../../net/geoip
require (
github.com/oschwald/geoip2-golang v1.13.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect

View File

@ -5,6 +5,7 @@ package main
import (
"context"
"embed"
"encoding/base64"
"encoding/csv"
"encoding/json"
@ -29,6 +30,9 @@ import (
"github.com/therootcompany/golib/sync/dataset"
)
//go:embed web/**
var webFS embed.FS
// Replaced by goreleaser / ldflags at build time.
var (
name = "check-ip"

View File

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
@ -13,6 +14,7 @@ import (
"strings"
"time"
"github.com/oschwald/geoip2-golang"
"github.com/therootcompany/golib/net/geoip"
"github.com/therootcompany/golib/net/ipcohort"
"github.com/therootcompany/golib/sync/dataset"
@ -21,6 +23,7 @@ import (
// Result is the JSON verdict for a single IP.
type Result struct {
IP string `json:"ip"`
ResolvedFrom string `json:"resolved_from,omitzero"`
Blocked bool `json:"blocked"`
BlockedInbound bool `json:"blocked_inbound"`
BlockedOutbound bool `json:"blocked_outbound"`
@ -49,6 +52,16 @@ func (c *IPCheck) lookup(ip string) Result {
return res
}
// lookupRaw returns the raw geoip.Info for an IP.
func (c *IPCheck) lookupRaw(ip string) geoip.Info {
return c.geo.Value().Lookup(ip)
}
// lookupRawCity returns the raw *geoip2.City record for an IP.
func (c *IPCheck) lookupRawCity(ip string) *geoip2.City {
return c.geo.Value().LookupRaw(ip)
}
// writePretty renders res as space-aligned plain text.
func (c *IPCheck) writePretty(w io.Writer, res Result) {
switch {
@ -82,18 +95,36 @@ func (c *IPCheck) writePretty(w io.Writer, res Result) {
}
func (c *IPCheck) handle(w http.ResponseWriter, r *http.Request) {
ip := strings.TrimSpace(r.URL.Query().Get("ip"))
if ip == "" {
query := strings.TrimSpace(r.URL.Query().Get("host"))
if query == "" {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
ip = strings.TrimSpace(first)
query = strings.TrimSpace(first)
} else if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ip = host
query = host
} else {
ip = r.RemoteAddr
query = r.RemoteAddr
}
}
// Resolve hostname to IP if needed.
ip := query
if net.ParseIP(ip) == nil {
addrs, err := net.LookupHost(ip)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
ip = addrs[0]
}
res := c.lookup(ip)
res.IP = ip
if query != ip {
res.ResolvedFrom = query
}
if r.URL.Query().Get("format") == "json" ||
strings.Contains(r.Header.Get("Accept"), "application/json") {
@ -108,6 +139,32 @@ func (c *IPCheck) handle(w http.ResponseWriter, r *http.Request) {
c.writePretty(w, res)
}
func (c *IPCheck) debugRaw(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("host"))
if query == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "?host= required"})
return
}
ip := query
if net.ParseIP(ip) == nil {
addrs, err := net.LookupHost(ip)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
ip = addrs[0]
}
rec := c.lookupRawCity(ip)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(rec)
}
type dsStatus struct {
Loaded bool `json:"loaded"`
Size int `json:"size,omitzero"`
@ -158,7 +215,11 @@ func (c *IPCheck) serve(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /check", c.handle)
mux.HandleFunc("GET /healthz", c.healthz)
mux.HandleFunc("GET /{$}", c.handle)
mux.HandleFunc("GET /api/raw", c.debugRaw)
// Serve embedded web files.
webSub, _ := fs.Sub(webFS, "web")
mux.Handle("/", http.FileServer(http.FS(webSub)))
srv := &http.Server{
Addr: c.Bind,

203
cmd/check-ip/web/app.js Normal file
View File

@ -0,0 +1,203 @@
(function () {
"use strict";
const form = document.querySelector(".search-form");
const input = form.querySelector('input[type="text"]');
const btn = form.querySelector("button");
const myIpLink = document.getElementById("my-ip-link");
const loading = document.querySelector(".loading");
const errorEl = document.querySelector(".error-msg");
const resultEl = document.querySelector(".result");
function show(el) { el.classList.add("visible"); }
function hide(el) { el.classList.remove("visible"); }
function render(result) {
hide(errorEl);
hide(resultEl);
const host = result.ip;
const resolvedFrom = result.resolved_from || "";
const blocked = result.blocked;
const blockedIn = result.blocked_inbound;
const blockedOut = result.blocked_outbound;
const whitelisted = result.allowlisted;
const geo = result.geo || {};
// Verdict
let verdictClass = "unknown";
let verdictText = "UNKNOWN";
if (whitelisted) {
verdictClass = "whitelist";
verdictText = "ALLOWED (whitelist)";
} else if (blocked) {
verdictClass = "blocked";
verdictText = "BLOCKED";
} else {
verdictClass = "allowed";
verdictText = "ALLOWED";
}
// Geo info
let geoParts = [];
if (geo.city) geoParts.push(geo.city);
if (geo.region) {
geoParts.push(geo.region + (geo.region_iso ? " (" + geo.region_iso + ")" : ""));
} else if (geo.region_iso) {
geoParts.push(geo.region_iso);
}
if (geo.country) {
geoParts.push(geo.country + (geo.country_iso ? " (" + geo.country_iso + ")" : ""));
}
if (geo.continent) {
geoParts.push(geo.continent + (geo.continent_code ? " (" + geo.continent_code + ")" : ""));
}
let geoStr = geoParts.length ? geoParts.join(", ") : "—";
let latLonStr = "";
if (geo.latitude || geo.longitude) {
latLonStr = geo.latitude.toFixed(4) + ", " + geo.longitude.toFixed(4);
}
let asnStr = geo.asn ? "AS" + geo.asn + " " + (geo.asn_org || "") : "—";
let postalStr = geo.postal_code || "";
let tzStr = geo.timezone || "";
let metroStr = geo.metro_code ? String(geo.metro_code) : "";
let accuracyStr = geo.accuracy_radius ? String(geo.accuracy_radius) : "";
let registeredStr = geo.registered_country ? (geo.registered_country + (geo.registered_country_iso ? " (" + geo.registered_country_iso + ")" : "")) : "";
// Trait badges
let traitBadges = "";
if (geo.anycast) {
traitBadges += '<span class="trait-badge anycast">anycast</span>';
}
if (geo.anonymous_proxy) {
traitBadges += '<span class="trait-badge anonymous">anonymous proxy</span>';
}
if (geo.satellite) {
traitBadges += '<span class="trait-badge satellite">satellite</span>';
}
if (geo.country_in_eu) {
traitBadges += '<span class="trait-badge eu">EU</span>';
}
// Block details
let blockHtml = "";
if (blockedIn || blockedOut) {
const parts = [];
if (blockedIn) parts.push("inbound");
if (blockedOut) parts.push("outbound");
blockHtml = '<span class="block-badge">BLOCKED: ' + parts.join(" + ") + '</span>';
} else {
blockHtml = '<span class="block-badge clean">clean</span>';
}
resultEl.innerHTML =
'<div class="result-header">' +
'<span class="verdict ' + verdictClass + '">' + verdictText + '</span>' +
'<span class="host">' + escapeHtml(host) + '</span>' +
(resolvedFrom ? '<span class="resolved">→ ' + escapeHtml(resolvedFrom) + '</span>' : '') +
'</div>' +
'<div class="result-body">' +
(geoStr !== "—" ?
'<div class="result-section">' +
'<h3>Location</h3>' +
'<p class="mono">' + escapeHtml(geoStr) + '</p>' +
'</div>' : '') +
(latLonStr ?
'<div class="result-section">' +
'<h3>Coordinates</h3>' +
'<p class="mono">' + escapeHtml(latLonStr) + '</p>' +
'</div>' : '') +
(postalStr || tzStr || metroStr || accuracyStr ?
'<div class="result-section">' +
'<h3>Details</h3>' +
'<p class="mono">' +
(postalStr ? escapeHtml(postalStr) + " " : "") +
(tzStr ? escapeHtml(tzStr) + " " : "") +
(metroStr ? "Metro " + escapeHtml(metroStr) + " " : "") +
(accuracyStr ? "±" + escapeHtml(accuracyStr) + "km" : "") +
'</p>' +
'</div>' : '') +
(traitBadges ?
'<div class="result-section">' +
'<h3>Traits</h3>' +
'<div class="block-list">' + traitBadges + '</div>' +
'</div>' : '') +
(registeredStr ?
'<div class="result-section">' +
'<h3>Registered Country</h3>' +
'<p class="mono">' + escapeHtml(registeredStr) + '</p>' +
'</div>' : '') +
'<div class="result-section">' +
'<h3>ASN</h3>' +
'<p class="mono">' + escapeHtml(asnStr) + '</p>' +
'</div>' +
'<div class="result-section">' +
'<h3>Blocklist</h3>' +
'<div class="block-list">' + blockHtml + '</div>' +
'</div>' +
'</div>';
show(resultEl);
}
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function submitHost(host) {
hide(errorEl);
hide(resultEl);
show(loading);
btn.disabled = true;
fetch("/check?host=" + encodeURIComponent(host) + "&format=json")
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (data) {
hide(loading);
render(data);
})
.catch(function (err) {
hide(loading);
errorEl.textContent = "Error: " + err.message;
show(errorEl);
})
.finally(function () {
btn.disabled = false;
});
}
form.addEventListener("submit", function (e) {
e.preventDefault();
submitHost(input.value.trim());
});
myIpLink.addEventListener("click", function (e) {
e.preventDefault();
submitHost("");
});
// Pre-fill from URL query param or hash fragment and auto-submit
(function init() {
let host = new URLSearchParams(window.location.search).get("host");
// Also check hash fragment (e.g. /#/?host=google.com)
if (!host) {
const hashQuery = window.location.hash.split("?")[1];
if (hashQuery) {
host = new URLSearchParams(hashQuery).get("host");
}
}
if (host) {
input.value = host;
submitHost(host);
}
// Focus input on load (only if no auto-submit triggered)
if (!host) {
input.focus();
}
})();
})();

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IP Location & Status</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>IP Location & Status</h1>
<p class="subtitle">Look up an IP address, domain, or your own IP against blocklists and GeoIP databases.</p>
<form class="search-form" autocomplete="off">
<input type="text" name="host" placeholder="Enter IP or domain…">
<button type="submit">Check</button>
</form>
<a href="#" class="my-ip-link" id="my-ip-link">Show my IP Info</a>
<div class="loading">
<span class="spinner"></span> Resolving and checking…
</div>
<div class="error-msg"></div>
<div class="result"></div>
<footer>
<a href="/healthz">/healthz</a>
</footer>
</div>
<script src="/app.js"></script>
</body>
</html>

377
cmd/check-ip/web/style.css Normal file
View File

@ -0,0 +1,377 @@
/* ── Reset & base ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #fafafa;
--surface: #ffffff;
--text: #1a1a2e;
--muted: #6b7280;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--ok: #16a34a;
--ok-bg: #dcfce7;
--ok-border: #86efac;
--bad: #dc2626;
--bad-bg: #fee2e2;
--bad-border:#fca5a5;
--warn: #d97706;
--warn-bg: #fef3c7;
--warn-border:#fcd34d;
--border: #e5e7eb;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
--shadow-lg: 0 4px 12px rgba(0,0,0,.1);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--mono: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace;
--max-w: 640px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f17;
--surface: #1a1a2e;
--text: #e5e7eb;
--muted: #9ca3af;
--primary: #60a5fa;
--primary-hover: #93bbfd;
--ok: #4ade80;
--ok-bg: #14532d;
--ok-border: #166534;
--bad: #f87171;
--bad-bg: #450a0a;
--bad-border:#991b1b;
--warn: #fbbf24;
--warn-bg: #451a03;
--warn-border:#92400e;
--border: #2d2d3f;
--shadow: 0 1px 3px rgba(0,0,0,.3);
--shadow-lg: 0 4px 12px rgba(0,0,0,.4);
}
}
html {
font-family: var(--font);
font-size: 16px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
/* ── Layout ───────────────────────────────────────────────── */
.container {
width: 100%;
max-width: var(--max-w);
}
/* ── Header ───────────────────────────────────────────────── */
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: .25rem;
}
.subtitle {
color: var(--muted);
font-size: .875rem;
margin-bottom: 2rem;
}
/* ── Form ─────────────────────────────────────────────────── */
.search-form {
display: flex;
gap: .5rem;
margin-bottom: 1.5rem;
}
input[type="text"] {
flex: 1;
padding: .625rem 1rem;
font-size: 1rem;
font-family: var(--font);
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
outline: none;
transition: border-color .15s;
}
input[type="text"]::placeholder { color: var(--muted); }
input[type="text"]:focus { border-color: var(--primary); }
button {
padding: .625rem 1.25rem;
font-size: .9375rem;
font-weight: 600;
font-family: var(--font);
color: #fff;
background: var(--primary);
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background .15s;
white-space: nowrap;
}
button:hover { background: var(--primary-hover); }
button:active { transform: scale(.98); }
button:disabled {
opacity: .6;
cursor: not-allowed;
}
/* ── Result card ──────────────────────────────────────────── */
.result {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.result.visible { display: block; }
.result-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: .75rem;
}
.result-header .host {
font-family: var(--mono);
font-size: 1.125rem;
font-weight: 600;
word-break: break-all;
}
.result-header .resolved {
font-size: .8125rem;
color: var(--muted);
font-family: var(--mono);
word-break: break-all;
}
.verdict {
display: inline-flex;
align-items: center;
gap: .375rem;
font-size: .75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
padding: .25rem .625rem;
border-radius: 999px;
white-space: nowrap;
}
.verdict.allowed {
background: var(--ok-bg);
color: var(--ok);
border: 1px solid var(--ok-border);
}
.verdict.blocked {
background: var(--bad-bg);
color: var(--bad);
border: 1px solid var(--bad-border);
}
.verdict.whitelist {
background: var(--warn-bg);
color: var(--warn);
border: 1px solid var(--warn-border);
}
.verdict.unknown {
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
}
.result-body {
padding: 1.25rem;
}
.result-section {
margin-bottom: 1rem;
}
.result-section:last-child { margin-bottom: 0; }
.result-section h3 {
font-size: .6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
margin-bottom: .375rem;
}
.result-section p {
font-size: .9375rem;
line-height: 1.5;
}
.result-section .mono {
font-family: var(--mono);
font-size: .875rem;
}
.block-list {
display: flex;
flex-wrap: wrap;
gap: .375rem;
}
.block-badge {
font-size: .75rem;
font-weight: 600;
padding: .1875rem .5rem;
border-radius: 4px;
background: var(--bad-bg);
color: var(--bad);
border: 1px solid var(--bad-border);
}
.block-badge.clean {
background: var(--ok-bg);
color: var(--ok);
border: 1px solid var(--ok-border);
}
/* ── Trait badges ─────────────────────────────────────────── */
.trait-badge {
font-size: .75rem;
font-weight: 600;
padding: .1875rem .5rem;
border-radius: 4px;
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
}
.trait-badge.anycast {
background: #eff6ff;
color: #3b82f6;
border-color: #93c5fd;
}
.trait-badge.anonymous {
background: #fef3c7;
color: #d97706;
border-color: #fcd34d;
}
.trait-badge.satellite {
background: #f3e8ff;
color: #7c3aed;
border-color: #c4b5fd;
}
.trait-badge.eu {
background: #dbeafe;
color: #2563eb;
border-color: #93c5fd;
}
@media (prefers-color-scheme: dark) {
.trait-badge.anycast {
background: #1e3a5f;
color: #60a5fa;
border-color: #1e40af;
}
.trait-badge.anonymous {
background: #451a03;
color: #fbbf24;
border-color: #92400e;
}
.trait-badge.satellite {
background: #3b0764;
color: #a78bfa;
border-color: #581c87;
}
.trait-badge.eu {
background: #1e3a5f;
color: #60a5fa;
border-color: #1e40af;
}
}
/* ── Loading spinner ──────────────────────────────────────── */
.loading {
display: none;
text-align: center;
padding: 2rem;
color: var(--muted);
font-size: .875rem;
}
.loading.visible { display: block; }
.spinner {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin .6s linear infinite;
vertical-align: middle;
margin-right: .5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Error ────────────────────────────────────────────────── */
.error-msg {
display: none;
background: var(--bad-bg);
color: var(--bad);
border: 1px solid var(--bad-border);
border-radius: var(--radius);
padding: .75rem 1rem;
margin-bottom: 1.5rem;
font-size: .875rem;
}
.error-msg.visible { display: block; }
/* ── My IP link ───────────────────────────────────────────── */
.my-ip-link {
display: inline-block;
margin-bottom: 1.5rem;
font-size: .875rem;
color: var(--primary);
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
cursor: pointer;
transition: text-decoration-color .15s;
}
.my-ip-link:hover { text-decoration-color: var(--primary); }
/* ── Footer ───────────────────────────────────────────────── */
footer {
margin-top: 2rem;
font-size: .75rem;
color: var(--muted);
text-align: center;
}
footer a { color: var(--primary); text-decoration: none; }
footer a:hover { text-decoration: underline; }

8
go.sum Normal file
View File

@ -0,0 +1,8 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=

View File

@ -105,12 +105,41 @@ func (d *Databases) Close() error {
// Info is the structured result of a GeoIP lookup.
type Info struct {
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
Country string `json:"country,omitempty"`
CountryISO string `json:"country_iso,omitempty"`
ASN uint `json:"asn,omitzero"`
ASNOrg string `json:"asn_org,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
RegionISO string `json:"region_iso,omitempty"`
Country string `json:"country,omitempty"`
CountryISO string `json:"country_iso,omitempty"`
CountryInEU bool `json:"country_in_eu,omitzero"`
RegisteredCountry string `json:"registered_country,omitempty"`
RegisteredCountryISO string `json:"registered_country_iso,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Continent string `json:"continent,omitempty"`
ContinentCode string `json:"continent_code,omitempty"`
Timezone string `json:"timezone,omitempty"`
Latitude float64 `json:"latitude,omitzero"`
Longitude float64 `json:"longitude,omitzero"`
MetroCode uint `json:"metro_code,omitzero"`
AccuracyRadius uint16 `json:"accuracy_radius,omitzero"`
ASN uint `json:"asn,omitzero"`
ASNOrg string `json:"asn_org,omitempty"`
Anycast bool `json:"anycast,omitzero"`
AnonymousProxy bool `json:"anonymous_proxy,omitzero"`
Satellite bool `json:"satellite,omitzero"`
}
// LookupRaw returns the raw maxmind record for ip. Returns nil on
// unparseable IP or database miss.
func (d *Databases) LookupRaw(ip string) *geoip2.City {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil
}
rec, err := d.City.City(addr.AsSlice())
if err != nil {
return nil
}
return rec
}
// Lookup returns city + ASN info for ip. Returns a zero Info on unparseable
@ -127,11 +156,38 @@ func (d *Databases) Lookup(ip string) Info {
info.City = rec.City.Names["en"]
info.Country = rec.Country.Names["en"]
info.CountryISO = rec.Country.IsoCode
info.CountryInEU = rec.Country.IsInEuropeanUnion
if rec.RegisteredCountry.IsoCode != "" {
info.RegisteredCountry = rec.RegisteredCountry.Names["en"]
info.RegisteredCountryISO = rec.RegisteredCountry.IsoCode
}
if len(rec.Subdivisions) > 0 {
if sub := rec.Subdivisions[0].Names["en"]; sub != "" && sub != info.City {
info.Region = sub
}
info.RegionISO = rec.Subdivisions[0].IsoCode
}
if rec.Postal.Code != "" {
info.PostalCode = rec.Postal.Code
}
if rec.Continent.Names["en"] != "" {
info.Continent = rec.Continent.Names["en"]
info.ContinentCode = rec.Continent.Code
}
if rec.Location.TimeZone != "" {
info.Timezone = rec.Location.TimeZone
}
if rec.Location.Latitude != 0 {
info.Latitude = rec.Location.Latitude
}
if rec.Location.Longitude != 0 {
info.Longitude = rec.Location.Longitude
}
info.MetroCode = rec.Location.MetroCode
info.AccuracyRadius = rec.Location.AccuracyRadius
info.Anycast = rec.Traits.IsAnycast
info.AnonymousProxy = rec.Traits.IsAnonymousProxy
info.Satellite = rec.Traits.IsSatelliteProvider
}
if rec, err := d.ASN.ASN(stdIP); err == nil {
info.ASN = rec.AutonomousSystemNumber