service_checks.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. package main
  2. import (
  3. "crypto/tls"
  4. "crypto/x509"
  5. "fmt"
  6. "net"
  7. "net/http"
  8. "net/url"
  9. "strings"
  10. "time"
  11. )
  12. // refreshStatuses checks every monitor, updates the store, and broadcasts changes.
  13. func refreshStatuses(store *statusStore, client *http.Client, monitors []serviceMonitor, hub *wsHub) {
  14. results := make([]serviceStatus, 0, len(monitors))
  15. for _, monitor := range monitors {
  16. result := checkService(client, monitor)
  17. results = append(results, result)
  18. store.upsert(result)
  19. store.sort()
  20. hub.broadcast(wsEnvelope{
  21. Type: "service_update",
  22. GeneratedAt: time.Now(),
  23. Service: &result,
  24. })
  25. }
  26. sortStatuses(results)
  27. store.set(results)
  28. }
  29. // checkService dispatches a monitor to the appropriate protocol-specific checker.
  30. func checkService(client *http.Client, monitor serviceMonitor) serviceStatus {
  31. parsed, err := parseMonitorURL(monitor.URL)
  32. if err != nil {
  33. return serviceStatus{
  34. Name: monitor.Name,
  35. URL: monitor.URL,
  36. Category: monitor.Category,
  37. Protocol: "invalid",
  38. Healthy: false,
  39. ResponseTime: "0s",
  40. LastChecked: time.Now(),
  41. Message: "invalid monitor URL",
  42. Details: err.Error(),
  43. }
  44. }
  45. switch parsed.Scheme {
  46. case "http", "https":
  47. return checkHTTPService(client, monitor, parsed)
  48. case "dns":
  49. return checkDNSService(monitor, parsed)
  50. case "ping":
  51. return checkPingService(monitor, parsed)
  52. case "tls":
  53. return checkTLSService(monitor, parsed)
  54. default:
  55. return serviceStatus{
  56. Name: monitor.Name,
  57. URL: monitor.URL,
  58. Category: monitor.Category,
  59. Protocol: parsed.Scheme,
  60. Healthy: false,
  61. ResponseTime: "0s",
  62. LastChecked: time.Now(),
  63. Message: "unsupported monitor scheme",
  64. Details: "use http://, https://, dns://, ping://, or tls://",
  65. }
  66. }
  67. }
  68. // checkHTTPService issues an HTTP GET request and reports the response status.
  69. func checkHTTPService(client *http.Client, monitor serviceMonitor, parsed *url.URL) serviceStatus {
  70. start := time.Now()
  71. req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
  72. if err != nil {
  73. return serviceStatus{
  74. Name: monitor.Name,
  75. URL: monitor.URL,
  76. Category: monitor.Category,
  77. Protocol: parsed.Scheme,
  78. Healthy: false,
  79. ResponseTime: time.Since(start).Round(time.Millisecond).String(),
  80. LastChecked: time.Now(),
  81. Message: "invalid HTTP request",
  82. Details: err.Error(),
  83. }
  84. }
  85. req.Header.Set("User-Agent", "status-page-monitor/1.0")
  86. resp, err := client.Do(req)
  87. if err != nil {
  88. return serviceStatus{
  89. Name: monitor.Name,
  90. URL: monitor.URL,
  91. Category: monitor.Category,
  92. Protocol: parsed.Scheme,
  93. Healthy: false,
  94. ResponseTime: time.Since(start).Round(time.Millisecond).String(),
  95. LastChecked: time.Now(),
  96. Message: "HTTP request failed",
  97. Details: err.Error(),
  98. }
  99. }
  100. defer resp.Body.Close()
  101. healthy := resp.StatusCode >= 200 && resp.StatusCode < 400
  102. message := "service responded normally"
  103. if !healthy {
  104. message = "service returned an unhealthy response"
  105. }
  106. return serviceStatus{
  107. Name: monitor.Name,
  108. URL: monitor.URL,
  109. Category: monitor.Category,
  110. Protocol: parsed.Scheme,
  111. Healthy: healthy,
  112. StatusCode: resp.StatusCode,
  113. ResponseTime: time.Since(start).Round(time.Millisecond).String(),
  114. LastChecked: time.Now(),
  115. Message: message,
  116. Details: "HTTP " + resp.Status,
  117. }
  118. }
  119. // checkDNSService resolves the monitor target and reports the lookup result.
  120. func checkDNSService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
  121. start := time.Now()
  122. target, err := monitorTarget(parsed)
  123. if err != nil {
  124. return failedStatus(monitor, parsed.Scheme, start, "invalid DNS target", err.Error())
  125. }
  126. addrs, err := net.LookupHost(target)
  127. if err != nil {
  128. return failedStatus(monitor, parsed.Scheme, start, "DNS lookup failed", err.Error())
  129. }
  130. return serviceStatus{
  131. Name: monitor.Name,
  132. URL: monitor.URL,
  133. Category: monitor.Category,
  134. Protocol: parsed.Scheme,
  135. Healthy: len(addrs) > 0,
  136. ResponseTime: time.Since(start).Round(time.Millisecond).String(),
  137. LastChecked: time.Now(),
  138. Message: fmt.Sprintf("resolved %d DNS record(s)", len(addrs)),
  139. Details: strings.Join(addrs, ", "),
  140. }
  141. }
  142. // checkPingService performs a TCP dial to confirm the target is reachable.
  143. func checkPingService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
  144. start := time.Now()
  145. address, err := targetAddress(parsed, "443")
  146. if err != nil {
  147. return failedStatus(monitor, parsed.Scheme, start, "invalid ping target", err.Error())
  148. }
  149. conn, err := net.DialTimeout("tcp", address, 5*time.Second)
  150. if err != nil {
  151. return failedStatus(monitor, parsed.Scheme, start, "TCP ping failed", err.Error())
  152. }
  153. _ = conn.Close()
  154. return serviceStatus{
  155. Name: monitor.Name,
  156. URL: monitor.URL,
  157. Category: monitor.Category,
  158. Protocol: parsed.Scheme,
  159. Healthy: true,
  160. ResponseTime: time.Since(start).Round(time.Millisecond).String(),
  161. LastChecked: time.Now(),
  162. Message: "TCP ping succeeded",
  163. Details: "connected to " + address,
  164. }
  165. }
  166. // checkTLSService inspects the remote TLS certificate and validates its basics.
  167. func checkTLSService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
  168. start := time.Now()
  169. address, err := targetAddress(parsed, "443")
  170. if err != nil {
  171. return failedStatus(monitor, parsed.Scheme, start, "invalid TLS target", err.Error())
  172. }
  173. host := addressHost(address)
  174. dialer := &net.Dialer{Timeout: 5 * time.Second}
  175. conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
  176. ServerName: host,
  177. InsecureSkipVerify: true,
  178. MinVersion: tls.VersionTLS12,
  179. })
  180. if err != nil {
  181. return failedStatus(monitor, parsed.Scheme, start, "TLS handshake failed", err.Error())
  182. }
  183. defer conn.Close()
  184. state := conn.ConnectionState()
  185. if len(state.PeerCertificates) == 0 {
  186. return failedStatus(monitor, parsed.Scheme, start, "no TLS certificate presented", "")
  187. }
  188. leaf := state.PeerCertificates[0]
  189. intermediates := x509.NewCertPool()
  190. for _, cert := range state.PeerCertificates[1:] {
  191. intermediates.AddCert(cert)
  192. }
  193. now := time.Now()
  194. hostnameValid := leaf.VerifyHostname(host) == nil
  195. timeValid := !now.Before(leaf.NotBefore) && !now.After(leaf.NotAfter)
  196. _, verifyErr := leaf.Verify(x509.VerifyOptions{
  197. Intermediates: intermediates,
  198. CurrentTime: now,
  199. })
  200. signed := verifyErr == nil
  201. healthy := signed && hostnameValid && timeValid
  202. details := []string{
  203. "signed: " + yesNo(signed),
  204. "valid: " + yesNo(healthy),
  205. "expires: " + leaf.NotAfter.Format(time.RFC1123),
  206. }
  207. if !hostnameValid {
  208. details = append(details, "hostname mismatch")
  209. }
  210. if verifyErr != nil {
  211. details = append(details, "verify error: "+verifyErr.Error())
  212. }
  213. message := "TLS certificate is healthy"
  214. if !healthy {
  215. message = "TLS certificate check failed"
  216. }
  217. return serviceStatus{
  218. Name: monitor.Name,
  219. URL: monitor.URL,
  220. Category: monitor.Category,
  221. Protocol: parsed.Scheme,
  222. Healthy: healthy,
  223. ResponseTime: time.Since(start).Round(time.Millisecond).String(),
  224. LastChecked: time.Now(),
  225. Message: message,
  226. Details: strings.Join(details, " | "),
  227. }
  228. }
  229. // failedStatus builds a failed service status using a consistent shape.
  230. func failedStatus(monitor serviceMonitor, protocol string, start time.Time, message, details string) serviceStatus {
  231. return serviceStatus{
  232. Name: monitor.Name,
  233. URL: monitor.URL,
  234. Category: monitor.Category,
  235. Protocol: protocol,
  236. Healthy: false,
  237. ResponseTime: time.Since(start).Round(time.Millisecond).String(),
  238. LastChecked: time.Now(),
  239. Message: message,
  240. Details: details,
  241. }
  242. }
  243. // parseMonitorURL parses and minimally validates a monitor URL.
  244. func parseMonitorURL(raw string) (*url.URL, error) {
  245. parsed, err := url.Parse(raw)
  246. if err != nil {
  247. return nil, err
  248. }
  249. if parsed.Scheme == "" {
  250. return nil, fmt.Errorf("missing scheme")
  251. }
  252. switch parsed.Scheme {
  253. case "http", "https", "dns", "ping", "tls":
  254. return parsed, nil
  255. default:
  256. return parsed, nil
  257. }
  258. }
  259. // monitorTarget extracts the host-like target from a parsed monitor URL.
  260. func monitorTarget(parsed *url.URL) (string, error) {
  261. switch {
  262. case parsed.Host != "":
  263. return parsed.Hostname(), nil
  264. case parsed.Opaque != "":
  265. return strings.TrimPrefix(parsed.Opaque, "//"), nil
  266. case parsed.Path != "":
  267. return strings.TrimPrefix(parsed.Path, "/"), nil
  268. default:
  269. return "", fmt.Errorf("missing target host")
  270. }
  271. }
  272. // targetAddress returns a host:port address for monitors that require dialing.
  273. func targetAddress(parsed *url.URL, defaultPort string) (string, error) {
  274. target, err := monitorTarget(parsed)
  275. if err != nil {
  276. return "", err
  277. }
  278. host, port, err := net.SplitHostPort(target)
  279. if err == nil {
  280. return net.JoinHostPort(host, port), nil
  281. }
  282. if strings.Contains(err.Error(), "missing port in address") {
  283. return net.JoinHostPort(target, defaultPort), nil
  284. }
  285. return "", err
  286. }
  287. // addressHost strips the port from a host:port address when present.
  288. func addressHost(address string) string {
  289. host, _, err := net.SplitHostPort(address)
  290. if err != nil {
  291. return address
  292. }
  293. return host
  294. }
  295. // yesNo converts a boolean into a stable yes or no string.
  296. func yesNo(value bool) string {
  297. if value {
  298. return "yes"
  299. }
  300. return "no"
  301. }