Files
Dash/apps/backend/internal/api/domains.go
T
Tomas DvorakandDevin 2f3e8a1879 feat: webhook alerts for monitor + domain events (merge plan M2)
Generic webhook notification delivery configured via the
notify_webhook settings key — no new schema. Fires on monitor
up/down transitions (pending stays silent), domain expiry,
domain expiring, and TLS cert expiring; dispatch is async and
failures are logged, never fatal.

POST /api/notify/test verifies configuration; Settings dialog
gains a webhook URL field with save + send-test. Payload carries
event/name/text/content/message so Slack, Discord, ntfy, and
custom receivers all work.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 18:43:48 +02:00

518 lines
17 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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. Expiry
// threshold crossings fire notify events on the edge (only when a state
// newly becomes true, not on every refresh).
func (s *Server) refreshDomain(id string) {
row, err := s.loadDomain(id)
if err != nil {
return
}
prev := viewOf(row)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
r := s.lookup(ctx, row.Name)
s.applyResult(id, r)
next, err := s.loadDomain(id)
if err != nil {
return
}
nv := viewOf(next)
switch {
case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0):
s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name))
case nv.Expiring && !prev.Expiring:
s.notify("domain.expiring", next.Name,
fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry))
}
if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil {
s.notify("domain.sslExpiring", next.Name,
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
}
}
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
}