mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
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:
parent
ee3c277a00
commit
1bcc160a61
27
cmd/check-ip/HANDOFF.md
Normal file
27
cmd/check-ip/HANDOFF.md
Normal 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.
|
||||||
@ -10,6 +10,8 @@ require (
|
|||||||
github.com/therootcompany/golib/sync/dataset v0.5.0
|
github.com/therootcompany/golib/sync/dataset v0.5.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/therootcompany/golib/net/geoip => ../../net/geoip
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/oschwald/geoip2-golang v1.13.0 // indirect
|
github.com/oschwald/geoip2-golang v1.13.0 // indirect
|
||||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
|
|||||||
@ -5,6 +5,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@ -29,6 +30,9 @@ import (
|
|||||||
"github.com/therootcompany/golib/sync/dataset"
|
"github.com/therootcompany/golib/sync/dataset"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed web/**
|
||||||
|
var webFS embed.FS
|
||||||
|
|
||||||
// Replaced by goreleaser / ldflags at build time.
|
// Replaced by goreleaser / ldflags at build time.
|
||||||
var (
|
var (
|
||||||
name = "check-ip"
|
name = "check-ip"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -13,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/oschwald/geoip2-golang"
|
||||||
"github.com/therootcompany/golib/net/geoip"
|
"github.com/therootcompany/golib/net/geoip"
|
||||||
"github.com/therootcompany/golib/net/ipcohort"
|
"github.com/therootcompany/golib/net/ipcohort"
|
||||||
"github.com/therootcompany/golib/sync/dataset"
|
"github.com/therootcompany/golib/sync/dataset"
|
||||||
@ -21,6 +23,7 @@ import (
|
|||||||
// Result is the JSON verdict for a single IP.
|
// Result is the JSON verdict for a single IP.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
|
ResolvedFrom string `json:"resolved_from,omitzero"`
|
||||||
Blocked bool `json:"blocked"`
|
Blocked bool `json:"blocked"`
|
||||||
BlockedInbound bool `json:"blocked_inbound"`
|
BlockedInbound bool `json:"blocked_inbound"`
|
||||||
BlockedOutbound bool `json:"blocked_outbound"`
|
BlockedOutbound bool `json:"blocked_outbound"`
|
||||||
@ -49,6 +52,16 @@ func (c *IPCheck) lookup(ip string) Result {
|
|||||||
return res
|
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.
|
// writePretty renders res as space-aligned plain text.
|
||||||
func (c *IPCheck) writePretty(w io.Writer, res Result) {
|
func (c *IPCheck) writePretty(w io.Writer, res Result) {
|
||||||
switch {
|
switch {
|
||||||
@ -82,18 +95,36 @@ func (c *IPCheck) writePretty(w io.Writer, res Result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *IPCheck) handle(w http.ResponseWriter, r *http.Request) {
|
func (c *IPCheck) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := strings.TrimSpace(r.URL.Query().Get("ip"))
|
query := strings.TrimSpace(r.URL.Query().Get("host"))
|
||||||
if ip == "" {
|
if query == "" {
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
first, _, _ := strings.Cut(xff, ",")
|
first, _, _ := strings.Cut(xff, ",")
|
||||||
ip = strings.TrimSpace(first)
|
query = strings.TrimSpace(first)
|
||||||
} else if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
} else if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
ip = host
|
query = host
|
||||||
} else {
|
} 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 := c.lookup(ip)
|
||||||
|
res.IP = ip
|
||||||
|
if query != ip {
|
||||||
|
res.ResolvedFrom = query
|
||||||
|
}
|
||||||
|
|
||||||
if r.URL.Query().Get("format") == "json" ||
|
if r.URL.Query().Get("format") == "json" ||
|
||||||
strings.Contains(r.Header.Get("Accept"), "application/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)
|
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 {
|
type dsStatus struct {
|
||||||
Loaded bool `json:"loaded"`
|
Loaded bool `json:"loaded"`
|
||||||
Size int `json:"size,omitzero"`
|
Size int `json:"size,omitzero"`
|
||||||
@ -158,7 +215,11 @@ func (c *IPCheck) serve(ctx context.Context) error {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /check", c.handle)
|
mux.HandleFunc("GET /check", c.handle)
|
||||||
mux.HandleFunc("GET /healthz", c.healthz)
|
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{
|
srv := &http.Server{
|
||||||
Addr: c.Bind,
|
Addr: c.Bind,
|
||||||
|
|||||||
203
cmd/check-ip/web/app.js
Normal file
203
cmd/check-ip/web/app.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
})();
|
||||||
36
cmd/check-ip/web/index.html
Normal file
36
cmd/check-ip/web/index.html
Normal 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
377
cmd/check-ip/web/style.css
Normal 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
8
go.sum
Normal 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=
|
||||||
@ -105,12 +105,41 @@ func (d *Databases) Close() error {
|
|||||||
|
|
||||||
// Info is the structured result of a GeoIP lookup.
|
// Info is the structured result of a GeoIP lookup.
|
||||||
type Info struct {
|
type Info struct {
|
||||||
City string `json:"city,omitempty"`
|
City string `json:"city,omitempty"`
|
||||||
Region string `json:"region,omitempty"`
|
Region string `json:"region,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
RegionISO string `json:"region_iso,omitempty"`
|
||||||
CountryISO string `json:"country_iso,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
ASN uint `json:"asn,omitzero"`
|
CountryISO string `json:"country_iso,omitempty"`
|
||||||
ASNOrg string `json:"asn_org,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
|
// 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.City = rec.City.Names["en"]
|
||||||
info.Country = rec.Country.Names["en"]
|
info.Country = rec.Country.Names["en"]
|
||||||
info.CountryISO = rec.Country.IsoCode
|
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 len(rec.Subdivisions) > 0 {
|
||||||
if sub := rec.Subdivisions[0].Names["en"]; sub != "" && sub != info.City {
|
if sub := rec.Subdivisions[0].Names["en"]; sub != "" && sub != info.City {
|
||||||
info.Region = sub
|
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 {
|
if rec, err := d.ASN.ASN(stdIP); err == nil {
|
||||||
info.ASN = rec.AutonomousSystemNumber
|
info.ASN = rec.AutonomousSystemNumber
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user