mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
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>
359 lines
9.7 KiB
Go
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
|
|
}
|
|
|
|
|