mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: domain monitoring (merge plan M2, phase 1)
Port of the domain-intelligence pipeline from the old Beszel fork, adapted to Dash's SQLite + no-PocketBase architecture: - internal/domain: RDAP via IANA bootstrap (cached), TCP WHOIS :43 fallback with multi-format parser, DNS (NS/MX/TXT/CNAME/A/AAAA), TLS leaf cert details, ip-api.com geolocation, provider detection (DNS/email/hosting/CA). HTML scraping, paid APIs, and exec-based whois deliberately dropped. - API: domains CRUD + synchronous refresh + domain_checks snapshot history; refresh runs inside the monitor sweep on interval_h. - UI: /domains + /domains/:id pages, add/edit dialog, domain board widget (favicon + expiry countdown), palette + nav entries, domainId picker in the widget dialog. - Test seam: Server.lookup injects the refresh; domain tests run hermetic against a stubbed result. Verified live: github.com resolves MarkMonitor expiry 2028-10, Route 53 DNS, GitHub Pages hosting, Sectigo cert with 69d left. Authored By: TDvorak <[email protected]> Generated with Devin (https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1ff1b29ae7
commit
f9fd1d9cf2
18 files changed
+2376
-14
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,404 @@
|
||||
// Package domain is the domain-intelligence half of the Beszel/Domain-Locker
|
||||
// merge: given a domain name it gathers WHOIS/RDAP registration data, DNS
|
||||
// records, TLS certificate details, host geolocation, and provider hints.
|
||||
// Pure lookup logic — no DB, no HTTP handlers.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lookup bundles the shared HTTP client used by the network probes.
|
||||
type Lookup struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewLookup() *Lookup {
|
||||
return &Lookup{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
// Result is everything one refresh learns about a domain. Fields stay flat —
|
||||
// the API layer maps them 1:1 onto the domains table columns.
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
TLD string `json:"tld"`
|
||||
|
||||
// WHOIS / RDAP
|
||||
ExpiryDate *time.Time `json:"expiryDate"`
|
||||
CreationDate *time.Time `json:"creationDate"`
|
||||
UpdatedDate *time.Time `json:"updatedDate"`
|
||||
RegistrarName string `json:"registrarName"`
|
||||
RegistrarID string `json:"registrarId"`
|
||||
RegistrarURL string `json:"registrarUrl"`
|
||||
RegistryDomainID string `json:"registryDomainId"`
|
||||
DNSSEC string `json:"dnssec"`
|
||||
Statuses []string `json:"statuses"`
|
||||
PrivacyEnabled bool `json:"privacyEnabled"`
|
||||
TransferLock bool `json:"transferLock"`
|
||||
RegistrantName string `json:"registrantName"`
|
||||
RegistrantOrg string `json:"registrantOrg"`
|
||||
RegistrantCountry string `json:"registrantCountry"`
|
||||
AbuseEmail string `json:"abuseEmail"`
|
||||
|
||||
// DNS
|
||||
NameServers []string `json:"nameServers"`
|
||||
MXRecords []string `json:"mxRecords"`
|
||||
TXTRecords []string `json:"txtRecords"`
|
||||
CNAME string `json:"cname"`
|
||||
IPv4 []string `json:"ipv4"`
|
||||
IPv6 []string `json:"ipv6"`
|
||||
|
||||
// TLS leaf certificate
|
||||
SSLIssuer string `json:"sslIssuer"`
|
||||
SSLValidFrom *time.Time `json:"sslValidFrom"`
|
||||
SSLValidTo *time.Time `json:"sslValidTo"`
|
||||
SSLSubject string `json:"sslSubject"`
|
||||
SSLFingerprint string `json:"sslFingerprint"`
|
||||
SSLKeySize int `json:"sslKeySize"`
|
||||
SSLSigAlgo string `json:"sslSigAlgo"`
|
||||
SSLAltNames []string `json:"sslAltNames"`
|
||||
|
||||
// Host geolocation (ip-api.com; empty when unreachable/private)
|
||||
HostCountry string `json:"hostCountry"`
|
||||
HostCountryCode string `json:"hostCountryCode"`
|
||||
HostRegion string `json:"hostRegion"`
|
||||
HostCity string `json:"hostCity"`
|
||||
HostISP string `json:"hostIsp"`
|
||||
HostOrg string `json:"hostOrg"`
|
||||
HostAS string `json:"hostAs"`
|
||||
HostLat float64 `json:"hostLat"`
|
||||
HostLon float64 `json:"hostLon"`
|
||||
|
||||
// Provider detection
|
||||
DNSProvider string `json:"dnsProvider"`
|
||||
EmailProvider string `json:"emailProvider"`
|
||||
HostingProvider string `json:"hostingProvider"`
|
||||
CAProvider string `json:"caProvider"`
|
||||
|
||||
Headers map[string]string `json:"headers"`
|
||||
FaviconURL string `json:"faviconUrl"`
|
||||
Error string `json:"error"` // non-fatal partial failure note
|
||||
}
|
||||
|
||||
// Clean normalises user input to a bare registrable hostname.
|
||||
func Clean(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
name = regexp.MustCompile(`^https?://`).ReplaceAllString(name, "")
|
||||
name = regexp.MustCompile(`^www\.`).ReplaceAllString(name, "")
|
||||
if i := strings.IndexAny(name, "/?#"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
if i := strings.Index(name, ":"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Valid reports whether s is a plausible DNS name we can query for.
|
||||
func Valid(s string) bool { return validDomainName(s) }
|
||||
|
||||
// Run gathers everything. Individual probe failures are non-fatal — a domain
|
||||
// with no HTTPS still returns WHOIS+DNS. The returned Error field carries the
|
||||
// first fatal WHOIS failure when even registration data is unavailable.
|
||||
func (l *Lookup) Run(ctx context.Context, rawName string) *Result {
|
||||
name := Clean(rawName)
|
||||
r := &Result{
|
||||
Name: name,
|
||||
TLD: tldOf(name),
|
||||
Statuses: []string{},
|
||||
NameServers: []string{},
|
||||
MXRecords: []string{},
|
||||
TXTRecords: []string{},
|
||||
IPv4: []string{},
|
||||
IPv6: []string{},
|
||||
SSLAltNames: []string{},
|
||||
Headers: map[string]string{},
|
||||
FaviconURL: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", name),
|
||||
}
|
||||
|
||||
if w, err := l.Whois(ctx, name); err == nil {
|
||||
r.ExpiryDate = w.ExpiryDate
|
||||
r.CreationDate = w.CreationDate
|
||||
r.UpdatedDate = w.UpdatedDate
|
||||
r.RegistrarName = w.RegistrarName
|
||||
r.RegistrarID = w.RegistrarID
|
||||
r.RegistrarURL = w.RegistrarURL
|
||||
r.RegistryDomainID = w.RegistryDomainID
|
||||
r.DNSSEC = w.DNSSEC
|
||||
r.Statuses = w.Statuses
|
||||
r.PrivacyEnabled = w.PrivacyEnabled
|
||||
r.TransferLock = w.TransferLock
|
||||
r.RegistrantName = w.RegistrantName
|
||||
r.RegistrantOrg = w.RegistrantOrg
|
||||
r.RegistrantCountry = w.RegistrantCountry
|
||||
r.AbuseEmail = w.AbuseEmail
|
||||
} else {
|
||||
r.Error = "whois: " + err.Error()
|
||||
}
|
||||
|
||||
l.dns(ctx, name, r)
|
||||
l.cert(ctx, name, r)
|
||||
if len(r.IPv4) > 0 {
|
||||
l.host(r.IPv4[0], r)
|
||||
}
|
||||
l.headers(ctx, name, r)
|
||||
|
||||
r.DNSProvider = detectDNSProvider(r.NameServers)
|
||||
r.EmailProvider = detectEmailProvider(r.MXRecords)
|
||||
r.HostingProvider = detectHostingProvider(r.Headers)
|
||||
r.CAProvider = detectCA(r.SSLIssuer)
|
||||
return r
|
||||
}
|
||||
|
||||
// --- DNS --------------------------------------------------------------------
|
||||
|
||||
func (l *Lookup) dns(ctx context.Context, name string, r *Result) {
|
||||
res := net.DefaultResolver
|
||||
if ns, err := res.LookupNS(ctx, name); err == nil {
|
||||
for _, n := range ns {
|
||||
r.NameServers = append(r.NameServers, strings.TrimSuffix(strings.ToLower(n.Host), "."))
|
||||
}
|
||||
}
|
||||
if mx, err := res.LookupMX(ctx, name); err == nil {
|
||||
for _, m := range mx {
|
||||
r.MXRecords = append(r.MXRecords, fmt.Sprintf("%s (pri %d)", strings.TrimSuffix(m.Host, "."), m.Pref))
|
||||
}
|
||||
}
|
||||
if txt, err := res.LookupTXT(ctx, name); err == nil {
|
||||
r.TXTRecords = txt
|
||||
}
|
||||
if c, err := res.LookupCNAME(ctx, name); err == nil && c != "" && strings.TrimSuffix(c, ".") != name {
|
||||
r.CNAME = strings.TrimSuffix(c, ".")
|
||||
}
|
||||
if ips, err := res.LookupIP(ctx, "ip4", name); err == nil {
|
||||
for _, ip := range ips {
|
||||
r.IPv4 = append(r.IPv4, ip.String())
|
||||
}
|
||||
}
|
||||
if ips, err := res.LookupIP(ctx, "ip6", name); err == nil {
|
||||
for _, ip := range ips {
|
||||
r.IPv6 = append(r.IPv6, ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TLS leaf certificate ----------------------------------------------------
|
||||
|
||||
func (l *Lookup) cert(ctx context.Context, name string, r *Result) {
|
||||
d := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := tls.DialWithDialer(d, "tcp", net.JoinHostPort(name, "443"), &tls.Config{
|
||||
ServerName: name,
|
||||
InsecureSkipVerify: true, // inspecting, not authenticating — expiry is data
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return
|
||||
}
|
||||
c := certs[0]
|
||||
if len(c.Issuer.Organization) > 0 {
|
||||
r.SSLIssuer = c.Issuer.Organization[0]
|
||||
} else {
|
||||
r.SSLIssuer = c.Issuer.CommonName
|
||||
}
|
||||
r.SSLValidFrom = &c.NotBefore
|
||||
r.SSLValidTo = &c.NotAfter
|
||||
r.SSLSubject = c.Subject.CommonName
|
||||
fp := sha256.Sum256(c.Raw)
|
||||
h := strings.ToUpper(hex.EncodeToString(fp[:]))
|
||||
var parts []string
|
||||
for i := 0; i+2 <= len(h); i += 2 {
|
||||
parts = append(parts, h[i:i+2])
|
||||
}
|
||||
r.SSLFingerprint = strings.Join(parts, ":")
|
||||
r.SSLSigAlgo = c.SignatureAlgorithm.String()
|
||||
switch k := c.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
r.SSLKeySize = k.N.BitLen()
|
||||
case *ecdsa.PublicKey:
|
||||
r.SSLKeySize = k.Curve.Params().BitSize
|
||||
}
|
||||
for _, n := range c.DNSNames {
|
||||
r.SSLAltNames = append(r.SSLAltNames, n)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Host geolocation (ip-api.com, free non-commercial tier) -----------------
|
||||
|
||||
func (l *Lookup) host(ip string, r *Result) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode,regionName,city,lat,lon,isp,org,as", ip), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var g struct {
|
||||
Status string `json:"status"`
|
||||
Country string `json:"country"`
|
||||
Code string `json:"countryCode"`
|
||||
Region string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
ISP string `json:"isp"`
|
||||
Org string `json:"org"`
|
||||
AS string `json:"as"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&g); err != nil || g.Status != "success" {
|
||||
return
|
||||
}
|
||||
r.HostCountry, r.HostCountryCode = g.Country, g.Code
|
||||
r.HostRegion, r.HostCity = g.Region, g.City
|
||||
r.HostISP, r.HostOrg, r.HostAS = g.ISP, g.Org, g.AS
|
||||
r.HostLat, r.HostLon = g.Lat, g.Lon
|
||||
}
|
||||
|
||||
// --- HTTP headers (feed provider detection) -----------------------------------
|
||||
|
||||
func (l *Lookup) headers(ctx context.Context, name string, r *Result) {
|
||||
for _, scheme := range []string{"https", "http"} {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, scheme+"://"+name, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("User-Agent", "Dash domain lookup")
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
r.Headers[strings.ToLower(k)] = v[0]
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider detection (condensed port of beszel's detect package) -----------
|
||||
|
||||
func detectDNSProvider(ns []string) string {
|
||||
table := []struct{ sub, name string }{
|
||||
{"cloudflare", "Cloudflare"}, {"awsdns", "Amazon Route 53"},
|
||||
{"googledomains", "Google Domains"}, {"google.com", "Google Domains"},
|
||||
{"namecheap", "Namecheap"}, {"godaddy", "GoDaddy"}, {"domaincontrol", "GoDaddy"},
|
||||
{"nsone.net", "NS1"}, {"digitalocean", "DigitalOcean"}, {"linode", "Linode"},
|
||||
{"vultr", "Vultr"}, {"he.net", "Hurricane Electric"}, {"dynect", "Dyn (Oracle)"},
|
||||
{"ultradns", "UltraDNS"}, {"dnsimple", "DNSimple"}, {"hover", "Hover"},
|
||||
{"enom", "eNom"}, {"worldnic", "Network Solutions"}, {"zoneedit", "ZoneEdit"},
|
||||
{"easydns", "EasyDNS"}, {"gandi", "Gandi"}, {"ovh", "OVH"},
|
||||
{"hetzner", "Hetzner"}, {"azure-dns", "Microsoft Azure"},
|
||||
{"dns.google", "Google Cloud DNS"}, {"porkbun", "Porkbun"},
|
||||
{"cloudns", "ClouDNS"}, {"desec", "deSEC"}, {"nic.cz", "CZ.NIC"},
|
||||
}
|
||||
for _, n := range ns {
|
||||
n = strings.ToLower(n)
|
||||
for _, t := range table {
|
||||
if strings.Contains(n, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectEmailProvider(mx []string) string {
|
||||
table := []struct{ sub, name string }{
|
||||
{"google.com", "Google Workspace"}, {"aspmx", "Google Workspace"},
|
||||
{"outlook.com", "Microsoft 365"}, {"protection.outlook", "Microsoft 365"},
|
||||
{"mail.protection", "Microsoft 365"}, {"protonmail", "Proton Mail"},
|
||||
{"proton.me", "Proton Mail"}, {"zoho", "Zoho Mail"},
|
||||
{"fastmail", "Fastmail"}, {"mimecast", "Mimecast"},
|
||||
{"proofpoint", "Proofpoint"}, {"barracuda", "Barracuda"},
|
||||
{"mailgun", "Mailgun"}, {"sendgrid", "SendGrid"},
|
||||
{"amazonses", "Amazon SES"}, {"yandex", "Yandex"},
|
||||
{"tuta", "Tuta"}, {"mailbox.org", "mailbox.org"},
|
||||
}
|
||||
for _, m := range mx {
|
||||
m = strings.ToLower(m)
|
||||
for _, t := range table {
|
||||
if strings.Contains(m, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectHostingProvider(h map[string]string) string {
|
||||
server := strings.ToLower(h["server"] + " " + h["x-powered-by"] + " " + h["via"])
|
||||
if _, ok := h["cf-ray"]; ok || strings.Contains(server, "cloudflare") {
|
||||
return "Cloudflare"
|
||||
}
|
||||
if _, ok := h["x-vercel-id"]; ok || strings.Contains(server, "vercel") {
|
||||
return "Vercel"
|
||||
}
|
||||
if _, ok := h["x-nf-request-id"]; ok || strings.Contains(server, "netlify") {
|
||||
return "Netlify"
|
||||
}
|
||||
if _, ok := h["x-amz-cf-id"]; ok || strings.Contains(server, "cloudfront") {
|
||||
return "AWS CloudFront"
|
||||
}
|
||||
if _, ok := h["x-github-request-id"]; ok || strings.Contains(server, "github") {
|
||||
return "GitHub Pages"
|
||||
}
|
||||
if strings.Contains(server, "fly.io") || h["fly-request-id"] != "" {
|
||||
return "Fly.io"
|
||||
}
|
||||
if strings.Contains(server, "render") {
|
||||
return "Render"
|
||||
}
|
||||
table := []struct{ sub, name string }{
|
||||
{"nginx", "nginx"}, {"apache", "Apache"}, {"caddy", "Caddy"},
|
||||
{"litespeed", "LiteSpeed"}, {"openresty", "OpenResty"},
|
||||
{"envoy", "Envoy"}, {"awselb", "AWS ELB"}, {"gws", "Google"},
|
||||
{"kestrel", "Kestrel/.NET"}, {"cowboy", "Cowboy/Erlang"},
|
||||
}
|
||||
for _, t := range table {
|
||||
if strings.Contains(server, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectCA(issuer string) string {
|
||||
i := strings.ToLower(issuer)
|
||||
table := []struct{ sub, name string }{
|
||||
{"let's encrypt", "Let's Encrypt"}, {"letsencrypt", "Let's Encrypt"},
|
||||
{"digicert", "DigiCert"}, {"sectigo", "Sectigo"}, {"comodoca", "Sectigo"},
|
||||
{"globalsign", "GlobalSign"}, {"godaddy", "GoDaddy"}, {"starfield", "GoDaddy"},
|
||||
{"entrust", "Entrust"}, {"google trust", "Google Trust Services"},
|
||||
{"amazon", "Amazon"}, {"zerossl", "ZeroSSL"}, {"buypass", "Buypass"},
|
||||
{"ssl.com", "SSL.com"}, {"actalis", "Actalis"}, {"certum", "Certum"},
|
||||
{"harica", "HARICA"}, {"swisssign", "SwissSign"}, {"cloudflare", "Cloudflare"},
|
||||
{"pki.goog", "Google Trust Services"}, {"microsoft", "Microsoft"},
|
||||
}
|
||||
for _, t := range table {
|
||||
if strings.Contains(i, t.sub) {
|
||||
return t.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// WHOIS data acquisition. Ported from Beszel's hub/domains/whois — the
|
||||
// portable parts only: RDAP via IANA bootstrap, TCP WHOIS on :43, and the
|
||||
// registry-format parser. Dropped: native `whois` exec (breaks the
|
||||
// single-binary model), EURid/whois.com HTML scraping (fragile), and the
|
||||
// paid WhoisXML API.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WhoisData is the registry half of a Lookup result.
|
||||
type WhoisData struct {
|
||||
DomainName string
|
||||
Statuses []string
|
||||
DNSSEC string // "signed" | "unsigned" | ""
|
||||
ExpiryDate *time.Time
|
||||
CreationDate *time.Time
|
||||
UpdatedDate *time.Time
|
||||
RegistrarName string
|
||||
RegistrarID string
|
||||
RegistrarURL string
|
||||
RegistryDomainID string
|
||||
RegistrantName string
|
||||
RegistrantOrg string
|
||||
RegistrantCountry string
|
||||
AbuseEmail string
|
||||
PrivacyEnabled bool
|
||||
TransferLock bool
|
||||
}
|
||||
|
||||
// Whois looks up registration data: RDAP first (clean JSON), TCP WHOIS as
|
||||
// fallback for TLDs without RDAP service.
|
||||
func (l *Lookup) Whois(ctx context.Context, name string) (*WhoisData, error) {
|
||||
if d, err := l.rdap(ctx, name); err == nil && d.valid() {
|
||||
return d, nil
|
||||
}
|
||||
if d, err := tcpWhois(ctx, name); err == nil && d.valid() {
|
||||
return d, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no whois data for %s", name)
|
||||
}
|
||||
|
||||
func (d *WhoisData) valid() bool {
|
||||
return d != nil && (d.ExpiryDate != nil || d.CreationDate != nil ||
|
||||
(d.RegistrarName != "" && d.RegistrarName != "Unknown") || len(d.Statuses) > 0)
|
||||
}
|
||||
|
||||
// --- RDAP ------------------------------------------------------------------
|
||||
|
||||
// rdapBases caches TLD -> RDAP base URL from the IANA bootstrap registry.
|
||||
var rdapBases = struct {
|
||||
sync.Mutex
|
||||
m map[string]string
|
||||
}{m: map[string]string{}}
|
||||
|
||||
func (l *Lookup) rdap(ctx context.Context, name string) (*WhoisData, error) {
|
||||
tld := tldOf(name)
|
||||
if tld == "" {
|
||||
return nil, fmt.Errorf("no tld")
|
||||
}
|
||||
base, err := rdapBase(ctx, tld)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/domain/"+name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/rdap+json")
|
||||
resp, err := l.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("rdap status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var r struct {
|
||||
LdhName string `json:"ldhName"`
|
||||
Handle string `json:"handle"`
|
||||
Status []string `json:"status"`
|
||||
Events []struct {
|
||||
Action string `json:"eventAction"`
|
||||
Date string `json:"eventDate"`
|
||||
} `json:"events"`
|
||||
Entities []struct {
|
||||
Roles []string `json:"roles"`
|
||||
PublicIDs []struct {
|
||||
Type string `json:"type"`
|
||||
Identifier string `json:"identifier"`
|
||||
} `json:"publicIds"`
|
||||
VCard []any `json:"vcardArray"`
|
||||
} `json:"entities"`
|
||||
SecureDNS struct {
|
||||
ZoneSigned bool `json:"zoneSigned"`
|
||||
} `json:"secureDNS"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := &WhoisData{DomainName: r.LdhName, Statuses: r.Status, RegistryDomainID: r.Handle}
|
||||
if r.SecureDNS.ZoneSigned {
|
||||
d.DNSSEC = "signed"
|
||||
}
|
||||
for _, e := range r.Events {
|
||||
t, err := time.Parse(time.RFC3339, e.Date)
|
||||
if err != nil || t.IsZero() {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case "registration":
|
||||
d.CreationDate = &t
|
||||
case "expiration":
|
||||
d.ExpiryDate = &t
|
||||
case "last changed":
|
||||
d.UpdatedDate = &t
|
||||
}
|
||||
}
|
||||
for _, ent := range r.Entities {
|
||||
if !hasRole(ent.Roles, "registrar") {
|
||||
continue
|
||||
}
|
||||
// vcardArray: ["vcard", [[name, params, type, value], ...]]
|
||||
if len(ent.VCard) > 1 {
|
||||
if props, ok := ent.VCard[1].([]any); ok {
|
||||
for _, p := range props {
|
||||
if arr, ok := p.([]any); ok && len(arr) >= 4 && arr[0] == "fn" {
|
||||
if s, ok := arr[3].(string); ok {
|
||||
d.RegistrarName = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pid := range ent.PublicIDs {
|
||||
if pid.Type == "IANA Registrar ID" {
|
||||
d.RegistrarID = pid.Identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
d.derive()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func hasRole(roles []string, want string) bool {
|
||||
for _, r := range roles {
|
||||
if r == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rdapBase(ctx context.Context, tld string) (string, error) {
|
||||
rdapBases.Lock()
|
||||
defer rdapBases.Unlock()
|
||||
if u, ok := rdapBases.m[tld]; ok {
|
||||
return u, nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://data.iana.org/rdap/dns.json", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var boot struct {
|
||||
Services [][]any `json:"services"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&boot); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, svc := range boot.Services {
|
||||
if len(svc) < 2 {
|
||||
continue
|
||||
}
|
||||
tlds, _ := svc[0].([]any)
|
||||
urls, _ := svc[1].([]any)
|
||||
if len(urls) == 0 {
|
||||
continue
|
||||
}
|
||||
base, _ := urls[0].(string)
|
||||
for _, t := range tlds {
|
||||
if s, ok := t.(string); ok {
|
||||
rdapBases.m[s] = strings.TrimSuffix(base, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
if u, ok := rdapBases.m[tld]; ok {
|
||||
return u, nil
|
||||
}
|
||||
return "", fmt.Errorf("no rdap server for .%s", tld)
|
||||
}
|
||||
|
||||
// --- TCP WHOIS (:43) --------------------------------------------------------
|
||||
|
||||
var whoisServers = map[string]string{
|
||||
"com": "whois.verisign-grs.com", "net": "whois.verisign-grs.com",
|
||||
"org": "whois.pir.org", "io": "whois.nic.io", "co": "whois.nic.co",
|
||||
"dev": "whois.nic.google", "app": "whois.nic.google", "xyz": "whois.nic.xyz",
|
||||
"info": "whois.afilias.net", "biz": "whois.biz", "us": "whois.nic.us",
|
||||
"uk": "whois.nic.uk", "de": "whois.denic.de", "fr": "whois.nic.fr",
|
||||
"eu": "whois.eu", "nl": "whois.domain-registry.nl", "ca": "whois.cira.ca",
|
||||
"au": "whois.auda.org.au", "me": "whois.nic.me", "tv": "whois.nic.tv",
|
||||
"cc": "whois.nic.cc", "name": "whois.nic.name", "pro": "whois.nic.pro",
|
||||
"cz": "whois.nic.cz", "sk": "whois.sk-nic.sk", "pl": "whois.dns.pl",
|
||||
"se": "whois.iis.se", "fi": "whois.fi", "no": "whois.norid.no",
|
||||
"ch": "whois.nic.ch", "at": "whois.nic.at", "be": "whois.dns.be",
|
||||
"it": "whois.nic.it", "es": "whois.nic.es", "pt": "whois.dns.pt",
|
||||
"jp": "whois.jprs.jp", "kr": "whois.kr", "in": "whois.registry.in",
|
||||
"br": "whois.registro.br", "za": "whois.registry.net.za",
|
||||
"cloud": "whois.nic.cloud", "online": "whois.nic.online",
|
||||
"site": "whois.nic.site", "store": "whois.nic.store", "tech": "whois.nic.tech",
|
||||
"ai": "whois.nic.ai", "gg": "whois.gg", "sh": "whois.nic.sh",
|
||||
}
|
||||
|
||||
func tcpWhois(ctx context.Context, name string) (*WhoisData, error) {
|
||||
tld := tldOf(name)
|
||||
server, ok := whoisServers[tld]
|
||||
if !ok {
|
||||
server = "whois.iana.org"
|
||||
}
|
||||
d := &net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(server, "43"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
if _, err := conn.Write([]byte(name + "\r\n")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(conn, 1<<20))
|
||||
if err != nil && len(raw) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
out := parseWhois(string(raw), name)
|
||||
out.derive()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseWhois flattens "Key: Value" lines and extracts the fields we store.
|
||||
// Registry formats vary wildly; the field-name fallbacks cover the common set.
|
||||
func parseWhois(raw, name string) *WhoisData {
|
||||
d := &WhoisData{DomainName: name}
|
||||
kv := map[string]string{}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
key = strings.NewReplacer(" ", "_", "/", "_", "-", "_").Replace(key)
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
if val == "" || strings.HasPrefix(strings.ToUpper(val), "REDACTED") {
|
||||
continue
|
||||
}
|
||||
if _, dup := kv[key]; !dup {
|
||||
kv[key] = val
|
||||
}
|
||||
switch key {
|
||||
case "domain_status", "status":
|
||||
if s := parseStatus(val); len(s) > 0 {
|
||||
d.Statuses = append(d.Statuses, s...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.ExpiryDate = parseDate(kv["registry_expiry_date"], kv["registrar_registration_expiration_date"],
|
||||
kv["expiry_date"], kv["expiration_time"], kv["expire"], kv["paid_until"],
|
||||
kv["expire_date"], kv["renewal_date"], kv["valid_until"], kv["expires"])
|
||||
d.CreationDate = parseDate(kv["creation_date"], kv["created_date"], kv["created"],
|
||||
kv["registration_time"], kv["registered_on"], kv["domain_registered"], kv["registered"])
|
||||
d.UpdatedDate = parseDate(kv["updated_date"], kv["last_updated"], kv["last_modified"],
|
||||
kv["modified_date"], kv["modified"], kv["changed"])
|
||||
|
||||
for _, k := range []string{"registrar", "registrar_name", "sponsoring_registrar", "registrar_organization"} {
|
||||
if v := kv[k]; v != "" {
|
||||
d.RegistrarName = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if d.RegistrarName == "" {
|
||||
d.RegistrarName = "Unknown"
|
||||
}
|
||||
d.RegistrarID = kv["registrar_iana_id"]
|
||||
d.RegistrarURL = kv["registrar_url"]
|
||||
d.RegistryDomainID = kv["registry_domain_id"]
|
||||
|
||||
d.RegistrantName = firstOf(kv, "registrant_name", "registrant", "holder", "domain_holder")
|
||||
d.RegistrantOrg = firstOf(kv, "registrant_organization", "org", "organization", "holder_org")
|
||||
d.RegistrantCountry = firstOf(kv, "registrant_country", "country", "holder_country")
|
||||
d.AbuseEmail = kv["registrar_abuse_contact_email"]
|
||||
|
||||
if v := strings.ToLower(firstOf(kv, "dnssec", "dnssec_signed", "signed_dnssec")); v == "signed" || v == "yes" || v == "true" || v == "signeddelegation" {
|
||||
d.DNSSEC = "signed"
|
||||
} else if v != "" {
|
||||
d.DNSSEC = "unsigned"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// derive fills computed flags after whichever lookup path populated d.
|
||||
func (d *WhoisData) derive() {
|
||||
low := strings.ToLower(d.RegistrantName + " " + d.RegistrantOrg)
|
||||
for _, marker := range []string{"redacted", "privacy", "whoisguard", "not disclosed", "hidden", "data protected", "gdpr"} {
|
||||
if strings.Contains(low, marker) {
|
||||
d.PrivacyEnabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, s := range d.Statuses {
|
||||
if sl := strings.ToLower(s); strings.Contains(sl, "transferprohibited") {
|
||||
d.TransferLock = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstOf(kv map[string]string, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := kv[k]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var knownStatuses = []string{
|
||||
"clientDeleteProhibited", "clientHold", "clientRenewProhibited",
|
||||
"clientTransferProhibited", "clientUpdateProhibited",
|
||||
"serverDeleteProhibited", "serverHold", "serverRenewProhibited",
|
||||
"serverTransferProhibited", "serverUpdateProhibited",
|
||||
"inactive", "ok", "pendingCreate", "pendingDelete", "pendingRenew",
|
||||
"pendingRestore", "pendingTransfer", "pendingUpdate",
|
||||
"addPeriod", "autoRenewPeriod", "renewPeriod", "transferPeriod",
|
||||
}
|
||||
|
||||
func parseStatus(s string) []string {
|
||||
s = strings.ToLower(s)
|
||||
var out []string
|
||||
for _, k := range knownStatuses {
|
||||
if strings.Contains(s, strings.ToLower(k)) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var dateFormats = []string{
|
||||
time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05", "2006-01-02 15:04:05.0", "2006-01-02",
|
||||
"01/02/2006", "02/01/2006", "02.01.2006", "02-Jan-2006", "2-Jan-2006",
|
||||
"Jan 2 2006", "January 2 2006", "2 Jan 2006", "Jan 02 2006",
|
||||
"2006-01-02 15:04:05 MST", "Mon, 02 Jan 2006 15:04:05 MST",
|
||||
"20060102", "20060102150405",
|
||||
}
|
||||
|
||||
func parseDate(candidates ...string) *time.Time {
|
||||
for _, s := range candidates {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "before "), "after "))
|
||||
if s == "" || strings.Contains(strings.ToUpper(s), "REDACTED") {
|
||||
continue
|
||||
}
|
||||
// Trim trailing "(YYYY-MM-DD)" style annotations and zone offsets in parens.
|
||||
if i := strings.Index(s, "("); i > 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
for _, f := range dateFormats {
|
||||
if t, err := time.Parse(f, s); err == nil && !t.IsZero() {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tldOf returns the last label; registrable-domain granularity is
|
||||
// unnecessary here since we only feed it to server maps.
|
||||
func tldOf(name string) string {
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
// whoisPattern guards against sending garbage to whois servers.
|
||||
var whoisPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
|
||||
|
||||
func validDomainName(s string) bool {
|
||||
return len(s) <= 253 && whoisPattern.MatchString(s)
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="/monitors/:id">
|
||||
{(p) => <MonitorDetailPage id={p.id} />}
|
||||
</Route>
|
||||
<Route path="/domains">
|
||||
<DomainsPage />
|
||||
</Route>
|
||||
<Route path="/domains/:id">
|
||||
{(p) => <DomainDetailPage id={p.id} />}
|
||||
</Route>
|
||||
|
||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "Edit domain" : "Track a domain"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-name">Domain</Label>
|
||||
<Input id="dom-name" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="example.com" className="font-mono text-[12px]" autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-int">Refresh every (hours)</Label>
|
||||
<Input id="dom-int" value={intervalH} onChange={(e) => setIntervalH(e.target.value)} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-alert">Alert days before expiry</Label>
|
||||
<Input id="dom-alert" value={alertDays} onChange={(e) => setAlertDays(e.target.value)} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
|
||||
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)} />
|
||||
Auto-renew enabled at the registrar
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-notes">Notes (optional)</Label>
|
||||
<Input id="dom-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "Looking up…" : editing ? "Save" : "Track domain"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export function MonitorDialog({
|
||||
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
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() {
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
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<WidgetType[]>)
|
||||
.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({
|
||||
<Label htmlFor={`wdg-${f.key}`}>
|
||||
{f.label}{f.required ? "" : " (optional)"}
|
||||
</Label>
|
||||
{f.key === "monitorId" ? (
|
||||
{f.key === "monitorId" || f.key === "domainId" ? (
|
||||
<Select
|
||||
value={values[f.key] ?? ""}
|
||||
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a monitor…" /></SelectTrigger>
|
||||
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{monitors.map((m) => (
|
||||
{(f.key === "monitorId" ? monitors : domains).map((m) => (
|
||||
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api";
|
||||
import type { DomainCheck, DomainView } from "@/types";
|
||||
import { DomainDialog } from "@/components/DomainDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
|
||||
if (v === undefined || v === null || v === "") return null;
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 border-b border-border/50 px-4 py-2 last:border-0">
|
||||
<span className="shrink-0 font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{k}</span>
|
||||
<span className={cn("truncate text-right text-[12px] text-text", mono && "font-mono text-[11px]")}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[10px] border border-border bg-surface">
|
||||
<h2 className="border-b border-border px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{title}</h2>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null | undefined) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function DomainDetailPage({ id }: { id: string }) {
|
||||
const [d, setD] = useState<DomainView | null>(null);
|
||||
const [checks, setChecks] = useState<DomainCheck[]>([]);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [{ data: dv }, { data: ck }] = await Promise.all([
|
||||
api.GET("/api/domains/{id}", { params: { path: { id } } }),
|
||||
api.GET("/api/domains/{id}/checks", { params: { path: { id } } }),
|
||||
]);
|
||||
if (dv) setD(dv);
|
||||
if (ck) setChecks(ck);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||
|
||||
const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<Link href="/domains" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||
<ArrowLeft size={12} /> Domains
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{d.faviconUrl ? <img src={d.faviconUrl} alt="" className="size-8 rounded-[7px]" /> : null}
|
||||
<div>
|
||||
<h1 className="text-[17px] font-semibold tracking-tight">{d.name}</h1>
|
||||
<p className="mt-0.5 flex items-center gap-2 font-mono text-[11px] text-text-faint">
|
||||
{d.tld ? `.${d.tld}` : ""}
|
||||
{d.dnssec === "signed" && <span className="flex items-center gap-1 text-up"><Shield size={10} /> dnssec</span>}
|
||||
{d.transferLock ? <span>transfer-locked</span> : null}
|
||||
{d.privacyEnabled ? <span>whois privacy</span> : null}
|
||||
{d.autoRenew ? <span>auto-renew</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={refreshing}
|
||||
onClick={() => {
|
||||
setRefreshing(true);
|
||||
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
|
||||
.then(load).finally(() => setRefreshing(false));
|
||||
}}>
|
||||
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => void api.PATCH("/api/domains/{id}", { params: { path: { id } }, body: { active: !d.active } }).then(load)}>
|
||||
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
|
||||
<Button variant="outline" size="sm" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.lookupError ? (
|
||||
<p className="mb-4 rounded-[8px] border border-down/40 bg-down/10 px-3 py-2 font-mono text-[11px] text-down">
|
||||
lookup: {d.lookupError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Domain expires</div>
|
||||
<div className={cn("mt-1 font-mono text-[17px] font-medium", expiring && "text-down")}>
|
||||
{d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined
|
||||
? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`)
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.expiryDate)}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">SSL expires</div>
|
||||
<div className={cn("mt-1 font-mono text-[17px] font-medium", d.sslExpiring && "text-down")}>
|
||||
{d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined
|
||||
? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`)
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.sslValidTo)}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Registrar</div>
|
||||
<div className="mt-1 truncate text-[13px] font-medium">{d.registrarName || "—"}</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Providers</div>
|
||||
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
|
||||
<Section title="Registration">
|
||||
<Row k="Created" v={fmtDate(d.creationDate)} />
|
||||
<Row k="Updated" v={fmtDate(d.updatedDate)} />
|
||||
<Row k="Registrar" v={d.registrarName} />
|
||||
<Row k="IANA ID" v={d.registrarId} mono />
|
||||
<Row k="Registry ID" v={d.registryDomainId} mono />
|
||||
<Row k="Registrant" v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
|
||||
<Row k="Country" v={d.registrantCountry} />
|
||||
<Row k="Abuse" v={d.abuseEmail} mono />
|
||||
{d.statuses && d.statuses.length > 0 ? <Row k="Status" v={d.statuses.join(", ")} mono /> : null}
|
||||
</Section>
|
||||
|
||||
<Section title="DNS">
|
||||
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
|
||||
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
|
||||
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
|
||||
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
|
||||
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
|
||||
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
|
||||
{d.dnsProvider ? <Row k="DNS provider" v={d.dnsProvider} /> : null}
|
||||
{d.emailProvider ? <Row k="Email" v={d.emailProvider} /> : null}
|
||||
</Section>
|
||||
|
||||
<Section title="TLS certificate">
|
||||
<Row k="Issuer" v={d.sslIssuer} />
|
||||
<Row k="CA" v={d.caProvider} />
|
||||
<Row k="Valid" v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
||||
<Row k="Subject" v={d.sslSubject} mono />
|
||||
<Row k="Key" v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
||||
<Row k="Algorithm" v={d.sslSigAlgo} mono />
|
||||
{d.sslAltNames && d.sslAltNames.length > 0 ? (
|
||||
<Row k="SANs" v={d.sslAltNames.slice(0, 5).join(", ") + (d.sslAltNames.length > 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono />
|
||||
) : null}
|
||||
<Row k="SHA-256" v={d.sslFingerprint ? d.sslFingerprint.slice(0, 29) + "…" : ""} mono />
|
||||
</Section>
|
||||
|
||||
<Section title="Host">
|
||||
<Row k="Location" v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
|
||||
<Row k="ISP" v={d.hostIsp} />
|
||||
<Row k="Org" v={d.hostOrg} />
|
||||
<Row k="AS" v={d.hostAs} mono />
|
||||
<Row k="Hosting" v={d.hostingProvider} />
|
||||
<Row k="Checked" v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{Object.keys(d.headers ?? {}).length > 0 ? (
|
||||
<details className="mt-3 overflow-hidden rounded-[10px] border border-border bg-surface">
|
||||
<summary className="cursor-pointer px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
|
||||
Response headers ({Object.keys(d.headers ?? {}).length})
|
||||
</summary>
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
{Object.entries(d.headers ?? {}).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-3 py-0.5 font-mono text-[11px]">
|
||||
<span className="w-44 shrink-0 text-text-faint">{k}</span>
|
||||
<span className="truncate text-text-dim">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Check history</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
<tr key={c.id} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">{new Date(c.checkedAt ?? "").toLocaleString()}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">exp {fmtDate(c.expiryDate)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">ssl {fmtDate(c.sslValidTo)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{(c.ipv4 ?? []).join(", ")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{checks.length === 0 && (
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">No snapshots yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={d} onSaved={load} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api";
|
||||
import type { DomainView } from "@/types";
|
||||
import { DomainDialog } from "@/components/DomainDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function daysLabel(days: number | null | undefined, warn?: boolean) {
|
||||
if (days === null || days === undefined) return "—";
|
||||
const cls = warn || days < 0 ? "text-down" : days <= 30 ? "text-amber-500" : "text-text-dim";
|
||||
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? "expired" : `${days}d`}</span>;
|
||||
}
|
||||
|
||||
export function DomainsPage() {
|
||||
const [domains, setDomains] = useState<DomainView[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DomainView | undefined>();
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data } = await api.GET("/api/domains");
|
||||
if (data) setDomains(data);
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function act(id: string | undefined, fn: () => Promise<unknown>) {
|
||||
if (!id) return;
|
||||
await fn();
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Domains</h1>
|
||||
<p className="text-[12px] text-text-faint">WHOIS, DNS, and certificate watch.</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
|
||||
<Plus size={13} strokeWidth={2.2} /> Track domain
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && domains.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No domains tracked yet.</p>
|
||||
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||
Expiry, registrar, DNS records, SSL certificates, and provider detection.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
<th className="px-4 py-2.5 font-medium">Domain</th>
|
||||
<th className="px-4 py-2.5 font-medium">Registrar</th>
|
||||
<th className="px-4 py-2.5 font-medium">Expires</th>
|
||||
<th className="px-4 py-2.5 font-medium">SSL</th>
|
||||
<th className="px-4 py-2.5 font-medium">Providers</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.map((d) => (
|
||||
<tr key={d.id} className={cn("border-b border-border/60 last:border-0 hover:bg-surface-hover/60", !d.active && "opacity-50")}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-2.5 font-medium">
|
||||
{d.faviconUrl ? (
|
||||
<img src={d.faviconUrl} alt="" className="size-4 rounded-sm" loading="lazy" />
|
||||
) : null}
|
||||
<Link href={`/domains/${d.id}`} className="hover:underline">{d.name}</Link>
|
||||
{d.lookupError ? (
|
||||
<span className="font-mono text-[10px] text-down" title={d.lookupError}>lookup error</span>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11.5px] text-text-dim">{d.registrarName || "—"}</td>
|
||||
<td className="px-4 py-3">{daysLabel(d.daysUntilExpiry, d.expiring)}</td>
|
||||
<td className="px-4 py-3">{daysLabel(d.sslDaysUntilExpiry, d.sslExpiring)}</td>
|
||||
<td className="px-4 py-3 font-mono text-[10.5px] text-text-faint">
|
||||
{[d.dnsProvider, d.hostingProvider].filter(Boolean).join(" · ") || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="icon" aria-label="Refresh now" disabled={refreshing === d.id}
|
||||
onClick={() => {
|
||||
if (!d.id) return;
|
||||
setRefreshing(d.id);
|
||||
void act(d.id, () => api.POST("/api/domains/{id}/refresh", { params: { path: { id: d.id } } }))
|
||||
.finally(() => setRefreshing(null));
|
||||
}}>
|
||||
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
|
||||
onClick={() => void act(d.id, () => api.PATCH("/api/domains/{id}", { params: { path: { id: d.id! } }, body: { active: !d.active } }))}>
|
||||
{d.active ? <Pause size={12} /> : <Play size={12} />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||
void act(d.id, () => api.DELETE("/api/domains/{id}", { params: { path: { id: d.id! } } }));
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={editing} onSaved={load} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export type Monitor = components["schemas"]["Monitor"];
|
||||
export type MonitorInput = components["schemas"]["MonitorInput"];
|
||||
export type MonitorView = components["schemas"]["MonitorView"];
|
||||
export type Heartbeat = components["schemas"]["Heartbeat"];
|
||||
export type Domain = components["schemas"]["Domain"];
|
||||
export type DomainInput = components["schemas"]["DomainInput"];
|
||||
export type DomainView = components["schemas"]["DomainView"];
|
||||
export type DomainCheck = components["schemas"]["DomainCheck"];
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type RendererName = "bento" | "cards" | "index" | "console";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { WidgetData } from "@/widgets";
|
||||
import type { Item } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Board tile for a tracked domain: favicon, name, expiry countdown.
|
||||
export function DomainWidget({ item, data, error }: {
|
||||
item: Item;
|
||||
data: WidgetData | undefined;
|
||||
error: string | undefined;
|
||||
}) {
|
||||
const days = typeof data?.daysUntilExpiry === "number" ? data.daysUntilExpiry : undefined;
|
||||
const sslDays = typeof data?.sslDaysUntilExpiry === "number" ? data.sslDaysUntilExpiry : undefined;
|
||||
const expiring = data?.expiring === true;
|
||||
const domainId = typeof item.config?.domainId === "string" ? item.config.domainId : "";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={domainId ? `/domains/${domainId}` : "/domains"}
|
||||
className="flex w-full items-center gap-2.5 text-left"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{typeof data?.faviconUrl === "string" && data.faviconUrl ? (
|
||||
<img src={data.faviconUrl} alt="" className="size-4 shrink-0 rounded-sm" loading="lazy" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
|
||||
{typeof data?.name === "string" ? data.name : item.name}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"shrink-0 font-mono text-[10.5px]",
|
||||
error ? "text-down" : expiring || (days !== undefined && days < 0) ? "text-down" : "text-text-faint",
|
||||
)}>
|
||||
{error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`}
|
||||
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ClockWidget } from "@/widgets/clock";
|
||||
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
|
||||
import { ImmichWidget } from "@/widgets/immich";
|
||||
import { MonitorWidget } from "@/widgets/monitor";
|
||||
import { DomainWidget } from "@/widgets/domain";
|
||||
|
||||
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
|
||||
// the component that fills it. Adding a widget = one fetcher in Go + one
|
||||
@@ -30,6 +31,7 @@ const registry: WidgetRegistry = {
|
||||
adguard: { view: AdguardWidget },
|
||||
immich: { view: ImmichWidget },
|
||||
monitor: { view: MonitorWidget },
|
||||
domain: { view: DomainWidget },
|
||||
};
|
||||
|
||||
export function cfgStr(item: Item, key: string): string {
|
||||
|
||||
+323
@@ -372,6 +372,122 @@ paths:
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/api/domains:
|
||||
get:
|
||||
operationId: listDomains
|
||||
summary: All tracked domains with expiry countdowns
|
||||
responses:
|
||||
"200":
|
||||
description: Domains ordered by position
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DomainView"
|
||||
post:
|
||||
operationId: createDomain
|
||||
summary: Track a domain; runs the first WHOIS/DNS/SSL lookup inline
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DomainInput"
|
||||
responses:
|
||||
"201":
|
||||
description: Domain after the initial lookup
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DomainView"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"409":
|
||||
description: Domain already tracked
|
||||
|
||||
/api/domains/{id}:
|
||||
get:
|
||||
operationId: getDomain
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
"200":
|
||||
description: Domain detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DomainView"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
patch:
|
||||
operationId: updateDomain
|
||||
summary: Sparse update — settings fields only; lookup data is refreshed separately
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DomainInput"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated domain
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DomainView"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
delete:
|
||||
operationId: deleteDomain
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
"204":
|
||||
description: Domain and its check history deleted
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/api/domains/{id}/refresh:
|
||||
post:
|
||||
operationId: refreshDomain
|
||||
summary: Re-run WHOIS/RDAP + DNS + TLS + geo lookup now
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
"200":
|
||||
description: Domain after the refresh
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DomainView"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/api/domains/{id}/checks:
|
||||
get:
|
||||
operationId: domainChecks
|
||||
summary: Snapshot history for change detection
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
maximum: 500
|
||||
responses:
|
||||
"200":
|
||||
description: Snapshots, newest first
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DomainCheck"
|
||||
|
||||
/api/widgets/types:
|
||||
get:
|
||||
operationId: widgetTypes
|
||||
@@ -854,6 +970,213 @@ components:
|
||||
checkedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
Domain:
|
||||
type: object
|
||||
required: [id, name, active]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
tld:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
autoRenew:
|
||||
type: boolean
|
||||
alertDaysBefore:
|
||||
type: integer
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
creationDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
updatedDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
registrarName:
|
||||
type: string
|
||||
registrarId:
|
||||
type: string
|
||||
registrarUrl:
|
||||
type: string
|
||||
registryDomainId:
|
||||
type: string
|
||||
dnssec:
|
||||
type: string
|
||||
statuses:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
privacyEnabled:
|
||||
type: boolean
|
||||
transferLock:
|
||||
type: boolean
|
||||
registrantName:
|
||||
type: string
|
||||
registrantOrg:
|
||||
type: string
|
||||
registrantCountry:
|
||||
type: string
|
||||
abuseEmail:
|
||||
type: string
|
||||
nameServers:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
mxRecords:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
txtRecords:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
cname:
|
||||
type: string
|
||||
ipv4:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
ipv6:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
sslIssuer:
|
||||
type: string
|
||||
sslValidFrom:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
sslValidTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
sslSubject:
|
||||
type: string
|
||||
sslFingerprint:
|
||||
type: string
|
||||
sslKeySize:
|
||||
type: integer
|
||||
sslSigAlgo:
|
||||
type: string
|
||||
sslAltNames:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
hostCountry:
|
||||
type: string
|
||||
hostCountryCode:
|
||||
type: string
|
||||
hostRegion:
|
||||
type: string
|
||||
hostCity:
|
||||
type: string
|
||||
hostIsp:
|
||||
type: string
|
||||
hostOrg:
|
||||
type: string
|
||||
hostAs:
|
||||
type: string
|
||||
hostLat:
|
||||
type: number
|
||||
hostLon:
|
||||
type: number
|
||||
dnsProvider:
|
||||
type: string
|
||||
emailProvider:
|
||||
type: string
|
||||
hostingProvider:
|
||||
type: string
|
||||
caProvider:
|
||||
type: string
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
faviconUrl:
|
||||
type: string
|
||||
lookupError:
|
||||
type: string
|
||||
intervalH:
|
||||
type: integer
|
||||
lastChecked:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
position:
|
||||
type: number
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
DomainInput:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
autoRenew:
|
||||
type: boolean
|
||||
alertDaysBefore:
|
||||
type: integer
|
||||
intervalH:
|
||||
type: integer
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
DomainView:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Domain"
|
||||
- type: object
|
||||
properties:
|
||||
daysUntilExpiry:
|
||||
type: integer
|
||||
nullable: true
|
||||
sslDaysUntilExpiry:
|
||||
type: integer
|
||||
nullable: true
|
||||
expiring:
|
||||
type: boolean
|
||||
sslExpiring:
|
||||
type: boolean
|
||||
DomainCheck:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
sslValidTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
ipv4:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nameServers:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
checkedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
WidgetField:
|
||||
type: object
|
||||
required: [key, label]
|
||||
|
||||
Reference in new issue
Block a user