From 1bcc160a6196a9b639c6c23e5bcff2ddbcaee007 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 27 May 2026 01:29:34 -0600 Subject: [PATCH] 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 --- cmd/check-ip/HANDOFF.md | 27 +++ cmd/check-ip/go.mod | 2 + cmd/check-ip/main.go | 4 + cmd/check-ip/server.go | 73 ++++++- cmd/check-ip/web/app.js | 203 +++++++++++++++++++ cmd/check-ip/web/index.html | 36 ++++ cmd/check-ip/web/style.css | 377 ++++++++++++++++++++++++++++++++++++ go.sum | 8 + net/geoip/databases.go | 68 ++++++- 9 files changed, 786 insertions(+), 12 deletions(-) create mode 100644 cmd/check-ip/HANDOFF.md create mode 100644 cmd/check-ip/web/app.js create mode 100644 cmd/check-ip/web/index.html create mode 100644 cmd/check-ip/web/style.css create mode 100644 go.sum diff --git a/cmd/check-ip/HANDOFF.md b/cmd/check-ip/HANDOFF.md new file mode 100644 index 0000000..5f121dc --- /dev/null +++ b/cmd/check-ip/HANDOFF.md @@ -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=` — 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. diff --git a/cmd/check-ip/go.mod b/cmd/check-ip/go.mod index 84bc850..ad5a9b7 100644 --- a/cmd/check-ip/go.mod +++ b/cmd/check-ip/go.mod @@ -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 diff --git a/cmd/check-ip/main.go b/cmd/check-ip/main.go index b0afa46..17ef89d 100644 --- a/cmd/check-ip/main.go +++ b/cmd/check-ip/main.go @@ -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" diff --git a/cmd/check-ip/server.go b/cmd/check-ip/server.go index bd19209..330babe 100644 --- a/cmd/check-ip/server.go +++ b/cmd/check-ip/server.go @@ -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, diff --git a/cmd/check-ip/web/app.js b/cmd/check-ip/web/app.js new file mode 100644 index 0000000..7c1438e --- /dev/null +++ b/cmd/check-ip/web/app.js @@ -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 += 'anycast'; + } + if (geo.anonymous_proxy) { + traitBadges += 'anonymous proxy'; + } + if (geo.satellite) { + traitBadges += 'satellite'; + } + if (geo.country_in_eu) { + traitBadges += 'EU'; + } + + // Block details + let blockHtml = ""; + if (blockedIn || blockedOut) { + const parts = []; + if (blockedIn) parts.push("inbound"); + if (blockedOut) parts.push("outbound"); + blockHtml = 'BLOCKED: ' + parts.join(" + ") + ''; + } else { + blockHtml = 'clean'; + } + + resultEl.innerHTML = + '
' + + '' + verdictText + '' + + '' + escapeHtml(host) + '' + + (resolvedFrom ? '→ ' + escapeHtml(resolvedFrom) + '' : '') + + '
' + + '
' + + (geoStr !== "—" ? + '
' + + '

Location

' + + '

' + escapeHtml(geoStr) + '

' + + '
' : '') + + (latLonStr ? + '
' + + '

Coordinates

' + + '

' + escapeHtml(latLonStr) + '

' + + '
' : '') + + (postalStr || tzStr || metroStr || accuracyStr ? + '
' + + '

Details

' + + '

' + + (postalStr ? escapeHtml(postalStr) + " " : "") + + (tzStr ? escapeHtml(tzStr) + " " : "") + + (metroStr ? "Metro " + escapeHtml(metroStr) + " " : "") + + (accuracyStr ? "±" + escapeHtml(accuracyStr) + "km" : "") + + '

' + + '
' : '') + + (traitBadges ? + '
' + + '

Traits

' + + '
' + traitBadges + '
' + + '
' : '') + + (registeredStr ? + '
' + + '

Registered Country

' + + '

' + escapeHtml(registeredStr) + '

' + + '
' : '') + + '
' + + '

ASN

' + + '

' + escapeHtml(asnStr) + '

' + + '
' + + '
' + + '

Blocklist

' + + '
' + blockHtml + '
' + + '
' + + '
'; + + 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(); + } + })(); +})(); diff --git a/cmd/check-ip/web/index.html b/cmd/check-ip/web/index.html new file mode 100644 index 0000000..167ed8d --- /dev/null +++ b/cmd/check-ip/web/index.html @@ -0,0 +1,36 @@ + + + + + + IP Location & Status + + + +
+

IP Location & Status

+

Look up an IP address, domain, or your own IP against blocklists and GeoIP databases.

+ +
+ + +
+ + Show my IP Info + +
+ Resolving and checking… +
+ +
+ +
+ + +
+ + + + diff --git a/cmd/check-ip/web/style.css b/cmd/check-ip/web/style.css new file mode 100644 index 0000000..3e8b3a8 --- /dev/null +++ b/cmd/check-ip/web/style.css @@ -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; } diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6995bb6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/net/geoip/databases.go b/net/geoip/databases.go index caa22f0..ca0e30a 100644 --- a/net/geoip/databases.go +++ b/net/geoip/databases.go @@ -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