Files
Dash/internal/checker/checker.go
T
arsam 1ae3f155da Import source: internal/checker/checker
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:19:51 +03:30

260 lines
6.7 KiB
Go

// Package checker implements the four probe types: http, tcp, tls, icmp.
package checker
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strconv"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"dash/internal/config"
)
// Result is the outcome of a single probe.
type Result struct {
TS time.Time
OK bool
Degraded bool // ok but worrying (slow, cert nearing expiry)
LatencyMS float64 // round-trip time
Detail string // machine-readable extra: http status, cert days left
Err string
}
func fail(start time.Time, format string, args ...any) Result {
return Result{
TS: start,
LatencyMS: float64(time.Since(start).Microseconds()) / 1000.0,
Err: fmt.Sprintf(format, args...),
}
}
// Run executes the probe described by t once.
func Run(ctx context.Context, t config.Target) Result {
ctx, cancel := context.WithTimeout(ctx, t.Timeout.Duration)
defer cancel()
var res Result
switch t.Type {
case config.CheckHTTP:
res = checkHTTP(ctx, t)
case config.CheckTCP:
res = checkTCP(ctx, t)
case config.CheckTLS:
res = checkTLS(ctx, t)
case config.CheckICMP:
res = checkICMP(ctx, t)
case config.CheckDocker:
res = checkDocker(ctx, t)
default:
res = fail(time.Now(), "unknown check type %q", t.Type)
}
if res.OK && t.DegradedLatencyMS > 0 && res.LatencyMS > t.DegradedLatencyMS {
res.Degraded = true
}
return res
}
func checkHTTP(ctx context.Context, t config.Target) Result {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.URL, nil)
if err != nil {
return fail(start, "bad url: %v", err)
}
req.Header.Set("User-Agent", "dash-monitor/1.0")
client := &http.Client{
// Each probe measures a full fresh connection, not a pooled one.
Transport: &http.Transport{DisableKeepAlives: true},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return fail(start, "%v", err)
}
defer resp.Body.Close()
lat := float64(time.Since(start).Microseconds()) / 1000.0
ok := resp.StatusCode < 400
if t.ExpectStatus != 0 {
ok = resp.StatusCode == t.ExpectStatus
}
res := Result{TS: start, OK: ok, LatencyMS: lat, Detail: strconv.Itoa(resp.StatusCode)}
if !ok {
res.Err = fmt.Sprintf("unexpected status %d", resp.StatusCode)
}
return res
}
func checkTCP(ctx context.Context, t config.Target) Result {
start := time.Now()
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", t.Address)
if err != nil {
return fail(start, "%v", err)
}
conn.Close()
return Result{TS: start, OK: true, LatencyMS: float64(time.Since(start).Microseconds()) / 1000.0}
}
func checkTLS(ctx context.Context, t config.Target) Result {
start := time.Now()
d := tls.Dialer{Config: &tls.Config{}}
conn, err := d.DialContext(ctx, "tcp", t.Address)
if err != nil {
return fail(start, "%v", err)
}
defer conn.Close()
lat := float64(time.Since(start).Microseconds()) / 1000.0
state := conn.(*tls.Conn).ConnectionState()
if len(state.PeerCertificates) == 0 {
return fail(start, "no peer certificates")
}
leaf := state.PeerCertificates[0]
days := int(time.Until(leaf.NotAfter).Hours() / 24)
res := Result{TS: start, OK: true, LatencyMS: lat, Detail: strconv.Itoa(days)}
switch {
case days < 0:
res.OK = false
res.Err = fmt.Sprintf("certificate expired %d days ago", -days)
case days < t.WarnDays:
res.Degraded = true
res.Err = fmt.Sprintf("certificate expires in %d days", days)
}
return res
}
// dockerInspect is the subset of the container-inspect response we read.
type dockerInspect struct {
State struct {
Status string `json:"Status"`
Running bool `json:"Running"`
Health *struct {
Status string `json:"Status"` // starting | healthy | unhealthy
} `json:"Health"`
} `json:"State"`
}
func checkDocker(ctx context.Context, t config.Target) Result {
start := time.Now()
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", t.Socket)
},
},
}
url := "http://docker/containers/" + t.Container + "/json"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fail(start, "bad request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
return fail(start, "docker socket: %v", err)
}
defer resp.Body.Close()
lat := float64(time.Since(start).Microseconds()) / 1000.0
if resp.StatusCode == http.StatusNotFound {
return fail(start, "container %q not found", t.Container)
}
if resp.StatusCode != http.StatusOK {
return fail(start, "docker api status %d", resp.StatusCode)
}
var info dockerInspect
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return fail(start, "decode: %v", err)
}
res := Result{TS: start, LatencyMS: lat, Detail: info.State.Status}
if !info.State.Running {
res.Err = fmt.Sprintf("container %s", info.State.Status)
return res
}
res.OK = true
if h := info.State.Health; h != nil {
res.Detail = h.Status
switch h.Status {
case "unhealthy":
res.OK = false
res.Err = "healthcheck failing"
case "starting":
res.Degraded = true
res.Err = "healthcheck starting"
}
}
return res
}
var icmpSeq uint32
func checkICMP(ctx context.Context, t config.Target) Result {
start := time.Now()
dst, err := net.ResolveIPAddr("ip4", t.Host)
if err != nil {
return fail(start, "resolve: %v", err)
}
// Prefer unprivileged datagram ICMP; fall back to a raw socket (needs
// root or CAP_NET_RAW).
var peer net.Addr = &net.UDPAddr{IP: dst.IP}
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil {
conn, err = icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return fail(start, "icmp socket: %v (needs CAP_NET_RAW or net.ipv4.ping_group_range)", err)
}
peer = dst
}
defer conn.Close()
icmpSeq++
msg := icmp.Message{
Type: ipv4.ICMPTypeEcho,
Body: &icmp.Echo{
ID: os.Getpid() & 0xffff,
Seq: int(icmpSeq & 0xffff),
Data: []byte("dash-monitor-ping"),
},
}
wire, err := msg.Marshal(nil)
if err != nil {
return fail(start, "marshal: %v", err)
}
deadline, _ := ctx.Deadline()
conn.SetDeadline(deadline)
sent := time.Now()
if _, err := conn.WriteTo(wire, peer); err != nil {
return fail(start, "send: %v", err)
}
buf := make([]byte, 1500)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
return fail(start, "recv: %v", err)
}
reply, err := icmp.ParseMessage(1, buf[:n]) // 1 = ICMPv4 protocol number
if err != nil {
continue
}
if reply.Type == ipv4.ICMPTypeEchoReply {
lat := float64(time.Since(sent).Microseconds()) / 1000.0
return Result{TS: start, OK: true, LatencyMS: lat}
}
}
}