spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
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