// Package http implements the "http" check: one GET over a fresh connection, timed with net/http/httptrace. // // Protocol rules honoured here: keep-alive disabled (so TCP + TLS are measured every time), no proxy, no // redirects followed (a 3xx is a successful response), body read capped at 16 KiB, exactly one request, no retry. package http import ( "context" "crypto/tls" "crypto/x509" "errors" "io" "net" "net/http" "net/http/httptrace" "os" "strings" "syscall" "time" "internetpressure.io/probe-agent/internal/protocol" ) const ( // Timeout is the whole-check budget. Timeout = 10 * time.Second // MaxBody is the number of body bytes read before closing the connection. MaxBody = 16 * 1024 ) // Checker runs http checks. UserAgent is sent verbatim. type Checker struct { UserAgent string } // testRoots lets tests trust a local CA; nil (system roots) in production. var testRoots *x509.CertPool // stage tracks how far the request got when an error occurred (used to classify timeouts). type stage int const ( stageDNS stage = iota stageConnect stageTLS stageRequest stageResponse ) // Run performs one GET against t.URL. If t.IP is set the connection goes to that IP while the hostname is kept // for SNI and the Host header. func (c *Checker) Run(ctx context.Context, t protocol.Target) protocol.Measurement { m := protocol.Measurement{TS: protocol.FormatTime(time.Now()), TargetID: t.TargetID, Kind: "http"} rawURL := t.URL if rawURL == "" { rawURL = "https://" + t.Hostname + "/" } ctx, cancel := context.WithTimeout(ctx, Timeout) defer cancel() var ( start = time.Now() dnsStart, dnsDone, connStart, connDone time.Time tlsStart, tlsDone, gotConn, firstByte time.Time st = stageDNS fixedIP = t.FixedIP() resolvedIP string tlsState *tls.ConnectionState ) dialer := &net.Dialer{Timeout: Timeout} tr := &http.Transport{ Proxy: nil, // never use a proxy: we measure the path to the target itself DisableKeepAlives: true, ForceAttemptHTTP2: true, MaxIdleConns: 0, TLSHandshakeTimeout: Timeout, TLSClientConfig: &tls.Config{ServerName: t.Hostname, MinVersion: tls.VersionTLS12, RootCAs: testRoots}, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { if fixedIP != "" { _, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } addr = net.JoinHostPort(fixedIP, port) } return dialer.DialContext(ctx, network, addr) }, } defer tr.CloseIdleConnections() trace := &httptrace.ClientTrace{ DNSStart: func(httptrace.DNSStartInfo) { dnsStart = time.Now() }, DNSDone: func(i httptrace.DNSDoneInfo) { dnsDone = time.Now() if i.Err == nil { st = stageConnect } }, ConnectStart: func(string, string) { connStart = time.Now() if st < stageConnect { st = stageConnect } }, ConnectDone: func(_, addr string, err error) { connDone = time.Now() if err == nil { if h, _, e := net.SplitHostPort(addr); e == nil { resolvedIP = h } st = stageTLS } }, TLSHandshakeStart: func() { tlsStart = time.Now(); st = stageTLS }, TLSHandshakeDone: func(cs tls.ConnectionState, err error) { tlsDone = time.Now() if err == nil { tlsState = &cs st = stageRequest } }, GotConn: func(i httptrace.GotConnInfo) { gotConn = time.Now() if resolvedIP == "" && i.Conn != nil { if h, _, e := net.SplitHostPort(i.Conn.RemoteAddr().String()); e == nil { resolvedIP = h } } st = stageRequest }, WroteRequest: func(httptrace.WroteRequestInfo) { st = stageResponse }, GotFirstResponseByte: func() { firstByte = time.Now() }, } req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, rawURL, nil) if err != nil { m.Error = protocol.ErrOther return m } req.Header.Set("User-Agent", c.UserAgent) req.Header.Set("Accept", "text/html,*/*;q=0.8") req.Host = t.Hostname // keep the Host header on the hostname even when dialing a fixed IP if req.URL.Hostname() != t.Hostname && t.Hostname != "" { // URL host differs from hostname (unusual): trust the URL for the wire, hostname for SNI/Host. tr.TLSClientConfig.ServerName = t.Hostname } client := &http.Client{ Transport: tr, Timeout: Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse // a 301/302 is a valid answer; never follow }, } resp, err := client.Do(req) if err != nil { m.Error = classify(err, st) fill(&m, start, dnsStart, dnsDone, connStart, connDone, tlsStart, tlsDone, gotConn, firstByte, time.Now(), fixedIP != "") m.ResolvedIP = resolvedIP if tlsState != nil { m.TLSVersion = tlsVersionName(tlsState.Version) } return m } // Read at most MaxBody bytes, then close — enough for TTFB/throughput sanity, never a full download. _, readErr := io.CopyN(io.Discard, resp.Body, MaxBody) resp.Body.Close() end := time.Now() fill(&m, start, dnsStart, dnsDone, connStart, connDone, tlsStart, tlsDone, gotConn, firstByte, end, fixedIP != "") m.HTTPStatus = protocol.I(resp.StatusCode) m.HTTPProto = resp.Proto m.ResolvedIP = resolvedIP if resp.TLS != nil { m.TLSVersion = tlsVersionName(resp.TLS.Version) } else if tlsState != nil { m.TLSVersion = tlsVersionName(tlsState.Version) } switch { case resp.StatusCode >= 500: m.Error = protocol.ErrHTTP5xx case resp.StatusCode >= 400: m.Error = protocol.ErrHTTP4xx // informational: ok stays true (connection + TLS worked, status < 500) m.OK = true default: m.OK = true } if readErr != nil && !errors.Is(readErr, io.EOF) && m.OK { // Body truncated by the server mid-way: still a successful HTTP exchange; note a reset only when // nothing else was reported. if isReset(readErr) && m.Error == "" { m.Error = protocol.ErrReset } } return m } func fill(m *protocol.Measurement, start, dnsStart, dnsDone, connStart, connDone, tlsStart, tlsDone, gotConn, firstByte, end time.Time, fixed bool) { if !fixed && !dnsStart.IsZero() && !dnsDone.IsZero() { m.DNSMs = protocol.F(protocol.Ms(dnsDone.Sub(dnsStart))) } if !connStart.IsZero() && !connDone.IsZero() { m.TCPMs = protocol.F(protocol.Ms(connDone.Sub(connStart))) } if !tlsStart.IsZero() && !tlsDone.IsZero() { m.TLSMs = protocol.F(protocol.Ms(tlsDone.Sub(tlsStart))) } if !firstByte.IsZero() { // TTFB is the conventional time-to-first-byte: from the start of the check (before DNS) to the first // response byte, so dns_ms + tcp_ms + tls_ms ≤ ttfb_ms ≤ total_ms as in the protocol example. m.TTFBMs = protocol.F(protocol.Ms(firstByte.Sub(start))) } _ = gotConn m.TotalMs = protocol.F(protocol.Ms(end.Sub(start))) } // classify maps a transport error to a protocol error code, using the stage reached for timeouts. func classify(err error, st stage) string { if err == nil { return "" } // Unwrap url.Error and friends. var dnsErr *net.DNSError if errors.As(err, &dnsErr) { return protocol.ErrDNSFail } if isCertError(err) { return protocol.ErrTLSCert } if isTLSError(err) { return protocol.ErrTLSFail } if errors.Is(err, syscall.ECONNREFUSED) { return protocol.ErrTCPRefused } if isReset(err) { if st <= stageConnect { return protocol.ErrTCPReset } return protocol.ErrReset } if isTimeout(err) { switch st { case stageDNS: return protocol.ErrDNSFail case stageConnect: return protocol.ErrTCPTimeout case stageTLS: return protocol.ErrTLSFail default: return protocol.ErrHTTPTimeout } } if errors.Is(err, syscall.EHOSTUNREACH) || errors.Is(err, syscall.ENETUNREACH) { return protocol.ErrTCPTimeout } if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { if st <= stageTLS { return protocol.ErrTLSFail } return protocol.ErrReset } return protocol.ErrOther } func isTimeout(err error) bool { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) { return true } var ne net.Error if errors.As(err, &ne) && ne.Timeout() { return true } return strings.Contains(err.Error(), "Client.Timeout exceeded") } func isReset(err error) bool { return errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || strings.Contains(err.Error(), "connection reset") } func isCertError(err error) bool { var ( ua x509.UnknownAuthorityError ci x509.CertificateInvalidError hn x509.HostnameError cve *tls.CertificateVerificationError ) return errors.As(err, &ua) || errors.As(err, &ci) || errors.As(err, &hn) || errors.As(err, &cve) || strings.Contains(err.Error(), "x509:") } func isTLSError(err error) bool { var rh tls.RecordHeaderError var alert tls.AlertError if errors.As(err, &rh) || errors.As(err, &alert) { return true } s := err.Error() return strings.Contains(s, "tls:") || strings.Contains(s, "handshake failure") || strings.Contains(s, "remote error") } func tlsVersionName(v uint16) string { switch v { case tls.VersionTLS13: return "TLS1.3" case tls.VersionTLS12: return "TLS1.2" case tls.VersionTLS11: return "TLS1.1" case tls.VersionTLS10: return "TLS1.0" default: return "" } }