// 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} } } }