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

+46
View File
@@ -0,0 +1,46 @@
package api
import "encoding/json"
// alertRules is the per-target rules blob stored in monitors.alerts /
// domains.alerts. Zero values keep pre-rules behavior: first failure alerts,
// no latency rule, cert threshold = alertDaysBefore, unmuted.
//
// consecutiveFailures — monitor must fail N checks in a row before it flips down
// latencyWarnMs — notify once when an up check's ping crosses above this
// certDays — domain TLS-expiry threshold (overrides alertDaysBefore)
// mute — suppress notifications; incidents/status still record
type alertRules struct {
ConsecutiveFailures int `json:"consecutiveFailures"`
LatencyWarnMs int `json:"latencyWarnMs"`
CertDays int `json:"certDays"`
Mute bool `json:"mute"`
}
func parseAlertRules(raw json.RawMessage) alertRules {
var r alertRules
_ = json.Unmarshal(raw, &r)
return r
}
// consecutiveDowns counts failed heartbeats, newest-first, stopping at the
// first up. The just-written heartbeat is included.
func (s *Server) consecutiveDowns(monitorID string, limit int) int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM (
SELECT status FROM heartbeats WHERE monitor_id = ?
ORDER BY checked_at DESC, rowid DESC LIMIT ?)
WHERE status = 'down'`, monitorID, limit).Scan(&n)
return n
}
// prevPing returns the ping of the heartbeat before the latest, or -1.
func (s *Server) prevPing(monitorID string) int {
var p int
if err := s.db.QueryRow(`SELECT ping_ms FROM heartbeats
WHERE monitor_id = ? ORDER BY checked_at DESC, rowid DESC
LIMIT 1 OFFSET 1`, monitorID).Scan(&p); err != nil {
return -1
}
return p
}
+324
View File
@@ -0,0 +1,324 @@
package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tdvorak/dash/internal/domain"
)
// ---------- transports ----------
func TestNotifyTransports(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// One receiver records body + headers + query per request.
type hit struct {
body map[string]any
raw string
title string
path string
}
got := make(chan hit, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
buf, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(buf, &m)
got <- hit{body: m, raw: string(buf), title: r.Header.Get("Title"), path: r.URL.RequestURI()}
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
put := func(kv string) {
if res, _ := do(t, "PUT", srv.URL+"/api/settings", kv); res.StatusCode != http.StatusOK {
t.Fatalf("put %s: %d", kv, res.StatusCode)
}
}
// slack + discord + gotify + ntfy all configured; test fires each.
put(`{"notify_slack":{"url":"` + hook.URL + `/slack"}}`)
put(`{"notify_discord":{"url":"` + hook.URL + `/discord"}}`)
put(`{"notify_gotify":{"server":"` + hook.URL + `","token":"gtok"}}`)
put(`{"notify_ntfy":{"server":"` + hook.URL + `","topic":"alerts"}}`)
res, out := do(t, "POST", srv.URL+"/api/notify/test", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("notify test: %d", res.StatusCode)
}
results := out["results"].(map[string]any)
for _, name := range []string{"slack", "discord", "gotify", "ntfy"} {
if results[name] != "ok" {
t.Fatalf("%s: %v", name, results[name])
}
}
seen := map[string]hit{}
for i := 0; i < 4; i++ {
select {
case h := <-got:
seen[h.path] = h
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for transports")
}
}
if seen["/slack"].body["text"] == "" {
t.Fatalf("slack payload missing text: %v", seen["/slack"].body)
}
if seen["/discord"].body["content"] == "" {
t.Fatalf("discord payload missing content: %v", seen["/discord"].body)
}
if !strings.HasPrefix(seen["/message?token=gtok"].path, "/message?token=") {
t.Fatalf("gotify hit missing: %v", seen)
}
if h := seen["/alerts"]; h.raw != "Dash test notification" || h.title != "dash" {
t.Fatalf("ntfy payload wrong: %q title=%q", h.raw, h.title)
}
// Legacy string webhook still works alongside presets.
put(`{"notify_webhook":"` + hook.URL + `/legacy"}`)
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"webhook"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("webhook test: %d", res.StatusCode)
}
select {
case h := <-got:
if h.path != "/legacy" || h.body["event"] != "test" {
t.Fatalf("legacy webhook payload: %v", h.body)
}
case <-time.After(3 * time.Second):
t.Fatal("legacy webhook never fired")
}
// Named unconfigured transport -> 400.
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for unconfigured smtp, got %d", res.StatusCode)
}
}
// fakeSMTP is the smallest lawful SMTP server: EHLO/MAIL/RCPT/DATA/QUIT.
func fakeSMTP(t *testing.T) (addr string, gotMsg <-chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
msgs := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
fmt.Fprint(conn, "220 fake\r\n")
var data strings.Builder
inData := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
if inData {
if line == "." {
inData = false
fmt.Fprint(conn, "250 queued\r\n")
continue
}
data.WriteString(line + "\n")
continue
}
switch {
case strings.HasPrefix(line, "EHLO"):
fmt.Fprint(conn, "250-fake\r\n250 OK\r\n")
case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
fmt.Fprint(conn, "250 OK\r\n")
case strings.HasPrefix(line, "DATA"):
fmt.Fprint(conn, "354 go\r\n")
inData = true
case strings.HasPrefix(line, "QUIT"):
fmt.Fprint(conn, "221 bye\r\n")
msgs <- data.String()
return
default:
fmt.Fprint(conn, "250 OK\r\n")
}
}
}()
return ln.Addr().String(), msgs
}
func TestNotifySMTP(t *testing.T) {
srv := testServer(t)
defer srv.Close()
addr, gotMsg := fakeSMTP(t)
host, port, _ := net.SplitHostPort(addr)
res, _ := do(t, "PUT", srv.URL+"/api/settings",
`{"notify_smtp":{"host":"`+host+`","port":"`+port+`","from":"dash@local","to":"me@local"}}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put smtp: %d", res.StatusCode)
}
res, out := do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusOK || out["results"].(map[string]any)["smtp"] != "ok" {
t.Fatalf("smtp test: %d %v", res.StatusCode, out)
}
select {
case msg := <-gotMsg:
if !strings.Contains(msg, "Subject: [Dash] Dash test notification") {
t.Fatalf("smtp message missing subject: %q", msg)
}
case <-time.After(3 * time.Second):
t.Fatal("smtp server never received the message")
}
}
// ---------- alert rules ----------
func TestAlertRulesConsecutiveAndMute(t *testing.T) {
srv := testServer(t)
defer srv.Close()
got := make(chan map[string]any, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
_ = json.NewDecoder(r.Body).Decode(&m)
got <- m
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer up.Close()
down := `{"url":"http://127.0.0.1:1","timeoutS":1}`
// Monitor requiring 2 consecutive failures before going down.
res, m := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Flap","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"consecutiveFailures":2}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", m)
}
id := m["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
do(t, "PATCH", srv.URL+"/api/monitors/"+id, down)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 1st failure: hold
_, mv := do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "up" {
t.Fatalf("expected held status up after 1 failure, got %v", mv["status"])
}
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 2nd failure: down
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "down" {
t.Fatalf("expected down after 2 failures, got %v", mv["status"])
}
select {
case body := <-got:
if body["event"] != "monitor.down" {
t.Fatalf("expected monitor.down, got %v", body["event"])
}
case <-time.After(3 * time.Second):
t.Fatal("monitor.down never fired after threshold")
}
// Muted monitor: status still flips, no notification.
res, m2 := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Quiet","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"mute":true}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create muted: %v", m2)
}
id2 := m2["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
do(t, "PATCH", srv.URL+"/api/monitors/"+id2, down)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id2, "")
if mv["status"] != "down" {
t.Fatalf("muted monitor should still go down, got %v", mv["status"])
}
select {
case body := <-got:
if body["name"] == "Quiet" {
t.Fatal("muted monitor still notified")
}
case <-time.After(1500 * time.Millisecond):
}
}
func TestAlertRulesBadJSON(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"X","type":"http","url":"http://127.0.0.1:1","alerts":"nope"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid alerts, got %d", res.StatusCode)
}
}
// ---------- subdomains ----------
func TestSubdomainDiscovery(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Stub the network seams: CT returns candidates, resolver answers a few.
oldCT, oldRes := domain.CTNames, domain.ResolveIPs
domain.CTNames = func(_ context.Context, d string) ([]string, error) {
return []string{"api." + d + "\nwww." + d, "*." + d, "other.com"}, nil
}
domain.ResolveIPs = func(_ context.Context, name string) ([]string, error) {
if strings.HasPrefix(name, "api.") {
return []string{"10.0.0.1"}, nil
}
return []string{"93.184.216.34"}, nil
}
t.Cleanup(func() { domain.CTNames, domain.ResolveIPs = oldCT, oldRes })
res, d := do(t, "POST", srv.URL+"/api/domains", `{"name":"example.com"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create domain: %v", d)
}
id := d["id"].(string)
// First refresh ran discovery inline (sub_checked_at was NULL).
res, subs := do(t, "GET", srv.URL+"/api/domains/"+id+"/subdomains", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("list subdomains: %d", res.StatusCode)
}
list, _ := subs["subdomains"].([]any) // response is a bare array — check below
_ = list
// do() decodes objects only; re-fetch raw for the array.
raw, err := http.Get(srv.URL + "/api/domains/" + id + "/subdomains")
if err != nil {
t.Fatal(err)
}
var arr []map[string]any
_ = json.NewDecoder(raw.Body).Decode(&arr)
raw.Body.Close()
names := map[string][]string{}
for _, s := range arr {
ips := []string{}
for _, ip := range s["ips"].([]any) {
ips = append(ips, ip.(string))
}
names[s["name"].(string)] = ips
}
if len(names) != 3 || len(names["api.example.com"]) != 1 || names["api.example.com"][0] != "10.0.0.1" {
t.Fatalf("unexpected subdomains: %v", names)
}
if _, bad := names["other.com"]; bad {
t.Fatal("foreign name leaked into subdomains")
}
}
+15
View File
@@ -60,8 +60,20 @@ func (s *Server) routes() *gin.Engine {
r.Use(accessLog(s.log), gin.Recovery())
v1 := r.Group("/api")
v1.Use(s.requireAuth())
v1.GET("/healthz", s.healthz)
v1.GET("/auth/session", s.authSession)
v1.POST("/auth/setup", s.authSetup)
v1.POST("/auth/login", s.authLogin)
v1.POST("/auth/logout", s.authLogout)
v1.POST("/auth/disable", s.authDisable)
v1.GET("/boards", s.listBoards)
v1.POST("/boards", s.createBoard)
v1.PATCH("/boards/:id", s.updateBoard)
v1.DELETE("/boards/:id", s.deleteBoard)
v1.GET("/sections", s.listSections)
v1.POST("/sections", s.createSection)
v1.PATCH("/sections/:id", s.updateSection)
@@ -91,6 +103,8 @@ func (s *Server) routes() *gin.Engine {
v1.DELETE("/domains/:id", s.deleteDomain)
v1.POST("/domains/:id/refresh", s.refreshDomainH)
v1.GET("/domains/:id/checks", s.domainChecks)
v1.GET("/domains/:id/subdomains", s.listSubdomains)
v1.POST("/domains/:id/subdomains/refresh", s.refreshSubdomains)
v1.POST("/notify/test", s.notifyTest)
v1.GET("/systems", s.listSystemsH)
@@ -167,6 +181,7 @@ type Section struct {
Name string `json:"name"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed"`
BoardID string `json:"boardId"`
Items []Item `json:"items"`
}
+70
View File
@@ -786,3 +786,73 @@ func TestValidURL(t *testing.T) {
}
}
}
func TestBoards(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Default board exists and owns new sections.
var boards []Board
resp, err := http.Get(srv.URL + "/api/boards")
if err != nil || json.NewDecoder(resp.Body).Decode(&boards) != nil {
t.Fatalf("list boards: %v", err)
}
resp.Body.Close()
if len(boards) != 1 || boards[0].ID != "b_home" {
t.Fatalf("expected seeded home board, got %+v", boards)
}
_, sec := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
if sec["boardId"] != "b_home" {
t.Fatalf("section should default to home board: %v", sec["boardId"])
}
// Create a second board; slug is derived and unique.
_, b2 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b2["slug"] != "lab-net" {
t.Fatalf("bad slug: %v", b2["slug"])
}
_, b3 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b3["slug"] == b2["slug"] {
t.Fatalf("slug collision: %v", b3["slug"])
}
// Sections scope to their board.
_, s2 := do(t, "POST", srv.URL+"/api/sections", `{"name":"B","boardId":"`+b2["id"].(string)+`"}`)
if s2["boardId"] != b2["id"] {
t.Fatalf("section not scoped to board: %v", s2["boardId"])
}
var home, lab []Section
r1, _ := http.Get(srv.URL + "/api/sections?board=b_home")
_ = json.NewDecoder(r1.Body).Decode(&home)
r1.Body.Close()
r2, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r2.Body).Decode(&lab)
r2.Body.Close()
if len(home) != 1 || home[0].Name != "A" || len(lab) != 1 || lab[0].Name != "B" {
t.Fatalf("board filter broken: home=%v lab=%v", home, lab)
}
// Rename keeps the slug; deleting cascades sections.
_, rb := do(t, "PATCH", srv.URL+"/api/boards/"+b3["id"].(string), `{"name":"Renamed"}`)
if rb["name"] != "Renamed" || rb["slug"] == "renamed" {
t.Fatalf("rename should not change slug: %+v", rb)
}
res, _ := do(t, "DELETE", srv.URL+"/api/boards/"+b2["id"].(string), "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete board: %d", res.StatusCode)
}
var labAfter []Section
r3, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r3.Body).Decode(&labAfter)
r3.Body.Close()
if len(labAfter) != 0 {
t.Fatalf("sections should cascade with board: %v", labAfter)
}
// Down to two boards — delete both and the last delete must refuse.
do(t, "DELETE", srv.URL+"/api/boards/"+b3["id"].(string), "")
res, _ = do(t, "DELETE", srv.URL+"/api/boards/b_home", "")
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("deleting last board should 400, got %d", res.StatusCode)
}
}
+261
View File
@@ -0,0 +1,261 @@
package api
// Opt-in local auth — disabled by default; enabling creates the first admin.
// Local username + bcrypt password only (no OIDC/SSO — deliberate non-goal).
// Sessions are random tokens in SQLite behind an httpOnly SameSite=Lax cookie;
// login is rate-limited per IP. Public endpoints (status pages, badges, push,
// agent ingest, metrics) bypass the middleware.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "dash_session"
sessionTTL = 7 * 24 * time.Hour
// Rotate when less than half the TTL remains.
sessionRotate = sessionTTL / 2
)
func (s *Server) authEnabled() bool {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'auth_enabled'`).Scan(&raw); err != nil {
return false
}
var on bool
_ = json.Unmarshal([]byte(raw), &on)
return on
}
func (s *Server) userCount() int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}
// ---------- sessions ----------
func (s *Server) createSession(c *gin.Context, userID string) {
var tok [32]byte
_, _ = rand.Read(tok[:])
token := hex.EncodeToString(tok[:])
exp := time.Now().Add(sessionTTL)
_, _ = s.db.Exec(`INSERT INTO sessions (token, user_id, expires_at) VALUES (?,?,?)`,
token, userID, exp.UTC().Format("2006-01-02T15:04:05.000Z"))
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: exp,
HttpOnly: true,
Secure: c.Request.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
// sessionUser resolves the cookie to a user, sliding-rotating near expiry.
// Returns "" when absent/expired.
func (s *Server) sessionUser(c *gin.Context) string {
ck, err := c.Cookie(sessionCookie)
if err != nil || ck == "" {
return ""
}
var userID, expires string
err = s.db.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token = ?`, ck).Scan(&userID, &expires)
if err != nil {
return ""
}
exp, err := time.Parse("2006-01-02T15:04:05.000Z", expires)
if err != nil || time.Now().After(exp) {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
return ""
}
if time.Until(exp) < sessionRotate {
// Rotate: drop the old token, issue a fresh one.
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
s.createSession(c, userID)
}
return userID
}
// ---------- rate limiting (login only, in-memory per-IP) ----------
var loginMu sync.Mutex
var loginHits = map[string][]time.Time{}
func loginAllowed(ip string) bool {
loginMu.Lock()
defer loginMu.Unlock()
cut := time.Now().Add(-time.Minute)
kept := loginHits[ip][:0]
for _, t := range loginHits[ip] {
if t.After(cut) {
kept = append(kept, t)
}
}
if len(kept) >= 5 {
loginHits[ip] = kept
return false
}
loginHits[ip] = append(kept, time.Now())
return true
}
// ---------- middleware ----------
// publicAPI lists /api paths reachable without a session. Auth endpoints must
// stay open (login itself); push/ingest/badge/metrics/public status carry
// their own tokens or are intentionally public.
func publicAPI(path string) bool {
switch {
case strings.HasPrefix(path, "/api/auth/"),
strings.HasPrefix(path, "/api/push/"),
path == "/api/systems/ingest",
strings.HasPrefix(path, "/api/badge/"),
path == "/api/metrics",
strings.HasPrefix(path, "/api/healthz"),
strings.HasSuffix(path, "/public"):
return true
}
return false
}
// requireAuth gates every /api route when the auth_enabled setting is on.
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if !s.authEnabled() || publicAPI(c.Request.URL.Path) {
c.Next()
return
}
if s.sessionUser(c) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
c.Next()
}
}
// ---------- handlers ----------
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
// sessionInfo reports auth state for the frontend shell — always public so
// the UI can decide between the board and the login screen.
func (s *Server) authSession(c *gin.Context) {
on := s.authEnabled()
out := gin.H{"enabled": on, "authed": false, "setup": false}
if !on {
c.JSON(http.StatusOK, out)
return
}
out["setup"] = s.userCount() == 0
if uid := s.sessionUser(c); uid != "" {
var name string
_ = s.db.QueryRow(`SELECT username FROM users WHERE id = ?`, uid).Scan(&name)
out["authed"] = true
out["username"] = name
}
c.JSON(http.StatusOK, out)
}
// setup creates the first user — only while auth is on and no users exist.
func (s *Server) authSetup(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
if s.userCount() > 0 {
fail(c, http.StatusConflict, "admin user already exists")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil ||
len(strings.TrimSpace(in.Username)) < 2 || len(in.Password) < 8 {
fail(c, http.StatusBadRequest, "username (2+) and password (8+) required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(c, http.StatusInternalServerError, "hash failed")
return
}
uid := newID("u")
if _, err := s.db.Exec(`INSERT INTO users (id, username, pass_hash) VALUES (?,?,?)`,
uid, strings.TrimSpace(in.Username), string(hash)); err != nil {
fail(c, http.StatusConflict, "username taken")
return
}
s.createSession(c, uid)
c.JSON(http.StatusCreated, gin.H{"username": in.Username})
}
func (s *Server) authLogin(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
ip := c.ClientIP()
if !loginAllowed(ip) {
fail(c, http.StatusTooManyRequests, "too many attempts — wait a minute")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
var uid, hash string
err := s.db.QueryRow(`SELECT id, pass_hash FROM users WHERE username = ?`,
strings.TrimSpace(in.Username)).Scan(&uid, &hash)
if err != nil {
// Unknown user — run bcrypt anyway so timing doesn't leak existence.
hash = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5sA5t6p7K0dGm7ZC4yQ0yCk0cFk0K"
}
// CompareHashAndPassword is constant-time; err != nil covers both
// unknown user (dummy hash above) and wrong password.
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
s.createSession(c, uid)
c.JSON(http.StatusOK, gin.H{"username": in.Username})
}
func (s *Server) authLogout(c *gin.Context) {
if ck, err := c.Cookie(sessionCookie); err == nil {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
}
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
})
c.Status(http.StatusNoContent)
}
// authDisable turns auth off and wipes users+sessions. Requires a session —
// you can't switch the lock off from outside.
func (s *Server) authDisable(c *gin.Context) {
if s.sessionUser(c) == "" {
fail(c, http.StatusUnauthorized, "authentication required")
return
}
_, _ = s.db.Exec(`DELETE FROM sessions`)
_, _ = s.db.Exec(`DELETE FROM users`)
if _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES ('auth_enabled','false')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
s.log.Error("auth disable", zap.Error(err))
}
c.Status(http.StatusNoContent)
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"go.uber.org/zap"
)
// authServer returns the bare server (no httptest) so tests can plant
// sessions and inspect cookies directly.
func authServer(t *testing.T) *Server {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
// Login rate limits are keyed by IP in a package-global map — reset so
// tests don't count each other's attempts.
loginMu.Lock()
loginHits = map[string][]time.Time{}
loginMu.Unlock()
return newServer(zap.NewNop(), d, t.TempDir())
}
func req(t *testing.T, s *Server, method, path, body, cookie string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if cookie != "" {
r.Header.Set("Cookie", sessionCookie+"="+cookie)
}
s.routes().ServeHTTP(w, r)
return w
}
func sessionCookieOf(w *httptest.ResponseRecorder) string {
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookie {
return c.Value
}
}
return ""
}
func enableAuth(t *testing.T, s *Server) {
t.Helper()
if w := req(t, s, "PUT", "/api/settings", `{"auth_enabled":true}`, ""); w.Code != 200 {
t.Fatalf("enable auth: %d", w.Code)
}
}
func TestAuthDisabledByDefault(t *testing.T) {
s := authServer(t)
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections open when auth off: %d", w.Code)
}
var out map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&out)
if out["enabled"] != false {
t.Fatalf("auth should report disabled: %v", out)
}
}
func TestAuthLifecycle(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
// Everything gates now.
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 401 {
t.Fatalf("expected 401, got %d", w.Code)
}
// Session endpoint stays public and reports setup mode.
var sess map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&sess)
if sess["enabled"] != true || sess["setup"] != true || sess["authed"] != false {
t.Fatalf("bad session payload: %v", sess)
}
// Public surfaces stay open even gated.
for _, p := range []string{"/api/healthz", "/api/metrics", "/api/badge/uptime/x.svg"} {
if w := req(t, s, "GET", p, "", ""); w.Code == 401 {
t.Fatalf("public path %s gated", p)
}
}
// Setup creates the first admin and returns a session cookie.
w = req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 201 {
t.Fatalf("setup: %d body=%s", w.Code, w.Body)
}
cookie := sessionCookieOf(w)
if cookie == "" {
t.Fatal("setup did not set a session cookie")
}
// Second setup blocked.
if w := req(t, s, "POST", "/api/auth/setup", `{"username":"x","password":"another-pass"}`, ""); w.Code != 409 {
t.Fatalf("second setup should 409, got %d", w.Code)
}
// Authed request with the cookie.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 200 {
t.Fatalf("authed request: %d", w.Code)
}
// Logout kills the session.
req(t, s, "POST", "/api/auth/logout", "", cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expected 401 after logout, got %d", w.Code)
}
// Login wrong password -> 401; right -> 200 + fresh cookie.
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"wrong-password"}`, ""); w.Code != 401 {
t.Fatalf("bad login should 401, got %d", w.Code)
}
w = req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 200 {
t.Fatalf("login: %d", w.Code)
}
if sessionCookieOf(w) == "" {
t.Fatal("login set no cookie")
}
}
func TestAuthLoginRateLimit(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
req(t, s, "POST", "/api/auth/logout", "", "")
for i := 0; i < 5; i++ {
req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"nope-nope"}`, "")
}
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, ""); w.Code != 429 {
t.Fatalf("6th login should be rate-limited, got %d", w.Code)
}
}
func TestAuthSessionExpiryAndRotation(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Expired session -> 401.
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expired session should 401, got %d", w.Code)
}
// Near-expiry session rotates: response carries a new cookie.
fresh := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
cookie = sessionCookieOf(fresh)
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
w = req(t, s, "GET", "/api/sections", "", cookie)
if w.Code != 200 {
t.Fatalf("rotating request should pass, got %d", w.Code)
}
newCookie := sessionCookieOf(w)
if newCookie == "" || newCookie == cookie {
t.Fatal("expected session rotation to issue a new token")
}
// Old token is dead.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("old token should be invalid after rotation, got %d", w.Code)
}
}
func TestAuthDisable(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Unauthenticated disable is refused.
if w := req(t, s, "POST", "/api/auth/disable", "", ""); w.Code != 401 {
t.Fatalf("disable without session should 401, got %d", w.Code)
}
if w := req(t, s, "POST", "/api/auth/disable", "", cookie); w.Code != 204 {
t.Fatalf("disable: %d", w.Code)
}
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections should reopen after disable, got %d", w.Code)
}
}
+28 -12
View File
@@ -10,8 +10,16 @@ import (
// board loads the full tree: sections ordered, each with items and urls.
// Three flat queries assembled in memory — the whole board is ~100 rows.
func (s *Server) board() ([]Section, error) {
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
// boardID filters to one board; "" returns every section (export, legacy).
// COALESCE maps pre-boards rows onto the default 'b_home' board.
func (s *Server) board(boardID string) ([]Section, error) {
q := `SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections`
var args []any
if boardID != "" {
q += ` WHERE board_id = ?`
args = append(args, boardID)
}
secRows, err := s.db.Query(q+` ORDER BY position`, args...)
if err != nil {
return nil, err
}
@@ -21,7 +29,7 @@ func (s *Server) board() ([]Section, error) {
byID := map[string]*Section{}
for secRows.Next() {
var sec Section
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
return nil, err
}
sec.Items = []Item{}
@@ -89,7 +97,7 @@ func (s *Server) board() ([]Section, error) {
}
func (s *Server) listSections(c *gin.Context) {
sections, err := s.board()
sections, err := s.board(c.Query("board"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -99,19 +107,27 @@ func (s *Server) listSections(c *gin.Context) {
func (s *Server) createSection(c *gin.Context) {
var in struct {
Name string `json:"name"`
Name string `json:"name"`
BoardID string `json:"boardId"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
boardID := in.BoardID
if boardID == "" {
boardID = "b_home"
}
var maxPos sql.NullFloat64
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections WHERE board_id = ?`, boardID).Scan(&maxPos); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), BoardID: boardID, Items: []Item{}}
// The subquery yields NULL for an unknown board id instead of an FK error;
// board() maps NULL back onto the default board.
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position, board_id)
VALUES (?, ?, ?, (SELECT id FROM boards WHERE id = ?))`, sec.ID, sec.Name, sec.Position, boardID); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
@@ -140,8 +156,8 @@ func (s *Server) updateSection(c *gin.Context) {
}
}
var sec Section
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID)
if notFound(err) {
fail(c, http.StatusNotFound, "section not found")
return
@@ -192,8 +208,8 @@ func (s *Server) reorderSection(c *gin.Context) {
return
}
var sec Section
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
if err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
+120
View File
@@ -0,0 +1,120 @@
package api
// Multiple boards — sections carry board_id; the board list is small and
// lives next to the section tree. Deleting a board cascades its sections.
import (
"database/sql"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// Board is a named dashboard page reachable at /b/<slug>.
type Board struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Position float64 `json:"position"`
}
func (s *Server) listBoards(c *gin.Context) {
rows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []Board{}
for rows.Next() {
var b Board
if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
out = append(out, b)
}
c.JSON(http.StatusOK, out)
}
func (s *Server) createBoard(c *gin.Context) {
var in struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || strings.TrimSpace(in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
name := strings.TrimSpace(in.Name)
slug := slugify(name)
if slug == "" {
slug = "board"
}
// Uniquify: 'media', 'media-2', 'media-3'…
base := slug
for i := 2; ; i++ {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards WHERE slug = ?`, slug).Scan(&n)
if n == 0 {
break
}
slug = base + "-" + strconv.Itoa(i)
}
var maxPos sql.NullFloat64
_ = s.db.QueryRow(`SELECT MAX(position) FROM boards`).Scan(&maxPos)
b := Board{ID: newID("b"), Slug: slug, Name: name, Position: midpoint(ptrOr(maxPos), nil)}
if _, err := s.db.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?,?,?,?)`,
b.ID, b.Slug, b.Name, b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, b)
}
func (s *Server) updateBoard(c *gin.Context) {
var in struct {
Name *string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || strings.TrimSpace(*in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
// Slug stays fixed on rename — links shouldn't break.
res, err := s.db.Exec(`UPDATE boards SET name = ? WHERE id = ?`, strings.TrimSpace(*in.Name), c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "board not found")
return
}
var b Board
if err := s.db.QueryRow(`SELECT id, slug, name, position FROM boards WHERE id = ?`, c.Param("id")).
Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, b)
}
func (s *Server) deleteBoard(c *gin.Context) {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards`).Scan(&n)
if n <= 1 {
fail(c, http.StatusBadRequest, "cannot delete the last board")
return
}
res, err := s.db.Exec(`DELETE FROM boards 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, "board not found")
return
}
c.Status(http.StatusNoContent)
}
+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) {
+2 -2
View File
@@ -30,11 +30,11 @@ func (s *Server) importExternal(c *gin.Context) {
fail(c, http.StatusBadRequest, err.Error())
return
}
if err := s.replaceBoard(sections, nil); err != nil {
if err := s.replaceBoard(sections, nil, nil); err != nil {
failImport(c, err)
return
}
board, err := s.board()
board, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
+44 -6
View File
@@ -20,21 +20,22 @@ import (
const monitorCols = `id, name, type, url, hostname, port, method, headers, body,
keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
retries, active, status, push_token, tags, notes, position, last_check, created_at`
retries, active, status, push_token, tags, notes, alerts, position, last_check, created_at`
func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
var m monitor.Monitor
var tags string
var tags, alerts string
var invert, active int
err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method,
&m.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType,
&m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken,
&tags, &m.Notes, &m.Position, &m.LastCheck, &m.CreatedAt)
&tags, &m.Notes, &alerts, &m.Position, &m.LastCheck, &m.CreatedAt)
if err != nil {
return nil, err
}
m.KeywordInvert = invert != 0
m.Active = active != 0
m.Alerts = json.RawMessage(alerts)
_ = json.Unmarshal([]byte(tags), &m.Tags)
if m.Tags == nil {
m.Tags = []string{}
@@ -142,6 +143,7 @@ type monitorInput struct {
Active *bool `json:"active"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
}
func validMonitorType(t string) bool {
@@ -209,17 +211,26 @@ func (s *Server) createMonitor(c *gin.Context) {
if in.Tags == nil {
tags = []byte("[]")
}
alerts := "{}"
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
}
alerts = string(in.Alerts)
}
_, err := s.db.Exec(`INSERT INTO monitors
(id, name, type, url, hostname, port, method, headers, body, keyword,
keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
retries, active, push_token, tags, notes, position)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
retries, active, push_token, tags, notes, alerts, position)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port),
strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body),
strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery),
strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60),
intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true),
token, string(tags), strOr(in.Notes), float64(time.Now().UnixNano())/1e9)
token, string(tags), strOr(in.Notes), alerts, float64(time.Now().UnixNano())/1e9)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -294,6 +305,14 @@ func (s *Server) patchMonitor(c *gin.Context) {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE monitors 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 monitors SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
if in.Active != nil {
// pause/resume flips the stored status back to pending for a fresh probe
status := "pending"
@@ -412,6 +431,9 @@ func (s *Server) runScheduler(done <-chan struct{}) {
s.log.Error("heartbeat prune", zap.Error(err))
}
// System samples are high-rate; a week of raw ticks is enough.
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now')`); err != nil {
s.log.Error("session prune", zap.Error(err))
}
if _, err := s.db.Exec(`DELETE FROM system_stats WHERE ts < datetime('now','-7 days')`); err != nil {
s.log.Error("system stats prune", zap.Error(err))
}
@@ -473,8 +495,13 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
s.writeHeartbeat(m, res)
prev := m.Status
next := "down"
rules := parseAlertRules(m.Alerts)
if res.Up {
next = "up"
} else if n := rules.ConsecutiveFailures; n > 1 && prev != "down" &&
s.consecutiveDowns(m.ID, n) < n {
// Flap damping: hold the previous status until N checks in a row fail.
next = prev
}
if _, err := s.db.Exec(`UPDATE monitors SET status = ?, last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
next, m.ID); err != nil {
@@ -483,6 +510,14 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
if prev != next && prev != "pending" {
s.onMonitorTransition(m, prev, next)
}
// Latency warn: notify once when ping crosses above the threshold.
if rules.LatencyWarnMs > 0 && res.Up && res.PingMs >= rules.LatencyWarnMs &&
!rules.Mute && prev != "pending" && !s.inMaintenance(m.ID) {
if p := s.prevPing(m.ID); p >= 0 && p < rules.LatencyWarnMs {
s.notify("monitor.slow", m.Name,
fmt.Sprintf("Monitor %q latency %dms (threshold %dms)", m.Name, res.PingMs, rules.LatencyWarnMs))
}
}
return res
}
@@ -507,6 +542,9 @@ func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
return
}
s.autoIncident(m.ID, m.Name, next)
if parseAlertRules(m.Alerts).Mute {
return
}
target := m.URL
if target == "" {
target = m.Hostname
+242 -43
View File
@@ -3,19 +3,29 @@ package api
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Notifications: one outbound webhook, configured via the notify_webhook
// settings key. Payload carries event/name/text/content/message so the same
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic
// receivers. SMTP and per-service channels are deliberately out of scope —
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them.
// Notifications: a small transport registry. Each transport is configured via
// the settings key notify_<name> holding a JSON object of fields; a transport
// is active when its required fields are set. Multiple transports may be
// active — notify() fans out to all of them.
//
// notify_webhook keeps back-compat: a plain string value means {"url": value}.
//
// Secrets (smtp_pass, tokens) live in settings like any other value and are
// never logged. Single-user self-hosted is the threat model; P5d auth gates
// the settings endpoint when enabled.
type notifyPayload struct {
Event string `json:"event"`
@@ -26,54 +36,213 @@ type notifyPayload struct {
At string `json:"at"`
}
func (s *Server) webhookURL() string {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil {
return ""
}
// Settings values are stored as JSON — unwrap the quoted string.
var u string
if err := json.Unmarshal([]byte(raw), &u); err != nil {
return ""
}
return u
type transport struct {
name string
required []string
send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
}
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
var transports = []transport{
{"webhook", []string{"url"}, sendWebhook},
{"slack", []string{"url"}, sendSlack},
{"discord", []string{"url"}, sendDiscord},
{"telegram", []string{"token", "chat_id"}, sendTelegram},
{"gotify", []string{"server", "token"}, sendGotify},
{"ntfy", []string{"server", "topic"}, sendNtfy},
{"smtp", []string{"host", "port", "from", "to"}, sendSMTP},
}
// notifyCfg loads notify_<name> from settings. Values are JSON: an object for
// presets, or a bare string (legacy webhook) meaning {"url": s}.
func (s *Server) notifyCfg(name string) map[string]string {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_`+name+`'`).Scan(&raw); err != nil {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
var str string
if err := json.Unmarshal([]byte(raw), &str); err == nil && name == "webhook" {
return map[string]string{"url": str}
}
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if str, ok := v.(string); ok {
out[k] = str
}
}
return out
}
// configured reports whether all required fields are present. Partially
// configured transports count as configured so their send error surfaces.
func (t transport) configured(cfg map[string]string) bool {
if cfg == nil {
return false
}
for _, k := range t.required {
if strings.TrimSpace(cfg[k]) == "" {
return false
}
}
return true
}
// notify fans an event out to every configured transport, asynchronously.
func (s *Server) notify(event, name, msg string) {
url := s.webhookURL()
if url == "" {
return
}
if u := parseHTTPURL(url); u == "" {
s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event))
return
}
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
At: time.Now().UTC().Format(time.RFC3339)}
go s.deliver(url, p)
for _, tr := range transports {
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
continue
}
tr, cfg := tr, cfg
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := tr.send(ctx, cfg, p); err != nil {
s.log.Warn("notify failed", zap.String("transport", tr.name),
zap.String("event", event), zap.Error(err))
}
}()
}
}
func (s *Server) deliver(url string, p notifyPayload) {
body, _ := json.Marshal(p)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// postJSON posts a JSON body and reports non-2xx as an error.
func postJSON(ctx context.Context, url string, payload any, headers map[string]string) error {
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Title", p.Text) // ntfy reads the title header
req.Header.Set("X-Dash-Event", p.Event)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
return
return err
}
resp.Body.Close()
if resp.StatusCode >= 400 {
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
func sendWebhook(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], p, map[string]string{
"Title": p.Text, "X-Dash-Event": p.Event,
})
}
func sendSlack(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"text": p.Text}, nil)
}
func sendDiscord(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"content": p.Content}, nil)
}
func sendTelegram(ctx context.Context, cfg map[string]string, p notifyPayload) error {
base := strings.TrimRight(cfg["server"], "/") // optional self-hosted bot-api
if base == "" {
base = "https://api.telegram.org"
}
url := base + "/bot" + cfg["token"] + "/sendMessage"
return postJSON(ctx, url, map[string]any{
"chat_id": cfg["chat_id"], "text": p.Text,
}, nil)
}
func sendGotify(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/message?token=" + cfg["token"]
return postJSON(ctx, url, map[string]any{
"title": p.Name, "message": p.Message, "priority": 5,
}, nil)
}
func sendNtfy(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/" + strings.TrimLeft(cfg["topic"], "/")
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(p.Message))
if err != nil {
return err
}
req.Header.Set("Title", p.Name)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
// sendSMTP uses net/smtp with a hard deadline, opportunistic STARTTLS, and
// plain auth only when a user is configured.
func sendSMTP(ctx context.Context, cfg map[string]string, p notifyPayload) error {
host, port := cfg["host"], cfg["port"]
addr := net.JoinHostPort(host, port)
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
defer conn.Close()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return err
}
}
if cfg["user"] != "" {
if err := c.Auth(smtp.PlainAuth("", cfg["user"], cfg["pass"], host)); err != nil {
return err
}
}
if err := c.Mail(cfg["from"]); err != nil {
return err
}
for _, rcpt := range strings.Split(cfg["to"], ",") {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [Dash] %s\r\n\r\n%s\r\n",
cfg["from"], cfg["to"], p.Text, p.Message)
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
@@ -85,12 +254,42 @@ func parseHTTPURL(raw string) string {
return raw
}
// notifyTest lets the settings UI verify the configured webhook.
// notifyTest sends a test event synchronously so the UI gets a real result.
// Optional body {"transport": "smtp"} limits the test to one transport.
func (s *Server) notifyTest(c *gin.Context) {
if s.webhookURL() == "" {
fail(c, http.StatusBadRequest, "notify_webhook not configured")
var in struct {
Transport string `json:"transport"`
}
_ = c.ShouldBindJSON(&in)
p := notifyPayload{Event: "test", Name: "dash", Text: "Dash test notification",
Content: "Dash test notification", Message: "Dash test notification",
At: time.Now().UTC().Format(time.RFC3339)}
results := map[string]string{}
for _, tr := range transports {
if in.Transport != "" && tr.name != in.Transport {
continue
}
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
if in.Transport != "" {
fail(c, http.StatusBadRequest, tr.name+" not configured")
return
}
continue
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
err := tr.send(ctx, cfg, p)
cancel()
if err != nil {
results[tr.name] = "error: " + err.Error()
} else {
results[tr.name] = "ok"
}
}
if len(results) == 0 {
fail(c, http.StatusBadRequest, "no notification transport configured")
return
}
s.notify("test", "dash", "Dash test notification")
c.JSON(http.StatusOK, gin.H{"status": "sent"})
c.JSON(http.StatusOK, gin.H{"results": results})
}
+95
View File
@@ -0,0 +1,95 @@
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)
}
+37 -4
View File
@@ -41,13 +41,46 @@ type StatSample struct {
Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps"`
Containers []ContainerStat `json:"containers"`
// Optional collectors — present only when the host has the tools.
Smart []SmartDisk `json:"smart,omitempty"`
ZFS []ZFSPool `json:"zfs,omitempty"`
GPU []GPUStat `json:"gpu,omitempty"`
}
// ContainerStat is one Docker container's liveness; per-container CPU/mem
// needs cgroup sampling — deferred (v1 ships name+state only).
// ContainerStat is one Docker container's liveness plus resource use.
// CPU is percent of one core (may exceed 100); Mem* are bytes. Zero when the
// agent couldn't reach the stats endpoint.
type ContainerStat struct {
Name string `json:"name"`
State string `json:"state"`
Name string `json:"name"`
State string `json:"state"`
CPU float64 `json:"cpu,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemLimit float64 `json:"memLimit,omitempty"`
}
// SmartDisk is one drive's SMART summary (smartctl).
type SmartDisk struct {
Device string `json:"device"`
Model string `json:"model"`
Passed *bool `json:"passed,omitempty"`
TempC float64 `json:"tempC,omitempty"`
}
// ZFSPool is one pool's health and space (zpool).
type ZFSPool struct {
Name string `json:"name"`
Health string `json:"health"`
Size float64 `json:"size"`
Free float64 `json:"free"`
}
// GPUStat is one GPU's basic metrics (sysfs or nvidia-smi).
type GPUStat struct {
Name string `json:"name"`
TempC float64 `json:"tempC,omitempty"`
UtilPct float64 `json:"utilPct,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemTotal float64 `json:"memTotal,omitempty"`
}
// System is one monitored host; `latest` is the newest StatSample.
+42 -7
View File
@@ -28,6 +28,7 @@ type Export struct {
Version int `json:"version"`
Settings map[string]json.RawMessage `json:"settings"`
Sections []Section `json:"sections"`
Boards []Board `json:"boards,omitempty"`
Monitors []json.RawMessage `json:"monitors,omitempty"`
Domains []json.RawMessage `json:"domains,omitempty"`
Systems []json.RawMessage `json:"systems,omitempty"`
@@ -74,12 +75,21 @@ func (s *Server) exportBoard(c *gin.Context) {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sections, err := s.board()
sections, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ex := Export{Version: 2, Settings: settings, Sections: sections}
if bRows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`); err == nil {
for bRows.Next() {
var b Board
if bRows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position) == nil {
ex.Boards = append(ex.Boards, b)
}
}
bRows.Close()
}
for _, t := range []struct {
name string
dst *[]json.RawMessage
@@ -103,11 +113,11 @@ func (s *Server) importBoard(c *gin.Context) {
fail(c, http.StatusBadRequest, "invalid export payload")
return
}
if err := s.replaceBoard(in.Sections, in.Settings); err != nil {
if err := s.replaceBoard(in.Sections, in.Boards, in.Settings); err != nil {
failImport(c, err)
return
}
sections, err := s.board()
sections, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -116,8 +126,10 @@ func (s *Server) importBoard(c *gin.Context) {
}
// replaceBoard swaps all board rows inside one transaction — shared by the
// Dash export import and the external-config importers.
func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error {
// Dash export import and the external-config importers. A payload carrying
// boards replaces the board list too; one without (v1/v2 exports, foreign
// imports) leaves boards alone and sections land on the default board.
func (s *Server) replaceBoard(sections []Section, boards []Board, settings map[string]json.RawMessage) error {
tx, err := s.db.Begin()
if err != nil {
return err
@@ -129,12 +141,35 @@ func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMe
return err
}
}
if len(boards) > 0 {
if _, err := tx.Exec(`DELETE FROM boards`); err != nil {
return err
}
for _, b := range boards {
id, slug := b.ID, b.Slug
if slug == "" {
slug = slugify(b.Name)
}
if id == "" || slug == "" {
return fmt.Errorf("%w: board missing id or slug", errBadInput)
}
if _, err := tx.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?, ?, ?, ?)`,
id, slug, orDefault(b.Name, slug), b.Position); err != nil {
return err
}
}
// The default board must survive import — orphaned sections map to it.
if _, err := tx.Exec(`INSERT OR IGNORE INTO boards (id, slug, name, position) VALUES ('b_home','home','Home',0)`); err != nil {
return err
}
}
for _, sec := range sections {
if sec.ID == "" || sec.Name == "" {
return fmt.Errorf("%w: section missing id or name", errBadInput)
}
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed, board_id)
VALUES (?, ?, ?, ?, (SELECT id FROM boards WHERE id = ?))`,
sec.ID, sec.Name, sec.Position, sec.Collapsed, sec.BoardID); err != nil {
return err
}
for _, it := range sec.Items {