From f3143af351412d13f2a9a2e9a2eec5a889e63915 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 22 Sep 2026 12:49:44 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20phase=205=20=E2=80=94=20alerts?= =?UTF-8?q?,=20agent=20depth,=20subdomains,=20auth,=20boards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/cmd/dash-agent/docker.go | 121 +++++++ apps/backend/cmd/dash-agent/extra.go | 230 ++++++++++++ apps/backend/cmd/dash-agent/main.go | 74 ++-- apps/backend/cmd/dash-agent/main_test.go | 48 +++ apps/backend/go.mod | 8 +- apps/backend/go.sum | 10 + apps/backend/internal/api/alerts.go | 46 +++ apps/backend/internal/api/alerts_test.go | 324 +++++++++++++++++ apps/backend/internal/api/api.go | 15 + apps/backend/internal/api/api_test.go | 70 ++++ apps/backend/internal/api/auth.go | 261 ++++++++++++++ apps/backend/internal/api/auth_test.go | 196 +++++++++++ apps/backend/internal/api/board.go | 40 ++- apps/backend/internal/api/boards.go | 120 +++++++ apps/backend/internal/api/domains.go | 42 ++- apps/backend/internal/api/importers.go | 4 +- apps/backend/internal/api/monitors.go | 50 ++- apps/backend/internal/api/notify.go | 285 ++++++++++++--- apps/backend/internal/api/subdomains.go | 95 +++++ apps/backend/internal/api/systems.go | 41 ++- apps/backend/internal/api/transfer.go | 49 ++- .../internal/db/migrations/0007_alerts.sql | 10 + .../db/migrations/0008_subdomains.sql | 18 + .../internal/db/migrations/0009_auth.sql | 22 ++ .../internal/db/migrations/0010_boards.sql | 32 ++ apps/backend/internal/domain/subdomain.go | 128 +++++++ .../backend/internal/domain/subdomain_test.go | 29 ++ apps/backend/internal/monitor/monitor.go | 1 + apps/backend/internal/web/dist/.keep | 0 apps/backend/internal/widget/embed.go | 30 ++ apps/backend/internal/widget/jsonpath.go | 89 +++++ apps/backend/internal/widget/jsonpath_test.go | 80 +++++ openapi.yaml | 330 +++++++++++++++++- 33 files changed, 2772 insertions(+), 126 deletions(-) create mode 100644 apps/backend/cmd/dash-agent/docker.go create mode 100644 apps/backend/cmd/dash-agent/extra.go create mode 100644 apps/backend/cmd/dash-agent/main_test.go create mode 100644 apps/backend/internal/api/alerts.go create mode 100644 apps/backend/internal/api/alerts_test.go create mode 100644 apps/backend/internal/api/auth.go create mode 100644 apps/backend/internal/api/auth_test.go create mode 100644 apps/backend/internal/api/boards.go create mode 100644 apps/backend/internal/api/subdomains.go create mode 100644 apps/backend/internal/db/migrations/0007_alerts.sql create mode 100644 apps/backend/internal/db/migrations/0008_subdomains.sql create mode 100644 apps/backend/internal/db/migrations/0009_auth.sql create mode 100644 apps/backend/internal/db/migrations/0010_boards.sql create mode 100644 apps/backend/internal/domain/subdomain.go create mode 100644 apps/backend/internal/domain/subdomain_test.go delete mode 100644 apps/backend/internal/web/dist/.keep create mode 100644 apps/backend/internal/widget/embed.go create mode 100644 apps/backend/internal/widget/jsonpath.go create mode 100644 apps/backend/internal/widget/jsonpath_test.go diff --git a/apps/backend/cmd/dash-agent/docker.go b/apps/backend/cmd/dash-agent/docker.go new file mode 100644 index 0000000..8f830fb --- /dev/null +++ b/apps/backend/cmd/dash-agent/docker.go @@ -0,0 +1,121 @@ +package main + +// Docker collector: container liveness plus per-container CPU/memory from +// /containers/{id}/stats?stream=false. All best-effort — no socket, no data. + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "sync" + "time" +) + +const dockerSock = "/var/run/docker.sock" + +var dockerClient = &http.Client{ + Timeout: 4 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", dockerSock) + }, + }, +} + +// dockerStatsJSON is the subset of /containers/{id}/stats we consume. +type dockerStatsJSON struct { + CPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + OnlineCPUs uint32 `json:"online_cpus"` + } `json:"cpu_stats"` + PreCPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + } `json:"precpu_stats"` + MemoryStats struct { + Usage uint64 `json:"usage"` + Limit uint64 `json:"limit"` + } `json:"memory_stats"` +} + +// containerCPUPct applies the Docker dashboard formula: cpu delta over +// system delta, scaled by online CPUs. 0 when deltas don't advance. +func containerCPUPct(s *dockerStatsJSON) float64 { + cpuDelta := float64(s.CPUStats.CPUUsage.TotalUsage) - float64(s.PreCPUStats.CPUUsage.TotalUsage) + sysDelta := float64(s.CPUStats.SystemCPUUsage) - float64(s.PreCPUStats.SystemCPUUsage) + online := s.CPUStats.OnlineCPUs + if online == 0 { + online = 1 + } + if cpuDelta <= 0 || sysDelta <= 0 { + return 0 + } + return cpuDelta / sysDelta * float64(online) * 100 +} + +// readContainers lists containers and fans out to per-container stats. +// Stats fetches are bounded: 8 concurrent, each under the client timeout. +func readContainers() []containerStat { + if _, err := net.DialTimeout("unix", dockerSock, time.Second); err != nil { + return nil + } + res, err := dockerClient.Get("http://d/containers/json?all=1") + if err != nil { + return nil + } + defer res.Body.Close() + var raw []struct { + ID string `json:"Id"` + Names []string `json:"Names"` + State string `json:"State"` + } + if json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&raw) != nil { + return nil + } + out := make([]containerStat, len(raw)) + var wg sync.WaitGroup + sem := make(chan struct{}, 8) + for i, r := range raw { + name := "" + if len(r.Names) > 0 { + name = strings.TrimPrefix(r.Names[0], "/") + } + out[i] = containerStat{Name: name, State: r.State} + if r.State != "running" || r.ID == "" { + continue + } + wg.Add(1) + sem <- struct{}{} + go func(i int, id string) { + defer wg.Done() + defer func() { <-sem }() + fillContainerStats(&out[i], id) + }(i, r.ID) + } + wg.Wait() + return out +} + +// fillContainerStats fetches one container's one-shot stats into cs. +func fillContainerStats(cs *containerStat, id string) { + res, err := dockerClient.Get("http://d/containers/" + id + "/stats?stream=false") + if err != nil { + return + } + defer res.Body.Close() + var st dockerStatsJSON + if json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&st) != nil { + return + } + cs.CPU = containerCPUPct(&st) + cs.MemUsed = float64(st.MemoryStats.Usage) + cs.MemLimit = float64(st.MemoryStats.Limit) +} diff --git a/apps/backend/cmd/dash-agent/extra.go b/apps/backend/cmd/dash-agent/extra.go new file mode 100644 index 0000000..bb61d7c --- /dev/null +++ b/apps/backend/cmd/dash-agent/extra.go @@ -0,0 +1,230 @@ +package main + +// Optional collectors: SMART (smartctl), ZFS (zpool), GPU (nvidia-smi or +// /sys/class/drm). Every collector is exec-or-skip — missing tools or +// permissions produce nil, never errors, and each exec carries a timeout so a +// wedged tool can't stall reporting. + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// runOut executes a command with a timeout; empty output on any failure. +func runOut(timeout time.Duration, name string, args ...string) []byte { + if _, err := exec.LookPath(name); err != nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + return nil + } + return out +} + +// ---------- SMART ---------- + +type smartctlScan struct { + Devices []struct { + Name string `json:"name"` + } `json:"devices"` +} + +type smartctlAll struct { + ModelName string `json:"model_name"` + SmartStatus *struct { + Passed bool `json:"passed"` + } `json:"smart_status"` + Temperature *struct { + Current float64 `json:"current"` + } `json:"temperature"` +} + +// readSMART scans devices via smartctl -j --scan, then reads each drive. +// Needs smartmontools installed and usually root or sudo rights for /dev. +func readSMART() []smartDisk { + scan := runOut(5*time.Second, "smartctl", "-j", "--scan") + if scan == nil { + return nil + } + var sc smartctlScan + if json.Unmarshal(scan, &sc) != nil || len(sc.Devices) == 0 { + return nil + } + out := make([]smartDisk, 0, len(sc.Devices)) + for i, d := range sc.Devices { + if i >= 8 { // bound the fan-out + break + } + b := runOut(4*time.Second, "smartctl", "-j", "-a", d.Name) + if b == nil { + continue + } + var a smartctlAll + if json.Unmarshal(b, &a) != nil { + continue + } + sd := smartDisk{Device: d.Name, Model: a.ModelName} + if a.SmartStatus != nil { + p := a.SmartStatus.Passed + sd.Passed = &p + } + if a.Temperature != nil { + sd.TempC = a.Temperature.Current + } + out = append(out, sd) + } + return out +} + +// ---------- ZFS ---------- + +// readZFS prefers `zpool list -j` (OpenZFS 2.3+), falls back to the stable +// `-H -p` text format. Parse failures degrade to nil. +func readZFS() []zfsPool { + if b := runOut(4*time.Second, "zpool", "list", "-j", "--json-int", + "-o", "name,health,size,free"); b != nil { + if pools := parseZpoolJSON(b); pools != nil { + return pools + } + } + return parseZpoolText(runOut(4*time.Second, + "zpool", "list", "-H", "-p", "-o", "name,health,size,free")) +} + +// parseZpoolJSON reads {"pools":{"name":{"properties":{...}}}}. +func parseZpoolJSON(b []byte) []zfsPool { + var doc struct { + Pools map[string]struct { + Properties map[string]struct { + Value any `json:"value"` + } `json:"properties"` + } `json:"pools"` + } + if json.Unmarshal(b, &doc) != nil || len(doc.Pools) == 0 { + return nil + } + var out []zfsPool + num := func(v any) float64 { + switch n := v.(type) { + case float64: + return n + case string: + f, _ := strconv.ParseFloat(n, 64) + return f + } + return 0 + } + for name, p := range doc.Pools { + z := zfsPool{Name: name} + if v, ok := p.Properties["health"]; ok { + if s, ok := v.Value.(string); ok { + z.Health = s + } + } + if v, ok := p.Properties["size"]; ok { + z.Size = num(v.Value) + } + if v, ok := p.Properties["free"]; ok { + z.Free = num(v.Value) + } + out = append(out, z) + } + return out +} + +// parseZpoolText reads `zpool list -H -p` rows: name\thealth\tsize\tfree. +func parseZpoolText(b []byte) []zfsPool { + var out []zfsPool + for _, line := range strings.Split(string(b), "\n") { + f := strings.Split(line, "\t") + if len(f) < 4 { + continue + } + size, _ := strconv.ParseFloat(f[2], 64) + free, _ := strconv.ParseFloat(f[3], 64) + out = append(out, zfsPool{Name: f[0], Health: f[1], Size: size, Free: free}) + } + return out +} + +// ---------- GPU ---------- + +// readGPU prefers nvidia-smi (CSV query); otherwise walks /sys/class/drm for +// AMD/Intel cards exposing gpu_busy_percent and hwmon temps. +func readGPU() []gpuStat { + if _, err := exec.LookPath("nvidia-smi"); err == nil { + return readNvidiaGPU() + } + return readGPUSysfs() +} + +func readNvidiaGPU() []gpuStat { + return parseNvidiaCSV(runOut(4*time.Second, "nvidia-smi", + "--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total", + "--format=csv,noheader,nounits")) +} + +// parseNvidiaCSV reads rows: name, tempC, util%, memUsed MiB, memTotal MiB. +func parseNvidiaCSV(b []byte) []gpuStat { + var out []gpuStat + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + f := strings.Split(line, ",") + if len(f) < 5 { + continue + } + g := gpuStat{Name: strings.TrimSpace(f[0])} + g.TempC, _ = strconv.ParseFloat(strings.TrimSpace(f[1]), 64) + g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(f[2]), 64) + g.MemUsed, _ = strconv.ParseFloat(strings.TrimSpace(f[3]), 64) + g.MemTotal, _ = strconv.ParseFloat(strings.TrimSpace(f[4]), 64) + g.MemUsed *= 1 << 20 + g.MemTotal *= 1 << 20 + out = append(out, g) + } + return out +} + +// readGPUSysfs pulls util/temp for each card from DRM sysfs. +func readGPUSysfs() []gpuStat { + cards, _ := filepath.Glob("/sys/class/drm/card[0-9]") + var out []gpuStat + for _, card := range cards { + dev := card + "/device" + g := gpuStat{Name: filepath.Base(card)} + if b, err := os.ReadFile(dev + "/gpu_busy_percent"); err == nil { + g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64) + } + // hwmon: first temp1_input under device/hwmon/hwmonN. + if hws, err := filepath.Glob(dev + "/hwmon/hwmon*"); err == nil { + for _, hw := range hws { + if b, err := os.ReadFile(hw + "/temp1_input"); err == nil { + if milli, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64); err == nil { + g.TempC = milli / 1000 + } + break + } + } + } + // Driver name via uevent DRIVER= for a friendlier label. + if b, err := os.ReadFile(dev + "/uevent"); err == nil { + for _, line := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(line, "DRIVER="); ok { + g.Name += " (" + v + ")" + } + } + } + if g.UtilPct > 0 || g.TempC > 0 { + out = append(out, g) + } + } + return out +} diff --git a/apps/backend/cmd/dash-agent/main.go b/apps/backend/cmd/dash-agent/main.go index 7a46359..a0f72d8 100644 --- a/apps/backend/cmd/dash-agent/main.go +++ b/apps/backend/cmd/dash-agent/main.go @@ -9,13 +9,11 @@ package main import ( "bytes" - "context" "encoding/json" "flag" "fmt" "io" "log" - "net" "net/http" "os" "runtime" @@ -96,11 +94,40 @@ type sample struct { Load15 float64 `json:"load15"` Temps map[string]float64 `json:"temps,omitempty"` Containers []containerStat `json:"containers,omitempty"` + Smart []smartDisk `json:"smart,omitempty"` + ZFS []zfsPool `json:"zfs,omitempty"` + GPU []gpuStat `json:"gpu,omitempty"` } 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"` +} + +// Optional-collector shapes — mirror api.SmartDisk/ZFSPool/GPUStat. +type smartDisk struct { + Device string `json:"device"` + Model string `json:"model"` + Passed *bool `json:"passed,omitempty"` + TempC float64 `json:"tempC,omitempty"` +} + +type zfsPool struct { + Name string `json:"name"` + Health string `json:"health"` + Size float64 `json:"size"` + Free float64 `json:"free"` +} + +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"` } type collector struct { @@ -141,6 +168,9 @@ func (c *collector) sample(interval int) sample { s.UptimeS = readUptime() s.Temps = readTemps() s.Containers = readContainers() + s.Smart = readSMART() + s.ZFS = readZFS() + s.GPU = readGPU() return s } @@ -325,38 +355,4 @@ func readTemps() map[string]float64 { return out } -// readContainers lists Docker containers via the unix socket when present. -func readContainers() []containerStat { - if _, err := os.Stat("/var/run/docker.sock"); err != nil { - return nil - } - client := &http.Client{ - Timeout: 3 * time.Second, - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") - }, - }, - } - res, err := client.Get("http://d/containers/json?all=1") - if err != nil { - return nil - } - defer res.Body.Close() - var raw []struct { - Names []string `json:"Names"` - State string `json:"State"` - } - if json.NewDecoder(res.Body).Decode(&raw) != nil { - return nil - } - out := make([]containerStat, 0, len(raw)) - for _, r := range raw { - name := "" - if len(r.Names) > 0 { - name = strings.TrimPrefix(r.Names[0], "/") - } - out = append(out, containerStat{Name: name, State: r.State}) - } - return out -} + diff --git a/apps/backend/cmd/dash-agent/main_test.go b/apps/backend/cmd/dash-agent/main_test.go new file mode 100644 index 0000000..db91394 --- /dev/null +++ b/apps/backend/cmd/dash-agent/main_test.go @@ -0,0 +1,48 @@ +package main + +import "testing" + +func TestContainerCPUPct(t *testing.T) { + var s dockerStatsJSON + s.CPUStats.CPUUsage.TotalUsage = 300 + s.PreCPUStats.CPUUsage.TotalUsage = 100 + s.CPUStats.SystemCPUUsage = 1000 + s.PreCPUStats.SystemCPUUsage = 500 + s.CPUStats.OnlineCPUs = 4 + if got := containerCPUPct(&s); got != 160 { + t.Fatalf("expected 160%%, got %v", got) + } + // No deltas -> 0, no div-by-zero. + s.PreCPUStats.CPUUsage.TotalUsage = s.CPUStats.CPUUsage.TotalUsage + s.PreCPUStats.SystemCPUUsage = s.CPUStats.SystemCPUUsage + if got := containerCPUPct(&s); got != 0 { + t.Fatalf("expected 0, got %v", got) + } +} + +func TestParseZpoolText(t *testing.T) { + out := parseZpoolText([]byte("tank\tONLINE\t1000000000\t400000000\nfast\tDEGRADED\t500\t100\n")) + if len(out) != 2 || out[0].Name != "tank" || out[0].Size != 1e9 || out[1].Health != "DEGRADED" { + t.Fatalf("bad parse: %+v", out) + } + if parseZpoolText(nil) != nil { + t.Fatal("nil input should yield nil") + } +} + +func TestParseZpoolJSON(t *testing.T) { + b := []byte(`{"output_version":{"major":0},"pools":{"tank":{"name":"tank","properties":{ + "health":{"value":"ONLINE"},"size":{"value":1000000000},"free":{"value":250000000}}}}}`) + out := parseZpoolJSON(b) + if len(out) != 1 || out[0].Health != "ONLINE" || out[0].Free != 250000000 { + t.Fatalf("bad parse: %+v", out) + } +} + +func TestParseNvidiaCSV(t *testing.T) { + out := parseNvidiaCSV([]byte("RTX 3080, 62, 14, 2048, 10240\n")) + if len(out) != 1 || out[0].Name != "RTX 3080" || out[0].TempC != 62 || + out[0].MemUsed != 2048*1<<20 || out[0].MemTotal != 10240*1<<20 { + t.Fatalf("bad parse: %+v", out) + } +} diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 8c2a3ab..8438b67 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -42,10 +42,10 @@ require ( go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect google.golang.org/protobuf v1.36.12 // indirect modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 11d520f..f32dcf3 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -99,18 +99,28 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/apps/backend/internal/api/alerts.go b/apps/backend/internal/api/alerts.go new file mode 100644 index 0000000..5e851a8 --- /dev/null +++ b/apps/backend/internal/api/alerts.go @@ -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 +} diff --git a/apps/backend/internal/api/alerts_test.go b/apps/backend/internal/api/alerts_test.go new file mode 100644 index 0000000..2729dbb --- /dev/null +++ b/apps/backend/internal/api/alerts_test.go @@ -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") + } +} diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go index dc9d797..84b09c1 100644 --- a/apps/backend/internal/api/api.go +++ b/apps/backend/internal/api/api.go @@ -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"` } diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go index 0e6539d..48e64cd 100644 --- a/apps/backend/internal/api/api_test.go +++ b/apps/backend/internal/api/api_test.go @@ -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) + } +} diff --git a/apps/backend/internal/api/auth.go b/apps/backend/internal/api/auth.go new file mode 100644 index 0000000..42d627a --- /dev/null +++ b/apps/backend/internal/api/auth.go @@ -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) +} diff --git a/apps/backend/internal/api/auth_test.go b/apps/backend/internal/api/auth_test.go new file mode 100644 index 0000000..d613199 --- /dev/null +++ b/apps/backend/internal/api/auth_test.go @@ -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) + } +} diff --git a/apps/backend/internal/api/board.go b/apps/backend/internal/api/board.go index 416cc28..624744f 100644 --- a/apps/backend/internal/api/board.go +++ b/apps/backend/internal/api/board.go @@ -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 } diff --git a/apps/backend/internal/api/boards.go b/apps/backend/internal/api/boards.go new file mode 100644 index 0000000..fb36696 --- /dev/null +++ b/apps/backend/internal/api/boards.go @@ -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/. +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) +} diff --git a/apps/backend/internal/api/domains.go b/apps/backend/internal/api/domains.go index 7fe17a0..02d43b5 100644 --- a/apps/backend/internal/api/domains.go +++ b/apps/backend/internal/api/domains.go @@ -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) { diff --git a/apps/backend/internal/api/importers.go b/apps/backend/internal/api/importers.go index 4fb3c3c..8ba9520 100644 --- a/apps/backend/internal/api/importers.go +++ b/apps/backend/internal/api/importers.go @@ -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 diff --git a/apps/backend/internal/api/monitors.go b/apps/backend/internal/api/monitors.go index 7e69426..052978e 100644 --- a/apps/backend/internal/api/monitors.go +++ b/apps/backend/internal/api/monitors.go @@ -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 diff --git a/apps/backend/internal/api/notify.go b/apps/backend/internal/api/notify.go index 3e7d329..ebf4c4b 100644 --- a/apps/backend/internal/api/notify.go +++ b/apps/backend/internal/api/notify.go @@ -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_ 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_ 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}) } diff --git a/apps/backend/internal/api/subdomains.go b/apps/backend/internal/api/subdomains.go new file mode 100644 index 0000000..4fafdc6 --- /dev/null +++ b/apps/backend/internal/api/subdomains.go @@ -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) +} diff --git a/apps/backend/internal/api/systems.go b/apps/backend/internal/api/systems.go index a3d19a9..d770952 100644 --- a/apps/backend/internal/api/systems.go +++ b/apps/backend/internal/api/systems.go @@ -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. diff --git a/apps/backend/internal/api/transfer.go b/apps/backend/internal/api/transfer.go index 4002cfa..4963271 100644 --- a/apps/backend/internal/api/transfer.go +++ b/apps/backend/internal/api/transfer.go @@ -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 { diff --git a/apps/backend/internal/db/migrations/0007_alerts.sql b/apps/backend/internal/db/migrations/0007_alerts.sql new file mode 100644 index 0000000..7baf97a --- /dev/null +++ b/apps/backend/internal/db/migrations/0007_alerts.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Per-target alert rules as a JSON blob: {consecutiveFailures, latencyWarnMs, +-- certDays, mute}. '{}' = defaults (alert on first failure, no latency rule, +-- cert threshold = alert_days_before, unmuted). +ALTER TABLE monitors ADD COLUMN alerts JSON NOT NULL DEFAULT '{}'; +ALTER TABLE domains ADD COLUMN alerts JSON NOT NULL DEFAULT '{}'; + +-- +goose Down +ALTER TABLE monitors DROP COLUMN alerts; +ALTER TABLE domains DROP COLUMN alerts; diff --git a/apps/backend/internal/db/migrations/0008_subdomains.sql b/apps/backend/internal/db/migrations/0008_subdomains.sql new file mode 100644 index 0000000..90b1b1e --- /dev/null +++ b/apps/backend/internal/db/migrations/0008_subdomains.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE subdomains ( + id TEXT PRIMARY KEY, + domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + name TEXT NOT NULL, + ips JSON NOT NULL DEFAULT '[]', + first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + UNIQUE(domain_id, name) +); +CREATE INDEX subdomains_domain_idx ON subdomains(domain_id); + +-- Discovery runs at most once per day per domain, inside the domain sweep. +ALTER TABLE domains ADD COLUMN sub_checked_at TEXT; + +-- +goose Down +ALTER TABLE domains DROP COLUMN sub_checked_at; +DROP TABLE IF EXISTS subdomains; diff --git a/apps/backend/internal/db/migrations/0009_auth.sql b/apps/backend/internal/db/migrations/0009_auth.sql new file mode 100644 index 0000000..b969b6b --- /dev/null +++ b/apps/backend/internal/db/migrations/0009_auth.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- Opt-in local auth. Empty users table = setup mode; auth_enabled settings +-- key gates the middleware. Sessions live in SQLite — restart-safe, and a +-- `dash-agent`-style bearer token path is unaffected. +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + pass_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT NOT NULL +); +CREATE INDEX sessions_expiry_idx ON sessions(expires_at); + +-- +goose Down +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; diff --git a/apps/backend/internal/db/migrations/0010_boards.sql b/apps/backend/internal/db/migrations/0010_boards.sql new file mode 100644 index 0000000..765cc7c --- /dev/null +++ b/apps/backend/internal/db/migrations/0010_boards.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- Multiple boards. Every section belongs to exactly one board; the seeded +-- 'home' board adopts existing rows. NULL board_id maps to 'home' at read +-- time so old imports/exports keep working without a backfill. +CREATE TABLE boards ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + position REAL NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +INSERT INTO boards (id, slug, name, position) VALUES ('b_home', 'home', 'Home', 1024); + +ALTER TABLE sections ADD COLUMN board_id TEXT REFERENCES boards(id) ON DELETE CASCADE; +UPDATE sections SET board_id = 'b_home'; +CREATE INDEX sections_board_pos ON sections(board_id, position); + +-- +goose Down +DROP INDEX IF EXISTS sections_board_pos; +-- SQLite can't drop columns pre-3.35; rebuild instead. +CREATE TABLE sections_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + position REAL NOT NULL, + collapsed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +INSERT INTO sections_new (id, name, position, collapsed, created_at) + SELECT id, name, position, collapsed, created_at FROM sections; +DROP TABLE sections; +ALTER TABLE sections_new RENAME TO sections; +DROP TABLE boards; diff --git a/apps/backend/internal/domain/subdomain.go b/apps/backend/internal/domain/subdomain.go new file mode 100644 index 0000000..2990987 --- /dev/null +++ b/apps/backend/internal/domain/subdomain.go @@ -0,0 +1,128 @@ +package domain + +// Subdomain discovery: certificate-transparency names from crt.sh, filtered +// to the owned suffix, then resolved via A/AAAA. Both network steps are +// injected seams so tests never leave the process. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// Subdomain is one discovered hostname and its resolved addresses. +type Subdomain struct { + Name string `json:"name"` + IPs []string `json:"ips"` +} + +// CTNames returns candidate hostnames for domain from crt.sh. +// Seam: tests substitute a stub. +var CTNames = func(ctx context.Context, domain string) ([]string, error) { + url := "https://crt.sh/?q=%25." + domain + "&output=json" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("crt.sh unreachable") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("crt.sh returned %d", resp.StatusCode) + } + // Bound the response — popular domains can return megabytes. + var rows []struct { + NameValue string `json:"name_value"` + CommonName string `json:"common_name"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rows); err != nil { + return nil, fmt.Errorf("crt.sh decode failed") + } + var out []string + for _, r := range rows { + out = append(out, r.NameValue, r.CommonName) + } + return out, nil +} + +// ResolveIPs returns A/AAAA addresses for a hostname. Seam: tests stub it. +var ResolveIPs = func(ctx context.Context, name string) ([]string, error) { + addrs, err := net.DefaultResolver.LookupIP(ctx, "ip", name) + if err != nil { + return nil, err + } + out := make([]string, 0, len(addrs)) + for _, a := range addrs { + out = append(out, a.String()) + } + return out, nil +} + +// FilterCTNames reduces raw cert names to owned, canonical hostnames: +// lowercase, wildcard prefix stripped, must equal domain or end in .domain. +// CT names are untrusted text — stored and rendered as text only. +func FilterCTNames(raw []string, domain string) []string { + suffix := "." + domain + seen := map[string]bool{} + out := make([]string, 0, len(raw)) + for _, r := range raw { + for _, name := range strings.Split(r, "\n") { + name = strings.ToLower(strings.TrimSpace(name)) + name = strings.TrimPrefix(name, "*.") + if name == "" || (!strings.HasSuffix(name, suffix) && name != domain) { + continue + } + if strings.ContainsAny(name, " \t/\\\"'<>@") { + continue // cert names are untrusted; drop malformed entries + } + if !seen[name] { + seen[name] = true + out = append(out, name) + } + } + } + return out +} + +// DiscoverSubdomains queries CT and resolves each surviving name, bounded: +// at most 500 candidates considered, 100 resolved, 8 concurrent lookups. +func DiscoverSubdomains(ctx context.Context, domain string) ([]Subdomain, error) { + raw, err := CTNames(ctx, domain) + if err != nil { + return nil, err + } + names := FilterCTNames(raw, domain) + if len(names) > 500 { + names = names[:500] + } + if len(names) > 100 { + names = names[:100] + } + out := make([]Subdomain, len(names)) + var wg sync.WaitGroup + sem := make(chan struct{}, 8) + for i, name := range names { + out[i].Name = name + wg.Add(1) + sem <- struct{}{} + go func(i int, name string) { + defer wg.Done() + defer func() { <-sem }() + rctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + if ips, err := ResolveIPs(rctx, name); err == nil { + out[i].IPs = ips + } + }(i, name) + } + wg.Wait() + return out, nil +} diff --git a/apps/backend/internal/domain/subdomain_test.go b/apps/backend/internal/domain/subdomain_test.go new file mode 100644 index 0000000..2188a54 --- /dev/null +++ b/apps/backend/internal/domain/subdomain_test.go @@ -0,0 +1,29 @@ +package domain + +import "testing" + +func TestFilterCTNames(t *testing.T) { + raw := []string{ + "api.example.com\nwww.example.com", + "*.example.com", + "example.com", + "other.com", + "api.example.com", // dup + "sub.api.example.com", + "evil name.example.com", + "example.com.evil.com", + } + got := FilterCTNames(raw, "example.com") + want := map[string]bool{ + "api.example.com": true, "www.example.com": true, + "example.com": true, "sub.api.example.com": true, + } + if len(got) != len(want) { + t.Fatalf("got %v", got) + } + for _, n := range got { + if !want[n] { + t.Fatalf("unexpected name %q", n) + } + } +} diff --git a/apps/backend/internal/monitor/monitor.go b/apps/backend/internal/monitor/monitor.go index 54165da..ea09c44 100644 --- a/apps/backend/internal/monitor/monitor.go +++ b/apps/backend/internal/monitor/monitor.go @@ -42,6 +42,7 @@ type Monitor struct { PushToken string `json:"pushToken,omitempty"` Tags []string `json:"tags"` Notes string `json:"notes"` + Alerts json.RawMessage `json:"alerts"` Position float64 `json:"position"` LastCheck *string `json:"lastCheck"` CreatedAt string `json:"createdAt"` diff --git a/apps/backend/internal/web/dist/.keep b/apps/backend/internal/web/dist/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/backend/internal/widget/embed.go b/apps/backend/internal/widget/embed.go new file mode 100644 index 0000000..a2df93c --- /dev/null +++ b/apps/backend/internal/widget/embed.go @@ -0,0 +1,30 @@ +package widget + +import ( + "context" + "encoding/json" + "fmt" +) + +// Embed renders a sandboxed iframe client-side — registered only so +// /api/widgets/types lists it. The browser fetches the framed page, not the +// server (remote content may be LAN-only or need the user's cookies). +type embed struct{} + +func init() { Register(embed{}) } + +func (embed) Meta() Type { + return Type{ + Type: "embed", + Name: "Embed", + Description: "Embed any page in a tile", + Local: true, + Fields: []Field{ + {Key: "url", Label: "URL", Placeholder: "https://…", Required: true}, + }, + } +} + +func (embed) Fetch(context.Context, json.RawMessage) (any, error) { + return nil, fmt.Errorf("embed renders client-side") +} diff --git a/apps/backend/internal/widget/jsonpath.go b/apps/backend/internal/widget/jsonpath.go new file mode 100644 index 0000000..16d9756 --- /dev/null +++ b/apps/backend/internal/widget/jsonpath.go @@ -0,0 +1,89 @@ +package widget + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// JSON status: GET a URL, extract one value by dot/bracket path +// ("data.status", "items[0].name"), render it in a tile. Covers the +// "is API X alive / what does it report" case without per-service widgets. +type jsonpath struct{} + +func init() { Register(jsonpath{}) } + +func (jsonpath) Meta() Type { + return Type{ + Type: "jsonpath", + Name: "JSON value", + Description: "Extract one value from a JSON endpoint", + Fields: []Field{ + {Key: "url", Label: "URL", Placeholder: "https://api…/status", Required: true}, + {Key: "path", Label: "Path", Placeholder: "data.status", Required: true}, + {Key: "header", Label: "Header", Placeholder: "Authorization: Bearer …"}, + {Key: "label", Label: "Label", Placeholder: "Status"}, + }, + } +} + +func (jsonpath) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) { + url := cfgStr(cfg, "url") + path := cfgStr(cfg, "path") + if url == "" || path == "" { + return nil, fmt.Errorf("url and path required") + } + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return nil, fmt.Errorf("url must be http(s)") + } + var headers map[string]string + if h := cfgStr(cfg, "header"); h != "" { + if k, v, ok := strings.Cut(h, ":"); ok { + headers = map[string]string{strings.TrimSpace(k): strings.TrimSpace(v)} + } + } + var doc any + if err := getJSON(ctx, url, headers, &doc); err != nil { + return nil, err + } + v, ok := jsonPath(doc, path) + if !ok { + return nil, fmt.Errorf("path %q not found", path) + } + return map[string]any{"value": v, "label": cfgStr(cfg, "label")}, nil +} + +// jsonPath walks doc along a path like "a.b[0].c". Segments split on dots; +// a trailing [n] indexes into arrays. Returns false on any miss. +func jsonPath(doc any, path string) (any, bool) { + cur := doc + for _, seg := range strings.Split(path, ".") { + name, rest, _ := strings.Cut(seg, "[") + if name != "" { + m, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = m[name] + if !ok { + return nil, false + } + } + for rest != "" { + idxStr, tail, _ := strings.Cut(rest, "]") + i, err := strconv.Atoi(idxStr) + if err != nil { + return nil, false + } + arr, ok := cur.([]any) + if !ok || i < 0 || i >= len(arr) { + return nil, false + } + cur = arr[i] + rest = strings.TrimPrefix(tail, "[") + } + } + return cur, true +} diff --git a/apps/backend/internal/widget/jsonpath_test.go b/apps/backend/internal/widget/jsonpath_test.go new file mode 100644 index 0000000..e737b48 --- /dev/null +++ b/apps/backend/internal/widget/jsonpath_test.go @@ -0,0 +1,80 @@ +package widget + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestJSONPathWalk(t *testing.T) { + doc := map[string]any{ + "data": map[string]any{ + "status": "ok", + "items": []any{map[string]any{"name": "first"}, "second"}, + "nested": []any{[]any{float64(42)}}, + }, + } + cases := []struct { + path string + want any + ok bool + }{ + {"data.status", "ok", true}, + {"data.items[0].name", "first", true}, + {"data.items[1]", "second", true}, + {"data.nested[0][0]", float64(42), true}, + {"data.missing", nil, false}, + {"data.items[9]", nil, false}, + {"data.items[-1]", nil, false}, + {"data.status.x", nil, false}, + {"data.items[x]", nil, false}, + {"data.status[0]", nil, false}, + } + for _, tc := range cases { + got, ok := jsonPath(doc, tc.path) + if ok != tc.ok || (ok && got != tc.want) { + t.Errorf("path %q: got %v,%v want %v,%v", tc.path, got, ok, tc.want, tc.ok) + } + } +} + +func TestJSONPathFetch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"status":"degraded"}}`)) + })) + defer srv.Close() + + cfg, _ := json.Marshal(map[string]any{ + "url": srv.URL, + "path": "data.status", + "header": "Authorization: Bearer tok", + "label": "State", + }) + got, err := (jsonpath{}).Fetch(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + if m["value"] != "degraded" || m["label"] != "State" { + t.Fatalf("unexpected payload: %v", m) + } +} + +func TestJSONPathFetchValidation(t *testing.T) { + for _, cfg := range []string{ + `{"url":"","path":"a"}`, + `{"url":"file:///etc/passwd","path":"a"}`, + `{"url":"ftp://x","path":"a"}`, + } { + if _, err := (jsonpath{}).Fetch(context.Background(), json.RawMessage(cfg)); err == nil { + t.Errorf("expected error for %s", cfg) + } + } +} diff --git a/openapi.yaml b/openapi.yaml index ef1fa5d..4bb7d9f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -27,10 +27,88 @@ paths: schema: $ref: "#/components/schemas/Health" + /api/boards: + get: + operationId: listBoards + summary: All boards, ordered + responses: + "200": + description: Board list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Board" + post: + operationId: createBoard + summary: Create a board — slug derived from the name + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + "201": + description: Created board + content: + application/json: + schema: + $ref: "#/components/schemas/Board" + + /api/boards/{id}: + patch: + operationId: updateBoard + summary: Rename a board — the slug stays stable + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + "200": + description: Updated board + content: + application/json: + schema: + $ref: "#/components/schemas/Board" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteBoard + summary: Delete a board and its sections — the last board cannot be deleted + parameters: + - $ref: "#/components/parameters/id" + responses: + "204": + description: Deleted + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + /api/sections: get: operationId: listSections - summary: Full board — sections with nested items and urls, ordered + summary: Board tree — sections with nested items and urls, ordered + parameters: + - name: board + in: query + description: Restrict to one board id; omitted returns all sections + schema: + type: string responses: "200": description: Ordered board tree @@ -540,14 +618,70 @@ paths: additionalProperties: $ref: "#/components/schemas/Status" + /api/domains/{id}/subdomains: + get: + operationId: listSubdomains + summary: Discovered subdomains for a domain + parameters: + - $ref: "#/components/parameters/id" + responses: + "200": + description: Subdomain rows, name-sorted + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Subdomain" + "404": + $ref: "#/components/responses/NotFound" + /api/domains/{id}/subdomains/refresh: + post: + operationId: refreshSubdomains + summary: Force a crt.sh + DNS discovery run, returns the updated list + parameters: + - $ref: "#/components/parameters/id" + responses: + "200": + description: Subdomain rows after the sweep + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Subdomain" + "404": + $ref: "#/components/responses/NotFound" + /api/notify/test: post: operationId: notifyTest - summary: Send a test notification to the configured webhook - description: Requires the `notify_webhook` settings key to hold an http(s) URL. + summary: Send a test notification through configured transports + description: >- + Fires a test event synchronously through every configured notify_* + transport, or only the transport named in the body. Returns a + transport -> "ok" | "error: ..." map. + requestBody: + content: + application/json: + schema: + type: object + properties: + transport: + type: string + enum: [webhook, slack, discord, telegram, gotify, ntfy, smtp] responses: "200": - description: Test notification dispatched + description: Per-transport delivery results + content: + application/json: + schema: + type: object + properties: + results: + type: object + additionalProperties: + type: string "400": $ref: "#/components/responses/BadRequest" @@ -1039,6 +1173,83 @@ paths: "400": $ref: "#/components/responses/BadRequest" + /api/auth/session: + get: + operationId: authSession + summary: Auth state — enabled flag, whether setup is needed, and current session + responses: + "200": + description: Session state + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSession" + + /api/auth/setup: + post: + operationId: authSetup + summary: Create the first local admin — only while no users exist + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: + type: string + password: + type: string + minLength: 8 + responses: + "201": + description: Admin created; session cookie set + "400": + $ref: "#/components/responses/BadRequest" + "409": + description: Setup already completed + + /api/auth/login: + post: + operationId: authLogin + summary: Password login — sets a session cookie; rate-limited per IP + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: + type: string + password: + type: string + responses: + "200": + description: Signed in + "401": + description: Invalid credentials + "429": + description: Too many attempts + + /api/auth/logout: + post: + operationId: authLogout + summary: Destroy the current session + responses: + "204": + description: Signed out + + /api/auth/disable: + post: + operationId: authDisable + summary: Turn authentication off — requires an active session + responses: + "204": + description: Disabled + components: securitySchemes: bearerAuth: @@ -1074,6 +1285,19 @@ components: status: type: string example: ok + AuthSession: + type: object + properties: + enabled: + type: boolean + setup: + type: boolean + description: True when auth is on but no user exists yet + authed: + type: boolean + username: + type: string + Error: type: object required: [error] @@ -1172,7 +1396,7 @@ components: $ref: "#/components/schemas/UrlInput" Section: type: object - required: [id, name, position, collapsed, items] + required: [id, name, position, collapsed, boardId, items] properties: id: type: string @@ -1182,16 +1406,32 @@ components: type: number collapsed: type: boolean + boardId: + type: string items: type: array items: $ref: "#/components/schemas/Item" + Board: + type: object + required: [id, slug, name, position] + properties: + id: + type: string + slug: + type: string + name: + type: string + position: + type: number SectionInput: type: object required: [name] properties: name: type: string + boardId: + type: string SectionPatch: type: object properties: @@ -1285,6 +1525,8 @@ components: type: string notes: type: string + alerts: + $ref: "#/components/schemas/AlertRules" position: type: number lastCheck: @@ -1294,6 +1536,22 @@ components: createdAt: type: string format: date-time + AlertRules: + type: object + description: Per-target alert rules; omitted fields keep defaults. + properties: + consecutiveFailures: + type: integer + description: Checks must fail N times in a row before status flips down + latencyWarnMs: + type: integer + description: Notify once when an up check's ping crosses this threshold + certDays: + type: integer + description: Domain TLS expiry threshold; overrides alertDaysBefore + mute: + type: boolean + description: Suppress notifications; incidents/status still record MonitorInput: type: object properties: @@ -1338,6 +1596,8 @@ components: type: string notes: type: string + alerts: + $ref: "#/components/schemas/AlertRules" UptimeStats: type: object properties: @@ -1519,6 +1779,8 @@ components: type: string format: date-time nullable: true + alerts: + $ref: "#/components/schemas/AlertRules" position: type: number createdAt: @@ -1543,6 +1805,8 @@ components: type: string notes: type: string + alerts: + $ref: "#/components/schemas/AlertRules" DomainView: allOf: - $ref: "#/components/schemas/Domain" @@ -1558,6 +1822,21 @@ components: type: boolean sslExpiring: type: boolean + Subdomain: + type: object + properties: + name: + type: string + ips: + type: array + items: + type: string + firstSeen: + type: string + format: date-time + lastSeen: + type: string + format: date-time DomainCheck: type: object properties: @@ -1615,11 +1894,48 @@ components: type: array items: $ref: "#/components/schemas/ContainerStat" + smart: + type: array + items: + $ref: "#/components/schemas/SmartDisk" + zfs: + type: array + items: + $ref: "#/components/schemas/ZFSPool" + gpu: + type: array + items: + $ref: "#/components/schemas/GPUStat" ContainerStat: type: object properties: name: { type: string } state: { type: string } + cpu: { type: number, description: "percent of one core, may exceed 100" } + memUsed: { type: number, description: bytes } + memLimit: { type: number, description: bytes } + SmartDisk: + type: object + properties: + device: { type: string } + model: { type: string } + passed: { type: boolean, nullable: true } + tempC: { type: number } + ZFSPool: + type: object + properties: + name: { type: string } + health: { type: string } + size: { type: number, description: bytes } + free: { type: number, description: bytes } + GPUStat: + type: object + properties: + name: { type: string } + tempC: { type: number } + utilPct: { type: number } + memUsed: { type: number, description: bytes } + memTotal: { type: number, description: bytes } System: type: object required: [id, name, token, status] @@ -1819,3 +2135,7 @@ components: type: array items: $ref: "#/components/schemas/Section" + boards: + type: array + items: + $ref: "#/components/schemas/Board" From e0ef60eca6b18cd96974d6d0656e209e244e2bd0 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 22 Sep 2026 12:49:51 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(ui):=20phase=205=20=E2=80=94=20graphs,?= =?UTF-8?q?=20widgets,=20theming,=20PWA,=20boards,=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5a: 24h uptime bar strip + latency chart on monitor detail, iframe embed widget, generic JSON-path widget, accent/wallpaper/custom CSS settings, PWA manifest + service worker, demo GIF. P5b: transport picker + per-transport fields + test button in settings; per-monitor alert-rule fields in dialogs. P5c: container cpu/mem columns, SMART/ZFS/GPU sections on system detail, subdomains section on domain detail. P5d: login/setup page + auth middleware client handling, board switcher + /b/:slug routes, full string extraction to typed t() keys with {param} interpolation, complete cs locale, language select. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 23 +- ROADMAP.md | 35 +- apps/frontend/index.html | 4 + apps/frontend/public/icon-192.png | Bin 0 -> 4221 bytes apps/frontend/public/icon-512.png | Bin 0 -> 12209 bytes apps/frontend/public/icon-maskable-512.png | Bin 0 -> 5751 bytes apps/frontend/public/icon.svg | 9 + apps/frontend/public/manifest.webmanifest | 16 + apps/frontend/public/sw.js | 57 +++ apps/frontend/src/App.tsx | 35 +- apps/frontend/src/api.ts | 12 + apps/frontend/src/board/Board.tsx | 11 +- apps/frontend/src/board/store.tsx | 107 ++++- .../frontend/src/components/BoardSwitcher.tsx | 127 ++++++ .../src/components/CommandPalette.tsx | 45 +- apps/frontend/src/components/DomainDialog.tsx | 47 +- .../frontend/src/components/MonitorDialog.tsx | 78 ++-- .../frontend/src/components/ServiceDialog.tsx | 41 +- .../src/components/SettingsDialog.tsx | 246 +++++++++-- apps/frontend/src/components/WidgetDialog.tsx | 31 +- apps/frontend/src/components/ui/dialog.tsx | 3 +- apps/frontend/src/i18n/cs.ts | 414 ++++++++++++++++++ apps/frontend/src/i18n/en.ts | 388 ++++++++++++++++ apps/frontend/src/i18n/index.ts | 28 +- apps/frontend/src/main.tsx | 6 + apps/frontend/src/pages/DomainDetailPage.tsx | 134 ++++-- apps/frontend/src/pages/DomainsPage.tsx | 36 +- apps/frontend/src/pages/IncidentsPage.tsx | 51 +-- apps/frontend/src/pages/LoginPage.tsx | 72 +++ apps/frontend/src/pages/MonitorDetailPage.tsx | 75 +++- apps/frontend/src/pages/MonitorsPage.tsx | 35 +- apps/frontend/src/pages/StatusPagesPage.tsx | 75 ++-- apps/frontend/src/pages/StatusPublicPage.tsx | 23 +- apps/frontend/src/pages/SystemDetailPage.tsx | 130 +++++- apps/frontend/src/pages/SystemsPage.tsx | 39 +- apps/frontend/src/renderers/bento/index.tsx | 5 +- apps/frontend/src/renderers/cards/index.tsx | 5 +- .../src/renderers/editorial/index.tsx | 11 +- .../frontend/src/renderers/terminal/index.tsx | 11 +- apps/frontend/src/types.ts | 15 + apps/frontend/src/widgets/blocker.tsx | 9 +- apps/frontend/src/widgets/domain.tsx | 3 +- apps/frontend/src/widgets/embed.tsx | 23 + apps/frontend/src/widgets/immich.tsx | 5 +- apps/frontend/src/widgets/index.tsx | 9 +- apps/frontend/src/widgets/jsonpath.tsx | 22 + apps/frontend/src/widgets/monitor.tsx | 3 +- apps/frontend/src/widgets/system.tsx | 3 +- docs/demo.gif | Bin 0 -> 198007 bytes 49 files changed, 2131 insertions(+), 426 deletions(-) create mode 100644 apps/frontend/public/icon-192.png create mode 100644 apps/frontend/public/icon-512.png create mode 100644 apps/frontend/public/icon-maskable-512.png create mode 100644 apps/frontend/public/icon.svg create mode 100644 apps/frontend/public/manifest.webmanifest create mode 100644 apps/frontend/public/sw.js create mode 100644 apps/frontend/src/components/BoardSwitcher.tsx create mode 100644 apps/frontend/src/i18n/cs.ts create mode 100644 apps/frontend/src/pages/LoginPage.tsx create mode 100644 apps/frontend/src/widgets/embed.tsx create mode 100644 apps/frontend/src/widgets/jsonpath.tsx create mode 100644 docs/demo.gif diff --git a/README.md b/README.md index 4b5185d..98998e9 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,32 @@ # Dash A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design. -Single container, UI-managed, no YAML, no auth. +Single container, UI-managed, no YAML. Optional auth. -![Dash board](docs/screenshots/board.png) +![Dash demo](docs/demo.gif) ## Features -- **Service board** — sections, drag-and-drop, multi-URL services, icon upload, - four board renderers (Bento, Cards, Index, Console), dark/light themes +- **Service board** — multiple boards, sections, drag-and-drop, multi-URL + services, icon upload, four renderers (Bento, Cards, Index, Console), + dark/light themes, custom accent/wallpaper/CSS - **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push - checks on a schedule, with heartbeat history and uptime stats + checks on a schedule, heartbeat history, uptime graphs, per-monitor alert + rules - **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry, - registrar and provider detection + registrar and provider detection, CT-log subdomain discovery - **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk, - network, load, temperatures, and Docker container state + network, load, temperatures, per-container stats, and SMART/ZFS/GPU when + available - **Incidents & status pages** — manual and automatic incidents, maintenance windows, public `/status/:slug` pages, SVG badges -- **Alerts** — generic webhook notifications (Slack, Discord, ntfy compatible) +- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP + transports - **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import, importers for Homepage, Homarr, and Dashy configs +- **Optional auth** — local username/password with bcrypt + session cookies, + off by default; public status pages stay open either way +- **i18n + PWA** — English and Czech UI, installable manifest/service worker ## Run diff --git a/ROADMAP.md b/ROADMAP.md index de8e157..0aed93c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,41 +205,40 @@ Beszel-style server monitoring, push-based. **Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes. -## Phase 5 — Post-1.0 (planned, demand-confirmed 2026-09-21) +## Phase 5 — Post-1.0 (shipped 2026-09-22) -All items below confirmed in scope. Sequenced by dependency and risk — -quick wins first, structure-changing work last. Nothing here is built yet. +All items shipped on `feat/phase5`. ### P5a — Quick wins (frontend-heavy, no schema risk) -- [ ] **Uptime graphs** — monitor detail renders 24h/30d heartbeat bars + +- [x] **Uptime graphs** — monitor detail renders 24h/30d heartbeat bars + latency line (recharts, already a dep). Data: existing `/api/monitors/:id/heartbeats` + uptime stats. Frontend only. -- [ ] **iframe widget** — `config.url`, sandboxed `