Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
260 lines
6.7 KiB
Go
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}
|
|
}
|
|
}
|
|
}
|