mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
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:
1 parent
8973be3b41
commit
f3143af351
33 files changed
+2772
-126
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,13 +9,11 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
@@ -96,11 +94,40 @@ type sample struct {
|
||||
Load15 float64 `json:"load15"`
|
||||
Temps map[string]float64 `json:"temps,omitempty"`
|
||||
Containers []containerStat `json:"containers,omitempty"`
|
||||
Smart []smartDisk `json:"smart,omitempty"`
|
||||
ZFS []zfsPool `json:"zfs,omitempty"`
|
||||
GPU []gpuStat `json:"gpu,omitempty"`
|
||||
}
|
||||
|
||||
type containerStat struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CPU float64 `json:"cpu,omitempty"`
|
||||
MemUsed float64 `json:"memUsed,omitempty"`
|
||||
MemLimit float64 `json:"memLimit,omitempty"`
|
||||
}
|
||||
|
||||
// Optional-collector shapes — mirror api.SmartDisk/ZFSPool/GPUStat.
|
||||
type smartDisk struct {
|
||||
Device string `json:"device"`
|
||||
Model string `json:"model"`
|
||||
Passed *bool `json:"passed,omitempty"`
|
||||
TempC float64 `json:"tempC,omitempty"`
|
||||
}
|
||||
|
||||
type zfsPool struct {
|
||||
Name string `json:"name"`
|
||||
Health string `json:"health"`
|
||||
Size float64 `json:"size"`
|
||||
Free float64 `json:"free"`
|
||||
}
|
||||
|
||||
type gpuStat struct {
|
||||
Name string `json:"name"`
|
||||
TempC float64 `json:"tempC,omitempty"`
|
||||
UtilPct float64 `json:"utilPct,omitempty"`
|
||||
MemUsed float64 `json:"memUsed,omitempty"`
|
||||
MemTotal float64 `json:"memTotal,omitempty"`
|
||||
}
|
||||
|
||||
type collector struct {
|
||||
@@ -141,6 +168,9 @@ func (c *collector) sample(interval int) sample {
|
||||
s.UptimeS = readUptime()
|
||||
s.Temps = readTemps()
|
||||
s.Containers = readContainers()
|
||||
s.Smart = readSMART()
|
||||
s.ZFS = readZFS()
|
||||
s.GPU = readGPU()
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -325,38 +355,4 @@ func readTemps() map[string]float64 {
|
||||
return out
|
||||
}
|
||||
|
||||
// readContainers lists Docker containers via the unix socket when present.
|
||||
func readContainers() []containerStat {
|
||||
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
||||
return nil
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
|
||||
},
|
||||
},
|
||||
}
|
||||
res, err := client.Get("http://d/containers/json?all=1")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var raw []struct {
|
||||
Names []string `json:"Names"`
|
||||
State string `json:"State"`
|
||||
}
|
||||
if json.NewDecoder(res.Body).Decode(&raw) != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]containerStat, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
name := ""
|
||||
if len(r.Names) > 0 {
|
||||
name = strings.TrimPrefix(r.Names[0], "/")
|
||||
}
|
||||
out = append(out, containerStat{Name: name, State: r.State})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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
@@ -42,10 +42,10 @@ require (
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -99,18 +99,28 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,20 @@ func (s *Server) routes() *gin.Engine {
|
||||
r.Use(accessLog(s.log), gin.Recovery())
|
||||
|
||||
v1 := r.Group("/api")
|
||||
v1.Use(s.requireAuth())
|
||||
v1.GET("/healthz", s.healthz)
|
||||
|
||||
v1.GET("/auth/session", s.authSession)
|
||||
v1.POST("/auth/setup", s.authSetup)
|
||||
v1.POST("/auth/login", s.authLogin)
|
||||
v1.POST("/auth/logout", s.authLogout)
|
||||
v1.POST("/auth/disable", s.authDisable)
|
||||
|
||||
v1.GET("/boards", s.listBoards)
|
||||
v1.POST("/boards", s.createBoard)
|
||||
v1.PATCH("/boards/:id", s.updateBoard)
|
||||
v1.DELETE("/boards/:id", s.deleteBoard)
|
||||
|
||||
v1.GET("/sections", s.listSections)
|
||||
v1.POST("/sections", s.createSection)
|
||||
v1.PATCH("/sections/:id", s.updateSection)
|
||||
@@ -91,6 +103,8 @@ func (s *Server) routes() *gin.Engine {
|
||||
v1.DELETE("/domains/:id", s.deleteDomain)
|
||||
v1.POST("/domains/:id/refresh", s.refreshDomainH)
|
||||
v1.GET("/domains/:id/checks", s.domainChecks)
|
||||
v1.GET("/domains/:id/subdomains", s.listSubdomains)
|
||||
v1.POST("/domains/:id/subdomains/refresh", s.refreshSubdomains)
|
||||
v1.POST("/notify/test", s.notifyTest)
|
||||
|
||||
v1.GET("/systems", s.listSystemsH)
|
||||
@@ -167,6 +181,7 @@ type Section struct {
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
BoardID string `json:"boardId"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,16 @@ import (
|
||||
|
||||
// board loads the full tree: sections ordered, each with items and urls.
|
||||
// Three flat queries assembled in memory — the whole board is ~100 rows.
|
||||
func (s *Server) board() ([]Section, error) {
|
||||
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
|
||||
// boardID filters to one board; "" returns every section (export, legacy).
|
||||
// COALESCE maps pre-boards rows onto the default 'b_home' board.
|
||||
func (s *Server) board(boardID string) ([]Section, error) {
|
||||
q := `SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections`
|
||||
var args []any
|
||||
if boardID != "" {
|
||||
q += ` WHERE board_id = ?`
|
||||
args = append(args, boardID)
|
||||
}
|
||||
secRows, err := s.db.Query(q+` ORDER BY position`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -21,7 +29,7 @@ func (s *Server) board() ([]Section, error) {
|
||||
byID := map[string]*Section{}
|
||||
for secRows.Next() {
|
||||
var sec Section
|
||||
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
|
||||
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec.Items = []Item{}
|
||||
@@ -89,7 +97,7 @@ func (s *Server) board() ([]Section, error) {
|
||||
}
|
||||
|
||||
func (s *Server) listSections(c *gin.Context) {
|
||||
sections, err := s.board()
|
||||
sections, err := s.board(c.Query("board"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -99,19 +107,27 @@ func (s *Server) listSections(c *gin.Context) {
|
||||
|
||||
func (s *Server) createSection(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
BoardID string `json:"boardId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
boardID := in.BoardID
|
||||
if boardID == "" {
|
||||
boardID = "b_home"
|
||||
}
|
||||
var maxPos sql.NullFloat64
|
||||
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
|
||||
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections WHERE board_id = ?`, boardID).Scan(&maxPos); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
|
||||
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
|
||||
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), BoardID: boardID, Items: []Item{}}
|
||||
// The subquery yields NULL for an unknown board id instead of an FK error;
|
||||
// board() maps NULL back onto the default board.
|
||||
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position, board_id)
|
||||
VALUES (?, ?, ?, (SELECT id FROM boards WHERE id = ?))`, sec.ID, sec.Name, sec.Position, boardID); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -140,8 +156,8 @@ func (s *Server) updateSection(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
var sec Section
|
||||
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
|
||||
err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, c.Param("id")).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID)
|
||||
if notFound(err) {
|
||||
fail(c, http.StatusNotFound, "section not found")
|
||||
return
|
||||
@@ -192,8 +208,8 @@ func (s *Server) reorderSection(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var sec Section
|
||||
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
|
||||
if err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, in.ID).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
|
||||
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
|
||||
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
|
||||
dns_provider, email_provider, hosting_provider, ca_provider, headers,
|
||||
favicon_url, lookup_error, interval_h, last_checked, position, created_at`
|
||||
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
|
||||
|
||||
// domainRow mirrors the domains table; JSON text columns decode on scan.
|
||||
type domainRow struct {
|
||||
@@ -87,12 +87,13 @@ type domainRow struct {
|
||||
IntervalH int `json:"intervalH"`
|
||||
LastChecked *string `json:"lastChecked"`
|
||||
Position float64 `json:"position"`
|
||||
Alerts json.RawMessage `json:"alerts"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
||||
var d domainRow
|
||||
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers string
|
||||
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, alerts string
|
||||
var active, autoRenew, privacy, lock int
|
||||
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
|
||||
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
|
||||
@@ -104,10 +105,11 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
||||
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
|
||||
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
|
||||
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
|
||||
&d.Position, &d.CreatedAt)
|
||||
&d.Position, &alerts, &d.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Alerts = json.RawMessage(alerts)
|
||||
d.Active = active != 0
|
||||
d.AutoRenew = autoRenew != 0
|
||||
d.PrivacyEnabled = privacy != 0
|
||||
@@ -200,6 +202,7 @@ type domainInput struct {
|
||||
IntervalH *int `json:"intervalH"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes *string `json:"notes"`
|
||||
Alerts json.RawMessage `json:"alerts"`
|
||||
}
|
||||
|
||||
func (s *Server) createDomain(c *gin.Context) {
|
||||
@@ -218,6 +221,13 @@ func (s *Server) createDomain(c *gin.Context) {
|
||||
if in.Tags == nil {
|
||||
tags = []byte("[]")
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO domains
|
||||
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
@@ -228,6 +238,9 @@ func (s *Server) createDomain(c *gin.Context) {
|
||||
fail(c, http.StatusConflict, "domain already tracked or insert failed")
|
||||
return
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
|
||||
}
|
||||
// First lookup runs inline so the response carries real data.
|
||||
s.refreshDomain(id)
|
||||
d, err := s.loadDomain(id)
|
||||
@@ -298,6 +311,14 @@ func (s *Server) patchDomain(c *gin.Context) {
|
||||
tags, _ := json.Marshal(in.Tags)
|
||||
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id)
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
|
||||
}
|
||||
d, err := s.loadDomain(id)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
@@ -338,6 +359,10 @@ func (s *Server) refreshDomain(id string) {
|
||||
return
|
||||
}
|
||||
nv := viewOf(next)
|
||||
rules := parseAlertRules(next.Alerts)
|
||||
if rules.Mute {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0):
|
||||
s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name))
|
||||
@@ -345,10 +370,19 @@ func (s *Server) refreshDomain(id string) {
|
||||
s.notify("domain.expiring", next.Name,
|
||||
fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry))
|
||||
}
|
||||
if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil {
|
||||
// TLS threshold: alerts.certDays overrides alertDaysBefore.
|
||||
certDays := next.AlertDaysBefore
|
||||
if rules.CertDays > 0 {
|
||||
certDays = rules.CertDays
|
||||
}
|
||||
sslNow := nv.SSLDaysUntilExpiry != nil && *nv.SSLDaysUntilExpiry <= certDays
|
||||
sslPrev := prev.SSLDaysUntilExpiry != nil && *prev.SSLDaysUntilExpiry <= certDays
|
||||
if sslNow && !sslPrev {
|
||||
s.notify("domain.sslExpiring", next.Name,
|
||||
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
|
||||
}
|
||||
// Subdomain discovery rides the refresh schedule, gated to once/day.
|
||||
s.maybeSweepSubdomains(next)
|
||||
}
|
||||
|
||||
func (s *Server) refreshDomainH(c *gin.Context) {
|
||||
|
||||
@@ -30,11 +30,11 @@ func (s *Server) importExternal(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.replaceBoard(sections, nil); err != nil {
|
||||
if err := s.replaceBoard(sections, nil, nil); err != nil {
|
||||
failImport(c, err)
|
||||
return
|
||||
}
|
||||
board, err := s.board()
|
||||
board, err := s.board("")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -20,21 +20,22 @@ import (
|
||||
|
||||
const monitorCols = `id, name, type, url, hostname, port, method, headers, body,
|
||||
keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
|
||||
retries, active, status, push_token, tags, notes, position, last_check, created_at`
|
||||
retries, active, status, push_token, tags, notes, alerts, position, last_check, created_at`
|
||||
|
||||
func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
|
||||
var m monitor.Monitor
|
||||
var tags string
|
||||
var tags, alerts string
|
||||
var invert, active int
|
||||
err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method,
|
||||
&m.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType,
|
||||
&m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken,
|
||||
&tags, &m.Notes, &m.Position, &m.LastCheck, &m.CreatedAt)
|
||||
&tags, &m.Notes, &alerts, &m.Position, &m.LastCheck, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.KeywordInvert = invert != 0
|
||||
m.Active = active != 0
|
||||
m.Alerts = json.RawMessage(alerts)
|
||||
_ = json.Unmarshal([]byte(tags), &m.Tags)
|
||||
if m.Tags == nil {
|
||||
m.Tags = []string{}
|
||||
@@ -142,6 +143,7 @@ type monitorInput struct {
|
||||
Active *bool `json:"active"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes *string `json:"notes"`
|
||||
Alerts json.RawMessage `json:"alerts"`
|
||||
}
|
||||
|
||||
func validMonitorType(t string) bool {
|
||||
@@ -209,17 +211,26 @@ func (s *Server) createMonitor(c *gin.Context) {
|
||||
if in.Tags == nil {
|
||||
tags = []byte("[]")
|
||||
}
|
||||
alerts := "{}"
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
alerts = string(in.Alerts)
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO monitors
|
||||
(id, name, type, url, hostname, port, method, headers, body, keyword,
|
||||
keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
|
||||
retries, active, push_token, tags, notes, position)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
retries, active, push_token, tags, notes, alerts, position)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port),
|
||||
strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body),
|
||||
strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery),
|
||||
strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60),
|
||||
intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true),
|
||||
token, string(tags), strOr(in.Notes), float64(time.Now().UnixNano())/1e9)
|
||||
token, string(tags), strOr(in.Notes), alerts, float64(time.Now().UnixNano())/1e9)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -294,6 +305,14 @@ func (s *Server) patchMonitor(c *gin.Context) {
|
||||
tags, _ := json.Marshal(in.Tags)
|
||||
_, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id)
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE monitors SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
|
||||
}
|
||||
if in.Active != nil {
|
||||
// pause/resume flips the stored status back to pending for a fresh probe
|
||||
status := "pending"
|
||||
@@ -412,6 +431,9 @@ func (s *Server) runScheduler(done <-chan struct{}) {
|
||||
s.log.Error("heartbeat prune", zap.Error(err))
|
||||
}
|
||||
// System samples are high-rate; a week of raw ticks is enough.
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now')`); err != nil {
|
||||
s.log.Error("session prune", zap.Error(err))
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM system_stats WHERE ts < datetime('now','-7 days')`); err != nil {
|
||||
s.log.Error("system stats prune", zap.Error(err))
|
||||
}
|
||||
@@ -473,8 +495,13 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
|
||||
s.writeHeartbeat(m, res)
|
||||
prev := m.Status
|
||||
next := "down"
|
||||
rules := parseAlertRules(m.Alerts)
|
||||
if res.Up {
|
||||
next = "up"
|
||||
} else if n := rules.ConsecutiveFailures; n > 1 && prev != "down" &&
|
||||
s.consecutiveDowns(m.ID, n) < n {
|
||||
// Flap damping: hold the previous status until N checks in a row fail.
|
||||
next = prev
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE monitors SET status = ?, last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
next, m.ID); err != nil {
|
||||
@@ -483,6 +510,14 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
|
||||
if prev != next && prev != "pending" {
|
||||
s.onMonitorTransition(m, prev, next)
|
||||
}
|
||||
// Latency warn: notify once when ping crosses above the threshold.
|
||||
if rules.LatencyWarnMs > 0 && res.Up && res.PingMs >= rules.LatencyWarnMs &&
|
||||
!rules.Mute && prev != "pending" && !s.inMaintenance(m.ID) {
|
||||
if p := s.prevPing(m.ID); p >= 0 && p < rules.LatencyWarnMs {
|
||||
s.notify("monitor.slow", m.Name,
|
||||
fmt.Sprintf("Monitor %q latency %dms (threshold %dms)", m.Name, res.PingMs, rules.LatencyWarnMs))
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -507,6 +542,9 @@ func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
|
||||
return
|
||||
}
|
||||
s.autoIncident(m.ID, m.Name, next)
|
||||
if parseAlertRules(m.Alerts).Mute {
|
||||
return
|
||||
}
|
||||
target := m.URL
|
||||
if target == "" {
|
||||
target = m.Hostname
|
||||
|
||||
@@ -3,19 +3,29 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Notifications: one outbound webhook, configured via the notify_webhook
|
||||
// settings key. Payload carries event/name/text/content/message so the same
|
||||
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic
|
||||
// receivers. SMTP and per-service channels are deliberately out of scope —
|
||||
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them.
|
||||
// Notifications: a small transport registry. Each transport is configured via
|
||||
// the settings key notify_<name> holding a JSON object of fields; a transport
|
||||
// is active when its required fields are set. Multiple transports may be
|
||||
// active — notify() fans out to all of them.
|
||||
//
|
||||
// notify_webhook keeps back-compat: a plain string value means {"url": value}.
|
||||
//
|
||||
// Secrets (smtp_pass, tokens) live in settings like any other value and are
|
||||
// never logged. Single-user self-hosted is the threat model; P5d auth gates
|
||||
// the settings endpoint when enabled.
|
||||
|
||||
type notifyPayload struct {
|
||||
Event string `json:"event"`
|
||||
@@ -26,54 +36,213 @@ type notifyPayload struct {
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
func (s *Server) webhookURL() string {
|
||||
var raw string
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil {
|
||||
return ""
|
||||
}
|
||||
// Settings values are stored as JSON — unwrap the quoted string.
|
||||
var u string
|
||||
if err := json.Unmarshal([]byte(raw), &u); err != nil {
|
||||
return ""
|
||||
}
|
||||
return u
|
||||
type transport struct {
|
||||
name string
|
||||
required []string
|
||||
send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
|
||||
}
|
||||
|
||||
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
|
||||
var transports = []transport{
|
||||
{"webhook", []string{"url"}, sendWebhook},
|
||||
{"slack", []string{"url"}, sendSlack},
|
||||
{"discord", []string{"url"}, sendDiscord},
|
||||
{"telegram", []string{"token", "chat_id"}, sendTelegram},
|
||||
{"gotify", []string{"server", "token"}, sendGotify},
|
||||
{"ntfy", []string{"server", "topic"}, sendNtfy},
|
||||
{"smtp", []string{"host", "port", "from", "to"}, sendSMTP},
|
||||
}
|
||||
|
||||
// notifyCfg loads notify_<name> from settings. Values are JSON: an object for
|
||||
// presets, or a bare string (legacy webhook) meaning {"url": s}.
|
||||
func (s *Server) notifyCfg(name string) map[string]string {
|
||||
var raw string
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_`+name+`'`).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
var str string
|
||||
if err := json.Unmarshal([]byte(raw), &str); err == nil && name == "webhook" {
|
||||
return map[string]string{"url": str}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
if str, ok := v.(string); ok {
|
||||
out[k] = str
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// configured reports whether all required fields are present. Partially
|
||||
// configured transports count as configured so their send error surfaces.
|
||||
func (t transport) configured(cfg map[string]string) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range t.required {
|
||||
if strings.TrimSpace(cfg[k]) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// notify fans an event out to every configured transport, asynchronously.
|
||||
func (s *Server) notify(event, name, msg string) {
|
||||
url := s.webhookURL()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
if u := parseHTTPURL(url); u == "" {
|
||||
s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event))
|
||||
return
|
||||
}
|
||||
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
|
||||
At: time.Now().UTC().Format(time.RFC3339)}
|
||||
go s.deliver(url, p)
|
||||
for _, tr := range transports {
|
||||
cfg := s.notifyCfg(tr.name)
|
||||
if !tr.configured(cfg) {
|
||||
continue
|
||||
}
|
||||
tr, cfg := tr, cfg
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := tr.send(ctx, cfg, p); err != nil {
|
||||
s.log.Warn("notify failed", zap.String("transport", tr.name),
|
||||
zap.String("event", event), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) deliver(url string, p notifyPayload) {
|
||||
body, _ := json.Marshal(p)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// postJSON posts a JSON body and reports non-2xx as an error.
|
||||
func postJSON(ctx context.Context, url string, payload any, headers map[string]string) error {
|
||||
if parseHTTPURL(url) == "" {
|
||||
return fmt.Errorf("not an http(s) URL")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Title", p.Text) // ntfy reads the title header
|
||||
req.Header.Set("X-Dash-Event", p.Event)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
|
||||
return
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendWebhook(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
return postJSON(ctx, cfg["url"], p, map[string]string{
|
||||
"Title": p.Text, "X-Dash-Event": p.Event,
|
||||
})
|
||||
}
|
||||
|
||||
func sendSlack(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
return postJSON(ctx, cfg["url"], map[string]any{"text": p.Text}, nil)
|
||||
}
|
||||
|
||||
func sendDiscord(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
return postJSON(ctx, cfg["url"], map[string]any{"content": p.Content}, nil)
|
||||
}
|
||||
|
||||
func sendTelegram(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
base := strings.TrimRight(cfg["server"], "/") // optional self-hosted bot-api
|
||||
if base == "" {
|
||||
base = "https://api.telegram.org"
|
||||
}
|
||||
url := base + "/bot" + cfg["token"] + "/sendMessage"
|
||||
return postJSON(ctx, url, map[string]any{
|
||||
"chat_id": cfg["chat_id"], "text": p.Text,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func sendGotify(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
server := strings.TrimRight(cfg["server"], "/")
|
||||
url := server + "/message?token=" + cfg["token"]
|
||||
return postJSON(ctx, url, map[string]any{
|
||||
"title": p.Name, "message": p.Message, "priority": 5,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func sendNtfy(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
server := strings.TrimRight(cfg["server"], "/")
|
||||
url := server + "/" + strings.TrimLeft(cfg["topic"], "/")
|
||||
if parseHTTPURL(url) == "" {
|
||||
return fmt.Errorf("not an http(s) URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(p.Message))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Title", p.Name)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSMTP uses net/smtp with a hard deadline, opportunistic STARTTLS, and
|
||||
// plain auth only when a user is configured.
|
||||
func sendSMTP(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
host, port := cfg["host"], cfg["port"]
|
||||
addr := net.JoinHostPort(host, port)
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
}
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg["user"] != "" {
|
||||
if err := c.Auth(smtp.PlainAuth("", cfg["user"], cfg["pass"], host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := c.Mail(cfg["from"]); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range strings.Split(cfg["to"], ",") {
|
||||
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [Dash] %s\r\n\r\n%s\r\n",
|
||||
cfg["from"], cfg["to"], p.Text, p.Message)
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
|
||||
@@ -85,12 +254,42 @@ func parseHTTPURL(raw string) string {
|
||||
return raw
|
||||
}
|
||||
|
||||
// notifyTest lets the settings UI verify the configured webhook.
|
||||
// notifyTest sends a test event synchronously so the UI gets a real result.
|
||||
// Optional body {"transport": "smtp"} limits the test to one transport.
|
||||
func (s *Server) notifyTest(c *gin.Context) {
|
||||
if s.webhookURL() == "" {
|
||||
fail(c, http.StatusBadRequest, "notify_webhook not configured")
|
||||
var in struct {
|
||||
Transport string `json:"transport"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
p := notifyPayload{Event: "test", Name: "dash", Text: "Dash test notification",
|
||||
Content: "Dash test notification", Message: "Dash test notification",
|
||||
At: time.Now().UTC().Format(time.RFC3339)}
|
||||
|
||||
results := map[string]string{}
|
||||
for _, tr := range transports {
|
||||
if in.Transport != "" && tr.name != in.Transport {
|
||||
continue
|
||||
}
|
||||
cfg := s.notifyCfg(tr.name)
|
||||
if !tr.configured(cfg) {
|
||||
if in.Transport != "" {
|
||||
fail(c, http.StatusBadRequest, tr.name+" not configured")
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
err := tr.send(ctx, cfg, p)
|
||||
cancel()
|
||||
if err != nil {
|
||||
results[tr.name] = "error: " + err.Error()
|
||||
} else {
|
||||
results[tr.name] = "ok"
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
fail(c, http.StatusBadRequest, "no notification transport configured")
|
||||
return
|
||||
}
|
||||
s.notify("test", "dash", "Dash test notification")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -41,13 +41,46 @@ type StatSample struct {
|
||||
Load15 float64 `json:"load15"`
|
||||
Temps map[string]float64 `json:"temps"`
|
||||
Containers []ContainerStat `json:"containers"`
|
||||
// Optional collectors — present only when the host has the tools.
|
||||
Smart []SmartDisk `json:"smart,omitempty"`
|
||||
ZFS []ZFSPool `json:"zfs,omitempty"`
|
||||
GPU []GPUStat `json:"gpu,omitempty"`
|
||||
}
|
||||
|
||||
// ContainerStat is one Docker container's liveness; per-container CPU/mem
|
||||
// needs cgroup sampling — deferred (v1 ships name+state only).
|
||||
// ContainerStat is one Docker container's liveness plus resource use.
|
||||
// CPU is percent of one core (may exceed 100); Mem* are bytes. Zero when the
|
||||
// agent couldn't reach the stats endpoint.
|
||||
type ContainerStat struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CPU float64 `json:"cpu,omitempty"`
|
||||
MemUsed float64 `json:"memUsed,omitempty"`
|
||||
MemLimit float64 `json:"memLimit,omitempty"`
|
||||
}
|
||||
|
||||
// SmartDisk is one drive's SMART summary (smartctl).
|
||||
type SmartDisk struct {
|
||||
Device string `json:"device"`
|
||||
Model string `json:"model"`
|
||||
Passed *bool `json:"passed,omitempty"`
|
||||
TempC float64 `json:"tempC,omitempty"`
|
||||
}
|
||||
|
||||
// ZFSPool is one pool's health and space (zpool).
|
||||
type ZFSPool struct {
|
||||
Name string `json:"name"`
|
||||
Health string `json:"health"`
|
||||
Size float64 `json:"size"`
|
||||
Free float64 `json:"free"`
|
||||
}
|
||||
|
||||
// GPUStat is one GPU's basic metrics (sysfs or nvidia-smi).
|
||||
type GPUStat struct {
|
||||
Name string `json:"name"`
|
||||
TempC float64 `json:"tempC,omitempty"`
|
||||
UtilPct float64 `json:"utilPct,omitempty"`
|
||||
MemUsed float64 `json:"memUsed,omitempty"`
|
||||
MemTotal float64 `json:"memTotal,omitempty"`
|
||||
}
|
||||
|
||||
// System is one monitored host; `latest` is the newest StatSample.
|
||||
|
||||
@@ -28,6 +28,7 @@ type Export struct {
|
||||
Version int `json:"version"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
Sections []Section `json:"sections"`
|
||||
Boards []Board `json:"boards,omitempty"`
|
||||
Monitors []json.RawMessage `json:"monitors,omitempty"`
|
||||
Domains []json.RawMessage `json:"domains,omitempty"`
|
||||
Systems []json.RawMessage `json:"systems,omitempty"`
|
||||
@@ -74,12 +75,21 @@ func (s *Server) exportBoard(c *gin.Context) {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sections, err := s.board()
|
||||
sections, err := s.board("")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ex := Export{Version: 2, Settings: settings, Sections: sections}
|
||||
if bRows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`); err == nil {
|
||||
for bRows.Next() {
|
||||
var b Board
|
||||
if bRows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position) == nil {
|
||||
ex.Boards = append(ex.Boards, b)
|
||||
}
|
||||
}
|
||||
bRows.Close()
|
||||
}
|
||||
for _, t := range []struct {
|
||||
name string
|
||||
dst *[]json.RawMessage
|
||||
@@ -103,11 +113,11 @@ func (s *Server) importBoard(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "invalid export payload")
|
||||
return
|
||||
}
|
||||
if err := s.replaceBoard(in.Sections, in.Settings); err != nil {
|
||||
if err := s.replaceBoard(in.Sections, in.Boards, in.Settings); err != nil {
|
||||
failImport(c, err)
|
||||
return
|
||||
}
|
||||
sections, err := s.board()
|
||||
sections, err := s.board("")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -116,8 +126,10 @@ func (s *Server) importBoard(c *gin.Context) {
|
||||
}
|
||||
|
||||
// replaceBoard swaps all board rows inside one transaction — shared by the
|
||||
// Dash export import and the external-config importers.
|
||||
func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error {
|
||||
// Dash export import and the external-config importers. A payload carrying
|
||||
// boards replaces the board list too; one without (v1/v2 exports, foreign
|
||||
// imports) leaves boards alone and sections land on the default board.
|
||||
func (s *Server) replaceBoard(sections []Section, boards []Board, settings map[string]json.RawMessage) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -129,12 +141,35 @@ func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMe
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(boards) > 0 {
|
||||
if _, err := tx.Exec(`DELETE FROM boards`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range boards {
|
||||
id, slug := b.ID, b.Slug
|
||||
if slug == "" {
|
||||
slug = slugify(b.Name)
|
||||
}
|
||||
if id == "" || slug == "" {
|
||||
return fmt.Errorf("%w: board missing id or slug", errBadInput)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?, ?, ?, ?)`,
|
||||
id, slug, orDefault(b.Name, slug), b.Position); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The default board must survive import — orphaned sections map to it.
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO boards (id, slug, name, position) VALUES ('b_home','home','Home',0)`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, sec := range sections {
|
||||
if sec.ID == "" || sec.Name == "" {
|
||||
return fmt.Errorf("%w: section missing id or name", errBadInput)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
|
||||
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
|
||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed, board_id)
|
||||
VALUES (?, ?, ?, ?, (SELECT id FROM boards WHERE id = ?))`,
|
||||
sec.ID, sec.Name, sec.Position, sec.Collapsed, sec.BoardID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range sec.Items {
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ type Monitor struct {
|
||||
PushToken string `json:"pushToken,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes string `json:"notes"`
|
||||
Alerts json.RawMessage `json:"alerts"`
|
||||
Position float64 `json:"position"`
|
||||
LastCheck *string `json:"lastCheck"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
|
||||
Whitespace-only changes.
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -27,10 +27,88 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Health"
|
||||
|
||||
/api/boards:
|
||||
get:
|
||||
operationId: listBoards
|
||||
summary: All boards, ordered
|
||||
responses:
|
||||
"200":
|
||||
description: Board list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Board"
|
||||
post:
|
||||
operationId: createBoard
|
||||
summary: Create a board — slug derived from the name
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: Created board
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Board"
|
||||
|
||||
/api/boards/{id}:
|
||||
patch:
|
||||
operationId: updateBoard
|
||||
summary: Rename a board — the slug stays stable
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Updated board
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Board"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
delete:
|
||||
operationId: deleteBoard
|
||||
summary: Delete a board and its sections — the last board cannot be deleted
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/api/sections:
|
||||
get:
|
||||
operationId: listSections
|
||||
summary: Full board — sections with nested items and urls, ordered
|
||||
summary: Board tree — sections with nested items and urls, ordered
|
||||
parameters:
|
||||
- name: board
|
||||
in: query
|
||||
description: Restrict to one board id; omitted returns all sections
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Ordered board tree
|
||||
@@ -540,14 +618,70 @@ paths:
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/Status"
|
||||
|
||||
/api/domains/{id}/subdomains:
|
||||
get:
|
||||
operationId: listSubdomains
|
||||
summary: Discovered subdomains for a domain
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
"200":
|
||||
description: Subdomain rows, name-sorted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Subdomain"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
/api/domains/{id}/subdomains/refresh:
|
||||
post:
|
||||
operationId: refreshSubdomains
|
||||
summary: Force a crt.sh + DNS discovery run, returns the updated list
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
"200":
|
||||
description: Subdomain rows after the sweep
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Subdomain"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/api/notify/test:
|
||||
post:
|
||||
operationId: notifyTest
|
||||
summary: Send a test notification to the configured webhook
|
||||
description: Requires the `notify_webhook` settings key to hold an http(s) URL.
|
||||
summary: Send a test notification through configured transports
|
||||
description: >-
|
||||
Fires a test event synchronously through every configured notify_*
|
||||
transport, or only the transport named in the body. Returns a
|
||||
transport -> "ok" | "error: ..." map.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
transport:
|
||||
type: string
|
||||
enum: [webhook, slack, discord, telegram, gotify, ntfy, smtp]
|
||||
responses:
|
||||
"200":
|
||||
description: Test notification dispatched
|
||||
description: Per-transport delivery results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
@@ -1039,6 +1173,83 @@ paths:
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/api/auth/session:
|
||||
get:
|
||||
operationId: authSession
|
||||
summary: Auth state — enabled flag, whether setup is needed, and current session
|
||||
responses:
|
||||
"200":
|
||||
description: Session state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthSession"
|
||||
|
||||
/api/auth/setup:
|
||||
post:
|
||||
operationId: authSetup
|
||||
summary: Create the first local admin — only while no users exist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, password]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
minLength: 8
|
||||
responses:
|
||||
"201":
|
||||
description: Admin created; session cookie set
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"409":
|
||||
description: Setup already completed
|
||||
|
||||
/api/auth/login:
|
||||
post:
|
||||
operationId: authLogin
|
||||
summary: Password login — sets a session cookie; rate-limited per IP
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, password]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Signed in
|
||||
"401":
|
||||
description: Invalid credentials
|
||||
"429":
|
||||
description: Too many attempts
|
||||
|
||||
/api/auth/logout:
|
||||
post:
|
||||
operationId: authLogout
|
||||
summary: Destroy the current session
|
||||
responses:
|
||||
"204":
|
||||
description: Signed out
|
||||
|
||||
/api/auth/disable:
|
||||
post:
|
||||
operationId: authDisable
|
||||
summary: Turn authentication off — requires an active session
|
||||
responses:
|
||||
"204":
|
||||
description: Disabled
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -1074,6 +1285,19 @@ components:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
AuthSession:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
setup:
|
||||
type: boolean
|
||||
description: True when auth is on but no user exists yet
|
||||
authed:
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
|
||||
Error:
|
||||
type: object
|
||||
required: [error]
|
||||
@@ -1172,7 +1396,7 @@ components:
|
||||
$ref: "#/components/schemas/UrlInput"
|
||||
Section:
|
||||
type: object
|
||||
required: [id, name, position, collapsed, items]
|
||||
required: [id, name, position, collapsed, boardId, items]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -1182,16 +1406,32 @@ components:
|
||||
type: number
|
||||
collapsed:
|
||||
type: boolean
|
||||
boardId:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Item"
|
||||
Board:
|
||||
type: object
|
||||
required: [id, slug, name, position]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
position:
|
||||
type: number
|
||||
SectionInput:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
boardId:
|
||||
type: string
|
||||
SectionPatch:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1285,6 +1525,8 @@ components:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
alerts:
|
||||
$ref: "#/components/schemas/AlertRules"
|
||||
position:
|
||||
type: number
|
||||
lastCheck:
|
||||
@@ -1294,6 +1536,22 @@ components:
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
AlertRules:
|
||||
type: object
|
||||
description: Per-target alert rules; omitted fields keep defaults.
|
||||
properties:
|
||||
consecutiveFailures:
|
||||
type: integer
|
||||
description: Checks must fail N times in a row before status flips down
|
||||
latencyWarnMs:
|
||||
type: integer
|
||||
description: Notify once when an up check's ping crosses this threshold
|
||||
certDays:
|
||||
type: integer
|
||||
description: Domain TLS expiry threshold; overrides alertDaysBefore
|
||||
mute:
|
||||
type: boolean
|
||||
description: Suppress notifications; incidents/status still record
|
||||
MonitorInput:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1338,6 +1596,8 @@ components:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
alerts:
|
||||
$ref: "#/components/schemas/AlertRules"
|
||||
UptimeStats:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1519,6 +1779,8 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
alerts:
|
||||
$ref: "#/components/schemas/AlertRules"
|
||||
position:
|
||||
type: number
|
||||
createdAt:
|
||||
@@ -1543,6 +1805,8 @@ components:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
alerts:
|
||||
$ref: "#/components/schemas/AlertRules"
|
||||
DomainView:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Domain"
|
||||
@@ -1558,6 +1822,21 @@ components:
|
||||
type: boolean
|
||||
sslExpiring:
|
||||
type: boolean
|
||||
Subdomain:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
ips:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
firstSeen:
|
||||
type: string
|
||||
format: date-time
|
||||
lastSeen:
|
||||
type: string
|
||||
format: date-time
|
||||
DomainCheck:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1615,11 +1894,48 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ContainerStat"
|
||||
smart:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SmartDisk"
|
||||
zfs:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ZFSPool"
|
||||
gpu:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GPUStat"
|
||||
ContainerStat:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
state: { type: string }
|
||||
cpu: { type: number, description: "percent of one core, may exceed 100" }
|
||||
memUsed: { type: number, description: bytes }
|
||||
memLimit: { type: number, description: bytes }
|
||||
SmartDisk:
|
||||
type: object
|
||||
properties:
|
||||
device: { type: string }
|
||||
model: { type: string }
|
||||
passed: { type: boolean, nullable: true }
|
||||
tempC: { type: number }
|
||||
ZFSPool:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
health: { type: string }
|
||||
size: { type: number, description: bytes }
|
||||
free: { type: number, description: bytes }
|
||||
GPUStat:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
tempC: { type: number }
|
||||
utilPct: { type: number }
|
||||
memUsed: { type: number, description: bytes }
|
||||
memTotal: { type: number, description: bytes }
|
||||
System:
|
||||
type: object
|
||||
required: [id, name, token, status]
|
||||
@@ -1819,3 +2135,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Section"
|
||||
boards:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Board"
|
||||
Reference in new issue
Block a user