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

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

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

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

Authored By: TDvorak <[email protected]>

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

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

No files matched your search

+121
View File
@@ -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)
}
+230
View File
@@ -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
}
+35 -39
View File
@@ -9,13 +9,11 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"runtime" "runtime"
@@ -96,11 +94,40 @@ type sample struct {
Load15 float64 `json:"load15"` Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps,omitempty"` Temps map[string]float64 `json:"temps,omitempty"`
Containers []containerStat `json:"containers,omitempty"` Containers []containerStat `json:"containers,omitempty"`
Smart []smartDisk `json:"smart,omitempty"`
ZFS []zfsPool `json:"zfs,omitempty"`
GPU []gpuStat `json:"gpu,omitempty"`
} }
type containerStat struct { type containerStat struct {
Name string `json:"name"` Name string `json:"name"`
State string `json:"state"` 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 { type collector struct {
@@ -141,6 +168,9 @@ func (c *collector) sample(interval int) sample {
s.UptimeS = readUptime() s.UptimeS = readUptime()
s.Temps = readTemps() s.Temps = readTemps()
s.Containers = readContainers() s.Containers = readContainers()
s.Smart = readSMART()
s.ZFS = readZFS()
s.GPU = readGPU()
return s return s
} }
@@ -325,38 +355,4 @@ func readTemps() map[string]float64 {
return out 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
}
+48
View File
@@ -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)
}
}
+4 -4
View File
@@ -42,10 +42,10 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.75.7 // indirect modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+10
View File
@@ -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/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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= 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 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= 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 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= 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 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= 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 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+46
View File
@@ -0,0 +1,46 @@
package api
import "encoding/json"
// alertRules is the per-target rules blob stored in monitors.alerts /
// domains.alerts. Zero values keep pre-rules behavior: first failure alerts,
// no latency rule, cert threshold = alertDaysBefore, unmuted.
//
// consecutiveFailures — monitor must fail N checks in a row before it flips down
// latencyWarnMs — notify once when an up check's ping crosses above this
// certDays — domain TLS-expiry threshold (overrides alertDaysBefore)
// mute — suppress notifications; incidents/status still record
type alertRules struct {
ConsecutiveFailures int `json:"consecutiveFailures"`
LatencyWarnMs int `json:"latencyWarnMs"`
CertDays int `json:"certDays"`
Mute bool `json:"mute"`
}
func parseAlertRules(raw json.RawMessage) alertRules {
var r alertRules
_ = json.Unmarshal(raw, &r)
return r
}
// consecutiveDowns counts failed heartbeats, newest-first, stopping at the
// first up. The just-written heartbeat is included.
func (s *Server) consecutiveDowns(monitorID string, limit int) int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM (
SELECT status FROM heartbeats WHERE monitor_id = ?
ORDER BY checked_at DESC, rowid DESC LIMIT ?)
WHERE status = 'down'`, monitorID, limit).Scan(&n)
return n
}
// prevPing returns the ping of the heartbeat before the latest, or -1.
func (s *Server) prevPing(monitorID string) int {
var p int
if err := s.db.QueryRow(`SELECT ping_ms FROM heartbeats
WHERE monitor_id = ? ORDER BY checked_at DESC, rowid DESC
LIMIT 1 OFFSET 1`, monitorID).Scan(&p); err != nil {
return -1
}
return p
}
+324
View File
@@ -0,0 +1,324 @@
package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tdvorak/dash/internal/domain"
)
// ---------- transports ----------
func TestNotifyTransports(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// One receiver records body + headers + query per request.
type hit struct {
body map[string]any
raw string
title string
path string
}
got := make(chan hit, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
buf, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(buf, &m)
got <- hit{body: m, raw: string(buf), title: r.Header.Get("Title"), path: r.URL.RequestURI()}
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
put := func(kv string) {
if res, _ := do(t, "PUT", srv.URL+"/api/settings", kv); res.StatusCode != http.StatusOK {
t.Fatalf("put %s: %d", kv, res.StatusCode)
}
}
// slack + discord + gotify + ntfy all configured; test fires each.
put(`{"notify_slack":{"url":"` + hook.URL + `/slack"}}`)
put(`{"notify_discord":{"url":"` + hook.URL + `/discord"}}`)
put(`{"notify_gotify":{"server":"` + hook.URL + `","token":"gtok"}}`)
put(`{"notify_ntfy":{"server":"` + hook.URL + `","topic":"alerts"}}`)
res, out := do(t, "POST", srv.URL+"/api/notify/test", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("notify test: %d", res.StatusCode)
}
results := out["results"].(map[string]any)
for _, name := range []string{"slack", "discord", "gotify", "ntfy"} {
if results[name] != "ok" {
t.Fatalf("%s: %v", name, results[name])
}
}
seen := map[string]hit{}
for i := 0; i < 4; i++ {
select {
case h := <-got:
seen[h.path] = h
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for transports")
}
}
if seen["/slack"].body["text"] == "" {
t.Fatalf("slack payload missing text: %v", seen["/slack"].body)
}
if seen["/discord"].body["content"] == "" {
t.Fatalf("discord payload missing content: %v", seen["/discord"].body)
}
if !strings.HasPrefix(seen["/message?token=gtok"].path, "/message?token=") {
t.Fatalf("gotify hit missing: %v", seen)
}
if h := seen["/alerts"]; h.raw != "Dash test notification" || h.title != "dash" {
t.Fatalf("ntfy payload wrong: %q title=%q", h.raw, h.title)
}
// Legacy string webhook still works alongside presets.
put(`{"notify_webhook":"` + hook.URL + `/legacy"}`)
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"webhook"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("webhook test: %d", res.StatusCode)
}
select {
case h := <-got:
if h.path != "/legacy" || h.body["event"] != "test" {
t.Fatalf("legacy webhook payload: %v", h.body)
}
case <-time.After(3 * time.Second):
t.Fatal("legacy webhook never fired")
}
// Named unconfigured transport -> 400.
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for unconfigured smtp, got %d", res.StatusCode)
}
}
// fakeSMTP is the smallest lawful SMTP server: EHLO/MAIL/RCPT/DATA/QUIT.
func fakeSMTP(t *testing.T) (addr string, gotMsg <-chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
msgs := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
fmt.Fprint(conn, "220 fake\r\n")
var data strings.Builder
inData := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
if inData {
if line == "." {
inData = false
fmt.Fprint(conn, "250 queued\r\n")
continue
}
data.WriteString(line + "\n")
continue
}
switch {
case strings.HasPrefix(line, "EHLO"):
fmt.Fprint(conn, "250-fake\r\n250 OK\r\n")
case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
fmt.Fprint(conn, "250 OK\r\n")
case strings.HasPrefix(line, "DATA"):
fmt.Fprint(conn, "354 go\r\n")
inData = true
case strings.HasPrefix(line, "QUIT"):
fmt.Fprint(conn, "221 bye\r\n")
msgs <- data.String()
return
default:
fmt.Fprint(conn, "250 OK\r\n")
}
}
}()
return ln.Addr().String(), msgs
}
func TestNotifySMTP(t *testing.T) {
srv := testServer(t)
defer srv.Close()
addr, gotMsg := fakeSMTP(t)
host, port, _ := net.SplitHostPort(addr)
res, _ := do(t, "PUT", srv.URL+"/api/settings",
`{"notify_smtp":{"host":"`+host+`","port":"`+port+`","from":"dash@local","to":"me@local"}}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put smtp: %d", res.StatusCode)
}
res, out := do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusOK || out["results"].(map[string]any)["smtp"] != "ok" {
t.Fatalf("smtp test: %d %v", res.StatusCode, out)
}
select {
case msg := <-gotMsg:
if !strings.Contains(msg, "Subject: [Dash] Dash test notification") {
t.Fatalf("smtp message missing subject: %q", msg)
}
case <-time.After(3 * time.Second):
t.Fatal("smtp server never received the message")
}
}
// ---------- alert rules ----------
func TestAlertRulesConsecutiveAndMute(t *testing.T) {
srv := testServer(t)
defer srv.Close()
got := make(chan map[string]any, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
_ = json.NewDecoder(r.Body).Decode(&m)
got <- m
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer up.Close()
down := `{"url":"http://127.0.0.1:1","timeoutS":1}`
// Monitor requiring 2 consecutive failures before going down.
res, m := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Flap","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"consecutiveFailures":2}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", m)
}
id := m["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
do(t, "PATCH", srv.URL+"/api/monitors/"+id, down)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 1st failure: hold
_, mv := do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "up" {
t.Fatalf("expected held status up after 1 failure, got %v", mv["status"])
}
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 2nd failure: down
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "down" {
t.Fatalf("expected down after 2 failures, got %v", mv["status"])
}
select {
case body := <-got:
if body["event"] != "monitor.down" {
t.Fatalf("expected monitor.down, got %v", body["event"])
}
case <-time.After(3 * time.Second):
t.Fatal("monitor.down never fired after threshold")
}
// Muted monitor: status still flips, no notification.
res, m2 := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Quiet","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"mute":true}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create muted: %v", m2)
}
id2 := m2["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
do(t, "PATCH", srv.URL+"/api/monitors/"+id2, down)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id2, "")
if mv["status"] != "down" {
t.Fatalf("muted monitor should still go down, got %v", mv["status"])
}
select {
case body := <-got:
if body["name"] == "Quiet" {
t.Fatal("muted monitor still notified")
}
case <-time.After(1500 * time.Millisecond):
}
}
func TestAlertRulesBadJSON(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"X","type":"http","url":"http://127.0.0.1:1","alerts":"nope"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid alerts, got %d", res.StatusCode)
}
}
// ---------- subdomains ----------
func TestSubdomainDiscovery(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Stub the network seams: CT returns candidates, resolver answers a few.
oldCT, oldRes := domain.CTNames, domain.ResolveIPs
domain.CTNames = func(_ context.Context, d string) ([]string, error) {
return []string{"api." + d + "\nwww." + d, "*." + d, "other.com"}, nil
}
domain.ResolveIPs = func(_ context.Context, name string) ([]string, error) {
if strings.HasPrefix(name, "api.") {
return []string{"10.0.0.1"}, nil
}
return []string{"93.184.216.34"}, nil
}
t.Cleanup(func() { domain.CTNames, domain.ResolveIPs = oldCT, oldRes })
res, d := do(t, "POST", srv.URL+"/api/domains", `{"name":"example.com"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create domain: %v", d)
}
id := d["id"].(string)
// First refresh ran discovery inline (sub_checked_at was NULL).
res, subs := do(t, "GET", srv.URL+"/api/domains/"+id+"/subdomains", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("list subdomains: %d", res.StatusCode)
}
list, _ := subs["subdomains"].([]any) // response is a bare array — check below
_ = list
// do() decodes objects only; re-fetch raw for the array.
raw, err := http.Get(srv.URL + "/api/domains/" + id + "/subdomains")
if err != nil {
t.Fatal(err)
}
var arr []map[string]any
_ = json.NewDecoder(raw.Body).Decode(&arr)
raw.Body.Close()
names := map[string][]string{}
for _, s := range arr {
ips := []string{}
for _, ip := range s["ips"].([]any) {
ips = append(ips, ip.(string))
}
names[s["name"].(string)] = ips
}
if len(names) != 3 || len(names["api.example.com"]) != 1 || names["api.example.com"][0] != "10.0.0.1" {
t.Fatalf("unexpected subdomains: %v", names)
}
if _, bad := names["other.com"]; bad {
t.Fatal("foreign name leaked into subdomains")
}
}
+15
View File
@@ -60,8 +60,20 @@ func (s *Server) routes() *gin.Engine {
r.Use(accessLog(s.log), gin.Recovery()) r.Use(accessLog(s.log), gin.Recovery())
v1 := r.Group("/api") v1 := r.Group("/api")
v1.Use(s.requireAuth())
v1.GET("/healthz", s.healthz) 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.GET("/sections", s.listSections)
v1.POST("/sections", s.createSection) v1.POST("/sections", s.createSection)
v1.PATCH("/sections/:id", s.updateSection) v1.PATCH("/sections/:id", s.updateSection)
@@ -91,6 +103,8 @@ func (s *Server) routes() *gin.Engine {
v1.DELETE("/domains/:id", s.deleteDomain) v1.DELETE("/domains/:id", s.deleteDomain)
v1.POST("/domains/:id/refresh", s.refreshDomainH) v1.POST("/domains/:id/refresh", s.refreshDomainH)
v1.GET("/domains/:id/checks", s.domainChecks) 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.POST("/notify/test", s.notifyTest)
v1.GET("/systems", s.listSystemsH) v1.GET("/systems", s.listSystemsH)
@@ -167,6 +181,7 @@ type Section struct {
Name string `json:"name"` Name string `json:"name"`
Position float64 `json:"position"` Position float64 `json:"position"`
Collapsed bool `json:"collapsed"` Collapsed bool `json:"collapsed"`
BoardID string `json:"boardId"`
Items []Item `json:"items"` Items []Item `json:"items"`
} }
+70
View File
@@ -786,3 +786,73 @@ func TestValidURL(t *testing.T) {
} }
} }
} }
func TestBoards(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Default board exists and owns new sections.
var boards []Board
resp, err := http.Get(srv.URL + "/api/boards")
if err != nil || json.NewDecoder(resp.Body).Decode(&boards) != nil {
t.Fatalf("list boards: %v", err)
}
resp.Body.Close()
if len(boards) != 1 || boards[0].ID != "b_home" {
t.Fatalf("expected seeded home board, got %+v", boards)
}
_, sec := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
if sec["boardId"] != "b_home" {
t.Fatalf("section should default to home board: %v", sec["boardId"])
}
// Create a second board; slug is derived and unique.
_, b2 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b2["slug"] != "lab-net" {
t.Fatalf("bad slug: %v", b2["slug"])
}
_, b3 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b3["slug"] == b2["slug"] {
t.Fatalf("slug collision: %v", b3["slug"])
}
// Sections scope to their board.
_, s2 := do(t, "POST", srv.URL+"/api/sections", `{"name":"B","boardId":"`+b2["id"].(string)+`"}`)
if s2["boardId"] != b2["id"] {
t.Fatalf("section not scoped to board: %v", s2["boardId"])
}
var home, lab []Section
r1, _ := http.Get(srv.URL + "/api/sections?board=b_home")
_ = json.NewDecoder(r1.Body).Decode(&home)
r1.Body.Close()
r2, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r2.Body).Decode(&lab)
r2.Body.Close()
if len(home) != 1 || home[0].Name != "A" || len(lab) != 1 || lab[0].Name != "B" {
t.Fatalf("board filter broken: home=%v lab=%v", home, lab)
}
// Rename keeps the slug; deleting cascades sections.
_, rb := do(t, "PATCH", srv.URL+"/api/boards/"+b3["id"].(string), `{"name":"Renamed"}`)
if rb["name"] != "Renamed" || rb["slug"] == "renamed" {
t.Fatalf("rename should not change slug: %+v", rb)
}
res, _ := do(t, "DELETE", srv.URL+"/api/boards/"+b2["id"].(string), "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete board: %d", res.StatusCode)
}
var labAfter []Section
r3, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r3.Body).Decode(&labAfter)
r3.Body.Close()
if len(labAfter) != 0 {
t.Fatalf("sections should cascade with board: %v", labAfter)
}
// Down to two boards — delete both and the last delete must refuse.
do(t, "DELETE", srv.URL+"/api/boards/"+b3["id"].(string), "")
res, _ = do(t, "DELETE", srv.URL+"/api/boards/b_home", "")
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("deleting last board should 400, got %d", res.StatusCode)
}
}
+261
View File
@@ -0,0 +1,261 @@
package api
// Opt-in local auth — disabled by default; enabling creates the first admin.
// Local username + bcrypt password only (no OIDC/SSO — deliberate non-goal).
// Sessions are random tokens in SQLite behind an httpOnly SameSite=Lax cookie;
// login is rate-limited per IP. Public endpoints (status pages, badges, push,
// agent ingest, metrics) bypass the middleware.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "dash_session"
sessionTTL = 7 * 24 * time.Hour
// Rotate when less than half the TTL remains.
sessionRotate = sessionTTL / 2
)
func (s *Server) authEnabled() bool {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'auth_enabled'`).Scan(&raw); err != nil {
return false
}
var on bool
_ = json.Unmarshal([]byte(raw), &on)
return on
}
func (s *Server) userCount() int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}
// ---------- sessions ----------
func (s *Server) createSession(c *gin.Context, userID string) {
var tok [32]byte
_, _ = rand.Read(tok[:])
token := hex.EncodeToString(tok[:])
exp := time.Now().Add(sessionTTL)
_, _ = s.db.Exec(`INSERT INTO sessions (token, user_id, expires_at) VALUES (?,?,?)`,
token, userID, exp.UTC().Format("2006-01-02T15:04:05.000Z"))
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: exp,
HttpOnly: true,
Secure: c.Request.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
// sessionUser resolves the cookie to a user, sliding-rotating near expiry.
// Returns "" when absent/expired.
func (s *Server) sessionUser(c *gin.Context) string {
ck, err := c.Cookie(sessionCookie)
if err != nil || ck == "" {
return ""
}
var userID, expires string
err = s.db.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token = ?`, ck).Scan(&userID, &expires)
if err != nil {
return ""
}
exp, err := time.Parse("2006-01-02T15:04:05.000Z", expires)
if err != nil || time.Now().After(exp) {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
return ""
}
if time.Until(exp) < sessionRotate {
// Rotate: drop the old token, issue a fresh one.
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
s.createSession(c, userID)
}
return userID
}
// ---------- rate limiting (login only, in-memory per-IP) ----------
var loginMu sync.Mutex
var loginHits = map[string][]time.Time{}
func loginAllowed(ip string) bool {
loginMu.Lock()
defer loginMu.Unlock()
cut := time.Now().Add(-time.Minute)
kept := loginHits[ip][:0]
for _, t := range loginHits[ip] {
if t.After(cut) {
kept = append(kept, t)
}
}
if len(kept) >= 5 {
loginHits[ip] = kept
return false
}
loginHits[ip] = append(kept, time.Now())
return true
}
// ---------- middleware ----------
// publicAPI lists /api paths reachable without a session. Auth endpoints must
// stay open (login itself); push/ingest/badge/metrics/public status carry
// their own tokens or are intentionally public.
func publicAPI(path string) bool {
switch {
case strings.HasPrefix(path, "/api/auth/"),
strings.HasPrefix(path, "/api/push/"),
path == "/api/systems/ingest",
strings.HasPrefix(path, "/api/badge/"),
path == "/api/metrics",
strings.HasPrefix(path, "/api/healthz"),
strings.HasSuffix(path, "/public"):
return true
}
return false
}
// requireAuth gates every /api route when the auth_enabled setting is on.
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if !s.authEnabled() || publicAPI(c.Request.URL.Path) {
c.Next()
return
}
if s.sessionUser(c) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
c.Next()
}
}
// ---------- handlers ----------
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
// sessionInfo reports auth state for the frontend shell — always public so
// the UI can decide between the board and the login screen.
func (s *Server) authSession(c *gin.Context) {
on := s.authEnabled()
out := gin.H{"enabled": on, "authed": false, "setup": false}
if !on {
c.JSON(http.StatusOK, out)
return
}
out["setup"] = s.userCount() == 0
if uid := s.sessionUser(c); uid != "" {
var name string
_ = s.db.QueryRow(`SELECT username FROM users WHERE id = ?`, uid).Scan(&name)
out["authed"] = true
out["username"] = name
}
c.JSON(http.StatusOK, out)
}
// setup creates the first user — only while auth is on and no users exist.
func (s *Server) authSetup(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
if s.userCount() > 0 {
fail(c, http.StatusConflict, "admin user already exists")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil ||
len(strings.TrimSpace(in.Username)) < 2 || len(in.Password) < 8 {
fail(c, http.StatusBadRequest, "username (2+) and password (8+) required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(c, http.StatusInternalServerError, "hash failed")
return
}
uid := newID("u")
if _, err := s.db.Exec(`INSERT INTO users (id, username, pass_hash) VALUES (?,?,?)`,
uid, strings.TrimSpace(in.Username), string(hash)); err != nil {
fail(c, http.StatusConflict, "username taken")
return
}
s.createSession(c, uid)
c.JSON(http.StatusCreated, gin.H{"username": in.Username})
}
func (s *Server) authLogin(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
ip := c.ClientIP()
if !loginAllowed(ip) {
fail(c, http.StatusTooManyRequests, "too many attempts — wait a minute")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
var uid, hash string
err := s.db.QueryRow(`SELECT id, pass_hash FROM users WHERE username = ?`,
strings.TrimSpace(in.Username)).Scan(&uid, &hash)
if err != nil {
// Unknown user — run bcrypt anyway so timing doesn't leak existence.
hash = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5sA5t6p7K0dGm7ZC4yQ0yCk0cFk0K"
}
// CompareHashAndPassword is constant-time; err != nil covers both
// unknown user (dummy hash above) and wrong password.
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
s.createSession(c, uid)
c.JSON(http.StatusOK, gin.H{"username": in.Username})
}
func (s *Server) authLogout(c *gin.Context) {
if ck, err := c.Cookie(sessionCookie); err == nil {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
}
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
})
c.Status(http.StatusNoContent)
}
// authDisable turns auth off and wipes users+sessions. Requires a session —
// you can't switch the lock off from outside.
func (s *Server) authDisable(c *gin.Context) {
if s.sessionUser(c) == "" {
fail(c, http.StatusUnauthorized, "authentication required")
return
}
_, _ = s.db.Exec(`DELETE FROM sessions`)
_, _ = s.db.Exec(`DELETE FROM users`)
if _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES ('auth_enabled','false')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
s.log.Error("auth disable", zap.Error(err))
}
c.Status(http.StatusNoContent)
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"go.uber.org/zap"
)
// authServer returns the bare server (no httptest) so tests can plant
// sessions and inspect cookies directly.
func authServer(t *testing.T) *Server {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
// Login rate limits are keyed by IP in a package-global map — reset so
// tests don't count each other's attempts.
loginMu.Lock()
loginHits = map[string][]time.Time{}
loginMu.Unlock()
return newServer(zap.NewNop(), d, t.TempDir())
}
func req(t *testing.T, s *Server, method, path, body, cookie string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if cookie != "" {
r.Header.Set("Cookie", sessionCookie+"="+cookie)
}
s.routes().ServeHTTP(w, r)
return w
}
func sessionCookieOf(w *httptest.ResponseRecorder) string {
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookie {
return c.Value
}
}
return ""
}
func enableAuth(t *testing.T, s *Server) {
t.Helper()
if w := req(t, s, "PUT", "/api/settings", `{"auth_enabled":true}`, ""); w.Code != 200 {
t.Fatalf("enable auth: %d", w.Code)
}
}
func TestAuthDisabledByDefault(t *testing.T) {
s := authServer(t)
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections open when auth off: %d", w.Code)
}
var out map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&out)
if out["enabled"] != false {
t.Fatalf("auth should report disabled: %v", out)
}
}
func TestAuthLifecycle(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
// Everything gates now.
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 401 {
t.Fatalf("expected 401, got %d", w.Code)
}
// Session endpoint stays public and reports setup mode.
var sess map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&sess)
if sess["enabled"] != true || sess["setup"] != true || sess["authed"] != false {
t.Fatalf("bad session payload: %v", sess)
}
// Public surfaces stay open even gated.
for _, p := range []string{"/api/healthz", "/api/metrics", "/api/badge/uptime/x.svg"} {
if w := req(t, s, "GET", p, "", ""); w.Code == 401 {
t.Fatalf("public path %s gated", p)
}
}
// Setup creates the first admin and returns a session cookie.
w = req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 201 {
t.Fatalf("setup: %d body=%s", w.Code, w.Body)
}
cookie := sessionCookieOf(w)
if cookie == "" {
t.Fatal("setup did not set a session cookie")
}
// Second setup blocked.
if w := req(t, s, "POST", "/api/auth/setup", `{"username":"x","password":"another-pass"}`, ""); w.Code != 409 {
t.Fatalf("second setup should 409, got %d", w.Code)
}
// Authed request with the cookie.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 200 {
t.Fatalf("authed request: %d", w.Code)
}
// Logout kills the session.
req(t, s, "POST", "/api/auth/logout", "", cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expected 401 after logout, got %d", w.Code)
}
// Login wrong password -> 401; right -> 200 + fresh cookie.
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"wrong-password"}`, ""); w.Code != 401 {
t.Fatalf("bad login should 401, got %d", w.Code)
}
w = req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 200 {
t.Fatalf("login: %d", w.Code)
}
if sessionCookieOf(w) == "" {
t.Fatal("login set no cookie")
}
}
func TestAuthLoginRateLimit(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
req(t, s, "POST", "/api/auth/logout", "", "")
for i := 0; i < 5; i++ {
req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"nope-nope"}`, "")
}
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, ""); w.Code != 429 {
t.Fatalf("6th login should be rate-limited, got %d", w.Code)
}
}
func TestAuthSessionExpiryAndRotation(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Expired session -> 401.
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expired session should 401, got %d", w.Code)
}
// Near-expiry session rotates: response carries a new cookie.
fresh := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
cookie = sessionCookieOf(fresh)
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
w = req(t, s, "GET", "/api/sections", "", cookie)
if w.Code != 200 {
t.Fatalf("rotating request should pass, got %d", w.Code)
}
newCookie := sessionCookieOf(w)
if newCookie == "" || newCookie == cookie {
t.Fatal("expected session rotation to issue a new token")
}
// Old token is dead.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("old token should be invalid after rotation, got %d", w.Code)
}
}
func TestAuthDisable(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Unauthenticated disable is refused.
if w := req(t, s, "POST", "/api/auth/disable", "", ""); w.Code != 401 {
t.Fatalf("disable without session should 401, got %d", w.Code)
}
if w := req(t, s, "POST", "/api/auth/disable", "", cookie); w.Code != 204 {
t.Fatalf("disable: %d", w.Code)
}
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections should reopen after disable, got %d", w.Code)
}
}
+28 -12
View File
@@ -10,8 +10,16 @@ import (
// board loads the full tree: sections ordered, each with items and urls. // board loads the full tree: sections ordered, each with items and urls.
// Three flat queries assembled in memory — the whole board is ~100 rows. // Three flat queries assembled in memory — the whole board is ~100 rows.
func (s *Server) board() ([]Section, error) { // boardID filters to one board; "" returns every section (export, legacy).
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`) // 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 { if err != nil {
return nil, err return nil, err
} }
@@ -21,7 +29,7 @@ func (s *Server) board() ([]Section, error) {
byID := map[string]*Section{} byID := map[string]*Section{}
for secRows.Next() { for secRows.Next() {
var sec Section 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 return nil, err
} }
sec.Items = []Item{} sec.Items = []Item{}
@@ -89,7 +97,7 @@ func (s *Server) board() ([]Section, error) {
} }
func (s *Server) listSections(c *gin.Context) { func (s *Server) listSections(c *gin.Context) {
sections, err := s.board() sections, err := s.board(c.Query("board"))
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -99,19 +107,27 @@ func (s *Server) listSections(c *gin.Context) {
func (s *Server) createSection(c *gin.Context) { func (s *Server) createSection(c *gin.Context) {
var in struct { var in struct {
Name string `json:"name"` Name string `json:"name"`
BoardID string `json:"boardId"`
} }
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" { if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
fail(c, http.StatusBadRequest, "name required") fail(c, http.StatusBadRequest, "name required")
return return
} }
boardID := in.BoardID
if boardID == "" {
boardID = "b_home"
}
var maxPos sql.NullFloat64 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()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}} sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), BoardID: boardID, Items: []Item{}}
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil { // 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()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
@@ -140,8 +156,8 @@ func (s *Server) updateSection(c *gin.Context) {
} }
} }
var sec Section var sec Section
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")). 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) Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID)
if notFound(err) { if notFound(err) {
fail(c, http.StatusNotFound, "section not found") fail(c, http.StatusNotFound, "section not found")
return return
@@ -192,8 +208,8 @@ func (s *Server) reorderSection(c *gin.Context) {
return return
} }
var sec Section var sec Section
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID). 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); err != nil { Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
+120
View File
@@ -0,0 +1,120 @@
package api
// Multiple boards — sections carry board_id; the board list is small and
// lives next to the section tree. Deleting a board cascades its sections.
import (
"database/sql"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// Board is a named dashboard page reachable at /b/<slug>.
type Board struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Position float64 `json:"position"`
}
func (s *Server) listBoards(c *gin.Context) {
rows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []Board{}
for rows.Next() {
var b Board
if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
out = append(out, b)
}
c.JSON(http.StatusOK, out)
}
func (s *Server) createBoard(c *gin.Context) {
var in struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || strings.TrimSpace(in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
name := strings.TrimSpace(in.Name)
slug := slugify(name)
if slug == "" {
slug = "board"
}
// Uniquify: 'media', 'media-2', 'media-3'…
base := slug
for i := 2; ; i++ {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards WHERE slug = ?`, slug).Scan(&n)
if n == 0 {
break
}
slug = base + "-" + strconv.Itoa(i)
}
var maxPos sql.NullFloat64
_ = s.db.QueryRow(`SELECT MAX(position) FROM boards`).Scan(&maxPos)
b := Board{ID: newID("b"), Slug: slug, Name: name, Position: midpoint(ptrOr(maxPos), nil)}
if _, err := s.db.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?,?,?,?)`,
b.ID, b.Slug, b.Name, b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, b)
}
func (s *Server) updateBoard(c *gin.Context) {
var in struct {
Name *string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || strings.TrimSpace(*in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
// Slug stays fixed on rename — links shouldn't break.
res, err := s.db.Exec(`UPDATE boards SET name = ? WHERE id = ?`, strings.TrimSpace(*in.Name), c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "board not found")
return
}
var b Board
if err := s.db.QueryRow(`SELECT id, slug, name, position FROM boards WHERE id = ?`, c.Param("id")).
Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, b)
}
func (s *Server) deleteBoard(c *gin.Context) {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards`).Scan(&n)
if n <= 1 {
fail(c, http.StatusBadRequest, "cannot delete the last board")
return
}
res, err := s.db.Exec(`DELETE FROM boards WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "board not found")
return
}
c.Status(http.StatusNoContent)
}
+38 -4
View File
@@ -27,7 +27,7 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code, 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, host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
dns_provider, email_provider, hosting_provider, ca_provider, headers, 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. // domainRow mirrors the domains table; JSON text columns decode on scan.
type domainRow struct { type domainRow struct {
@@ -87,12 +87,13 @@ type domainRow struct {
IntervalH int `json:"intervalH"` IntervalH int `json:"intervalH"`
LastChecked *string `json:"lastChecked"` LastChecked *string `json:"lastChecked"`
Position float64 `json:"position"` Position float64 `json:"position"`
Alerts json.RawMessage `json:"alerts"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) { func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
var d domainRow 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 var active, autoRenew, privacy, lock int
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore, err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate, &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.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider, &d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked, &headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
&d.Position, &d.CreatedAt) &d.Position, &alerts, &d.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
d.Alerts = json.RawMessage(alerts)
d.Active = active != 0 d.Active = active != 0
d.AutoRenew = autoRenew != 0 d.AutoRenew = autoRenew != 0
d.PrivacyEnabled = privacy != 0 d.PrivacyEnabled = privacy != 0
@@ -200,6 +202,7 @@ type domainInput struct {
IntervalH *int `json:"intervalH"` IntervalH *int `json:"intervalH"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
} }
func (s *Server) createDomain(c *gin.Context) { func (s *Server) createDomain(c *gin.Context) {
@@ -218,6 +221,13 @@ func (s *Server) createDomain(c *gin.Context) {
if in.Tags == nil { if in.Tags == nil {
tags = []byte("[]") 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 _, err := s.db.Exec(`INSERT INTO domains
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position) (id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
VALUES (?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?)`,
@@ -228,6 +238,9 @@ func (s *Server) createDomain(c *gin.Context) {
fail(c, http.StatusConflict, "domain already tracked or insert failed") fail(c, http.StatusConflict, "domain already tracked or insert failed")
return 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. // First lookup runs inline so the response carries real data.
s.refreshDomain(id) s.refreshDomain(id)
d, err := s.loadDomain(id) d, err := s.loadDomain(id)
@@ -298,6 +311,14 @@ func (s *Server) patchDomain(c *gin.Context) {
tags, _ := json.Marshal(in.Tags) tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id) _, _ = 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) d, err := s.loadDomain(id)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
@@ -338,6 +359,10 @@ func (s *Server) refreshDomain(id string) {
return return
} }
nv := viewOf(next) nv := viewOf(next)
rules := parseAlertRules(next.Alerts)
if rules.Mute {
return
}
switch { switch {
case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0): 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)) 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, s.notify("domain.expiring", next.Name,
fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry)) 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, s.notify("domain.sslExpiring", next.Name,
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry)) 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) { func (s *Server) refreshDomainH(c *gin.Context) {
+2 -2
View File
@@ -30,11 +30,11 @@ func (s *Server) importExternal(c *gin.Context) {
fail(c, http.StatusBadRequest, err.Error()) fail(c, http.StatusBadRequest, err.Error())
return return
} }
if err := s.replaceBoard(sections, nil); err != nil { if err := s.replaceBoard(sections, nil, nil); err != nil {
failImport(c, err) failImport(c, err)
return return
} }
board, err := s.board() board, err := s.board("")
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
+44 -6
View File
@@ -20,21 +20,22 @@ import (
const monitorCols = `id, name, type, url, hostname, port, method, headers, body, const monitorCols = `id, name, type, url, hostname, port, method, headers, body,
keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s, 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) { func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
var m monitor.Monitor var m monitor.Monitor
var tags string var tags, alerts string
var invert, active int var invert, active int
err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method, 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.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType,
&m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken, &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 { if err != nil {
return nil, err return nil, err
} }
m.KeywordInvert = invert != 0 m.KeywordInvert = invert != 0
m.Active = active != 0 m.Active = active != 0
m.Alerts = json.RawMessage(alerts)
_ = json.Unmarshal([]byte(tags), &m.Tags) _ = json.Unmarshal([]byte(tags), &m.Tags)
if m.Tags == nil { if m.Tags == nil {
m.Tags = []string{} m.Tags = []string{}
@@ -142,6 +143,7 @@ type monitorInput struct {
Active *bool `json:"active"` Active *bool `json:"active"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
} }
func validMonitorType(t string) bool { func validMonitorType(t string) bool {
@@ -209,17 +211,26 @@ func (s *Server) createMonitor(c *gin.Context) {
if in.Tags == nil { if in.Tags == nil {
tags = []byte("[]") 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 _, err := s.db.Exec(`INSERT INTO monitors
(id, name, type, url, hostname, port, method, headers, body, keyword, (id, name, type, url, hostname, port, method, headers, body, keyword,
keyword_invert, json_query, expected, dns_type, interval_s, timeout_s, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
retries, active, push_token, tags, notes, position) retries, active, push_token, tags, notes, alerts, position)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port), id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port),
strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body), strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body),
strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery), strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery),
strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60), strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60),
intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true), 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 { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -294,6 +305,14 @@ func (s *Server) patchMonitor(c *gin.Context) {
tags, _ := json.Marshal(in.Tags) tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id) _, _ = 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 { if in.Active != nil {
// pause/resume flips the stored status back to pending for a fresh probe // pause/resume flips the stored status back to pending for a fresh probe
status := "pending" status := "pending"
@@ -412,6 +431,9 @@ func (s *Server) runScheduler(done <-chan struct{}) {
s.log.Error("heartbeat prune", zap.Error(err)) s.log.Error("heartbeat prune", zap.Error(err))
} }
// System samples are high-rate; a week of raw ticks is enough. // 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 { 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)) 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) s.writeHeartbeat(m, res)
prev := m.Status prev := m.Status
next := "down" next := "down"
rules := parseAlertRules(m.Alerts)
if res.Up { if res.Up {
next = "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 = ?`, 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 { next, m.ID); err != nil {
@@ -483,6 +510,14 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
if prev != next && prev != "pending" { if prev != next && prev != "pending" {
s.onMonitorTransition(m, prev, next) 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 return res
} }
@@ -507,6 +542,9 @@ func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
return return
} }
s.autoIncident(m.ID, m.Name, next) s.autoIncident(m.ID, m.Name, next)
if parseAlertRules(m.Alerts).Mute {
return
}
target := m.URL target := m.URL
if target == "" { if target == "" {
target = m.Hostname target = m.Hostname
+242 -43
View File
@@ -3,19 +3,29 @@ package api
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt"
"net"
"net/http" "net/http"
"net/smtp"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
) )
// Notifications: one outbound webhook, configured via the notify_webhook // Notifications: a small transport registry. Each transport is configured via
// settings key. Payload carries event/name/text/content/message so the same // the settings key notify_<name> holding a JSON object of fields; a transport
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic // is active when its required fields are set. Multiple transports may be
// receivers. SMTP and per-service channels are deliberately out of scope — // active — notify() fans out to all of them.
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers 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 { type notifyPayload struct {
Event string `json:"event"` Event string `json:"event"`
@@ -26,54 +36,213 @@ type notifyPayload struct {
At string `json:"at"` At string `json:"at"`
} }
func (s *Server) webhookURL() string { type transport struct {
var raw string name string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil { required []string
return "" send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
}
// 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
} }
// notify fires the webhook asynchronously; delivery failures are logged, never fatal. var transports = []transport{
{"webhook", []string{"url"}, sendWebhook},
{"slack", []string{"url"}, sendSlack},
{"discord", []string{"url"}, sendDiscord},
{"telegram", []string{"token", "chat_id"}, sendTelegram},
{"gotify", []string{"server", "token"}, sendGotify},
{"ntfy", []string{"server", "topic"}, sendNtfy},
{"smtp", []string{"host", "port", "from", "to"}, sendSMTP},
}
// notifyCfg loads notify_<name> from settings. Values are JSON: an object for
// presets, or a bare string (legacy webhook) meaning {"url": s}.
func (s *Server) notifyCfg(name string) map[string]string {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_`+name+`'`).Scan(&raw); err != nil {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
var str string
if err := json.Unmarshal([]byte(raw), &str); err == nil && name == "webhook" {
return map[string]string{"url": str}
}
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if str, ok := v.(string); ok {
out[k] = str
}
}
return out
}
// configured reports whether all required fields are present. Partially
// configured transports count as configured so their send error surfaces.
func (t transport) configured(cfg map[string]string) bool {
if cfg == nil {
return false
}
for _, k := range t.required {
if strings.TrimSpace(cfg[k]) == "" {
return false
}
}
return true
}
// notify fans an event out to every configured transport, asynchronously.
func (s *Server) notify(event, name, msg string) { 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, p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
At: time.Now().UTC().Format(time.RFC3339)} 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) { // postJSON posts a JSON body and reports non-2xx as an error.
body, _ := json.Marshal(p) func postJSON(ctx context.Context, url string, payload any, headers map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) if parseHTTPURL(url) == "" {
defer cancel() return fmt.Errorf("not an http(s) URL")
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { if err != nil {
return return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Title", p.Text) // ntfy reads the title header for k, v := range headers {
req.Header.Set("X-Dash-Event", p.Event) req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err)) return err
return
} }
resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 300 {
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode)) 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 // parseHTTPURL enforces http/https on the configured webhook — same SSRF
@@ -85,12 +254,42 @@ func parseHTTPURL(raw string) string {
return raw 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) { func (s *Server) notifyTest(c *gin.Context) {
if s.webhookURL() == "" { var in struct {
fail(c, http.StatusBadRequest, "notify_webhook not configured") 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 return
} }
s.notify("test", "dash", "Dash test notification") c.JSON(http.StatusOK, gin.H{"results": results})
c.JSON(http.StatusOK, gin.H{"status": "sent"})
} }
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/domain"
"go.uber.org/zap"
)
// Subdomain discovery: crt.sh + DNS, persisted per domain and swept at most
// once a day inside the domain refresh. CT names are untrusted strings —
// stored as text, rendered as text, never linked.
type subdomainView struct {
Name string `json:"name"`
IPs []string `json:"ips"`
FirstSeen string `json:"firstSeen"`
LastSeen string `json:"lastSeen"`
}
func (s *Server) listSubdomains(c *gin.Context) {
rows, err := s.db.Query(`SELECT name, ips, first_seen, last_seen
FROM subdomains WHERE domain_id = ? ORDER BY name`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []subdomainView{}
for rows.Next() {
var v subdomainView
var ips string
if err := rows.Scan(&v.Name, &ips, &v.FirstSeen, &v.LastSeen); err != nil {
continue
}
_ = json.Unmarshal([]byte(ips), &v.IPs)
if v.IPs == nil {
v.IPs = []string{}
}
out = append(out, v)
}
c.JSON(http.StatusOK, out)
}
// refreshSubdomains forces a discovery run for one domain.
func (s *Server) refreshSubdomains(c *gin.Context) {
id := c.Param("id")
d, err := s.loadDomain(id)
if notFound(err) {
fail(c, http.StatusNotFound, "domain not found")
return
}
s.sweepSubdomains(d)
s.listSubdomains(c)
}
// sweepSubdomains runs CT+DNS discovery and upserts rows. Called from
// refreshDomain when sub_checked_at is stale (>24h), and on manual refresh.
func (s *Server) sweepSubdomains(d *domainRow) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
found, err := domain.DiscoverSubdomains(ctx, d.Name)
if err != nil {
s.log.Warn("subdomain discovery", zap.String("domain", d.Name), zap.Error(err))
return
}
for _, f := range found {
ips, _ := json.Marshal(f.IPs)
if _, err := s.db.Exec(`INSERT INTO subdomains (id, domain_id, name, ips)
VALUES (?,?,?,?)
ON CONFLICT(domain_id, name) DO UPDATE SET ips = excluded.ips,
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
newID("sub"), d.ID, f.Name, string(ips)); err != nil {
s.log.Error("subdomain upsert", zap.Error(err))
}
}
_, _ = s.db.Exec(`UPDATE domains SET sub_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, d.ID)
}
// maybeSweepSubdomains gates discovery to once per day per domain.
func (s *Server) maybeSweepSubdomains(d *domainRow) {
var checked *string
_ = s.db.QueryRow(`SELECT sub_checked_at FROM domains WHERE id = ?`, d.ID).Scan(&checked)
if checked != nil && *checked != "" {
if t, err := time.Parse("2006-01-02T15:04:05.000Z", *checked); err == nil &&
time.Since(t) < 24*time.Hour {
return
}
}
s.sweepSubdomains(d)
}
+37 -4
View File
@@ -41,13 +41,46 @@ type StatSample struct {
Load15 float64 `json:"load15"` Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps"` Temps map[string]float64 `json:"temps"`
Containers []ContainerStat `json:"containers"` 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 // ContainerStat is one Docker container's liveness plus resource use.
// needs cgroup sampling — deferred (v1 ships name+state only). // 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 { type ContainerStat struct {
Name string `json:"name"` Name string `json:"name"`
State string `json:"state"` 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. // System is one monitored host; `latest` is the newest StatSample.
+42 -7
View File
@@ -28,6 +28,7 @@ type Export struct {
Version int `json:"version"` Version int `json:"version"`
Settings map[string]json.RawMessage `json:"settings"` Settings map[string]json.RawMessage `json:"settings"`
Sections []Section `json:"sections"` Sections []Section `json:"sections"`
Boards []Board `json:"boards,omitempty"`
Monitors []json.RawMessage `json:"monitors,omitempty"` Monitors []json.RawMessage `json:"monitors,omitempty"`
Domains []json.RawMessage `json:"domains,omitempty"` Domains []json.RawMessage `json:"domains,omitempty"`
Systems []json.RawMessage `json:"systems,omitempty"` Systems []json.RawMessage `json:"systems,omitempty"`
@@ -74,12 +75,21 @@ func (s *Server) exportBoard(c *gin.Context) {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
sections, err := s.board() sections, err := s.board("")
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
} }
ex := Export{Version: 2, Settings: settings, Sections: sections} 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 { for _, t := range []struct {
name string name string
dst *[]json.RawMessage dst *[]json.RawMessage
@@ -103,11 +113,11 @@ func (s *Server) importBoard(c *gin.Context) {
fail(c, http.StatusBadRequest, "invalid export payload") fail(c, http.StatusBadRequest, "invalid export payload")
return 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) failImport(c, err)
return return
} }
sections, err := s.board() sections, err := s.board("")
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, err.Error()) fail(c, http.StatusInternalServerError, err.Error())
return return
@@ -116,8 +126,10 @@ func (s *Server) importBoard(c *gin.Context) {
} }
// replaceBoard swaps all board rows inside one transaction — shared by the // replaceBoard swaps all board rows inside one transaction — shared by the
// Dash export import and the external-config importers. // Dash export import and the external-config importers. A payload carrying
func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error { // 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() tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return err
@@ -129,12 +141,35 @@ func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMe
return err 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 { for _, sec := range sections {
if sec.ID == "" || sec.Name == "" { if sec.ID == "" || sec.Name == "" {
return fmt.Errorf("%w: section missing id or name", errBadInput) return fmt.Errorf("%w: section missing id or name", errBadInput)
} }
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`, if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed, board_id)
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil { VALUES (?, ?, ?, ?, (SELECT id FROM boards WHERE id = ?))`,
sec.ID, sec.Name, sec.Position, sec.Collapsed, sec.BoardID); err != nil {
return err return err
} }
for _, it := range sec.Items { for _, it := range sec.Items {
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+128
View File
@@ -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
}
@@ -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)
}
}
}
+1
View File
@@ -42,6 +42,7 @@ type Monitor struct {
PushToken string `json:"pushToken,omitempty"` PushToken string `json:"pushToken,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Notes string `json:"notes"` Notes string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
Position float64 `json:"position"` Position float64 `json:"position"`
LastCheck *string `json:"lastCheck"` LastCheck *string `json:"lastCheck"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
View File
Whitespace-only changes.
+30
View File
@@ -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")
}
+89
View File
@@ -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
}
@@ -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)
}
}
}
+325 -5
View File
@@ -27,10 +27,88 @@ paths:
schema: schema:
$ref: "#/components/schemas/Health" $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: /api/sections:
get: get:
operationId: listSections 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: responses:
"200": "200":
description: Ordered board tree description: Ordered board tree
@@ -540,14 +618,70 @@ paths:
additionalProperties: additionalProperties:
$ref: "#/components/schemas/Status" $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: /api/notify/test:
post: post:
operationId: notifyTest operationId: notifyTest
summary: Send a test notification to the configured webhook summary: Send a test notification through configured transports
description: Requires the `notify_webhook` settings key to hold an http(s) URL. 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: responses:
"200": "200":
description: Test notification dispatched description: Per-transport delivery results
content:
application/json:
schema:
type: object
properties:
results:
type: object
additionalProperties:
type: string
"400": "400":
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
@@ -1039,6 +1173,83 @@ paths:
"400": "400":
$ref: "#/components/responses/BadRequest" $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: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
@@ -1074,6 +1285,19 @@ components:
status: status:
type: string type: string
example: ok 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: Error:
type: object type: object
required: [error] required: [error]
@@ -1172,7 +1396,7 @@ components:
$ref: "#/components/schemas/UrlInput" $ref: "#/components/schemas/UrlInput"
Section: Section:
type: object type: object
required: [id, name, position, collapsed, items] required: [id, name, position, collapsed, boardId, items]
properties: properties:
id: id:
type: string type: string
@@ -1182,16 +1406,32 @@ components:
type: number type: number
collapsed: collapsed:
type: boolean type: boolean
boardId:
type: string
items: items:
type: array type: array
items: items:
$ref: "#/components/schemas/Item" $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: SectionInput:
type: object type: object
required: [name] required: [name]
properties: properties:
name: name:
type: string type: string
boardId:
type: string
SectionPatch: SectionPatch:
type: object type: object
properties: properties:
@@ -1285,6 +1525,8 @@ components:
type: string type: string
notes: notes:
type: string type: string
alerts:
$ref: "#/components/schemas/AlertRules"
position: position:
type: number type: number
lastCheck: lastCheck:
@@ -1294,6 +1536,22 @@ components:
createdAt: createdAt:
type: string type: string
format: date-time 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: MonitorInput:
type: object type: object
properties: properties:
@@ -1338,6 +1596,8 @@ components:
type: string type: string
notes: notes:
type: string type: string
alerts:
$ref: "#/components/schemas/AlertRules"
UptimeStats: UptimeStats:
type: object type: object
properties: properties:
@@ -1519,6 +1779,8 @@ components:
type: string type: string
format: date-time format: date-time
nullable: true nullable: true
alerts:
$ref: "#/components/schemas/AlertRules"
position: position:
type: number type: number
createdAt: createdAt:
@@ -1543,6 +1805,8 @@ components:
type: string type: string
notes: notes:
type: string type: string
alerts:
$ref: "#/components/schemas/AlertRules"
DomainView: DomainView:
allOf: allOf:
- $ref: "#/components/schemas/Domain" - $ref: "#/components/schemas/Domain"
@@ -1558,6 +1822,21 @@ components:
type: boolean type: boolean
sslExpiring: sslExpiring:
type: boolean 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: DomainCheck:
type: object type: object
properties: properties:
@@ -1615,11 +1894,48 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/ContainerStat" $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: ContainerStat:
type: object type: object
properties: properties:
name: { type: string } name: { type: string }
state: { 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: System:
type: object type: object
required: [id, name, token, status] required: [id, name, token, status]
@@ -1819,3 +2135,7 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/Section" $ref: "#/components/schemas/Section"
boards:
type: array
items:
$ref: "#/components/schemas/Board"