553 lines
18 KiB
Go
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)
|
|
}
|