Eavesdrop on a Golang http.Client

Peter Flood
5 min readNov 3, 2017

--

When debugging http requests I find myself wanting to just see the raw data. I know about httputil.DumpRequest and its partner httputil.DumpResponse, however I’m not keen on them because, by their own admission, their output is not what goes over the wire.

The returned representation is an approximation only; some details of the initial request are lost while parsing it into an http.Request. In particular, the order and case of header field names are lost.

Why is this important? I’ve spent many unnecessary hours debugging errors due to inaccurate information, if I’d had the correct information I would have found the problems much sooner.
An ‘after the fact’ recreation is not a copy of what data was actually exchanged. DumpRequest/DumpResponse are good enough for most cases but let’s just say I’ve learned my lesson the hard way.

GET Request

Here’s how we’re making the request with a client that times out.

t := &http.Transport{
ResponseHeaderTimeout: time.Second * 10 // don't wait forever
}

timeoutClient := &http.Client{
Transport: t,
}

resp, _ := timeoutClient.Get("http://example.com/")
// do something with resp

The Wrapper

Create a net.Conn wrapper that uses old favourites io.TeeReader and io.MultiWriter to output reads and writes to stderr.

// spyConnection wraps a net.Conn, all reads and writes are output to stderr, via WrapConnection().
type spyConnection struct {
net.Conn
io.Reader
io.Writer
}

// Read writes all data read from the underlying connection to sc.Writer.
func (sc *spyConnection) Read(b []byte) (int, error) {
return sc.Reader.Read(b)
}

// Write writes all data written to the underlying connection to sc.Writer.
func (sc *spyConnection) Write(b []byte) (int, error) {
return sc.Writer.Write(b)
}
// WrapConnection wraps an existing connection, all data read/written is written to w (os.Stderr if w == nil).
func WrapConnection(c net.Conn, output io.Writer) net.Conn {
if output == nil {
output = os.Stderr
}
return &spyConnection{
Conn: c,
Reader: io.TeeReader(c, output),
Writer: io.MultiWriter(output, c),
}
}

Now we just have to get the connection in order to wrap it.

Dial

http.Transport takes a Dial function that returns an established connection (a wrapped one in our case).

dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}

dial := func(network, address string) (net.Conn, error) {
conn, err := dialer.Dial(network, address)
if err != nil {
return nil, err
}
return WrapConnection(conn, os.Stderr}, nil // return a wrapped net.Conn
}
t := &http.Transport{
ResponseHeaderTimeout: 10 * time.Second,
DisableCompression: true, // humans can't read a compressed response
Dial: dial, // use our new dial in the existing transport
}

So with this we can eavesdrop on http connections, though unfortunately not ones that utilise https.

TLS

Conveniently http.Transport also lets you provide a DialTLS function in much the same way as the Dial function. Here’s my version that more or less replicates what happens when you don’t provide a DialTLS function.

dialTLS := func(network, address string) (net.Conn, error) {
plainConn, err := dialer.Dial(network, address)
if err != nil {
return nil, err
}

//Initiate TLS and check remote host name against certificate.
cfg := new(tls.Config)

// add https:// to satisfy url.Parse(), we won't use it
u, err := url.Parse(fmt.Sprintf("https://%s", address))
if err != nil {
return nil, err
}

serverName := u.Host[:strings.LastIndex(u.Host, ":")]
cfg.ServerName = serverName

tlsConn := tls.Client(plainConn, cfg)

errc := make(chan error, 2)
timer := time.AfterFunc(time.Second, func() {
errc <- errors.New("TLS handshake timeout")
})
go func() {
err := tlsConn.Handshake()
timer.Stop()
errc <- err
}()
if err := <-errc; err != nil {
plainConn.Close()
return nil, err
}
if !cfg.InsecureSkipVerify {
if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil {
plainConn.Close()
return nil, err
}
}

return WrapConnection(tlsConn, os.Stderr, nil // wrap the resulting conn
}

Now we’re in business.

Working code example

Here’s our complete code (and a playground link that doesn’t work due to limitations of the playground).

package main

import (
"crypto/tls"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)

// WrapConnection wraps an existing connection, all data read/written is written to w (os.Stderr if w == nil).
func WrapConnection(c net.Conn, output io.Writer) net.Conn {
return &spyConnection{
Conn: c,
Reader: io.TeeReader(c, output),
Writer: io.MultiWriter(output, c),
}
}

// spyConnection wraps a net.Conn, all reads and writes are output to stderr, via WrapConnection().
type spyConnection struct {
net.Conn
io.Reader
io.Writer
}

// Read writes all data read from the underlying connection to sc.Writer.
func (sc *spyConnection) Read(b []byte) (int, error) {
return sc.Reader.Read(b)
}

// Write writes all data written to the underlying connection to sc.Writer.
func (sc *spyConnection) Write(b []byte) (int, error) {
return sc.Writer.Write(b)
}
func main() {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}

dial := func(network, address string) (net.Conn, error) {
conn, err := dialer.Dial(network, address)
if err != nil {
return nil, err
}

fmt.Fprint(os.Stderr, fmt.Sprintf("\n%s\n\n", strings.Repeat("-", 80)))
return WrapConnection(conn, os.Stderr), nil // return a wrapped net.Conn
}

dialTLS := func(network, address string) (net.Conn, error) {
plainConn, err := dialer.Dial(network, address)
if err != nil {
return nil, err
}

//Initiate TLS and check remote host name against certificate.
cfg := new(tls.Config)

// add https:// to satisfy url.Parse(), we won't use it
u, err := url.Parse(fmt.Sprintf("https://%s", address))
if err != nil {
return nil, err
}

serverName := u.Host[:strings.LastIndex(u.Host, ":")]
cfg.ServerName = serverName

tlsConn := tls.Client(plainConn, cfg)

errc := make(chan error, 2)
timer := time.AfterFunc(time.Second, func() {
errc <- errors.New("TLS handshake timeout")
})
go func() {
err := tlsConn.Handshake()
timer.Stop()
errc <- err
}()
if err := <-errc; err != nil {
plainConn.Close()
return nil, err
}
if !cfg.InsecureSkipVerify {
if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil {
plainConn.Close()
return nil, err
}
}

fmt.Fprint(os.Stderr, fmt.Sprintf("\n%s\n\n", strings.Repeat("-", 80)))
return WrapConnection(tlsConn, os.Stderr), nil // return a wrapped net.Conn
}

t := &http.Transport{
Dial: dial,
DialTLS: dialTLS,
DisableCompression: true, // humans can't read a compressed response
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}

timeoutClient := &http.Client{
Transport: t,
}

// http
resp, _ := timeoutClient.Get("http://example.com/")
// reads are incomplete unless we force the body to be read
ioutil.ReadAll(resp.Body)
resp.Body.Close()

// https
resp, _ = timeoutClient.Get("https://example.com/")
ioutil.ReadAll(resp.Body)
resp.Body.Close()
time.Sleep(time.Second)
}

Package

Since I started writing this I decided to turn it into a package, see https://github.com/j0hnsmith/connspy.

Beyond http

POP3, IMAP, DNS, redis, you name it… any TCP/IP connection can be wrapped with SpyConnection (to be useful, the protocol should be plaintext). All connections have to call dialer.Dial somewhere along the line and SpyConnection can be used to wrap the resulting net.Conn.

Servers too

The same technique can adapted to net.Listener to be used in servers too but since they usually deal with many requests concurrently, you’ll probably want to write the output for each connection to a separate file. I’ll cover that in a follow up post.

--

--