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:
Tomas DvorakandDevin committed 2026-09-21 18:35:47 +02:00
1 parent 1ff1b29ae7
commit f9fd1d9cf2
18 files changed
+2376 -14

No files matched your search

+26 -4
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/domain"
"github.com/tdvorak/dash/internal/widget"
"go.uber.org/zap"
)
@@ -24,11 +25,12 @@ type Server struct {
log *zap.Logger
status *statusCache
widgets *widgetCache
// lookup performs domain refreshes; swappable so tests don't hit the
// real WHOIS/RDAP/DNS path.
lookup func(ctx context.Context, name string) *domain.Result
}
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
// A non-nil ctx starts the monitor scheduler; nil keeps it off (tests).
func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
func newServer(logger *zap.Logger, db *sql.DB, iconsDir string) *Server {
s := &Server{
db: db,
iconsDir: iconsDir,
@@ -36,13 +38,25 @@ func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir str
status: newStatusCache(60 * time.Second),
widgets: newWidgetCache(30 * time.Second),
}
s.lookup = domain.NewLookup().Run
widget.Register(&monitorWidget{db: db})
widget.Register(&domainWidget{db: db})
return s
}
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
// A non-nil ctx starts the monitor scheduler; nil keeps it off (tests).
func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
s := newServer(logger, db, iconsDir)
if ctx != nil {
go s.runScheduler(ctx.Done())
}
return s.routes()
}
func (s *Server) routes() *gin.Engine {
r := gin.New()
r.Use(accessLog(logger), gin.Recovery())
r.Use(accessLog(s.log), gin.Recovery())
v1 := r.Group("/api")
v1.GET("/healthz", s.healthz)
@@ -69,6 +83,14 @@ func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir str
v1.POST("/monitors/:id/check", s.checkNow)
v1.Any("/push/:token", s.pushIngest)
v1.GET("/domains", s.listDomainsH)
v1.POST("/domains", s.createDomain)
v1.GET("/domains/:id", s.getDomain)
v1.PATCH("/domains/:id", s.patchDomain)
v1.DELETE("/domains/:id", s.deleteDomain)
v1.POST("/domains/:id/refresh", s.refreshDomainH)
v1.GET("/domains/:id/checks", s.domainChecks)
v1.GET("/status", s.getStatus)
v1.GET("/widgets/types", s.widgetTypes)
v1.GET("/widgets/:id/data", s.widgetData)
+96 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -11,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"github.com/tdvorak/dash/internal/domain"
"go.uber.org/zap"
)
@@ -22,7 +24,22 @@ func testServer(t *testing.T) *httptest.Server {
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
return httptest.NewServer(NewRouter(nil, zap.NewNop(), d, t.TempDir()))
s := newServer(zap.NewNop(), d, t.TempDir())
// Stub the domain lookup — real RDAP/WHOIS/DNS would make tests slow and
// network-dependent.
s.lookup = func(_ context.Context, name string) *domain.Result {
exp := time.Now().Add(90 * 24 * time.Hour).UTC()
return &domain.Result{
Name: name,
RegistrarName: "Test Registrar",
ExpiryDate: &exp,
IPv4: []string{"93.184.216.34"},
NameServers: []string{"ns1.example.com"},
Headers: map[string]string{},
FaviconURL: "https://www.google.com/s2/favicons?domain=" + name,
}
}
return httptest.NewServer(s.routes())
}
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
@@ -381,6 +398,84 @@ func TestPushMonitor(t *testing.T) {
}
}
func TestDomainLifecycle(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Garbage name -> 400 before any lookup happens.
res, body := do(t, "POST", srv.URL+"/api/domains", `{"name":"not a domain!!"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %v", res.StatusCode, body)
}
// Valid name creates even when lookups fail (unresolvable TLD).
res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid","alertDaysBefore":14}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", res.StatusCode, dv)
}
if dv["tld"] != "invalid" || dv["name"] != "nonexistent.invalid" {
t.Fatalf("bad domain row: %v", dv)
}
// Stubbed lookup populated registration data + computed countdown.
if dv["registrarName"] != "Test Registrar" || dv["daysUntilExpiry"] == nil {
t.Fatalf("lookup not applied: %v", dv)
}
id := dv["id"].(string)
// Duplicate name -> 409.
res, _ = do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid"}`)
if res.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", res.StatusCode)
}
// Patch alert threshold round-trips.
res, dv = do(t, "PATCH", srv.URL+"/api/domains/"+id, `{"alertDaysBefore":7,"autoRenew":true}`)
if res.StatusCode != http.StatusOK || dv["alertDaysBefore"] != float64(7) || dv["autoRenew"] != true {
t.Fatalf("patch: %v", dv)
}
// Refresh endpoint answers (lookup result content is network-dependent).
res, dv = do(t, "POST", srv.URL+"/api/domains/"+id+"/refresh", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("refresh: %d %v", res.StatusCode, dv)
}
// Checks history endpoint responds with a list.
resp, err := http.Get(srv.URL + "/api/domains/" + id + "/checks")
if err != nil {
t.Fatalf("checks: %v", err)
}
var checks []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&checks)
resp.Body.Close()
res, _ = do(t, "DELETE", srv.URL+"/api/domains/"+id, "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", res.StatusCode)
}
res, _ = do(t, "GET", srv.URL+"/api/domains/"+id, "")
if res.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after delete, got %d", res.StatusCode)
}
}
func TestDomainWidget(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"widget.invalid"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", res.StatusCode, dv)
}
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"D","config":{"type":"domain","domainId":"`+dv["id"].(string)+`"}}`)
res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
inner, _ := data["data"].(map[string]any)
if res.StatusCode != http.StatusOK || inner["name"] != "widget.invalid" {
t.Fatalf("domain widget: %d %v", res.StatusCode, data)
}
}
func TestMidpoint(t *testing.T) {
f := func(v float64) *float64 { return &v }
if got := midpoint(nil, nil); got != 1024 {
+496
View File
@@ -0,0 +1,496 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/domain"
"github.com/tdvorak/dash/internal/widget"
"go.uber.org/zap"
)
// ---------- model ----------
const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags, notes,
expiry_date, creation_date, updated_date, registrar_name, registrar_id,
registrar_url, registry_domain_id, dnssec, statuses, privacy_enabled,
transfer_lock, registrant_name, registrant_org, registrant_country,
abuse_email, name_servers, mx_records, txt_records, cname, ipv4, ipv6,
ssl_issuer, ssl_valid_from, ssl_valid_to, ssl_subject, ssl_fingerprint,
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
dns_provider, email_provider, hosting_provider, ca_provider, headers,
favicon_url, lookup_error, interval_h, last_checked, position, created_at`
// domainRow mirrors the domains table; JSON text columns decode on scan.
type domainRow struct {
ID string `json:"id"`
Name string `json:"name"`
TLD string `json:"tld"`
Active bool `json:"active"`
AutoRenew bool `json:"autoRenew"`
AlertDaysBefore int `json:"alertDaysBefore"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
ExpiryDate *string `json:"expiryDate"`
CreationDate *string `json:"creationDate"`
UpdatedDate *string `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"`
NameServers []string `json:"nameServers"`
MXRecords []string `json:"mxRecords"`
TXTRecords []string `json:"txtRecords"`
CNAME string `json:"cname"`
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
SSLIssuer string `json:"sslIssuer"`
SSLValidFrom *string `json:"sslValidFrom"`
SSLValidTo *string `json:"sslValidTo"`
SSLSubject string `json:"sslSubject"`
SSLFingerprint string `json:"sslFingerprint"`
SSLKeySize int `json:"sslKeySize"`
SSLSigAlgo string `json:"sslSigAlgo"`
SSLAltNames []string `json:"sslAltNames"`
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"`
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"`
LookupError string `json:"lookupError"`
IntervalH int `json:"intervalH"`
LastChecked *string `json:"lastChecked"`
Position float64 `json:"position"`
CreatedAt string `json:"createdAt"`
}
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
var d domainRow
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers string
var active, autoRenew, privacy, lock int
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
&d.RegistrarName, &d.RegistrarID, &d.RegistrarURL, &d.RegistryDomainID,
&d.DNSSEC, &statuses, &privacy, &lock, &d.RegistrantName, &d.RegistrantOrg,
&d.RegistrantCountry, &d.AbuseEmail, &ns, &mx, &txt, &d.CNAME, &v4, &v6,
&d.SSLIssuer, &d.SSLValidFrom, &d.SSLValidTo, &d.SSLSubject, &d.SSLFingerprint,
&d.SSLKeySize, &d.SSLSigAlgo, &altNames, &d.HostCountry, &d.HostCountryCode,
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
&d.Position, &d.CreatedAt)
if err != nil {
return nil, err
}
d.Active = active != 0
d.AutoRenew = autoRenew != 0
d.PrivacyEnabled = privacy != 0
d.TransferLock = lock != 0
d.Tags = decodeList(tags)
d.Statuses = decodeList(statuses)
d.NameServers = decodeList(ns)
d.MXRecords = decodeList(mx)
d.TXTRecords = decodeList(txt)
d.IPv4 = decodeList(v4)
d.IPv6 = decodeList(v6)
d.SSLAltNames = decodeList(altNames)
d.Headers = map[string]string{}
_ = json.Unmarshal([]byte(headers), &d.Headers)
return &d, nil
}
func decodeList(s string) []string {
var out []string
_ = json.Unmarshal([]byte(s), &out)
if out == nil {
out = []string{}
}
return out
}
func (s *Server) loadDomain(id string) (*domainRow, error) {
return scanDomain(s.db.QueryRow(`SELECT `+domainCols+` FROM domains WHERE id = ?`, id))
}
// domainView adds computed countdowns the cards/detail page render directly.
type domainView struct {
*domainRow
DaysUntilExpiry *int `json:"daysUntilExpiry"`
SSLDaysUntilExpiry *int `json:"sslDaysUntilExpiry"`
Expiring bool `json:"expiring"`
SSLExpiring bool `json:"sslExpiring"`
}
func daysUntil(iso *string) *int {
if iso == nil {
return nil
}
t, err := time.Parse(time.RFC3339, *iso)
if err != nil {
if t, err = time.Parse("2006-01-02", *iso); err != nil {
return nil
}
}
d := int(time.Until(t).Hours() / 24)
return &d
}
func viewOf(d *domainRow) domainView {
v := domainView{domainRow: d}
v.DaysUntilExpiry = daysUntil(d.ExpiryDate)
v.SSLDaysUntilExpiry = daysUntil(d.SSLValidTo)
if v.DaysUntilExpiry != nil {
v.Expiring = *v.DaysUntilExpiry <= d.AlertDaysBefore
}
if v.SSLDaysUntilExpiry != nil {
v.SSLExpiring = *v.SSLDaysUntilExpiry <= d.AlertDaysBefore
}
return v
}
// ---------- handlers ----------
func (s *Server) listDomainsH(c *gin.Context) {
rows, err := s.db.Query(`SELECT ` + domainCols + ` FROM domains ORDER BY position, name`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []domainView{}
for rows.Next() {
if d, err := scanDomain(rows); err == nil {
out = append(out, viewOf(d))
}
}
c.JSON(http.StatusOK, out)
}
type domainInput struct {
Name *string `json:"name"`
Active *bool `json:"active"`
AutoRenew *bool `json:"autoRenew"`
AlertDaysBefore *int `json:"alertDaysBefore"`
IntervalH *int `json:"intervalH"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
}
func (s *Server) createDomain(c *gin.Context) {
var in domainInput
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil {
fail(c, http.StatusBadRequest, "name required")
return
}
name := domain.Clean(*in.Name)
if !domain.Valid(name) {
fail(c, http.StatusBadRequest, "invalid domain name")
return
}
id := newID("d")
tags, _ := json.Marshal(in.Tags)
if in.Tags == nil {
tags = []byte("[]")
}
_, err := s.db.Exec(`INSERT INTO domains
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
id, name, tldFor(name), boolOr2(in.Active, true), boolOr(in.AutoRenew),
intOr2(in.AlertDaysBefore, 30), string(tags), strOr(in.Notes),
intOr2(in.IntervalH, 24), float64(time.Now().UnixNano())/1e9)
if err != nil {
fail(c, http.StatusConflict, "domain already tracked or insert failed")
return
}
// First lookup runs inline so the response carries real data.
s.refreshDomain(id)
d, err := s.loadDomain(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, viewOf(d))
}
func tldFor(name string) string {
for i := len(name) - 1; i >= 0; i-- {
if name[i] == '.' {
return name[i+1:]
}
}
return ""
}
func (s *Server) getDomain(c *gin.Context) {
d, err := s.loadDomain(c.Param("id"))
if notFound(err) {
fail(c, http.StatusNotFound, "domain not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, viewOf(d))
}
func (s *Server) patchDomain(c *gin.Context) {
id := c.Param("id")
if _, err := s.loadDomain(id); notFound(err) {
fail(c, http.StatusNotFound, "domain not found")
return
}
var in domainInput
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if in.Name != nil {
name := domain.Clean(*in.Name)
if !domain.Valid(name) {
fail(c, http.StatusBadRequest, "invalid domain name")
return
}
_, _ = s.db.Exec(`UPDATE domains SET name = ?, tld = ? WHERE id = ?`, name, tldFor(name), id)
}
if in.Notes != nil {
_, _ = s.db.Exec(`UPDATE domains SET notes = ? WHERE id = ?`, *in.Notes, id)
}
if in.AlertDaysBefore != nil {
_, _ = s.db.Exec(`UPDATE domains SET alert_days_before = ? WHERE id = ?`, *in.AlertDaysBefore, id)
}
if in.IntervalH != nil {
_, _ = s.db.Exec(`UPDATE domains SET interval_h = ? WHERE id = ?`, *in.IntervalH, id)
}
if in.Active != nil {
_, _ = s.db.Exec(`UPDATE domains SET active = ? WHERE id = ?`, *in.Active, id)
}
if in.AutoRenew != nil {
_, _ = s.db.Exec(`UPDATE domains SET auto_renew = ? WHERE id = ?`, *in.AutoRenew, id)
}
if in.Tags != nil {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id)
}
d, err := s.loadDomain(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, viewOf(d))
}
func (s *Server) deleteDomain(c *gin.Context) {
res, err := s.db.Exec(`DELETE FROM domains WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "domain not found")
return
}
c.Status(http.StatusNoContent)
}
// refreshDomain re-runs the full lookup and writes a snapshot row.
func (s *Server) refreshDomain(id string) {
row, err := s.loadDomain(id)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
r := s.lookup(ctx, row.Name)
s.applyResult(id, r)
}
func (s *Server) refreshDomainH(c *gin.Context) {
id := c.Param("id")
if _, err := s.loadDomain(id); notFound(err) {
fail(c, http.StatusNotFound, "domain not found")
return
}
s.refreshDomain(id)
d, err := s.loadDomain(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, viewOf(d))
}
// applyResult persists a lookup Result and appends a domain_checks snapshot.
func (s *Server) applyResult(id string, r *domain.Result) {
enc := func(v []string) string {
b, _ := json.Marshal(v)
return string(b)
}
var expiry, created, updated, sslFrom, sslTo *string
fmtT := func(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format(time.RFC3339)
return &s
}
expiry, created, updated = fmtT(r.ExpiryDate), fmtT(r.CreationDate), fmtT(r.UpdatedDate)
sslFrom, sslTo = fmtT(r.SSLValidFrom), fmtT(r.SSLValidTo)
hdr, _ := json.Marshal(r.Headers)
_, err := s.db.Exec(`UPDATE domains SET
expiry_date=?, creation_date=?, updated_date=?, registrar_name=?,
registrar_id=?, registrar_url=?, registry_domain_id=?, dnssec=?,
statuses=?, privacy_enabled=?, transfer_lock=?, registrant_name=?,
registrant_org=?, registrant_country=?, abuse_email=?, name_servers=?,
mx_records=?, txt_records=?, cname=?, ipv4=?, ipv6=?, ssl_issuer=?,
ssl_valid_from=?, ssl_valid_to=?, ssl_subject=?, ssl_fingerprint=?,
ssl_key_size=?, ssl_sig_algo=?, ssl_alt_names=?, host_country=?,
host_country_code=?, host_region=?, host_city=?, host_isp=?, host_org=?,
host_as=?, host_lat=?, host_lon=?, dns_provider=?, email_provider=?,
hosting_provider=?, ca_provider=?, headers=?, favicon_url=?,
lookup_error=?, last_checked=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?`,
expiry, created, updated, r.RegistrarName, r.RegistrarID, r.RegistrarURL,
r.RegistryDomainID, r.DNSSEC, enc(r.Statuses), r.PrivacyEnabled,
r.TransferLock, r.RegistrantName, r.RegistrantOrg, r.RegistrantCountry,
r.AbuseEmail, enc(r.NameServers), enc(r.MXRecords), enc(r.TXTRecords),
r.CNAME, enc(r.IPv4), enc(r.IPv6), r.SSLIssuer, sslFrom, sslTo,
r.SSLSubject, r.SSLFingerprint, r.SSLKeySize, r.SSLSigAlgo,
enc(r.SSLAltNames), r.HostCountry, r.HostCountryCode, r.HostRegion,
r.HostCity, r.HostISP, r.HostOrg, r.HostAS, r.HostLat, r.HostLon,
r.DNSProvider, r.EmailProvider, r.HostingProvider, r.CAProvider,
string(hdr), r.FaviconURL, truncate(r.Error, 300), id)
if err != nil {
s.log.Error("domain apply", zap.Error(err))
return
}
_, _ = s.db.Exec(`INSERT INTO domain_checks (domain_id, expiry_date, ssl_valid_to, ipv4, name_servers)
VALUES (?,?,?,?,?)`, id, expiry, sslTo, enc(r.IPv4), enc(r.NameServers))
}
// domainChecks returns the snapshot history — powers change timelines later.
func (s *Server) domainChecks(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.db.Query(`SELECT id, expiry_date, ssl_valid_to, ipv4, name_servers, checked_at
FROM domain_checks WHERE domain_id = ? ORDER BY checked_at DESC LIMIT ?`,
c.Param("id"), limit)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
type chk struct {
ID int `json:"id"`
ExpiryDate *string `json:"expiryDate"`
SSLValidTo *string `json:"sslValidTo"`
IPv4 []string `json:"ipv4"`
NameServers []string `json:"nameServers"`
CheckedAt string `json:"checkedAt"`
}
out := []chk{}
for rows.Next() {
var k chk
var v4, ns string
if err := rows.Scan(&k.ID, &k.ExpiryDate, &k.SSLValidTo, &v4, &ns, &k.CheckedAt); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
k.IPv4, k.NameServers = decodeList(v4), decodeList(ns)
out = append(out, k)
}
c.JSON(http.StatusOK, out)
}
// ---------- scheduler ----------
// sweepDomains refreshes domains whose interval_h has elapsed. Runs inside
// the monitor tick — the SQL gate keeps it near-free when nothing is due.
func (s *Server) sweepDomains() {
rows, err := s.db.Query(`SELECT ` + domainCols + ` FROM domains
WHERE active = 1
AND (last_checked IS NULL OR datetime(last_checked, '+' || interval_h || ' hours') <= datetime('now'))`)
if err != nil {
s.log.Error("domain sweep", zap.Error(err))
return
}
var due []domainRow
for rows.Next() {
if d, err := scanDomain(rows); err == nil {
due = append(due, *d)
}
}
rows.Close()
for i := range due {
checkSem <- struct{}{}
go func(id string) {
defer func() { <-checkSem }()
s.refreshDomain(id)
}(due[i].ID)
}
}
// ---------- widget fetcher ----------
// domainWidget serves board tiles bound to a domain via config.domainId.
type domainWidget struct{ db *sql.DB }
func (w *domainWidget) Meta() widget.Type {
return widget.Type{
Type: "domain", Name: "Domain",
Description: "Expiry countdown for a tracked domain",
Fields: []widget.Field{
{Key: "domainId", Label: "Domain", Required: true},
},
}
}
func (w *domainWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) {
var c struct {
DomainID string `json:"domainId"`
}
if err := json.Unmarshal(cfg, &c); err != nil || c.DomainID == "" {
return nil, errors.New("config.domainId required")
}
d, err := scanDomain(w.db.QueryRow(`SELECT `+domainCols+` FROM domains WHERE id = ?`, c.DomainID))
if err != nil {
return nil, errors.New("domain not found")
}
v := viewOf(d)
return map[string]any{
"name": d.Name,
"faviconUrl": d.FaviconURL,
"daysUntilExpiry": v.DaysUntilExpiry,
"sslDaysUntilExpiry": v.SSLDaysUntilExpiry,
"expiring": v.Expiring,
"registrar": d.RegistrarName,
}, nil
}
+2
View File
@@ -450,6 +450,8 @@ func (s *Server) sweep() {
AND status != 'down'`); err != nil {
s.log.Error("push stale sweep", zap.Error(err))
}
s.sweepDomains()
}
// runCheck executes one monitor check (with retries) and persists the result.
@@ -0,0 +1,84 @@
-- +goose Up
-- Domain monitoring (merge phase M2). One wide table mirrors the flat
-- lookup Result; domain_checks snapshots each refresh for change history.
CREATE TABLE domains (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
tld TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
auto_renew INTEGER NOT NULL DEFAULT 0,
alert_days_before INTEGER NOT NULL DEFAULT 30,
tags JSON NOT NULL DEFAULT '[]',
notes TEXT NOT NULL DEFAULT '',
-- whois / rdap
expiry_date TEXT,
creation_date TEXT,
updated_date TEXT,
registrar_name TEXT NOT NULL DEFAULT '',
registrar_id TEXT NOT NULL DEFAULT '',
registrar_url TEXT NOT NULL DEFAULT '',
registry_domain_id TEXT NOT NULL DEFAULT '',
dnssec TEXT NOT NULL DEFAULT '',
statuses JSON NOT NULL DEFAULT '[]',
privacy_enabled INTEGER NOT NULL DEFAULT 0,
transfer_lock INTEGER NOT NULL DEFAULT 0,
registrant_name TEXT NOT NULL DEFAULT '',
registrant_org TEXT NOT NULL DEFAULT '',
registrant_country TEXT NOT NULL DEFAULT '',
abuse_email TEXT NOT NULL DEFAULT '',
-- dns
name_servers JSON NOT NULL DEFAULT '[]',
mx_records JSON NOT NULL DEFAULT '[]',
txt_records JSON NOT NULL DEFAULT '[]',
cname TEXT NOT NULL DEFAULT '',
ipv4 JSON NOT NULL DEFAULT '[]',
ipv6 JSON NOT NULL DEFAULT '[]',
-- tls leaf cert
ssl_issuer TEXT NOT NULL DEFAULT '',
ssl_valid_from TEXT,
ssl_valid_to TEXT,
ssl_subject TEXT NOT NULL DEFAULT '',
ssl_fingerprint TEXT NOT NULL DEFAULT '',
ssl_key_size INTEGER NOT NULL DEFAULT 0,
ssl_sig_algo TEXT NOT NULL DEFAULT '',
ssl_alt_names JSON NOT NULL DEFAULT '[]',
-- host geo
host_country TEXT NOT NULL DEFAULT '',
host_country_code TEXT NOT NULL DEFAULT '',
host_region TEXT NOT NULL DEFAULT '',
host_city TEXT NOT NULL DEFAULT '',
host_isp TEXT NOT NULL DEFAULT '',
host_org TEXT NOT NULL DEFAULT '',
host_as TEXT NOT NULL DEFAULT '',
host_lat REAL NOT NULL DEFAULT 0,
host_lon REAL NOT NULL DEFAULT 0,
-- provider detection
dns_provider TEXT NOT NULL DEFAULT '',
email_provider TEXT NOT NULL DEFAULT '',
hosting_provider TEXT NOT NULL DEFAULT '',
ca_provider TEXT NOT NULL DEFAULT '',
headers JSON NOT NULL DEFAULT '{}',
favicon_url TEXT NOT NULL DEFAULT '',
lookup_error TEXT NOT NULL DEFAULT '',
-- scheduling
interval_h INTEGER NOT NULL DEFAULT 24,
last_checked TEXT,
position REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX domains_due ON domains(active, last_checked);
CREATE TABLE domain_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
expiry_date TEXT,
ssl_valid_to TEXT,
ipv4 JSON NOT NULL DEFAULT '[]',
name_servers JSON NOT NULL DEFAULT '[]',
checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX domain_checks_domain_time ON domain_checks(domain_id, checked_at);
-- +goose Down
DROP TABLE domain_checks;
DROP TABLE domains;
+404
View File
@@ -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 ""
}
+411
View File
@@ -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)
}