Files
Dash/apps/backend/cmd/dash-agent/main.go
T
Tomas DvorakandDevin f3143af351 feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/
gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules
(failsBeforeDown, latencyWarn, certDays, mute).

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

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

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 12:49:44 +02:00

359 lines
9.7 KiB
Go

// Command dash-agent collects host metrics and pushes them to a Dash server.
// Linux-first: /proc for cpu/mem/net/load/uptime, /sys hwmon for temps,
// statfs for disk, and the Docker socket for container liveness when present.
//
// env DASH_URL=http://dash:8080 DASH_TOKEN=agent_xxx dash-agent
//
// Flags override env: -url, -token, -interval (seconds), -once.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
// version is stamped by goreleaser via -X main.version.
var version = "dev"
func main() {
url := flag.String("url", os.Getenv("DASH_URL"), "dash server base URL")
token := flag.String("token", os.Getenv("DASH_TOKEN"), "system token")
interval := flag.Int("interval", envInt("DASH_INTERVAL", 10), "push interval seconds")
once := flag.Bool("once", false, "collect and print one sample, no push")
flag.Parse()
c := &collector{}
c.host, _ = os.Hostname()
c.os = runtime.GOOS
c.arch = runtime.GOARCH
c.cpuModel, c.cores = cpuInfo()
c.prevCPU = readCPUTicks()
c.prevNet = readNetBytes()
c.prevAt = time.Now()
if *once {
// Rates need a delta window — idle briefly so cpu/net are real.
time.Sleep(500 * time.Millisecond)
s := c.sample(*interval)
out, _ := json.MarshalIndent(s, "", " ")
fmt.Println(string(out))
return
}
if *url == "" || *token == "" {
log.Fatal("dash-agent: -url and -token (or DASH_URL/DASH_TOKEN) required")
}
log.Printf("dash-agent %s: pushing to %s every %ds", version, *url, *interval)
for {
s := c.sample(*interval)
if err := push(*url, *token, s); err != nil {
log.Printf("dash-agent: push: %v", err)
}
time.Sleep(time.Duration(*interval) * time.Second)
}
}
func envInt(k string, def int) int {
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
return v
}
return def
}
// sample mirrors api.StatSample — duplicated here so the agent stays a
// standalone binary with zero imports from the server module.
type sample struct {
Host string `json:"host"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUModel string `json:"cpuModel"`
Cores int `json:"cores"`
IntervalS int `json:"intervalS"`
UptimeS float64 `json:"uptimeS"`
CPU float64 `json:"cpu"`
MemTotal float64 `json:"memTotal"`
MemUsed float64 `json:"memUsed"`
SwapTotal float64 `json:"swapTotal"`
SwapUsed float64 `json:"swapUsed"`
DiskTotal float64 `json:"diskTotal"`
DiskUsed float64 `json:"diskUsed"`
NetRx float64 `json:"netRx"`
NetTx float64 `json:"netTx"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
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"`
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 {
host, os, arch, cpuModel string
cores int
prevCPU cpuTicks
prevNet [2]uint64 // rx, tx bytes
prevAt time.Time
}
type cpuTicks struct{ idle, total uint64 }
func (c *collector) sample(interval int) sample {
s := sample{
Host: c.host, OS: c.os, Arch: c.arch,
CPUModel: c.cpuModel, Cores: c.cores, IntervalS: interval,
}
now := time.Now()
dt := now.Sub(c.prevAt).Seconds()
if dt <= 0 {
dt = float64(interval)
}
if cur := readCPUTicks(); cur.total > c.prevCPU.total {
busy := float64(cur.total-cur.idle) - float64(c.prevCPU.total-c.prevCPU.idle)
s.CPU = busy / float64(cur.total-c.prevCPU.total) * 100
c.prevCPU = cur
}
s.MemTotal, s.MemUsed, s.SwapTotal, s.SwapUsed = readMem()
s.DiskTotal, s.DiskUsed = diskUsage("/")
if cur := readNetBytes(); cur[0] >= c.prevNet[0] {
s.NetRx = float64(cur[0]-c.prevNet[0]) / dt
s.NetTx = float64(cur[1]-c.prevNet[1]) / dt
c.prevNet = cur
}
c.prevAt = now
s.Load1, s.Load5, s.Load15 = readLoad()
s.UptimeS = readUptime()
s.Temps = readTemps()
s.Containers = readContainers()
s.Smart = readSMART()
s.ZFS = readZFS()
s.GPU = readGPU()
return s
}
func push(url, token string, s sample) error {
body, _ := json.Marshal(s)
req, _ := http.NewRequest(http.MethodPost,
strings.TrimRight(url, "/")+"/api/systems/ingest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return err
}
defer res.Body.Close()
io.Copy(io.Discard, res.Body)
if res.StatusCode >= 300 {
return fmt.Errorf("server returned %d", res.StatusCode)
}
return nil
}
// ---------- /proc + /sys readers (Linux; all no-op-safe elsewhere) ----------
// readCPUTicks parses the aggregate cpu line: fields are user nice system
// idle iowait irq softirq steal — everything but idle+iowait counts busy.
func readCPUTicks() cpuTicks {
b, err := os.ReadFile("/proc/stat")
if err != nil {
return cpuTicks{}
}
var t cpuTicks
for _, line := range strings.Split(string(b), "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line)[1:]
var vals []uint64
for _, v := range f {
n, _ := strconv.ParseUint(v, 10, 64)
vals = append(vals, n)
t.total += n
}
if len(vals) >= 5 {
t.idle = vals[3] + vals[4]
}
break
}
return t
}
func cpuInfo() (model string, cores int) {
b, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return "", runtime.NumCPU()
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "processor") {
cores++
}
if model == "" && strings.HasPrefix(line, "model name") {
model = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
}
}
if cores == 0 {
cores = runtime.NumCPU()
}
return model, cores
}
// readMem returns total/used bytes for RAM and swap from /proc/meminfo.
func readMem() (memTotal, memUsed, swapTotal, swapUsed float64) {
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return
}
kb := map[string]float64{}
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) >= 2 {
kb[strings.TrimSuffix(f[0], ":")], _ = strconv.ParseFloat(f[1], 64)
}
}
memTotal = kb["MemTotal"] * 1024
memUsed = (kb["MemTotal"] - kb["MemAvailable"]) * 1024
swapTotal = kb["SwapTotal"] * 1024
swapUsed = (kb["SwapTotal"] - kb["SwapFree"]) * 1024
return
}
func diskUsage(path string) (total, used float64) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, 0
}
total = float64(st.Blocks) * float64(st.Bsize)
used = float64(st.Blocks-st.Bfree) * float64(st.Bsize)
return total, used
}
// readNetBytes sums rx/tx across interfaces, skipping loopback.
func readNetBytes() [2]uint64 {
b, err := os.ReadFile("/proc/net/dev")
if err != nil {
return [2]uint64{}
}
var rx, tx uint64
for _, line := range strings.Split(string(b), "\n") {
i := strings.Index(line, ":")
if i < 0 || strings.Contains(line[:i], "lo") {
continue
}
f := strings.Fields(line[i+1:])
if len(f) >= 9 {
r, _ := strconv.ParseUint(f[0], 10, 64)
t, _ := strconv.ParseUint(f[8], 10, 64)
rx += r
tx += t
}
}
return [2]uint64{rx, tx}
}
func readLoad() (l1, l5, l15 float64) {
b, err := os.ReadFile("/proc/loadavg")
if err != nil {
return
}
f := strings.Fields(string(b))
if len(f) >= 3 {
l1, _ = strconv.ParseFloat(f[0], 64)
l5, _ = strconv.ParseFloat(f[1], 64)
l15, _ = strconv.ParseFloat(f[2], 64)
}
return
}
func readUptime() float64 {
b, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
f := strings.Fields(string(b))
if len(f) > 0 {
v, _ := strconv.ParseFloat(f[0], 64)
return v
}
return 0
}
// readTemps walks /sys/class/hwmon for temp*_input sensors.
func readTemps() map[string]float64 {
out := map[string]float64{}
hwmons, _ := os.ReadDir("/sys/class/hwmon")
for _, hw := range hwmons {
dir := "/sys/class/hwmon/" + hw.Name()
chip, _ := os.ReadFile(dir + "/name")
entries, _ := os.ReadDir(dir)
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, "temp") || !strings.HasSuffix(name, "_input") {
continue
}
b, err := os.ReadFile(dir + "/" + name)
if err != nil {
continue
}
millideg, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
if err != nil || millideg <= 0 || millideg > 150000 {
continue
}
label := strings.TrimSpace(string(chip))
if lb, err := os.ReadFile(dir + "/" + strings.TrimSuffix(name, "_input") + "_label"); err == nil {
if l := strings.TrimSpace(string(lb)); l != "" {
label = l
}
}
if _, dup := out[label]; !dup {
out[label] = millideg / 1000
}
}
}
return out
}