Files
Tomas DvorakandDevin f3143af351 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>
2026-09-22 12:49:44 +02:00

96 lines
2.8 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/domain"
"go.uber.org/zap"
)
// Subdomain discovery: crt.sh + DNS, persisted per domain and swept at most
// once a day inside the domain refresh. CT names are untrusted strings —
// stored as text, rendered as text, never linked.
type subdomainView struct {
Name string `json:"name"`
IPs []string `json:"ips"`
FirstSeen string `json:"firstSeen"`
LastSeen string `json:"lastSeen"`
}
func (s *Server) listSubdomains(c *gin.Context) {
rows, err := s.db.Query(`SELECT name, ips, first_seen, last_seen
FROM subdomains WHERE domain_id = ? ORDER BY name`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []subdomainView{}
for rows.Next() {
var v subdomainView
var ips string
if err := rows.Scan(&v.Name, &ips, &v.FirstSeen, &v.LastSeen); err != nil {
continue
}
_ = json.Unmarshal([]byte(ips), &v.IPs)
if v.IPs == nil {
v.IPs = []string{}
}
out = append(out, v)
}
c.JSON(http.StatusOK, out)
}
// refreshSubdomains forces a discovery run for one domain.
func (s *Server) refreshSubdomains(c *gin.Context) {
id := c.Param("id")
d, err := s.loadDomain(id)
if notFound(err) {
fail(c, http.StatusNotFound, "domain not found")
return
}
s.sweepSubdomains(d)
s.listSubdomains(c)
}
// sweepSubdomains runs CT+DNS discovery and upserts rows. Called from
// refreshDomain when sub_checked_at is stale (>24h), and on manual refresh.
func (s *Server) sweepSubdomains(d *domainRow) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
found, err := domain.DiscoverSubdomains(ctx, d.Name)
if err != nil {
s.log.Warn("subdomain discovery", zap.String("domain", d.Name), zap.Error(err))
return
}
for _, f := range found {
ips, _ := json.Marshal(f.IPs)
if _, err := s.db.Exec(`INSERT INTO subdomains (id, domain_id, name, ips)
VALUES (?,?,?,?)
ON CONFLICT(domain_id, name) DO UPDATE SET ips = excluded.ips,
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
newID("sub"), d.ID, f.Name, string(ips)); err != nil {
s.log.Error("subdomain upsert", zap.Error(err))
}
}
_, _ = s.db.Exec(`UPDATE domains SET sub_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, d.ID)
}
// maybeSweepSubdomains gates discovery to once per day per domain.
func (s *Server) maybeSweepSubdomains(d *domainRow) {
var checked *string
_ = s.db.QueryRow(`SELECT sub_checked_at FROM domains WHERE id = ?`, d.ID).Scan(&checked)
if checked != nil && *checked != "" {
if t, err := time.Parse("2006-01-02T15:04:05.000Z", *checked); err == nil &&
time.Since(t) < 24*time.Hour {
return
}
}
s.sweepSubdomains(d)
}