Files

553 lines
18 KiB
Go

package monitor
import "context"
import "crypto/tls"
import "crypto/x509"
import "encoding/json"
import "fmt"
import "io"
import "net"
import "net/http"
import "net/http/httptrace"
import "net/url"
import "strings"
import "sync"
import "time"
import "codit/internal/models"
// urlCheckConfig is the type-specific JSON config for a url monitor. The probe
// runs DNS resolve + TCP connect, and for http/https a request, and for https
// (unless disabled) a TLS-certificate-expiry facet — all as phases of one probe.
type urlCheckConfig struct {
Protocol string `json:"protocol"`
Port int `json:"port"`
Method string `json:"method"`
ExpectedStatus int `json:"expected_status"`
Keyword string `json:"keyword"`
CheckCert *bool `json:"check_cert"`
CertWarnDays int `json:"cert_warn_days"`
ClientCertRef string `json:"client_cert_ref"`
}
const urlBodyLimit int64 = 64 * 1024
const certDefaultWarnDays int = 14
func parseURLConfig(raw string) urlCheckConfig {
var cfg urlCheckConfig
if strings.TrimSpace(raw) != "" {
_ = json.Unmarshal([]byte(raw), &cfg)
}
if cfg.Protocol == "" {
cfg.Protocol = models.MonitorProtocolHTTPS
}
if cfg.CertWarnDays <= 0 {
cfg.CertWarnDays = certDefaultWarnDays
}
return cfg
}
func defaultPortFor(protocol string) string {
switch protocol {
case models.MonitorProtocolHTTP:
return "80"
case models.MonitorProtocolHTTPS:
return "443"
default:
return ""
}
}
// urlTrace captures per-phase timing/errors from an httptrace-instrumented
// request. The trace callbacks may fire from a background dial goroutine that
// outlives client.Do when the request is canceled or times out, so all access
// is guarded by mu; readers take a point-in-time snapshot() instead of touching
// fields directly.
type urlTrace struct {
mu sync.Mutex
connectStart time.Time
connectMs int64
connectDone bool
connectErr error
tlsStart time.Time
tlsMs int64
tlsDone bool
tlsErr error
certs []*x509.Certificate
}
// urlTraceSnapshot is an unsynchronized, point-in-time copy of the fields
// readers need. Once taken it is safe to read without locking.
type urlTraceSnapshot struct {
connectMs int64
connectDone bool
connectErr error
tlsMs int64
tlsDone bool
tlsErr error
certs []*x509.Certificate
}
func (tr *urlTrace) snapshot() urlTraceSnapshot {
tr.mu.Lock()
defer tr.mu.Unlock()
return urlTraceSnapshot{
connectMs: tr.connectMs,
connectDone: tr.connectDone,
connectErr: tr.connectErr,
tlsMs: tr.tlsMs,
tlsDone: tr.tlsDone,
tlsErr: tr.tlsErr,
certs: tr.certs,
}
}
func checkURL(ctx context.Context, policy EgressPolicy, m models.Monitor, productID string, tlsCtx *TLSContext) models.MonitorCheckResult {
var cfg urlCheckConfig
var target string
var protocol string
var host string
var port string
var requestURL string
cfg = parseURLConfig(m.Config)
protocol = cfg.Protocol
target = strings.TrimSpace(m.Target)
if target == "" {
return failResult("no target configured")
}
if strings.Contains(target, "://") {
var u *url.URL
var err error
u, err = url.Parse(target)
if err != nil || u.Hostname() == "" {
return failResult(fmt.Sprintf("invalid URL: %v", err))
}
if u.Scheme == models.MonitorProtocolHTTP || u.Scheme == models.MonitorProtocolHTTPS {
protocol = u.Scheme
}
host = u.Hostname()
port = u.Port()
if port == "" {
port = defaultPortFor(protocol)
}
requestURL = target
} else {
host, port = hostAndPort(target, defaultPortFor(protocol))
if protocol != models.MonitorProtocolTCP {
requestURL = protocol + "://" + net.JoinHostPort(host, port)
}
}
if cfg.Port > 0 {
port = fmt.Sprintf("%d", cfg.Port)
}
if port == "" {
return failResult("target must include a port for this protocol")
}
if protocol == models.MonitorProtocolTCP {
return probeTCP(ctx, policy, m, host, port)
}
return probeHTTP(ctx, policy, m, cfg, protocol, host, port, requestURL, productID, tlsCtx)
}
// probeTCP runs the DNS + connect facets only.
func probeTCP(ctx context.Context, policy EgressPolicy, m models.Monitor, host string, port string) models.MonitorCheckResult {
var facets []models.MonitorFacetResult
var ips []net.IP
var chosen net.IP
var resolved string
var start time.Time
var dnsMs int64
var conn net.Conn
var dialer net.Dialer
var connMs int64
var err error
start = time.Now()
ips, chosen, err = policy.ResolveValidated(ctx, host)
dnsMs = time.Since(start).Milliseconds()
if err != nil {
facets = append(facets, facet(models.MonitorFacetDNS, models.MonitorStateDown, dnsMs, classifyDialError(err)))
return aggregateResult(facets, "", 0)
}
resolved = joinIPs(ips)
facets = append(facets, facet(models.MonitorFacetDNS, models.MonitorStateUp, dnsMs, resolved))
dialer = net.Dialer{Timeout: checkTimeout(m)}
start = time.Now()
conn, err = dialer.DialContext(ctx, "tcp", net.JoinHostPort(chosen.String(), port))
connMs = time.Since(start).Milliseconds()
if err != nil {
facets = append(facets, facet(models.MonitorFacetConnect, models.MonitorStateDown, connMs, classifyDialError(err)))
return aggregateResult(facets, resolved, 0)
}
conn.Close()
facets = append(facets, facet(models.MonitorFacetConnect, models.MonitorStateUp, connMs, fmt.Sprintf("connected to %s:%s", host, port)))
return aggregateResult(facets, resolved, 0)
}
// probeHTTP runs DNS + connect + (https) TLS-cert + HTTP facets via one request.
func probeHTTP(ctx context.Context, policy EgressPolicy, m models.Monitor, cfg urlCheckConfig, protocol string, host string, port string, requestURL string, productID string, tlsCtx *TLSContext) models.MonitorCheckResult {
var facets []models.MonitorFacetResult
var ips []net.IP
var chosen net.IP
var resolved string
var start time.Time
var dnsMs int64
var timeout time.Duration
var tr *urlTrace
var snap urlTraceSnapshot
var tlsClientCfg *tls.Config
var transport *http.Transport
var client *http.Client
var req *http.Request
var resp *http.Response
var body []byte
var method string
var reqMs int64
var isHTTPS bool
var checkCert bool
var ci certInfo
var err error
isHTTPS = protocol == models.MonitorProtocolHTTPS
checkCert = isHTTPS && (cfg.CheckCert == nil || *cfg.CheckCert)
timeout = checkTimeout(m)
start = time.Now()
ips, chosen, err = policy.ResolveValidated(ctx, host)
dnsMs = time.Since(start).Milliseconds()
if err != nil {
facets = append(facets, facet(models.MonitorFacetDNS, models.MonitorStateDown, dnsMs, classifyDialError(err)))
return aggregateResult(facets, "", 0)
}
resolved = joinIPs(ips)
facets = append(facets, facet(models.MonitorFacetDNS, models.MonitorStateUp, dnsMs, resolved))
tr = &urlTrace{}
// Probe reachability/response independent of chain trust; the cert facet
// judges expiry separately so an expired cert is "degraded/down cert" rather
// than an opaque connection failure. A client cert (mTLS) is presented when
// the deployment resolved one for this monitor.
tlsClientCfg = &tls.Config{InsecureSkipVerify: true, ServerName: host}
if tlsCtx != nil && tlsCtx.ClientCert != nil {
tlsClientCfg.Certificates = []tls.Certificate{*tlsCtx.ClientCert}
}
transport = &http.Transport{
// Pin every dial to the policy-validated IP (anti DNS-rebinding); the
// request still uses the hostname for SNI/Host.
DialContext: func(dialCtx context.Context, network string, addr string) (net.Conn, error) {
var d net.Dialer
d = net.Dialer{Timeout: timeout}
return d.DialContext(dialCtx, network, net.JoinHostPort(chosen.String(), port))
},
TLSClientConfig: tlsClientCfg,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
DisableKeepAlives: true,
}
client = &http.Client{Timeout: timeout, Transport: transport}
method = strings.ToUpper(strings.TrimSpace(cfg.Method))
if method == "" {
method = http.MethodGet
}
req, err = http.NewRequestWithContext(httptrace.WithClientTrace(ctx, traceHooks(tr)), method, requestURL, nil)
if err != nil {
facets = append(facets, facet(models.MonitorFacetConnect, models.MonitorStateDown, 0, fmt.Sprintf("request error: %v", err)))
return aggregateResult(facets, resolved, 0)
}
req.Header.Set("User-Agent", productID+"-monitor/1.0")
start = time.Now()
resp, err = client.Do(req)
reqMs = time.Since(start).Milliseconds()
// Snapshot the trace once Do returns; a background dial goroutine may still
// mutate tr after a timeout/cancel, so never read its fields directly.
snap = tr.snapshot()
// connect facet
if snap.connectDone {
if snap.connectErr != nil {
facets = append(facets, facet(models.MonitorFacetConnect, models.MonitorStateDown, snap.connectMs, classifyDialError(snap.connectErr)))
return aggregateResult(facets, resolved, 0)
}
facets = append(facets, facet(models.MonitorFacetConnect, models.MonitorStateUp, snap.connectMs, fmt.Sprintf("connected to %s:%s", host, port)))
} else {
facets = append(facets, facet(models.MonitorFacetConnect, models.MonitorStateDown, reqMs, classifyDialError(orErr(err, "connection failed"))))
return aggregateResult(facets, resolved, 0)
}
// Flag whether the presented server cert chains to one of our own PKI CAs
// (informational; computed regardless of the cert facet being enabled).
if tlsCtx != nil && issuerIsOurs(snap.certs, tlsCtx.OurCAs) {
ci.issuerKnown = true
}
// tls/cert facet (https only, unless disabled)
if checkCert {
var f models.MonitorFacetResult
var cf certInfo
f, cf = certFacet(snap, cfg.CertWarnDays, host)
cf.issuerKnown = ci.issuerKnown
ci = cf
facets = append(facets, f)
if f.State == models.MonitorStateDown && resp == nil {
return finalize(facets, resolved, ci)
}
}
// http facet
if err != nil {
facets = append(facets, facet(models.MonitorFacetHTTP, models.MonitorStateDown, reqMs, classifyDialError(err)))
return finalize(facets, resolved, ci)
}
defer resp.Body.Close()
body, _ = io.ReadAll(io.LimitReader(resp.Body, urlBodyLimit))
facets = append(facets, httpFacet(cfg, resp.StatusCode, string(body), reqMs))
return finalize(facets, resolved, ci)
}
func traceHooks(tr *urlTrace) *httptrace.ClientTrace {
return &httptrace.ClientTrace{
ConnectStart: func(network string, addr string) {
tr.mu.Lock()
tr.connectStart = time.Now()
tr.mu.Unlock()
},
ConnectDone: func(network string, addr string, err error) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.connectDone = true
tr.connectErr = err
if !tr.connectStart.IsZero() {
tr.connectMs = time.Since(tr.connectStart).Milliseconds()
}
},
TLSHandshakeStart: func() {
tr.mu.Lock()
tr.tlsStart = time.Now()
tr.mu.Unlock()
},
TLSHandshakeDone: func(state tls.ConnectionState, err error) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.tlsDone = true
tr.tlsErr = err
tr.certs = state.PeerCertificates
if !tr.tlsStart.IsZero() {
tr.tlsMs = time.Since(tr.tlsStart).Milliseconds()
}
},
}
}
// certInfo carries the leaf-certificate detail surfaced on the status row.
// TLSContext carries deployment-resolved TLS material for a check: a client
// certificate to present for mTLS, and this deployment's own CA pool so the
// probe can flag a server cert issued by an internal CA. Resolved by the manager
// (which has DB access) and passed to the checker; nil fields mean "not used".
type TLSContext struct {
ClientCert *tls.Certificate
OurCAs *x509.CertPool
}
type certInfo struct {
expiresAt int64
subject string
issuer string
sans string
mismatch bool
issuerKnown bool
}
// issuerIsOurs reports whether the presented leaf chains to a CA in our own PKI
// store, using the presented intermediates. Trust here is informational (the
// probe judges reachability separately), so verification is done as-of the
// leaf's own validity window to avoid failing merely on expiry.
func issuerIsOurs(certs []*x509.Certificate, pool *x509.CertPool) bool {
var leaf *x509.Certificate
var inter *x509.CertPool
var c *x509.Certificate
var err error
if pool == nil || len(certs) == 0 {
return false
}
leaf = certs[0]
inter = x509.NewCertPool()
for _, c = range certs[1:] {
inter.AddCert(c)
}
_, err = leaf.Verify(x509.VerifyOptions{
Roots: pool,
Intermediates: inter,
CurrentTime: leaf.NotBefore,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
})
return err == nil
}
// certFacet evaluates the leaf certificate: validity window + hostname coverage.
// The probe ignores cert trust for reachability, so an expiry warning or a
// hostname mismatch is surfaced here (degraded) rather than failing the check.
func certFacet(tr urlTraceSnapshot, warnDays int, host string) (models.MonitorFacetResult, certInfo) {
var ci certInfo
var leaf *x509.Certificate
var now time.Time
var days int64
var state string
var notes []string
if !tr.tlsDone || tr.tlsErr != nil {
var msg string
msg = "TLS handshake failed"
if tr.tlsErr != nil {
msg = fmt.Sprintf("TLS handshake failed: %v", tr.tlsErr)
}
return facet(models.MonitorFacetTLS, models.MonitorStateDown, tr.tlsMs, msg), ci
}
if len(tr.certs) == 0 {
return facet(models.MonitorFacetTLS, models.MonitorStateDown, tr.tlsMs, "server presented no certificate"), ci
}
leaf = tr.certs[0]
ci.expiresAt = leaf.NotAfter.Unix()
// Modern certs often leave the Subject CN empty (identity lives in the SAN);
// fall back to the full Subject/Issuer DN so the row is never blank.
ci.subject = strings.TrimSpace(leaf.Subject.CommonName)
if ci.subject == "" {
ci.subject = strings.TrimSpace(leaf.Subject.String())
}
ci.issuer = strings.TrimSpace(leaf.Issuer.CommonName)
if ci.issuer == "" {
ci.issuer = strings.TrimSpace(leaf.Issuer.String())
}
ci.sans = strings.Join(leaf.DNSNames, ", ")
now = time.Now()
state = models.MonitorStateUp
if now.Before(leaf.NotBefore) {
state = models.MonitorStateDown
notes = append(notes, fmt.Sprintf("not valid until %s", leaf.NotBefore.UTC().Format("2006-01-02")))
} else if now.After(leaf.NotAfter) {
state = models.MonitorStateDown
notes = append(notes, fmt.Sprintf("expired %s", leaf.NotAfter.UTC().Format("2006-01-02")))
} else {
days = int64(leaf.NotAfter.Sub(now).Hours() / 24)
if days <= int64(warnDays) {
state = models.MonitorStateDegraded
notes = append(notes, fmt.Sprintf("expires in %d days (%s)", days, leaf.NotAfter.UTC().Format("2006-01-02")))
} else {
notes = append(notes, fmt.Sprintf("valid, expires in %d days", days))
}
}
// Hostname coverage: surfaced (degraded) but never fails the probe.
if host != "" && net.ParseIP(host) == nil && leaf.VerifyHostname(host) != nil {
ci.mismatch = true
state = worseState(state, models.MonitorStateDegraded)
notes = append(notes, fmt.Sprintf("hostname %q not covered by certificate", host))
}
return facet(models.MonitorFacetTLS, state, tr.tlsMs, strings.Join(notes, "; ")), ci
}
// finalize aggregates facets and attaches cert detail to the result.
func finalize(facets []models.MonitorFacetResult, resolved string, ci certInfo) models.MonitorCheckResult {
var r models.MonitorCheckResult
r = aggregateResult(facets, resolved, ci.expiresAt)
r.CertSubject = ci.subject
r.CertIssuer = ci.issuer
r.CertSANs = ci.sans
r.CertHostMismatch = ci.mismatch
r.CertIssuerKnown = ci.issuerKnown
return r
}
func httpFacet(cfg urlCheckConfig, status int, body string, latencyMs int64) models.MonitorFacetResult {
var statusOK bool
if cfg.ExpectedStatus > 0 {
statusOK = status == cfg.ExpectedStatus
} else {
statusOK = status >= 200 && status < 400
}
if !statusOK {
return facet(models.MonitorFacetHTTP, models.MonitorStateDown, latencyMs, fmt.Sprintf("unexpected status %d %s", status, http.StatusText(status)))
}
if strings.TrimSpace(cfg.Keyword) != "" && !strings.Contains(body, cfg.Keyword) {
return facet(models.MonitorFacetHTTP, models.MonitorStateDown, latencyMs, fmt.Sprintf("keyword %q not found", cfg.Keyword))
}
return facet(models.MonitorFacetHTTP, models.MonitorStateUp, latencyMs, fmt.Sprintf("%d %s", status, http.StatusText(status)))
}
func facet(name string, state string, latencyMs int64, message string) models.MonitorFacetResult {
return models.MonitorFacetResult{Name: name, State: state, LatencyMs: latencyMs, Message: message}
}
// aggregateResult folds facets into a single result: state = worst facet state,
// latency = the http/connect facet latency, message = summary.
func aggregateResult(facets []models.MonitorFacetResult, resolved string, certExpiresAt int64) models.MonitorCheckResult {
var f models.MonitorFacetResult
var state string
var latency int64
var summary string
state = models.MonitorStateUp
for _, f = range facets {
state = worseState(state, f.State)
if f.Name == models.MonitorFacetHTTP || f.Name == models.MonitorFacetConnect {
latency = f.LatencyMs
}
}
// Summary: on failure/degraded, the FIRST facet at the worst state (the phase
// that failed). When up, the LAST successful facet — the deepest phase reached
// (http > tls > connect > dns) — which reads more usefully (e.g. "http: 200 OK"
// rather than the DNS-resolved IP).
if state == models.MonitorStateUp {
var i int
for i = len(facets) - 1; i >= 0; i-- {
if facets[i].State == state {
summary = fmt.Sprintf("%s: %s", facets[i].Name, facets[i].Message)
break
}
}
} else {
for _, f = range facets {
if f.State == state {
summary = fmt.Sprintf("%s: %s", f.Name, f.Message)
break
}
}
}
return models.MonitorCheckResult{
// degraded (e.g. cert nearing expiry) is still reachable, so it does not
// count as a failure for the down threshold.
OK: state != models.MonitorStateDown,
State: state,
LatencyMs: latency,
Message: summary,
CertExpiresAt: certExpiresAt,
ResolvedValue: resolved,
Facets: facets,
}
}
func joinIPs(ips []net.IP) string {
var parts []string
var ip net.IP
for _, ip = range ips {
parts = append(parts, ip.String())
}
return strings.Join(parts, ", ")
}
func orErr(err error, fallback string) error {
if err != nil {
return err
}
return fmt.Errorf("%s", fallback)
}