Websocket proxy sniffer

Guide to write a websocket proxy sniffer in golang

Add a sniffer to your websocket proxy

Since Go 1.12 was released, setting up a reverse websocket proxy is as easy as writing a few lines of code.

Thanks to httputil!

func main() {
	u, err := url.Parse("http://backend:8080")
	if err != nil {
	proxy := httputil.NewSingleHostReverseProxy(u)
	http.ListenAndServe(":8080", proxy)

Internally this works with a hijacked http.ResponseWriter before switching from http to websocket protocol. If you are curious search for http.Hijacker here src/net/http/httputil/reverseproxy.go

So how could we forward websocket requests and at the same time add a sniffer to that connection?

Sniffer API

Let’s start by defining our API as a combination of 2 elements:

  • A callback that will be called every time a request has been hijacked. It will return the request that has been hijacked and two readers corresponding to data received and sent on the hijacked connection.
  • A Sniffer function that will wrap an existing http.Handler with our callback.
type OnHijacked func(r *http.Request, in, out io.Reader)
func Sniffer(h http.Handler, callback OnHijacked) http.Handler 

Now that our API is defined is time to build the code that satisfies it.

Tee-ing a net.Conn

First we will create a net.Conn implementation that will forward any reads or writes to a pair of io.Writer. Same concept that is implemented by io.TeeReader applied to a net.Conn.

func TeeConn(conn net.Conn, in, out io.Writer) net.Conn {
	return &teeConn{
		Conn:   conn,
		reader: io.TeeReader(conn, in),
		writer: io.MultiWriter(conn, out),

type teeConn struct {
	reader io.Reader
	writer io.Writer

func (c *teeConn) Read(p []byte) (n int, err error) {
	return c.reader.Read(p)

func (c *teeConn) Write(p []byte) (n int, err error) {
	return c.writer.Write(p)

Invoke our OnHijacked callback

The next step will be to create a wrapper around an http.ResponseWriter that will invoke our OnHijacked callback whenever a Hijack() is successfully done.

func CallbackHijacker(w http.ResponseWriter, r *http.Request,
	cb OnHijacked) http.ResponseWriter {
	if h, ok := w.(http.Hijacker); ok {
		w = &callbackHijacker{
			ResponseWriter: w,
			hijacker:       h,
			request:        r,
			callback:       cb,
	return w

type callbackHijacker struct {
	hijacker http.Hijacker
	request  *http.Request
	callback OnHijacked

In order to do this we have to override Hijack() with our custom version.

Time to use our awesome TeeConn function! But, wait a moment… our callback works with io.Readers

type OnHijacked func() (r http.Request, in, out io.Reader)

and TeeConn works with io.Writer 😟

func TeeConn(conn net.Conn, in, out io.Writer) net.Conn

No problem at all! This is a perfect task for io.Pipe(). As the doc says, it can be used to connect code expecting an io.Reader with code expecting an io.Writer.

func (h *callbackHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
	conn, buf, err := h.hijacker.Hijack()
	if err != nil {
		return conn, buf, err
	// use `io.Pipe` to connect code expecting an `io.Reader` with code
	// expecting an `io.Writer`
	rIn, wIn := io.Pipe()
	rOut, wOut := io.Pipe()

	// invoke callback
	h.callback(h.request, rIn, rOut)

	// return wrapped conn
	return TeeConn(conn, wIn, wOut), buf, nil

Our Sniffer wrapper

Finally we will create a wrapper around http.Handler (our initially defined Sniffer) that will invoke CallbackHijacker every time ServeHTTP() is called.

func Sniffer(h http.Handler, callback OnHijacked) http.Handler {
	return &sniffer{
		handler:  h,
		callback: callback,

type sniffer struct {
	handler  http.Handler
	callback OnHijacked

func (s *sniffer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w = CallbackHijacker(w, r, s.callback)
	s.handler.ServeHTTP(w, r)

Final look

This is how our sniffer will look like applied to the reverse proxy and connected to echo.websocket.org.

We’ve added Director setting to rewrite requests scheme and host before sending them.

func main() {
	u, err := url.Parse("http://echo.websocket.org")
	if err != nil {
	proxy := httputil.NewSingleHostReverseProxy(u)
	proxy.Director = func(r *http.Request) {
		r.URL.Scheme = u.Scheme
		r.URL.Host = u.Host
		r.Host = u.Host
	handler := Sniffer(proxy, func(r *http.Request, in, out io.Reader) {
		go readLoop(r, in, "<")
		go readLoop(r, out, ">")
	log.Println("listening on :8080")
	http.ListenAndServe(":8080", handler)

func readLoop(req *http.Request, r io.Reader, dir string) {
	data := make([]byte, 1024)
	for {
		n, err := r.Read(data)
		if err != nil {
		log.Printf("%s %s: %x\n", dir, req.RemoteAddr, data[:n])

Source code available in this repository: https://github.com/igolaizola/websocket-proxy-sniffer

Iñigo Garcia Olaizola
Iñigo Garcia Olaizola

software engineer, gopher, triathlete, husband & father.