SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
9.1 KB · 321 lines go
Raw Blame History
1// Package http implements the "http" check: one GET over a fresh connection, timed with net/http/httptrace.2//3// Protocol rules honoured here: keep-alive disabled (so TCP + TLS are measured every time), no proxy, no4// redirects followed (a 3xx is a successful response), body read capped at 16 KiB, exactly one request, no retry.5package http67import (8	"context"9	"crypto/tls"10	"crypto/x509"11	"errors"12	"io"13	"net"14	"net/http"15	"net/http/httptrace"16	"os"17	"strings"18	"syscall"19	"time"2021	"internetpressure.io/probe-agent/internal/protocol"22)2324const (25	// Timeout is the whole-check budget.26	Timeout = 10 * time.Second27	// MaxBody is the number of body bytes read before closing the connection.28	MaxBody = 16 * 102429)3031// Checker runs http checks. UserAgent is sent verbatim.32type Checker struct {33	UserAgent string34}3536// testRoots lets tests trust a local CA; nil (system roots) in production.37var testRoots *x509.CertPool3839// stage tracks how far the request got when an error occurred (used to classify timeouts).40type stage int4142const (43	stageDNS stage = iota44	stageConnect45	stageTLS46	stageRequest47	stageResponse48)4950// Run performs one GET against t.URL. If t.IP is set the connection goes to that IP while the hostname is kept51// for SNI and the Host header.52func (c *Checker) Run(ctx context.Context, t protocol.Target) protocol.Measurement {53	m := protocol.Measurement{TS: protocol.FormatTime(time.Now()), TargetID: t.TargetID, Kind: "http"}54	rawURL := t.URL55	if rawURL == "" {56		rawURL = "https://" + t.Hostname + "/"57	}5859	ctx, cancel := context.WithTimeout(ctx, Timeout)60	defer cancel()6162	var (63		start                                  = time.Now()64		dnsStart, dnsDone, connStart, connDone time.Time65		tlsStart, tlsDone, gotConn, firstByte  time.Time66		st                                     = stageDNS67		fixedIP                                = t.FixedIP()68		resolvedIP                             string69		tlsState                               *tls.ConnectionState70	)7172	dialer := &net.Dialer{Timeout: Timeout}73	tr := &http.Transport{74		Proxy:               nil, // never use a proxy: we measure the path to the target itself75		DisableKeepAlives:   true,76		ForceAttemptHTTP2:   true,77		MaxIdleConns:        0,78		TLSHandshakeTimeout: Timeout,79		TLSClientConfig:     &tls.Config{ServerName: t.Hostname, MinVersion: tls.VersionTLS12, RootCAs: testRoots},80		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {81			if fixedIP != "" {82				_, port, err := net.SplitHostPort(addr)83				if err != nil {84					return nil, err85				}86				addr = net.JoinHostPort(fixedIP, port)87			}88			return dialer.DialContext(ctx, network, addr)89		},90	}91	defer tr.CloseIdleConnections()9293	trace := &httptrace.ClientTrace{94		DNSStart: func(httptrace.DNSStartInfo) { dnsStart = time.Now() },95		DNSDone: func(i httptrace.DNSDoneInfo) {96			dnsDone = time.Now()97			if i.Err == nil {98				st = stageConnect99			}100		},101		ConnectStart: func(string, string) {102			connStart = time.Now()103			if st < stageConnect {104				st = stageConnect105			}106		},107		ConnectDone: func(_, addr string, err error) {108			connDone = time.Now()109			if err == nil {110				if h, _, e := net.SplitHostPort(addr); e == nil {111					resolvedIP = h112				}113				st = stageTLS114			}115		},116		TLSHandshakeStart: func() { tlsStart = time.Now(); st = stageTLS },117		TLSHandshakeDone: func(cs tls.ConnectionState, err error) {118			tlsDone = time.Now()119			if err == nil {120				tlsState = &cs121				st = stageRequest122			}123		},124		GotConn: func(i httptrace.GotConnInfo) {125			gotConn = time.Now()126			if resolvedIP == "" && i.Conn != nil {127				if h, _, e := net.SplitHostPort(i.Conn.RemoteAddr().String()); e == nil {128					resolvedIP = h129				}130			}131			st = stageRequest132		},133		WroteRequest:         func(httptrace.WroteRequestInfo) { st = stageResponse },134		GotFirstResponseByte: func() { firstByte = time.Now() },135	}136137	req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, rawURL, nil)138	if err != nil {139		m.Error = protocol.ErrOther140		return m141	}142	req.Header.Set("User-Agent", c.UserAgent)143	req.Header.Set("Accept", "text/html,*/*;q=0.8")144	req.Host = t.Hostname // keep the Host header on the hostname even when dialing a fixed IP145	if req.URL.Hostname() != t.Hostname && t.Hostname != "" {146		// URL host differs from hostname (unusual): trust the URL for the wire, hostname for SNI/Host.147		tr.TLSClientConfig.ServerName = t.Hostname148	}149150	client := &http.Client{151		Transport: tr,152		Timeout:   Timeout,153		CheckRedirect: func(*http.Request, []*http.Request) error {154			return http.ErrUseLastResponse // a 301/302 is a valid answer; never follow155		},156	}157158	resp, err := client.Do(req)159	if err != nil {160		m.Error = classify(err, st)161		fill(&m, start, dnsStart, dnsDone, connStart, connDone, tlsStart, tlsDone, gotConn, firstByte, time.Now(), fixedIP != "")162		m.ResolvedIP = resolvedIP163		if tlsState != nil {164			m.TLSVersion = tlsVersionName(tlsState.Version)165		}166		return m167	}168	// Read at most MaxBody bytes, then close — enough for TTFB/throughput sanity, never a full download.169	_, readErr := io.CopyN(io.Discard, resp.Body, MaxBody)170	resp.Body.Close()171	end := time.Now()172173	fill(&m, start, dnsStart, dnsDone, connStart, connDone, tlsStart, tlsDone, gotConn, firstByte, end, fixedIP != "")174	m.HTTPStatus = protocol.I(resp.StatusCode)175	m.HTTPProto = resp.Proto176	m.ResolvedIP = resolvedIP177	if resp.TLS != nil {178		m.TLSVersion = tlsVersionName(resp.TLS.Version)179	} else if tlsState != nil {180		m.TLSVersion = tlsVersionName(tlsState.Version)181	}182183	switch {184	case resp.StatusCode >= 500:185		m.Error = protocol.ErrHTTP5xx186	case resp.StatusCode >= 400:187		m.Error = protocol.ErrHTTP4xx // informational: ok stays true (connection + TLS worked, status < 500)188		m.OK = true189	default:190		m.OK = true191	}192	if readErr != nil && !errors.Is(readErr, io.EOF) && m.OK {193		// Body truncated by the server mid-way: still a successful HTTP exchange; note a reset only when194		// nothing else was reported.195		if isReset(readErr) && m.Error == "" {196			m.Error = protocol.ErrReset197		}198	}199	return m200}201202func fill(m *protocol.Measurement, start, dnsStart, dnsDone, connStart, connDone, tlsStart, tlsDone, gotConn, firstByte, end time.Time, fixed bool) {203	if !fixed && !dnsStart.IsZero() && !dnsDone.IsZero() {204		m.DNSMs = protocol.F(protocol.Ms(dnsDone.Sub(dnsStart)))205	}206	if !connStart.IsZero() && !connDone.IsZero() {207		m.TCPMs = protocol.F(protocol.Ms(connDone.Sub(connStart)))208	}209	if !tlsStart.IsZero() && !tlsDone.IsZero() {210		m.TLSMs = protocol.F(protocol.Ms(tlsDone.Sub(tlsStart)))211	}212	if !firstByte.IsZero() {213		// TTFB is the conventional time-to-first-byte: from the start of the check (before DNS) to the first214		// response byte, so dns_ms + tcp_ms + tls_ms ≤ ttfb_ms ≤ total_ms as in the protocol example.215		m.TTFBMs = protocol.F(protocol.Ms(firstByte.Sub(start)))216	}217	_ = gotConn218	m.TotalMs = protocol.F(protocol.Ms(end.Sub(start)))219}220221// classify maps a transport error to a protocol error code, using the stage reached for timeouts.222func classify(err error, st stage) string {223	if err == nil {224		return ""225	}226	// Unwrap url.Error and friends.227	var dnsErr *net.DNSError228	if errors.As(err, &dnsErr) {229		return protocol.ErrDNSFail230	}231	if isCertError(err) {232		return protocol.ErrTLSCert233	}234	if isTLSError(err) {235		return protocol.ErrTLSFail236	}237	if errors.Is(err, syscall.ECONNREFUSED) {238		return protocol.ErrTCPRefused239	}240	if isReset(err) {241		if st <= stageConnect {242			return protocol.ErrTCPReset243		}244		return protocol.ErrReset245	}246	if isTimeout(err) {247		switch st {248		case stageDNS:249			return protocol.ErrDNSFail250		case stageConnect:251			return protocol.ErrTCPTimeout252		case stageTLS:253			return protocol.ErrTLSFail254		default:255			return protocol.ErrHTTPTimeout256		}257	}258	if errors.Is(err, syscall.EHOSTUNREACH) || errors.Is(err, syscall.ENETUNREACH) {259		return protocol.ErrTCPTimeout260	}261	if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {262		if st <= stageTLS {263			return protocol.ErrTLSFail264		}265		return protocol.ErrReset266	}267	return protocol.ErrOther268}269270func isTimeout(err error) bool {271	if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {272		return true273	}274	var ne net.Error275	if errors.As(err, &ne) && ne.Timeout() {276		return true277	}278	return strings.Contains(err.Error(), "Client.Timeout exceeded")279}280281func isReset(err error) bool {282	return errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) ||283		strings.Contains(err.Error(), "connection reset")284}285286func isCertError(err error) bool {287	var (288		ua  x509.UnknownAuthorityError289		ci  x509.CertificateInvalidError290		hn  x509.HostnameError291		cve *tls.CertificateVerificationError292	)293	return errors.As(err, &ua) || errors.As(err, &ci) || errors.As(err, &hn) || errors.As(err, &cve) ||294		strings.Contains(err.Error(), "x509:")295}296297func isTLSError(err error) bool {298	var rh tls.RecordHeaderError299	var alert tls.AlertError300	if errors.As(err, &rh) || errors.As(err, &alert) {301		return true302	}303	s := err.Error()304	return strings.Contains(s, "tls:") || strings.Contains(s, "handshake failure") || strings.Contains(s, "remote error")305}306307func tlsVersionName(v uint16) string {308	switch v {309	case tls.VersionTLS13:310		return "TLS1.3"311	case tls.VersionTLS12:312		return "TLS1.2"313	case tls.VersionTLS11:314		return "TLS1.1"315	case tls.VersionTLS10:316		return "TLS1.0"317	default:318		return ""319	}320}321