Skip to content

Stuck Hijack() on net.Conn compatible I/O server rnet/http.(*conn).hijackLocked  #48167

@prologic

Description

@prologic

What version of Go are you using (go version)?

$ go version
go version go1.16.6 darwin/amd64

Does this issue reproduce with the latest release?

Yes

What operating system and processor architecture are you using (go env)?

go env Output
$ go env
GO111MODULE=""
GOARCH="amd64"
GOBIN=""
GOCACHE="/Users/prologic/Library/Caches/go-build"
GOENV="/Users/prologic/Library/Application Support/go/env"
GOEXE=""
GOFLAGS=""
GOHOSTARCH="amd64"
GOHOSTOS="darwin"
GOINSECURE=""
GOMODCACHE="/Users/prologic/go/pkg/mod"
GONOPROXY=""
GONOSUMDB=""
GOOS="darwin"
GOPATH="/Users/prologic/go"
GOPRIVATE=""
GOPROXY="https://goproxy.mills.io/"
GOROOT="/usr/local/Cellar/go/1.16.6/libexec"
GOSUMDB="sum.golang.org"
GOTMPDIR=""
GOTOOLDIR="/usr/local/Cellar/go/1.16.6/libexec/pkg/tool/darwin_amd64"
GOVCS=""
GOVERSION="go1.16.6"
GCCGO="gccgo"
AR="ar"
CC="clang"
CXX="clang++"
CGO_ENABLED="1"
GOMOD="/Users/prologic/Projects/go-http-bug/go.mod"
CGO_CFLAGS="-g -O2"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-g -O2"
CGO_FFLAGS="-g -O2"
CGO_LDFLAGS="-g -O2"
PKG_CONFIG="pkg-config"
GOGCCFLAGS="-fPIC -arch x86_64 -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -fdebug-prefix-map=/var/folders/8h/c9gl4gms3fjb0kyf7nb42ybh0000gn/T/go-build2407089176=/tmp/go-build -gno-record-gcc-switches -fno-common"

What did you do?

The follow code (re-pasted here for posterity) exhibits a bug? very similar to this issue that was discussed and closed with no fixes/changes? to the Go std library some years ago.

package main

import (
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httputil"
	"sync"
	"time"

	"github.com/gliderlabs/ssh"
	log "github.com/sirupsen/logrus"
	"github.com/tv42/httpunix"
)

// StdioAddr implements net.Addr to provide an emulated network
// address for use with StdioConn.
type StdioAddr struct {
	id string
}

func (s *StdioAddr) Network() string {
	return "stdio"
}

func (s *StdioAddr) String() string {
	return s.id
}

func NewStdioConn(localID, remoteID string, in io.ReadCloser, out io.WriteCloser) *StdioConn {
	return &StdioConn{
		local:  &StdioAddr{localID},
		remote: &StdioAddr{remoteID},
		in:     in,
		out:    out,
	}
}

// StdioConn implements net.Conn to provide an emulated network
// connection between two processes over stdin/stdout.
type StdioConn struct {
	sync.RWMutex
	readClosed  bool
	writeClosed bool

	local  *StdioAddr
	remote *StdioAddr
	in     io.ReadCloser
	out    io.WriteCloser
}

func (sc *StdioConn) String() string {
	return fmt.Sprintf("%s<->%s", sc.LocalAddr(), sc.RemoteAddr())
}

func (sc *StdioConn) isClosed() bool {
	sc.RLock()
	defer sc.RUnlock()
	return sc.writeClosed && sc.readClosed
}

func (sc *StdioConn) isReadClosed() bool {
	sc.RLock()
	defer sc.RUnlock()
	return sc.readClosed
}

func (sc *StdioConn) isWriteClosed() bool {
	sc.RLock()
	defer sc.RUnlock()
	return sc.writeClosed
}

func (sc *StdioConn) Read(b []byte) (int, error) {
	if sc.isReadClosed() {
		return 0, errors.New("read on closed conn")
	}
	return sc.in.Read(b)
}

func (sc *StdioConn) Write(b []byte) (int, error) {
	if sc.isWriteClosed() {
		return 0, errors.New("write on closed conn")
	}
	return sc.out.Write(b)
}

func (sc *StdioConn) CloseRead() error {
	sc.Lock()
	defer sc.Unlock()
	sc.readClosed = true
	return sc.in.Close()
}

func (sc *StdioConn) CloseWrite() error {
	sc.Lock()
	defer sc.Unlock()
	sc.writeClosed = true
	return sc.out.Close()
}

func (sc *StdioConn) Close() error {
	if err := sc.CloseRead(); err != nil {
		return err
	}

	return sc.CloseWrite()
}

func (sc *StdioConn) LocalAddr() net.Addr {
	return sc.local
}

func (sc *StdioConn) RemoteAddr() net.Addr {
	return sc.remote
}

func (sc *StdioConn) SetDeadline(t time.Time) error {
	return nil
}

func (sc *StdioConn) SetReadDeadline(t time.Time) error {
	return nil
}

func (sc *StdioConn) SetWriteDeadline(t time.Time) error {
	return nil
}

func NewStdioListener(conn *StdioConn) *StdioListener {
	sl := &StdioListener{
		ready: make(chan *StdioConn),
		conn:  conn,
	}
	sl.Close()
	return sl
}

// StdioListener wraps a *StdioConn to implement net.Listener.
type StdioListener struct {
	ready chan *StdioConn
	conn  *StdioConn
}

func (sl *StdioListener) Accept() (net.Conn, error) {
	if sl.conn.isClosed() {
		return nil, errors.New("accept on closed conn")
	}
	return <-sl.ready, nil
}

func (sl *StdioListener) Addr() net.Addr {
	return sl.conn.LocalAddr()
}

func (sl *StdioListener) Close() error {
	if sl.conn.isClosed() {
		return nil
	}

	go func() {
		sl.ready <- sl.conn
	}()
	return nil
}

func main() {
	ssh.Handle(func(sess ssh.Session) {
		switch sess.RawCommand() {
		case "docker system dial-stdio":
		default:
			sess.Write([]byte("This is not a normal SSH server\n"))
			sess.Exit(255)
		}

		conn := NewStdioConn(
			sess.LocalAddr().String(),
			sess.RemoteAddr().String(),
			sess.(io.ReadCloser),
			sess.(io.WriteCloser),
		)
		ln := NewStdioListener(conn)

		t := &httpunix.Transport{
			DialTimeout:           100 * time.Millisecond,
			RequestTimeout:        1 * time.Second,
			ResponseHeaderTimeout: 1 * time.Second,
		}
		t.RegisterLocation("docker", "/var/run/docker.sock")

		proxy := &httputil.ReverseProxy{
			Director: func(req *http.Request) {
				req.URL.Scheme = "http+unix"
				req.URL.Host = "docker"
			},
			Transport: t,
		}

		svr := &http.Server{Handler: proxy}

		if err := svr.Serve(ln); err != nil {
			log.WithError(err).Error("error serving http session")
		}
	})

	log.Fatal(ssh.ListenAndServe(":2222", nil))
}

Compiling and running this:

  1. Ensure you have a Docker daemon running on your system (either Linux or Docker for Desktop)
  2. Compile the above code with Go111Modules enabled
  3. Run without arguments
  4. Connect a docker client with a simple:
    DOCKER_HOST=ssh://localhost:2222 docker run -i --rm alpine echo Hello World

For clarity:

$ mkdir go-http-hijack-bug
$ cd go-http-hijack-bug
$ wget https://gist.github.com/prologic/62dfb545e86aa1d066f8e7e1f3bf8de7/raw/5e0e8abe6572be0afb24116b1fe1c747363ae537/main.go
$ go mod init go-http-hijack-bug
$ go mod tidy
$ go build
$ ./go-http-hijack-bug

And connect a Docker CLI client:

$ DOCKER_HOST=ssh://localhost:2222 docker run -i --rm alpine echo Hello World

What did you expect to see?

I expect to see:

Hello World

from running the Docker CLI client with the above sample.

This should also in turn start and attach to the created container. You can see the container has in fact been created in a "created" state:

$ docker ps -a | head
CONTAINER ID   IMAGE     COMMAND              CREATED              STATUS                    PORTS     NAMES
e2c51aca3d97   alpine    "echo Hello World"   About a minute ago   Created                             zealous_goldberg

What did you see instead?

Observe that the Docker CLI client above "hangs" with the expected output of "Hello World" not shown and the container is never run.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions