|
@@ -0,0 +1,335 @@
|
|
|
|
|
+package main
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "crypto/tls"
|
|
|
|
|
+ "crypto/x509"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "net"
|
|
|
|
|
+ "net/http"
|
|
|
|
|
+ "net/url"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// refreshStatuses checks every monitor, updates the store, and broadcasts changes.
|
|
|
|
|
+func refreshStatuses(store *statusStore, client *http.Client, monitors []serviceMonitor, hub *wsHub) {
|
|
|
|
|
+ results := make([]serviceStatus, 0, len(monitors))
|
|
|
|
|
+
|
|
|
|
|
+ for _, monitor := range monitors {
|
|
|
|
|
+ result := checkService(client, monitor)
|
|
|
|
|
+ results = append(results, result)
|
|
|
|
|
+ store.upsert(result)
|
|
|
|
|
+ store.sort()
|
|
|
|
|
+ hub.broadcast(wsEnvelope{
|
|
|
|
|
+ Type: "service_update",
|
|
|
|
|
+ GeneratedAt: time.Now(),
|
|
|
|
|
+ Service: &result,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ sortStatuses(results)
|
|
|
|
|
+ store.set(results)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkService dispatches a monitor to the appropriate protocol-specific checker.
|
|
|
|
|
+func checkService(client *http.Client, monitor serviceMonitor) serviceStatus {
|
|
|
|
|
+ parsed, err := parseMonitorURL(monitor.URL)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: "invalid",
|
|
|
|
|
+ Healthy: false,
|
|
|
|
|
+ ResponseTime: "0s",
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: "invalid monitor URL",
|
|
|
|
|
+ Details: err.Error(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch parsed.Scheme {
|
|
|
|
|
+ case "http", "https":
|
|
|
|
|
+ return checkHTTPService(client, monitor, parsed)
|
|
|
|
|
+ case "dns":
|
|
|
|
|
+ return checkDNSService(monitor, parsed)
|
|
|
|
|
+ case "ping":
|
|
|
|
|
+ return checkPingService(monitor, parsed)
|
|
|
|
|
+ case "tls":
|
|
|
|
|
+ return checkTLSService(monitor, parsed)
|
|
|
|
|
+ default:
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: parsed.Scheme,
|
|
|
|
|
+ Healthy: false,
|
|
|
|
|
+ ResponseTime: "0s",
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: "unsupported monitor scheme",
|
|
|
|
|
+ Details: "use http://, https://, dns://, ping://, or tls://",
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkHTTPService issues an HTTP GET request and reports the response status.
|
|
|
|
|
+func checkHTTPService(client *http.Client, monitor serviceMonitor, parsed *url.URL) serviceStatus {
|
|
|
|
|
+ start := time.Now()
|
|
|
|
|
+ req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: parsed.Scheme,
|
|
|
|
|
+ Healthy: false,
|
|
|
|
|
+ ResponseTime: time.Since(start).Round(time.Millisecond).String(),
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: "invalid HTTP request",
|
|
|
|
|
+ Details: err.Error(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ req.Header.Set("User-Agent", "status-page-monitor/1.0")
|
|
|
|
|
+ resp, err := client.Do(req)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: parsed.Scheme,
|
|
|
|
|
+ Healthy: false,
|
|
|
|
|
+ ResponseTime: time.Since(start).Round(time.Millisecond).String(),
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: "HTTP request failed",
|
|
|
|
|
+ Details: err.Error(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ defer resp.Body.Close()
|
|
|
|
|
+
|
|
|
|
|
+ healthy := resp.StatusCode >= 200 && resp.StatusCode < 400
|
|
|
|
|
+ message := "service responded normally"
|
|
|
|
|
+ if !healthy {
|
|
|
|
|
+ message = "service returned an unhealthy response"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: parsed.Scheme,
|
|
|
|
|
+ Healthy: healthy,
|
|
|
|
|
+ StatusCode: resp.StatusCode,
|
|
|
|
|
+ ResponseTime: time.Since(start).Round(time.Millisecond).String(),
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: message,
|
|
|
|
|
+ Details: "HTTP " + resp.Status,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkDNSService resolves the monitor target and reports the lookup result.
|
|
|
|
|
+func checkDNSService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
|
|
|
|
|
+ start := time.Now()
|
|
|
|
|
+ target, err := monitorTarget(parsed)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return failedStatus(monitor, parsed.Scheme, start, "invalid DNS target", err.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addrs, err := net.LookupHost(target)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return failedStatus(monitor, parsed.Scheme, start, "DNS lookup failed", err.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: parsed.Scheme,
|
|
|
|
|
+ Healthy: len(addrs) > 0,
|
|
|
|
|
+ ResponseTime: time.Since(start).Round(time.Millisecond).String(),
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: fmt.Sprintf("resolved %d DNS record(s)", len(addrs)),
|
|
|
|
|
+ Details: strings.Join(addrs, ", "),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkPingService performs a TCP dial to confirm the target is reachable.
|
|
|
|
|
+func checkPingService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
|
|
|
|
|
+ start := time.Now()
|
|
|
|
|
+ address, err := targetAddress(parsed, "443")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return failedStatus(monitor, parsed.Scheme, start, "invalid ping target", err.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ conn, err := net.DialTimeout("tcp", address, 5*time.Second)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return failedStatus(monitor, parsed.Scheme, start, "TCP ping failed", err.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = conn.Close()
|
|
|
|
|
+
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: parsed.Scheme,
|
|
|
|
|
+ Healthy: true,
|
|
|
|
|
+ ResponseTime: time.Since(start).Round(time.Millisecond).String(),
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: "TCP ping succeeded",
|
|
|
|
|
+ Details: "connected to " + address,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkTLSService inspects the remote TLS certificate and validates its basics.
|
|
|
|
|
+func checkTLSService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
|
|
|
|
|
+ start := time.Now()
|
|
|
|
|
+ address, err := targetAddress(parsed, "443")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return failedStatus(monitor, parsed.Scheme, start, "invalid TLS target", err.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ host := addressHost(address)
|
|
|
|
|
+ dialer := &net.Dialer{Timeout: 5 * time.Second}
|
|
|
|
|
+ conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
|
|
|
|
|
+ ServerName: host,
|
|
|
|
|
+ InsecureSkipVerify: true,
|
|
|
|
|
+ MinVersion: tls.VersionTLS12,
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return failedStatus(monitor, parsed.Scheme, start, "TLS handshake failed", err.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+ defer conn.Close()
|
|
|
|
|
+
|
|
|
|
|
+ state := conn.ConnectionState()
|
|
|
|
|
+ if len(state.PeerCertificates) == 0 {
|
|
|
|
|
+ return failedStatus(monitor, parsed.Scheme, start, "no TLS certificate presented", "")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ leaf := state.PeerCertificates[0]
|
|
|
|
|
+ intermediates := x509.NewCertPool()
|
|
|
|
|
+ for _, cert := range state.PeerCertificates[1:] {
|
|
|
|
|
+ intermediates.AddCert(cert)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ now := time.Now()
|
|
|
|
|
+ hostnameValid := leaf.VerifyHostname(host) == nil
|
|
|
|
|
+ timeValid := !now.Before(leaf.NotBefore) && !now.After(leaf.NotAfter)
|
|
|
|
|
+ _, verifyErr := leaf.Verify(x509.VerifyOptions{
|
|
|
|
|
+ Intermediates: intermediates,
|
|
|
|
|
+ CurrentTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ signed := verifyErr == nil
|
|
|
|
|
+ healthy := signed && hostnameValid && timeValid
|
|
|
|
|
+
|
|
|
|
|
+ details := []string{
|
|
|
|
|
+ "signed: " + yesNo(signed),
|
|
|
|
|
+ "valid: " + yesNo(healthy),
|
|
|
|
|
+ "expires: " + leaf.NotAfter.Format(time.RFC1123),
|
|
|
|
|
+ }
|
|
|
|
|
+ if !hostnameValid {
|
|
|
|
|
+ details = append(details, "hostname mismatch")
|
|
|
|
|
+ }
|
|
|
|
|
+ if verifyErr != nil {
|
|
|
|
|
+ details = append(details, "verify error: "+verifyErr.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ message := "TLS certificate is healthy"
|
|
|
|
|
+ if !healthy {
|
|
|
|
|
+ message = "TLS certificate check failed"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: parsed.Scheme,
|
|
|
|
|
+ Healthy: healthy,
|
|
|
|
|
+ ResponseTime: time.Since(start).Round(time.Millisecond).String(),
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: message,
|
|
|
|
|
+ Details: strings.Join(details, " | "),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// failedStatus builds a failed service status using a consistent shape.
|
|
|
|
|
+func failedStatus(monitor serviceMonitor, protocol string, start time.Time, message, details string) serviceStatus {
|
|
|
|
|
+ return serviceStatus{
|
|
|
|
|
+ Name: monitor.Name,
|
|
|
|
|
+ URL: monitor.URL,
|
|
|
|
|
+ Category: monitor.Category,
|
|
|
|
|
+ Protocol: protocol,
|
|
|
|
|
+ Healthy: false,
|
|
|
|
|
+ ResponseTime: time.Since(start).Round(time.Millisecond).String(),
|
|
|
|
|
+ LastChecked: time.Now(),
|
|
|
|
|
+ Message: message,
|
|
|
|
|
+ Details: details,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// parseMonitorURL parses and minimally validates a monitor URL.
|
|
|
|
|
+func parseMonitorURL(raw string) (*url.URL, error) {
|
|
|
|
|
+ parsed, err := url.Parse(raw)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if parsed.Scheme == "" {
|
|
|
|
|
+ return nil, fmt.Errorf("missing scheme")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch parsed.Scheme {
|
|
|
|
|
+ case "http", "https", "dns", "ping", "tls":
|
|
|
|
|
+ return parsed, nil
|
|
|
|
|
+ default:
|
|
|
|
|
+ return parsed, nil
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// monitorTarget extracts the host-like target from a parsed monitor URL.
|
|
|
|
|
+func monitorTarget(parsed *url.URL) (string, error) {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case parsed.Host != "":
|
|
|
|
|
+ return parsed.Hostname(), nil
|
|
|
|
|
+ case parsed.Opaque != "":
|
|
|
|
|
+ return strings.TrimPrefix(parsed.Opaque, "//"), nil
|
|
|
|
|
+ case parsed.Path != "":
|
|
|
|
|
+ return strings.TrimPrefix(parsed.Path, "/"), nil
|
|
|
|
|
+ default:
|
|
|
|
|
+ return "", fmt.Errorf("missing target host")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// targetAddress returns a host:port address for monitors that require dialing.
|
|
|
|
|
+func targetAddress(parsed *url.URL, defaultPort string) (string, error) {
|
|
|
|
|
+ target, err := monitorTarget(parsed)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "", err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ host, port, err := net.SplitHostPort(target)
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ return net.JoinHostPort(host, port), nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if strings.Contains(err.Error(), "missing port in address") {
|
|
|
|
|
+ return net.JoinHostPort(target, defaultPort), nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return "", err
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// addressHost strips the port from a host:port address when present.
|
|
|
|
|
+func addressHost(address string) string {
|
|
|
|
|
+ host, _, err := net.SplitHostPort(address)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return address
|
|
|
|
|
+ }
|
|
|
|
|
+ return host
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// yesNo converts a boolean into a stable yes or no string.
|
|
|
|
|
+func yesNo(value bool) string {
|
|
|
|
|
+ if value {
|
|
|
|
|
+ return "yes"
|
|
|
|
|
+ }
|
|
|
|
|
+ return "no"
|
|
|
|
|
+}
|