init work of uptime

This commit is contained in:
Blake Ridgway
2026-03-22 11:30:31 -05:00
parent 854cba4c24
commit f0db70c840
18 changed files with 2252 additions and 0 deletions

69
internal/monitor/dns.go Normal file
View File

@@ -0,0 +1,69 @@
package monitor
import (
"context"
"fmt"
"net"
"time"
"arclineit/arcline-uptime/internal/config"
)
// DNSChecker resolves a hostname and optionally asserts a specific IP is returned.
type DNSChecker struct {
cfg config.MonitorConfig
timeout time.Duration
}
func NewDNSChecker(cfg config.MonitorConfig, timeout time.Duration) *DNSChecker {
return &DNSChecker{cfg: cfg, timeout: timeout}
}
func (d *DNSChecker) Name() string { return d.cfg.Name }
func (d *DNSChecker) Interval() time.Duration { return time.Duration(d.cfg.Interval) * time.Second }
func (d *DNSChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: d.cfg.Name,
CheckedAt: start,
}
ctx, cancel := context.WithTimeout(context.Background(), d.timeout)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(ctx, d.cfg.Host)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
if d.cfg.ExpectedIP != "" {
want := normalizeIP(d.cfg.ExpectedIP)
found := false
for _, addr := range addrs {
if normalizeIP(addr) == want {
found = true
break
}
}
if !found {
result.Error = fmt.Sprintf("expected IP %q not found in results %v", d.cfg.ExpectedIP, addrs)
return result
}
}
result.Up = true
applyThreshold(&result, d.cfg.MaxResponseMS)
return result
}
// normalizeIP parses and re-serialises an IP string so different representations
// of the same address compare equal (e.g. "::1" and "0:0:0:0:0:0:0:1").
func normalizeIP(s string) string {
if ip := net.ParseIP(s); ip != nil {
return ip.String()
}
return s
}

101
internal/monitor/http.go Normal file
View File

@@ -0,0 +1,101 @@
package monitor
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"arclineit/arcline-uptime/internal/config"
)
type HTTPChecker struct {
cfg config.MonitorConfig
client *http.Client
}
func NewHTTPChecker(cfg config.MonitorConfig, timeout time.Duration) *HTTPChecker {
return &HTTPChecker{
cfg: cfg,
client: &http.Client{
Timeout: timeout,
CheckRedirect: redirectPolicy(cfg.ExpectedStatus),
},
}
}
// redirectPolicy stops at the first redirect when the expected status is 3xx so
// the raw redirect code is returned. Otherwise follows up to 10 redirects.
func redirectPolicy(expected int) func(*http.Request, []*http.Request) error {
if expected >= 300 && expected < 400 {
return func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
}
return nil
}
func (h *HTTPChecker) Name() string { return h.cfg.Name }
func (h *HTTPChecker) Interval() time.Duration { return time.Duration(h.cfg.Interval) * time.Second }
func (h *HTTPChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: h.cfg.Name,
CheckedAt: start,
}
var bodyReader io.Reader
if h.cfg.Body != "" {
bodyReader = strings.NewReader(h.cfg.Body)
}
req, err := http.NewRequest(h.cfg.Method, h.cfg.URL, bodyReader)
if err != nil {
result.Error = fmt.Sprintf("build request: %v", err)
result.ResponseTime = time.Since(start)
return result
}
req.Header.Set("User-Agent", "arcline-uptime/1.0")
for k, v := range h.cfg.Headers {
req.Header.Set(k, v)
}
resp, err := h.client.Do(req)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
defer resp.Body.Close()
result.StatusCode = resp.StatusCode
// Response time threshold check — before reading body to fail fast.
applyThreshold(&result, h.cfg.MaxResponseMS)
if !result.Up && result.Error != "" {
return result
}
if resp.StatusCode != h.cfg.ExpectedStatus {
result.Error = fmt.Sprintf("expected status %d, got %d", h.cfg.ExpectedStatus, resp.StatusCode)
return result
}
if h.cfg.Contains != "" {
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
result.Error = fmt.Sprintf("read body: %v", err)
return result
}
if !strings.Contains(string(body), h.cfg.Contains) {
result.Error = fmt.Sprintf("body does not contain %q", h.cfg.Contains)
return result
}
}
result.Up = true
applyThreshold(&result, h.cfg.MaxResponseMS)
return result
}

View File

@@ -0,0 +1,59 @@
package monitor
import (
"fmt"
"time"
"arclineit/arcline-uptime/internal/config"
)
// Result is returned by every Checker after a single probe.
type Result struct {
MonitorName string
Up bool
StatusCode int // HTTP only; 0 for TCP/TLS/DNS
ResponseTime time.Duration
Error string // empty when Up == true
CheckedAt time.Time
}
// Checker probes one endpoint.
type Checker interface {
Name() string
Check() Result
Interval() time.Duration // per-monitor interval; 0 means use the global default
}
// BuildCheckers constructs one Checker per MonitorConfig entry.
// globalTimeout is the fallback when a monitor's own Timeout is 0.
func BuildCheckers(monitors []config.MonitorConfig, globalTimeout time.Duration) ([]Checker, error) {
checkers := make([]Checker, 0, len(monitors))
for _, m := range monitors {
timeout := globalTimeout
if m.Timeout > 0 {
timeout = time.Duration(m.Timeout) * time.Second
}
switch m.Type {
case "http":
checkers = append(checkers, NewHTTPChecker(m, timeout))
case "tcp":
checkers = append(checkers, NewTCPChecker(m, timeout))
case "tls":
checkers = append(checkers, NewTLSChecker(m, timeout))
case "dns":
checkers = append(checkers, NewDNSChecker(m, timeout))
default:
return nil, fmt.Errorf("unknown monitor type %q for %q", m.Type, m.Name)
}
}
return checkers, nil
}
// applyThreshold marks a successful result as failed if response time exceeds max.
func applyThreshold(r *Result, maxResponseMS int64) {
if maxResponseMS > 0 && r.Up && r.ResponseTime.Milliseconds() > maxResponseMS {
r.Up = false
r.Error = fmt.Sprintf("response time %dms exceeded threshold %dms",
r.ResponseTime.Milliseconds(), maxResponseMS)
}
}

46
internal/monitor/tcp.go Normal file
View File

@@ -0,0 +1,46 @@
package monitor
import (
"fmt"
"net"
"time"
"arclineit/arcline-uptime/internal/config"
)
type TCPChecker struct {
cfg config.MonitorConfig
addr string
timeout time.Duration
}
func NewTCPChecker(cfg config.MonitorConfig, timeout time.Duration) *TCPChecker {
return &TCPChecker{
cfg: cfg,
addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
timeout: timeout,
}
}
func (t *TCPChecker) Name() string { return t.cfg.Name }
func (t *TCPChecker) Interval() time.Duration { return time.Duration(t.cfg.Interval) * time.Second }
func (t *TCPChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: t.cfg.Name,
CheckedAt: start,
}
conn, err := net.DialTimeout("tcp", t.addr, t.timeout)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
conn.Close()
result.Up = true
applyThreshold(&result, t.cfg.MaxResponseMS)
return result
}

68
internal/monitor/tls.go Normal file
View File

@@ -0,0 +1,68 @@
package monitor
import (
"crypto/tls"
"fmt"
"net"
"time"
"arclineit/arcline-uptime/internal/config"
)
// TLSChecker dials a TLS endpoint and alerts when the certificate is about
// to expire (within ExpiryWarningDays days).
type TLSChecker struct {
cfg config.MonitorConfig
timeout time.Duration
}
func NewTLSChecker(cfg config.MonitorConfig, timeout time.Duration) *TLSChecker {
return &TLSChecker{cfg: cfg, timeout: timeout}
}
func (t *TLSChecker) Name() string { return t.cfg.Name }
func (t *TLSChecker) Interval() time.Duration { return time.Duration(t.cfg.Interval) * time.Second }
func (t *TLSChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: t.cfg.Name,
CheckedAt: start,
}
addr := fmt.Sprintf("%s:%d", t.cfg.Host, t.cfg.Port)
dialer := &tls.Dialer{
NetDialer: &net.Dialer{Timeout: t.timeout},
Config: &tls.Config{ServerName: t.cfg.Host},
}
conn, err := dialer.Dial("tcp", addr)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
defer conn.Close()
tlsConn := conn.(*tls.Conn)
state := tlsConn.ConnectionState()
// Find the earliest-expiring certificate in the chain.
var earliest time.Time
for _, cert := range state.PeerCertificates {
if earliest.IsZero() || cert.NotAfter.Before(earliest) {
earliest = cert.NotAfter
}
}
daysLeft := int(time.Until(earliest).Hours() / 24)
if daysLeft <= t.cfg.ExpiryWarningDays {
result.Error = fmt.Sprintf("certificate expires in %d day(s) on %s",
daysLeft, earliest.UTC().Format("2006-01-02"))
return result
}
result.Up = true
applyThreshold(&result, t.cfg.MaxResponseMS)
return result
}