mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
feat: external importers + uptime monitors (merge plan M1)
Two tracks from the Beszel/Domain-Locker merge plan in ROADMAP.md: - Importers: POST /api/import/external sniffs Homepage services.yml, Homarr JSON, and Dashy conf.yml into sections/items; SettingsDialog import now accepts all four formats. - Monitors (M1): monitors + heartbeats schema, pure checkers for http/tcp/ping/dns/keyword/json-query/push, scheduler on app ctx, CRUD + check-now + push ingest API, /monitors + /monitors/:id pages (wouter), monitor board widget, ServiceDialog monitor opt-in. 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
f428e721fe
commit
1ff1b29ae7
29 files changed
+3050
-98
No files matched your search
@@ -0,0 +1,315 @@
|
||||
// Package monitor holds the uptime-check domain: the Monitor model mirroring
|
||||
// the monitors table, plus the check implementations the scheduler invokes.
|
||||
// No DB access, no HTTP handlers — pure check logic.
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
)
|
||||
|
||||
// Monitor mirrors one row of the monitors table.
|
||||
type Monitor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Hostname string `json:"hostname"`
|
||||
Port int `json:"port"`
|
||||
Method string `json:"method"`
|
||||
Headers string `json:"headers"` // JSON object
|
||||
Body string `json:"body"`
|
||||
Keyword string `json:"keyword"`
|
||||
KeywordInvert bool `json:"keywordInvert"`
|
||||
JSONQuery string `json:"jsonQuery"`
|
||||
Expected string `json:"expected"`
|
||||
DNSType string `json:"dnsType"`
|
||||
IntervalS int `json:"intervalS"`
|
||||
TimeoutS int `json:"timeoutS"`
|
||||
Retries int `json:"retries"`
|
||||
Active bool `json:"active"`
|
||||
Status string `json:"status"`
|
||||
PushToken string `json:"pushToken,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes string `json:"notes"`
|
||||
Position float64 `json:"position"`
|
||||
LastCheck *string `json:"lastCheck"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Result is the outcome of one check.
|
||||
type Result struct {
|
||||
Up bool
|
||||
PingMs int
|
||||
Msg string
|
||||
CertExpiry int // days until TLS cert expiry; -1 when not applicable
|
||||
}
|
||||
|
||||
// Types lists the check kinds the add-monitor dialog offers.
|
||||
func Types() []string {
|
||||
return []string{"http", "tcp", "ping", "dns", "keyword", "json", "push"}
|
||||
}
|
||||
|
||||
// Check dispatches to the checker for m.Type. Retries happen in the caller.
|
||||
func Check(ctx context.Context, m *Monitor) *Result {
|
||||
timeout := time.Duration(m.TimeoutS) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
switch m.Type {
|
||||
case "http", "keyword", "json":
|
||||
return checkHTTP(ctx, m)
|
||||
case "tcp":
|
||||
return checkTCP(ctx, m)
|
||||
case "ping":
|
||||
return checkPing(ctx, m)
|
||||
case "dns":
|
||||
return checkDNS(ctx, m)
|
||||
default:
|
||||
return &Result{Msg: "unknown monitor type: " + m.Type}
|
||||
}
|
||||
}
|
||||
|
||||
func hostOf(m *Monitor) string {
|
||||
if m.Hostname != "" {
|
||||
return m.Hostname
|
||||
}
|
||||
return m.URL
|
||||
}
|
||||
|
||||
func checkHTTP(ctx context.Context, m *Monitor) *Result {
|
||||
method := m.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
var body io.Reader
|
||||
if m.Body != "" {
|
||||
body = strings.NewReader(m.Body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, m.URL, body)
|
||||
if err != nil {
|
||||
return &Result{Msg: "bad url: " + m.URL}
|
||||
}
|
||||
var hdrs map[string]string
|
||||
if json.Unmarshal([]byte(m.Headers), &hdrs) == nil {
|
||||
for k, v := range hdrs {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
if m.Body != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
ping := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
return &Result{Msg: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
res := &Result{PingMs: ping}
|
||||
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||
res.CertExpiry = int(time.Until(resp.TLS.PeerCertificates[0].NotAfter).Hours() / 24)
|
||||
} else {
|
||||
res.CertExpiry = -1
|
||||
}
|
||||
|
||||
switch m.Type {
|
||||
case "keyword", "json":
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
res.Msg = "read body: " + err.Error()
|
||||
return res
|
||||
}
|
||||
if m.Type == "keyword" {
|
||||
found := strings.Contains(string(raw), m.Keyword)
|
||||
if m.KeywordInvert {
|
||||
found = !found
|
||||
}
|
||||
res.Up = found
|
||||
res.Msg = fmt.Sprintf("keyword %q %s", m.Keyword, map[bool]string{true: "found", false: "absent"}[found])
|
||||
return res
|
||||
}
|
||||
return checkJSONBody(res, raw, m)
|
||||
default:
|
||||
res.Up = resp.StatusCode < 400
|
||||
res.Msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
// checkJSONBody walks a dot path ("a.b.0.c") into decoded JSON and compares
|
||||
// the leaf to m.Expected.
|
||||
func checkJSONBody(res *Result, raw []byte, m *Monitor) *Result {
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
res.Msg = "invalid JSON: " + err.Error()
|
||||
return res
|
||||
}
|
||||
for _, key := range strings.Split(m.JSONQuery, ".") {
|
||||
switch n := v.(type) {
|
||||
case map[string]any:
|
||||
v = n[key]
|
||||
case []any:
|
||||
i, err := strconv.Atoi(key)
|
||||
if err != nil || i < 0 || i >= len(n) {
|
||||
res.Msg = "json path missing: " + m.JSONQuery
|
||||
return res
|
||||
}
|
||||
v = n[i]
|
||||
default:
|
||||
res.Msg = "json path missing: " + m.JSONQuery
|
||||
return res
|
||||
}
|
||||
}
|
||||
got := fmt.Sprint(v)
|
||||
res.Up = got == m.Expected
|
||||
res.Msg = fmt.Sprintf("%s = %q (want %q)", m.JSONQuery, got, m.Expected)
|
||||
return res
|
||||
}
|
||||
|
||||
func checkTCP(ctx context.Context, m *Monitor) *Result {
|
||||
host := hostOf(m)
|
||||
if host == "" {
|
||||
return &Result{Msg: "hostname or url required"}
|
||||
}
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(m.Port))
|
||||
start := time.Now()
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return &Result{Msg: err.Error()}
|
||||
}
|
||||
_ = conn.Close()
|
||||
return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "port open"}
|
||||
}
|
||||
|
||||
// checkPing sends one ICMP echo. Raw sockets need CAP_NET_RAW; the udp4
|
||||
// datagram path works unprivileged on Linux via ping_group_range.
|
||||
func checkPing(ctx context.Context, m *Monitor) *Result {
|
||||
host := hostOf(m)
|
||||
if host == "" {
|
||||
return &Result{Msg: "hostname required"}
|
||||
}
|
||||
dst, err := net.ResolveIPAddr("ip4", host)
|
||||
if err != nil {
|
||||
if dst, err = net.ResolveIPAddr("ip6", host); err != nil {
|
||||
return &Result{Msg: "resolve: " + err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err != nil {
|
||||
conn, err = icmp.ListenPacket("udp4", "0.0.0.0")
|
||||
}
|
||||
if err != nil {
|
||||
return &Result{Msg: "icmp unavailable (needs cap_net_raw or ping_group_range): " + err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(4 * time.Second))
|
||||
|
||||
wm := icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{
|
||||
ID: 1,
|
||||
Seq: 1,
|
||||
Data: []byte("dash"),
|
||||
}}
|
||||
wb, err := wm.Marshal(nil)
|
||||
if err != nil {
|
||||
return &Result{Msg: err.Error()}
|
||||
}
|
||||
start := time.Now()
|
||||
var dstAddr net.Addr = dst
|
||||
if _, err := conn.WriteTo(wb, dstAddr); err != nil {
|
||||
return &Result{Msg: err.Error()}
|
||||
}
|
||||
rb := make([]byte, 1500)
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(rb)
|
||||
if err != nil {
|
||||
return &Result{Msg: "no reply: " + err.Error()}
|
||||
}
|
||||
rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if rm.Type == ipv4.ICMPTypeEchoReply {
|
||||
return &Result{Up: true, PingMs: int(time.Since(start).Milliseconds()), Msg: "echo reply"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkDNS(ctx context.Context, m *Monitor) *Result {
|
||||
host := hostOf(m)
|
||||
if host == "" {
|
||||
return &Result{Msg: "hostname required"}
|
||||
}
|
||||
r := net.DefaultResolver
|
||||
start := time.Now()
|
||||
var answers []string
|
||||
var err error
|
||||
switch strings.ToUpper(m.DNSType) {
|
||||
case "AAAA":
|
||||
var ips []net.IP
|
||||
ips, err = r.LookupIP(ctx, "ip6", host)
|
||||
for _, ip := range ips {
|
||||
answers = append(answers, ip.String())
|
||||
}
|
||||
case "MX":
|
||||
var mxs []*net.MX
|
||||
mxs, err = r.LookupMX(ctx, host)
|
||||
for _, mx := range mxs {
|
||||
answers = append(answers, mx.Host)
|
||||
}
|
||||
case "NS":
|
||||
var nss []*net.NS
|
||||
nss, err = r.LookupNS(ctx, host)
|
||||
for _, ns := range nss {
|
||||
answers = append(answers, ns.Host)
|
||||
}
|
||||
case "TXT":
|
||||
answers, err = r.LookupTXT(ctx, host)
|
||||
case "CNAME":
|
||||
var c string
|
||||
c, err = r.LookupCNAME(ctx, host)
|
||||
if c != "" {
|
||||
answers = []string{c}
|
||||
}
|
||||
default: // A
|
||||
var ips []net.IP
|
||||
ips, err = r.LookupIP(ctx, "ip4", host)
|
||||
for _, ip := range ips {
|
||||
answers = append(answers, ip.String())
|
||||
}
|
||||
}
|
||||
ping := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
return &Result{Msg: err.Error()}
|
||||
}
|
||||
if len(answers) == 0 {
|
||||
return &Result{Msg: "no records"}
|
||||
}
|
||||
res := &Result{Up: true, PingMs: ping, Msg: strings.Join(answers, ", ")}
|
||||
if m.Expected != "" {
|
||||
for _, a := range answers {
|
||||
if strings.Contains(a, m.Expected) {
|
||||
return res
|
||||
}
|
||||
}
|
||||
res.Up = false
|
||||
res.Msg = fmt.Sprintf("no record contains %q (got %s)", m.Expected, res.Msg)
|
||||
}
|
||||
return res
|
||||
}
|
||||
Reference in new issue
Block a user