feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards

P5b: notifier transport registry (webhook/slack/discord/telegram/
gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules
(failsBeforeDown, latencyWarn, certDays, mute).

P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip
collectors, crt.sh subdomain discovery with daily sweep.

P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login
rate limit, public-route bypass) and multiple boards (boards table,
sections.board_id, /api/boards CRUD, slug routing).

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-22 12:49:44 +02:00
1 parent 8973be3b41
commit f3143af351
33 files changed
+2772 -126

No files matched your search

+38 -4
View File
@@ -27,7 +27,7 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
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`
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
// domainRow mirrors the domains table; JSON text columns decode on scan.
type domainRow struct {
@@ -87,12 +87,13 @@ type domainRow struct {
IntervalH int `json:"intervalH"`
LastChecked *string `json:"lastChecked"`
Position float64 `json:"position"`
Alerts json.RawMessage `json:"alerts"`
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 tags, statuses, ns, mx, txt, v4, v6, altNames, headers, alerts 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,
@@ -104,10 +105,11 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
&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)
&d.Position, &alerts, &d.CreatedAt)
if err != nil {
return nil, err
}
d.Alerts = json.RawMessage(alerts)
d.Active = active != 0
d.AutoRenew = autoRenew != 0
d.PrivacyEnabled = privacy != 0
@@ -200,6 +202,7 @@ type domainInput struct {
IntervalH *int `json:"intervalH"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
}
func (s *Server) createDomain(c *gin.Context) {
@@ -218,6 +221,13 @@ func (s *Server) createDomain(c *gin.Context) {
if in.Tags == nil {
tags = []byte("[]")
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
}
_, err := s.db.Exec(`INSERT INTO domains
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
@@ -228,6 +238,9 @@ func (s *Server) createDomain(c *gin.Context) {
fail(c, http.StatusConflict, "domain already tracked or insert failed")
return
}
if in.Alerts != nil {
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
// First lookup runs inline so the response carries real data.
s.refreshDomain(id)
d, err := s.loadDomain(id)
@@ -298,6 +311,14 @@ func (s *Server) patchDomain(c *gin.Context) {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id)
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
d, err := s.loadDomain(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
@@ -338,6 +359,10 @@ func (s *Server) refreshDomain(id string) {
return
}
nv := viewOf(next)
rules := parseAlertRules(next.Alerts)
if rules.Mute {
return
}
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))
@@ -345,10 +370,19 @@ func (s *Server) refreshDomain(id string) {
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 {
// TLS threshold: alerts.certDays overrides alertDaysBefore.
certDays := next.AlertDaysBefore
if rules.CertDays > 0 {
certDays = rules.CertDays
}
sslNow := nv.SSLDaysUntilExpiry != nil && *nv.SSLDaysUntilExpiry <= certDays
sslPrev := prev.SSLDaysUntilExpiry != nil && *prev.SSLDaysUntilExpiry <= certDays
if sslNow && !sslPrev {
s.notify("domain.sslExpiring", next.Name,
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
}
// Subdomain discovery rides the refresh schedule, gated to once/day.
s.maybeSweepSubdomains(next)
}
func (s *Server) refreshDomainH(c *gin.Context) {