mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: domain monitoring (merge plan M2, phase 1)
Port of the domain-intelligence pipeline from the old Beszel fork, adapted to Dash's SQLite + no-PocketBase architecture: - internal/domain: RDAP via IANA bootstrap (cached), TCP WHOIS :43 fallback with multi-format parser, DNS (NS/MX/TXT/CNAME/A/AAAA), TLS leaf cert details, ip-api.com geolocation, provider detection (DNS/email/hosting/CA). HTML scraping, paid APIs, and exec-based whois deliberately dropped. - API: domains CRUD + synchronous refresh + domain_checks snapshot history; refresh runs inside the monitor sweep on interval_h. - UI: /domains + /domains/:id pages, add/edit dialog, domain board widget (favicon + expiry countdown), palette + nav entries, domainId picker in the widget dialog. - Test seam: Server.lookup injects the refresh; domain tests run hermetic against a stubbed result. Verified live: github.com resolves MarkMonitor expiry 2028-10, Route 53 DNS, GitHub Pages hosting, Sectigo cert with 69d left. Authored By: TDvorak <[email protected]> Generated with Devin (https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1ff1b29ae7
commit
f9fd1d9cf2
18 files changed
+2376
-14
No files matched your search
@@ -0,0 +1,404 @@
|
||||
// Package domain is the domain-intelligence half of the Beszel/Domain-Locker
|
||||
// merge: given a domain name it gathers WHOIS/RDAP registration data, DNS
|
||||
// records, TLS certificate details, host geolocation, and provider hints.
|
||||
// Pure lookup logic — no DB, no HTTP handlers.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lookup bundles the shared HTTP client used by the network probes.
|
||||
type Lookup struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewLookup() *Lookup {
|
||||
return &Lookup{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
// Result is everything one refresh learns about a domain. Fields stay flat —
|
||||
// the API layer maps them 1:1 onto the domains table columns.
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
TLD string `json:"tld"`
|
||||
|
||||
// WHOIS / RDAP
|
||||
ExpiryDate *time.Time `json:"expiryDate"`
|
||||
CreationDate *time.Time `json:"creationDate"`
|
||||
UpdatedDate *time.Time `json:"updatedDate"`
|
||||
RegistrarName string `json:"registrarName"`
|
||||
RegistrarID string `json:"registrarId"`
|
||||
RegistrarURL string `json:"registrarUrl"`
|
||||
RegistryDomainID string `json:"registryDomainId"`
|
||||
DNSSEC string `json:"dnssec"`
|
||||
Statuses []string `json:"statuses"`
|
||||
PrivacyEnabled bool `json:"privacyEnabled"`
|
||||
TransferLock bool `json:"transferLock"`
|
||||
RegistrantName string `json:"registrantName"`
|
||||
RegistrantOrg string `json:"registrantOrg"`
|
||||
RegistrantCountry string `json:"registrantCountry"`
|
||||
AbuseEmail string `json:"abuseEmail"`
|
||||
|
||||
// DNS
|
||||
NameServers []string `json:"nameServers"`
|
||||
MXRecords []string `json:"mxRecords"`
|
||||
TXTRecords []string `json:"txtRecords"`
|
||||
CNAME string `json:"cname"`
|
||||
IPv4 []string `json:"ipv4"`
|
||||
IPv6 []string `json:"ipv6"`
|
||||
|
||||
// TLS leaf certificate
|
||||
SSLIssuer string `json:"sslIssuer"`
|
||||
SSLValidFrom *time.Time `json:"sslValidFrom"`
|
||||
SSLValidTo *time.Time `json:"sslValidTo"`
|
||||
SSLSubject string `json:"sslSubject"`
|
||||
SSLFingerprint string `json:"sslFingerprint"`
|
||||
SSLKeySize int `json:"sslKeySize"`
|
||||
SSLSigAlgo string `json:"sslSigAlgo"`
|
||||
SSLAltNames []string `json:"sslAltNames"`
|
||||
|
||||
// Host geolocation (ip-api.com; empty when unreachable/private)
|
||||
HostCountry string `json:"hostCountry"`
|
||||
HostCountryCode string `json:"hostCountryCode"`
|
||||
HostRegion string `json:"hostRegion"`
|
||||
HostCity string `json:"hostCity"`
|
||||
HostISP string `json:"hostIsp"`
|
||||
HostOrg string `json:"hostOrg"`
|
||||
HostAS string `json:"hostAs"`
|
||||
HostLat float64 `json:"hostLat"`
|
||||
HostLon float64 `json:"hostLon"`
|
||||
|
||||
// Provider detection
|
||||
DNSProvider string `json:"dnsProvider"`
|
||||
EmailProvider string `json:"emailProvider"`
|
||||
HostingProvider string `json:"hostingProvider"`
|
||||
CAProvider string `json:"caProvider"`
|
||||
|
||||
Headers map[string]string `json:"headers"`
|
||||
FaviconURL string `json:"faviconUrl"`
|
||||
Error string `json:"error"` // non-fatal partial failure note
|
||||
}
|
||||
|
||||
// Clean normalises user input to a bare registrable hostname.
|
||||
func Clean(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
name = regexp.MustCompile(`^https?://`).ReplaceAllString(name, "")
|
||||
name = regexp.MustCompile(`^www\.`).ReplaceAllString(name, "")
|
||||
if i := strings.IndexAny(name, "/?#"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
if i := strings.Index(name, ":"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Valid reports whether s is a plausible DNS name we can query for.
|
||||
func Valid(s string) bool { return validDomainName(s) }
|
||||
|
||||
// Run gathers everything. Individual probe failures are non-fatal — a domain
|
||||
// with no HTTPS still returns WHOIS+DNS. The returned Error field carries the
|
||||
// first fatal WHOIS failure when even registration data is unavailable.
|
||||
func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
|
||||
name := Clean(rawName)
|
||||
r := &Result{
|
||||
Name: name,
|
||||
TLD: tldOf(name),
|
||||
Statuses: []string{},
|
||||
NameServers: []string{},
|
||||
MXRecords: []string{},
|
||||
TXTRecords: []string{},
|
||||
IPv4: []string{},
|
||||
IPv6: []string{},
|
||||
SSLAltNames: []string{},
|
||||
Headers: map[string]string{},
|
||||
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
|
||||
}
|
||||
|
||||
if w, err := l.Whois(ctx, name); err == nil {
|
||||
r.ExpiryDate = w.ExpiryDate
|
||||
r.CreationDate = w.CreationDate
|
||||
r.UpdatedDate = w.UpdatedDate
|
||||
r.RegistrarName = w.RegistrarName
|
||||
r.RegistrarID = w.RegistrarID
|
||||
r.RegistrarURL = w.RegistrarURL
|
||||
r.RegistryDomainID = w.RegistryDomainID
|
||||
r.DNSSEC = w.DNSSEC
|
||||
r.Statuses = w.Statuses
|
||||
r.PrivacyEnabled = w.PrivacyEnabled
|
||||
r.TransferLock = w.TransferLock
|
||||
r.RegistrantName = w.RegistrantName
|
||||
r.RegistrantOrg = w.RegistrantOrg
|
||||
r.RegistrantCountry = w.RegistrantCountry
|
||||
r.AbuseEmail = w.AbuseEmail
|
||||
} else {
|
||||
r.Error = "whois: " + err.Error()
|
||||
}
|
||||
|
||||
l.dns(ctx, name, r)
|
||||
l.cert(ctx, name, r)
|
||||
if len(r.IPv4) > 0 {
|
||||
l.host(r.IPv4[0], r)
|
||||
}
|
||||
l.headers(ctx, name, r)
|
||||
|
||||
r.DNSProvider = detectDNSProvider(r.NameServers)
|
||||
r.EmailProvider = detectEmailProvider(r.MXRecords)
|
||||
r.HostingProvider = detectHostingProvider(r.Headers)
|
||||
r.CAProvider = detectCA(r.SSLIssuer)
|
||||
return r
|
||||
}
|
||||
|
||||
// --- DNS --------------------------------------------------------------------
|
||||
|
||||
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
||||
res := net.DefaultResolver
|
||||
if ns, err := res.LookupNS(ctx, name); err == nil {
|
||||
for _, n := range ns {
|
||||
r.NameServers = append(r.NameServers, strings.TrimSuffix(strings.ToLower(n.Host), "."))
|
||||
}
|
||||
}
|
||||
if mx, err := res.LookupMX(ctx, name); err == nil {
|
||||
for _, m := range mx {
|
||||
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref))
|
||||
}
|
||||
}
|
||||
if txt, err := res.LookupTXT(ctx, name); err == nil {
|
||||
r.TXTRecords = txt
|
||||
}
|
||||
if c, err := res.LookupCNAME(ctx, name); err == nil && c != "" && strings.TrimSuffix(c, ".") != name {
|
||||
r.CNAME = strings.TrimSuffix(c, ".")
|
||||
}
|
||||
if ips, err := res.LookupIP(ctx, "ip4", name); err == nil {
|
||||
for _, ip := range ips {
|
||||
r.IPv4 = append(r.IPv4, ip.String())
|
||||
}
|
||||
}
|
||||
if ips, err := res.LookupIP(ctx, "ip6", name); err == nil {
|
||||
for _, ip := range ips {
|
||||
r.IPv6 = append(r.IPv6, ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TLS leaf certificate ----------------------------------------------------
|
||||
|
||||
func (l *Lookup) cert(ctx context.Context, name string, r *Result) {
|
||||
d := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := tls.DialWithDialer(d, "tcp", net.JoinHostPort(name, "443"), &tls.Config{
|
||||
ServerName: name,
|
||||
InsecureSkipVerify: true, // inspecting, not authenticating — expiry is data
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return
|
||||
}
|
||||
c := certs[0]
|
||||
if len(c.Issuer.Organization) > 0 {
|
||||
r.SSLIssuer = c.Issuer.Organization[0]
|
||||
} else {
|
||||
r.SSLIssuer = c.Issuer.CommonName
|
||||
}
|
||||
r.SSLValidFrom = &c.NotBefore
|
||||
r.SSLValidTo = &c.NotAfter
|
||||
r.SSLSubject = c.Subject.CommonName
|
||||
fp := sha256.Sum256(c.Raw)
|
||||
h := strings.ToUpper(hex.EncodeToString(fp[:]))
|
||||
var parts []string
|
||||
for i := 0; i+2 <= len(h); i += 2 {
|
||||
parts = append(parts, h[i:i+2])
|
||||
}
|
||||
r.SSLFingerprint = strings.Join(parts, ":")
|
||||
r.SSLSigAlgo = c.SignatureAlgorithm.String()
|
||||
switch k := c.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
r.SSLKeySize = k.N.BitLen()
|
||||
case *ecdsa.PublicKey:
|
||||
r.SSLKeySize = k.Curve.Params().BitSize
|
||||
}
|
||||
for _, n := range c.DNSNames {
|
||||
r.SSLAltNames = append(r.SSLAltNames, n)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Host geolocation (ip-api.com, free non-commercial tier) -----------------
|
||||
|
||||
func (l *Lookup) host(ip string, r *Result) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode,regionName,city,lat,lon,isp,org,as", ip), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var g struct {
|
||||
Status string `json:"status"`
|
||||
Country string `json:"country"`
|
||||
Code string `json:"countryCode"`
|
||||
Region string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
ISP string `json:"isp"`
|
||||
Org string `json:"org"`
|
||||
AS string `json:"as"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&g); err != nil || g.Status != "success" {
|
||||
return
|
||||
}
|
||||
r.HostCountry, r.HostCountryCode = g.Country, g.Code
|
||||
r.HostRegion, r.HostCity = g.Region, g.City
|
||||
r.HostISP, r.HostOrg, r.HostAS = g.ISP, g.Org, g.AS
|
||||
r.HostLat, r.HostLon = g.Lat, g.Lon
|
||||
}
|
||||
|
||||
// --- HTTP headers (feed provider detection) -----------------------------------
|
||||
|
||||
func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
|
||||
for _, scheme := range []string{"https", "http"} {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, scheme+"://"+name, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("User-Agent", "Dash domain lookup")
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
r.Headers[strings.ToLower(k)] = v[0]
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider detection (condensed port of beszel's detect package) -----------
|
||||
|
||||
func detectDNSProvider(ns []string) string {
|
||||
table := []struct{ sub, name string }{
|
||||
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
|
||||
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
|
||||
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
|
||||
{"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"},
|
||||
{"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"},
|
||||
{"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"},
|
||||
{"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"},
|
||||
{"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"},
|
||||
{"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"},
|
||||
{"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"},
|
||||
{"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"},
|
||||
}
|
||||
for _, n := range ns {
|
||||
n = strings.ToLower(n)
|
||||
for _, t := range table {
|
||||
if strings.Contains(n, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectEmailProvider(mx []string) string {
|
||||
table := []struct{ sub, name string }{
|
||||
{"google.com", "Google Workspace"}, {"aspmx", "Google Workspace"},
|
||||
{"outlook.com", "Microsoft 365"}, {"protection.outlook", "Microsoft 365"},
|
||||
{"mail.protection", "Microsoft 365"}, {"protonmail", "Proton Mail"},
|
||||
{"proton.me", "Proton Mail"}, {"zoho", "Zoho Mail"},
|
||||
{"fastmail", "Fastmail"}, {"mimecast", "Mimecast"},
|
||||
{"proofpoint", "Proofpoint"}, {"barracuda", "Barracuda"},
|
||||
{"mailgun", "Mailgun"}, {"sendgrid", "SendGrid"},
|
||||
{"amazonses", "Amazon SES"}, {"yandex", "Yandex"},
|
||||
{"tuta", "Tuta"}, {"mailbox.org", "mailbox.org"},
|
||||
}
|
||||
for _, m := range mx {
|
||||
m = strings.ToLower(m)
|
||||
for _, t := range table {
|
||||
if strings.Contains(m, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectHostingProvider(h map[string]string) string {
|
||||
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
|
||||
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
|
||||
return "Cloudflare"
|
||||
}
|
||||
if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") {
|
||||
return "Vercel"
|
||||
}
|
||||
if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") {
|
||||
return "Netlify"
|
||||
}
|
||||
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
|
||||
return "AWS CloudFront"
|
||||
}
|
||||
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
|
||||
return "GitHub Pages"
|
||||
}
|
||||
if strings.Contains(server, "fly.io") || h["fly-request-id"] != "" {
|
||||
return "Fly.io"
|
||||
}
|
||||
if strings.Contains(server, "render") {
|
||||
return "Render"
|
||||
}
|
||||
table := []struct{ sub, name string }{
|
||||
{"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"},
|
||||
{"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"},
|
||||
{"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"},
|
||||
{"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"},
|
||||
}
|
||||
for _, t := range table {
|
||||
if strings.Contains(server, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectCA(issuer string) string {
|
||||
i := strings.ToLower(issuer)
|
||||
table := []struct{ sub, name string }{
|
||||
{"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"},
|
||||
{"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"},
|
||||
{"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"},
|
||||
{"entrust", "Entrust"}, {"google trust", "Google Trust Services"},
|
||||
{"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"},
|
||||
{"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"},
|
||||
{"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"},
|
||||
{"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"},
|
||||
}
|
||||
for _, t := range table {
|
||||
if strings.Contains(i, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// WHOIS data acquisition. Ported from Beszel's hub/domains/whois — the
|
||||
// portable parts only: RDAP via IANA bootstrap, TCP WHOIS on :43, and the
|
||||
// registry-format parser. Dropped: native `whois` exec (breaks the
|
||||
// single-binary model), EURid/whois.com HTML scraping (fragile), and the
|
||||
// paid WhoisXML API.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WhoisData is the registry half of a Lookup result.
|
||||
type WhoisData struct {
|
||||
DomainName string
|
||||
Statuses []string
|
||||
DNSSEC string // "signed" | "unsigned" | ""
|
||||
ExpiryDate *time.Time
|
||||
CreationDate *time.Time
|
||||
UpdatedDate *time.Time
|
||||
RegistrarName string
|
||||
RegistrarID string
|
||||
RegistrarURL string
|
||||
RegistryDomainID string
|
||||
RegistrantName string
|
||||
RegistrantOrg string
|
||||
RegistrantCountry string
|
||||
AbuseEmail string
|
||||
PrivacyEnabled bool
|
||||
TransferLock bool
|
||||
}
|
||||
|
||||
// Whois looks up registration data: RDAP first (clean JSON), TCP WHOIS as
|
||||
// fallback for TLDs without RDAP service.
|
||||
func (l *Lookup) Whois(ctx context.Context, name string) (*WhoisData, error) {
|
||||
if d, err := l.rdap(ctx, name); err == nil && d.valid() {
|
||||
return d, nil
|
||||
}
|
||||
if d, err := tcpWhois(ctx, name); err == nil && d.valid() {
|
||||
return d, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no whois data for %s", name)
|
||||
}
|
||||
|
||||
func (d *WhoisData) valid() bool {
|
||||
return d != nil && (d.ExpiryDate != nil || d.CreationDate != nil ||
|
||||
(d.RegistrarName != "" && d.RegistrarName != "Unknown") || len(d.Statuses) > 0)
|
||||
}
|
||||
|
||||
// --- RDAP ------------------------------------------------------------------
|
||||
|
||||
// rdapBases caches TLD -> RDAP base URL from the IANA bootstrap registry.
|
||||
var rdapBases = struct {
|
||||
sync.Mutex
|
||||
m map[string]string
|
||||
}{m: map[string]string{}}
|
||||
|
||||
func (l *Lookup) rdap(ctx context.Context, name string) (*WhoisData, error) {
|
||||
tld := tldOf(name)
|
||||
if tld == "" {
|
||||
return nil, fmt.Errorf("no tld")
|
||||
}
|
||||
base, err := rdapBase(ctx, tld)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/domain/"+name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/rdap+json")
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("rdap status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var r struct {
|
||||
LdhName string `json:"ldhName"`
|
||||
Handle string `json:"handle"`
|
||||
Status []string `json:"status"`
|
||||
Events []struct {
|
||||
Action string `json:"eventAction"`
|
||||
Date string `json:"eventDate"`
|
||||
} `json:"events"`
|
||||
Entities []struct {
|
||||
Roles []string `json:"roles"`
|
||||
PublicIDs []struct {
|
||||
Type string `json:"type"`
|
||||
Identifier string `json:"identifier"`
|
||||
} `json:"publicIds"`
|
||||
VCard []any `json:"vcardArray"`
|
||||
} `json:"entities"`
|
||||
SecureDNS struct {
|
||||
ZoneSigned bool `json:"zoneSigned"`
|
||||
} `json:"secureDNS"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := &WhoisData{DomainName: r.LdhName, Statuses: r.Status, RegistryDomainID: r.Handle}
|
||||
if r.SecureDNS.ZoneSigned {
|
||||
d.DNSSEC = "signed"
|
||||
}
|
||||
for _, e := range r.Events {
|
||||
t, err := time.Parse(time.RFC3339, e.Date)
|
||||
if err != nil || t.IsZero() {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case "registration":
|
||||
d.CreationDate = &t
|
||||
case "expiration":
|
||||
d.ExpiryDate = &t
|
||||
case "last changed":
|
||||
d.UpdatedDate = &t
|
||||
}
|
||||
}
|
||||
for _, ent := range r.Entities {
|
||||
if !hasRole(ent.Roles, "registrar") {
|
||||
continue
|
||||
}
|
||||
// vcardArray: ["vcard", [[name, params, type, value], ...]]
|
||||
if len(ent.VCard) > 1 {
|
||||
if props, ok := ent.VCard[1].([]any); ok {
|
||||
for _, p := range props {
|
||||
if arr, ok := p.([]any); ok && len(arr) >= 4 && arr[0] == "fn" {
|
||||
if s, ok := arr[3].(string); ok {
|
||||
d.RegistrarName = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pid := range ent.PublicIDs {
|
||||
if pid.Type == "IANA Registrar ID" {
|
||||
d.RegistrarID = pid.Identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
d.derive()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func hasRole(roles []string, want string) bool {
|
||||
for _, r := range roles {
|
||||
if r == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rdapBase(ctx context.Context, tld string) (string, error) {
|
||||
rdapBases.Lock()
|
||||
defer rdapBases.Unlock()
|
||||
if u, ok := rdapBases.m[tld]; ok {
|
||||
return u, nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://data.iana.org/rdap/dns.json", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var boot struct {
|
||||
Services [][]any `json:"services"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&boot); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, svc := range boot.Services {
|
||||
if len(svc) < 2 {
|
||||
continue
|
||||
}
|
||||
tlds, _ := svc[0].([]any)
|
||||
urls, _ := svc[1].([]any)
|
||||
if len(urls) == 0 {
|
||||
continue
|
||||
}
|
||||
base, _ := urls[0].(string)
|
||||
for _, t := range tlds {
|
||||
if s, ok := t.(string); ok {
|
||||
rdapBases.m[s] = strings.TrimSuffix(base, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
if u, ok := rdapBases.m[tld]; ok {
|
||||
return u, nil
|
||||
}
|
||||
return "", fmt.Errorf("no rdap server for .%s", tld)
|
||||
}
|
||||
|
||||
// --- TCP WHOIS (:43) --------------------------------------------------------
|
||||
|
||||
var whoisServers = map[string]string{
|
||||
"com": "whois.verisign-grs.com", "net": "whois.verisign-grs.com",
|
||||
"org": "whois.pir.org", "io": "whois.nic.io", "co": "whois.nic.co",
|
||||
"dev": "whois.nic.google", "app": "whois.nic.google", "xyz": "whois.nic.xyz",
|
||||
"info": "whois.afilias.net", "biz": "whois.biz", "us": "whois.nic.us",
|
||||
"uk": "whois.nic.uk", "de": "whois.denic.de", "fr": "whois.nic.fr",
|
||||
"eu": "whois.eu", "nl": "whois.domain-registry.nl", "ca": "whois.cira.ca",
|
||||
"au": "whois.auda.org.au", "me": "whois.nic.me", "tv": "whois.nic.tv",
|
||||
"cc": "whois.nic.cc", "name": "whois.nic.name", "pro": "whois.nic.pro",
|
||||
"cz": "whois.nic.cz", "sk": "whois.sk-nic.sk", "pl": "whois.dns.pl",
|
||||
"se": "whois.iis.se", "fi": "whois.fi", "no": "whois.norid.no",
|
||||
"ch": "whois.nic.ch", "at": "whois.nic.at", "be": "whois.dns.be",
|
||||
"it": "whois.nic.it", "es": "whois.nic.es", "pt": "whois.dns.pt",
|
||||
"jp": "whois.jprs.jp", "kr": "whois.kr", "in": "whois.registry.in",
|
||||
"br": "whois.registro.br", "za": "whois.registry.net.za",
|
||||
"cloud": "whois.nic.cloud", "online": "whois.nic.online",
|
||||
"site": "whois.nic.site", "store": "whois.nic.store", "tech": "whois.nic.tech",
|
||||
"ai": "whois.nic.ai", "gg": "whois.gg", "sh": "whois.nic.sh",
|
||||
}
|
||||
|
||||
func tcpWhois(ctx context.Context, name string) (*WhoisData, error) {
|
||||
tld := tldOf(name)
|
||||
server, ok := whoisServers[tld]
|
||||
if !ok {
|
||||
server = "whois.iana.org"
|
||||
}
|
||||
d := &net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(server, "43"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
if _, err := conn.Write([]byte(name + "\r\n")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(conn, 1<<20))
|
||||
if err != nil && len(raw) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
out := parseWhois(string(raw), name)
|
||||
out.derive()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseWhois flattens "Key: Value" lines and extracts the fields we store.
|
||||
// Registry formats vary wildly; the field-name fallbacks cover the common set.
|
||||
func parseWhois(raw, name string) *WhoisData {
|
||||
d := &WhoisData{DomainName: name}
|
||||
kv := map[string]string{}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
key = strings.NewReplacer(" ", "_", "/", "_", "-", "_").Replace(key)
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
if val == "" || strings.HasPrefix(strings.ToUpper(val), "REDACTED") {
|
||||
continue
|
||||
}
|
||||
if _, dup := kv[key]; !dup {
|
||||
kv[key] = val
|
||||
}
|
||||
switch key {
|
||||
case "domain_status", "status":
|
||||
if s := parseStatus(val); len(s) > 0 {
|
||||
d.Statuses = append(d.Statuses, s...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.ExpiryDate = parseDate(kv["registry_expiry_date"], kv["registrar_registration_expiration_date"],
|
||||
kv["expiry_date"], kv["expiration_time"], kv["expire"], kv["paid_until"],
|
||||
kv["expire_date"], kv["renewal_date"], kv["valid_until"], kv["expires"])
|
||||
d.CreationDate = parseDate(kv["creation_date"], kv["created_date"], kv["created"],
|
||||
kv["registration_time"], kv["registered_on"], kv["domain_registered"], kv["registered"])
|
||||
d.UpdatedDate = parseDate(kv["updated_date"], kv["last_updated"], kv["last_modified"],
|
||||
kv["modified_date"], kv["modified"], kv["changed"])
|
||||
|
||||
for _, k := range []string{"registrar", "registrar_name", "sponsoring_registrar", "registrar_organization"} {
|
||||
if v := kv[k]; v != "" {
|
||||
d.RegistrarName = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if d.RegistrarName == "" {
|
||||
d.RegistrarName = "Unknown"
|
||||
}
|
||||
d.RegistrarID = kv["registrar_iana_id"]
|
||||
d.RegistrarURL = kv["registrar_url"]
|
||||
d.RegistryDomainID = kv["registry_domain_id"]
|
||||
|
||||
d.RegistrantName = firstOf(kv, "registrant_name", "registrant", "holder", "domain_holder")
|
||||
d.RegistrantOrg = firstOf(kv, "registrant_organization", "org", "organization", "holder_org")
|
||||
d.RegistrantCountry = firstOf(kv, "registrant_country", "country", "holder_country")
|
||||
d.AbuseEmail = kv["registrar_abuse_contact_email"]
|
||||
|
||||
if v := strings.ToLower(firstOf(kv, "dnssec", "dnssec_signed", "signed_dnssec")); v == "signed" || v == "yes" || v == "true" || v == "signeddelegation" {
|
||||
d.DNSSEC = "signed"
|
||||
} else if v != "" {
|
||||
d.DNSSEC = "unsigned"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// derive fills computed flags after whichever lookup path populated d.
|
||||
func (d *WhoisData) derive() {
|
||||
low := strings.ToLower(d.RegistrantName + " " + d.RegistrantOrg)
|
||||
for _, marker := range []string{"redacted", "privacy", "whoisguard", "not disclosed", "hidden", "data protected", "gdpr"} {
|
||||
if strings.Contains(low, marker) {
|
||||
d.PrivacyEnabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, s := range d.Statuses {
|
||||
if sl := strings.ToLower(s); strings.Contains(sl, "transferprohibited") {
|
||||
d.TransferLock = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstOf(kv map[string]string, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := kv[k]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var knownStatuses = []string{
|
||||
"clientDeleteProhibited", "clientHold", "clientRenewProhibited",
|
||||
"clientTransferProhibited", "clientUpdateProhibited",
|
||||
"serverDeleteProhibited", "serverHold", "serverRenewProhibited",
|
||||
"serverTransferProhibited", "serverUpdateProhibited",
|
||||
"inactive", "ok", "pendingCreate", "pendingDelete", "pendingRenew",
|
||||
"pendingRestore", "pendingTransfer", "pendingUpdate",
|
||||
"addPeriod", "autoRenewPeriod", "renewPeriod", "transferPeriod",
|
||||
}
|
||||
|
||||
func parseStatus(s string) []string {
|
||||
s = strings.ToLower(s)
|
||||
var out []string
|
||||
for _, k := range knownStatuses {
|
||||
if strings.Contains(s, strings.ToLower(k)) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var dateFormats = []string{
|
||||
time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05", "2006-01-02 15:04:05.0", "2006-01-02",
|
||||
"01/02/2006", "02/01/2006", "02.01.2006", "02-Jan-2006", "2-Jan-2006",
|
||||
"Jan 2 2006", "January 2 2006", "2 Jan 2006", "Jan 02 2006",
|
||||
"2006-01-02 15:04:05 MST", "Mon, 02 Jan 2006 15:04:05 MST",
|
||||
"20060102", "20060102150405",
|
||||
}
|
||||
|
||||
func parseDate(candidates ...string) *time.Time {
|
||||
for _, s := range candidates {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "before "), "after "))
|
||||
if s == "" || strings.Contains(strings.ToUpper(s), "REDACTED") {
|
||||
continue
|
||||
}
|
||||
// Trim trailing "(YYYY-MM-DD)" style annotations and zone offsets in parens.
|
||||
if i := strings.Index(s, "("); i > 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
for _, f := range dateFormats {
|
||||
if t, err := time.Parse(f, s); err == nil && !t.IsZero() {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tldOf returns the last label; registrable-domain granularity is
|
||||
// unnecessary here since we only feed it to server maps.
|
||||
func tldOf(name string) string {
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
// whoisPattern guards against sending garbage to whois servers.
|
||||
var whoisPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
|
||||
|
||||
func validDomainName(s string) bool {
|
||||
return len(s) <= 253 && whoisPattern.MatchString(s)
|
||||
}
|
||||
Reference in new issue
Block a user