diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go index 0e90731..5463ffe 100644 --- a/apps/backend/internal/api/api.go +++ b/apps/backend/internal/api/api.go @@ -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) diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go index b147fda..fb664de 100644 --- a/apps/backend/internal/api/api_test.go +++ b/apps/backend/internal/api/api_test.go @@ -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 { diff --git a/apps/backend/internal/api/domains.go b/apps/backend/internal/api/domains.go new file mode 100644 index 0000000..bd9f172 --- /dev/null +++ b/apps/backend/internal/api/domains.go @@ -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 +} diff --git a/apps/backend/internal/api/monitors.go b/apps/backend/internal/api/monitors.go index 38915c7..a4d0843 100644 --- a/apps/backend/internal/api/monitors.go +++ b/apps/backend/internal/api/monitors.go @@ -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. diff --git a/apps/backend/internal/db/migrations/0004_domains.sql b/apps/backend/internal/db/migrations/0004_domains.sql new file mode 100644 index 0000000..6358540 --- /dev/null +++ b/apps/backend/internal/db/migrations/0004_domains.sql @@ -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; diff --git a/apps/backend/internal/domain/domain.go b/apps/backend/internal/domain/domain.go new file mode 100644 index 0000000..419f046 --- /dev/null +++ b/apps/backend/internal/domain/domain.go @@ -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 "" +} diff --git a/apps/backend/internal/domain/whois.go b/apps/backend/internal/domain/whois.go new file mode 100644 index 0000000..f501423 --- /dev/null +++ b/apps/backend/internal/domain/whois.go @@ -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) +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index f36c3f5..ee8dd93 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, Route, useLocation } from "wouter"; -import { Activity, LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; +import { Activity, Globe, LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; import { BoardProvider, useBoard } from "@/board/store"; import { Board } from "@/board/Board"; import { ServiceDialog } from "@/components/ServiceDialog"; @@ -9,6 +9,8 @@ import { SettingsDialog } from "@/components/SettingsDialog"; import { CommandPalette } from "@/components/CommandPalette"; import { MonitorsPage } from "@/pages/MonitorsPage"; import { MonitorDetailPage } from "@/pages/MonitorDetailPage"; +import { DomainsPage } from "@/pages/DomainsPage"; +import { DomainDetailPage } from "@/pages/DomainDetailPage"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { Item } from "@/types"; @@ -24,6 +26,7 @@ export default function App() { const NAV = [ { href: "/", label: "Board", icon: LayoutGrid }, { href: "/monitors", label: "Monitors", icon: Activity }, + { href: "/domains", label: "Domains", icon: Globe }, ]; function Shell() { @@ -132,6 +135,12 @@ function Shell() { {(p) => } + + + + + {(p) => } + diff --git a/apps/frontend/src/components/CommandPalette.tsx b/apps/frontend/src/components/CommandPalette.tsx index b12b365..1ff6612 100644 --- a/apps/frontend/src/components/CommandPalette.tsx +++ b/apps/frontend/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "wouter"; -import { Activity, ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react"; +import { Activity, ArrowRight, Download, Globe, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react"; import { useBoard } from "@/board/store"; import { hostOf, IconImg } from "@/board/primitives"; import { rendererLabels, rendererList } from "@/renderers"; @@ -44,6 +44,7 @@ export function CommandPalette({ const cmds: Command[] = [ { id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") }, { id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") }, + { id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") }, { id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService }, { id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget }, { id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings }, diff --git a/apps/frontend/src/components/DomainDialog.tsx b/apps/frontend/src/components/DomainDialog.tsx new file mode 100644 index 0000000..a6632e3 --- /dev/null +++ b/apps/frontend/src/components/DomainDialog.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { api } from "@/api"; +import type { Domain, DomainInput } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +// Add/edit a tracked domain. The first WHOIS/DNS/TLS lookup runs on create — +// expect a few seconds of spinner. +export function DomainDialog({ + open, + onOpenChange, + domain, + onSaved, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + domain?: Domain | undefined; + onSaved: () => void; +}) { + const editing = Boolean(domain); + const [name, setName] = useState(""); + const [intervalH, setIntervalH] = useState("24"); + const [alertDays, setAlertDays] = useState("30"); + const [autoRenew, setAutoRenew] = useState(false); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + useEffect(() => { + if (!open) return; + setErr(""); + setName(domain?.name ?? ""); + setIntervalH(String(domain?.intervalH ?? 24)); + setAlertDays(String(domain?.alertDaysBefore ?? 30)); + setAutoRenew(domain?.autoRenew ?? false); + setNotes(domain?.notes ?? ""); + }, [open, domain]); + + async function submit() { + if (!name.trim()) return setErr("Domain is required"), undefined; + setBusy(true); + const body: DomainInput = { + name: name.trim(), + intervalH: Math.max(1, parseInt(intervalH) || 24), + alertDaysBefore: Math.max(0, parseInt(alertDays) || 30), + autoRenew, + notes, + }; + try { + if (editing && domain) { + await api.PATCH("/api/domains/{id}", { params: { path: { id: domain.id! } }, body }); + } else { + const { error } = await api.POST("/api/domains", { body }); + if (error) throw new Error("create failed"); + } + onSaved(); + onOpenChange(false); + } catch (e) { + setErr(e instanceof Error ? e.message : "save failed"); + } finally { + setBusy(false); + } + } + + return ( + + + + {editing ? "Edit domain" : "Track a domain"} + + WHOIS, DNS, TLS certificate, and host info — refreshed on your interval. + + + +
+
+ + setName(e.target.value)} + placeholder="example.com" className="font-mono text-[12px]" autoFocus /> +
+ +
+
+ + setIntervalH(e.target.value)} className="font-mono text-[12px]" /> +
+
+ + setAlertDays(e.target.value)} className="font-mono text-[12px]" /> +
+
+ + + +
+ + setNotes(e.target.value)} /> +
+ + {err ?

{err}

: null} +
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/components/MonitorDialog.tsx b/apps/frontend/src/components/MonitorDialog.tsx index fc9aee7..47e9393 100644 --- a/apps/frontend/src/components/MonitorDialog.tsx +++ b/apps/frontend/src/components/MonitorDialog.tsx @@ -54,6 +54,8 @@ export function MonitorDialog({ const set = (k: string) => (e: React.ChangeEvent) => setF((s) => ({ ...s, [k]: e.target.value })); + // SAFETY: f.type only ever receives values from the TYPES select or a + // persisted monitor row, both constrained to MonType. const type = (f.type || "http") as MonType; async function submit() { diff --git a/apps/frontend/src/components/WidgetDialog.tsx b/apps/frontend/src/components/WidgetDialog.tsx index d39224c..664abd6 100644 --- a/apps/frontend/src/components/WidgetDialog.tsx +++ b/apps/frontend/src/components/WidgetDialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useBoard } from "@/board/store"; +import { api } from "@/api"; import type { Item } from "@/types"; import { Button } from "@/components/ui/button"; import { @@ -32,6 +33,7 @@ export function WidgetDialog({ const [sectionId, setSectionId] = useState(""); const [values, setValues] = useState>({}); const [monitors, setMonitors] = useState<{ id?: string; name?: string }[]>([]); + const [domains, setDomains] = useState<{ id?: string; name?: string }[]>([]); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); @@ -43,10 +45,8 @@ export function WidgetDialog({ .then((r) => r.json() as Promise) .then(setTypes) .catch(() => setTypes([])); - fetch("/api/monitors") - .then((r) => r.json() as Promise<{ id?: string; name?: string }[]>) - .then(setMonitors) - .catch(() => setMonitors([])); + void api.GET("/api/monitors").then(({ data }) => setMonitors(data ?? [])); + void api.GET("/api/domains").then(({ data }) => setDomains(data ?? [])); const cfg = item?.config ?? null; setType(cfg && typeof cfg.type === "string" ? cfg.type : ""); @@ -146,14 +146,14 @@ export function WidgetDialog({ - {f.key === "monitorId" ? ( + {f.key === "monitorId" || f.key === "domainId" ? (