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) }