| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- 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"
- }
|