Sending http request to SSL server succeeds when client and server are in same process

I have a simple negative test that starts a server with out security and sends a request to the server. Then stops the server and restarts it with SSL. Same http client is used to send the request again, it gets good response (there by failing the test case). It gets a good response even if the server is stopped.

After further investigation I have found that if the response of the first request is NOT read using ioutil.ReadAll, then an error is returned for the second request. It seems like calling ioutil.ReadAll keeps the connection alive. I have looked at the source code but could not find the root cause.

Any idea what might be happening?

My test is as follows:

package main
import (
	"fmt"
	"testing"
)

func TestGet(t *testing.T) {
	port = 8839
	url := "http://localhost:8839/hello"

	// start server without security
	startServer(false)

	// Get a non-secure client (without tls credentials)
	netClient := getClient()
	fmt.Println("Sending request to ", url)
	response, err := netClient.Get(url)
	if err != nil {
		t.Fatalf("Error sending request: %s", err)
	}
	fmt.Println("Response: ", response)
	// If you comment this line test will succeed else it fails
	readResponse(response)

	stopServer()

	// start server with security
	startServer(true)
	defer func() {
		stopServer()
	}()

	// use the same client to send request to secure server
	fmt.Println("Sending request to ", url)
	response, err = netClient.Get(url)
	if err == nil {
		t.Error("Expected failure sending a request to a SSL server from a non-SSL client")
		fmt.Println("Response: ", response)
		readResponse(response)
	} else {
		fmt.Println("Expected error: ", err)
	}
}

The code is:

package main
import (
	"crypto/tls"
	"fmt"
	"io/ioutil"
	"log"
	"net"
	"net/http"
	"os"
	"strconv"
	"time"
)

// only needed below for sample processing
var listener net.Listener
var port int

func startHTTPServer(secure bool) {
	mux := http.NewServeMux()
	mux.Handle("/hello", &handler{})
	addr := net.JoinHostPort("0.0.0.0", strconv.Itoa(port))
	if secure {
		config, err := getTLSConfig("server.pem", "server.key")
		if err != nil {
			log.Fatal("tls config: ", err)
		}
		listener, err = tls.Listen("tcp", addr, config)
		if err != nil {
			log.Fatal("listen: ", err)
		}
	} else {
		listener, _ = net.Listen("tcp", addr)
	}
	http.Serve(listener, mux)
}

func getTLSConfig(certfile, keyfile string) (*tls.Config, error) {
	cer, err := tls.LoadX509KeyPair("server.pem", "server.key")
	if err != nil {
		return nil, err
	}
	config := &tls.Config{
		Certificates: []tls.Certificate{cer},
		ClientAuth:   tls.NoClientCert,
		MinVersion:   tls.VersionTLS12,
		MaxVersion:   tls.VersionTLS12,
	}
	return config, nil
}

func stopServer() {
	fmt.Println("Stopping server")
	if listener != nil {
		listener.Close()
	}
	fmt.Println("Sleeping 5 seconds for server to stop")
	time.Sleep(time.Second * 5)
}

func startServer(secure bool) {
	if secure {
		fmt.Println("Starting https server")
	} else {
		fmt.Println("Starting http server")
	}
	go func() {
		startHTTPServer(secure)
	}()
	fmt.Println("Sleeping 5 seconds for server to start")
	time.Sleep(time.Second * 5)
}

type handler struct{}

func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.Write([]byte("This is an example server.\n"))
}

func getClient() *http.Client {
	tr := new(http.Transport)
	//rootCAPool := x509.NewCertPool()
	//cacert, err := ioutil.ReadFile("root.pem")
	//if err != nil {
	//	fmt.Printf("Failed to read '%s': %s\n", cacert, err)
	//}
	//ok := rootCAPool.AppendCertsFromPEM(cacert)
	//if !ok {
	//	fmt.Printf("Failed to process certificate from file %s\n", cacert)
	//}
	//tr.TLSClientConfig = &tls.Config{
	//	RootCAs: rootCAPool,
	//}
	var netClient = &http.Client{Transport: tr, Timeout: time.Second * 10}
	return netClient
}

func getRequest(url string) (*http.Request, error) {
	req, err := http.NewRequest("POST", url, nil)
	if err != nil {
		return nil, err
	}
	return req, nil
}

func readResponse(resp *http.Response) error {
	if resp.Body != nil {
		respBody, err := ioutil.ReadAll(resp.Body)
		defer func() {
			err := resp.Body.Close()
			if err != nil {
				fmt.Printf("Failed to close the response body: %s", err.Error())
			}
		}()
		if err != nil {
			return err
		}
		fmt.Println("Response: ", respBody)
	}
	return nil
}

func main() {
	port = 8839
	startServer(false)
	url := "http://localhost:8839/hello"
	netClient := getClient()
	fmt.Println("Sending request to ", url)
	req, err := getRequest(url)
	resp, err := netClient.Do(req)
	if err != nil {
		fmt.Println("Error was received: ", err)
		os.Exit(1)
	}
	readResponse(resp)

	stopServer()
	startServer(true)

	fmt.Println("Sending request to ", url)
	resp, err = netClient.Do(req)
	if err != nil {
		fmt.Println("Error was received: ", err)
		os.Exit(1)
	}
	readResponse(resp)
}

The idle (kept-alive) connection(s) can be closed by running

netClient.Transport.(*http.Transport).CloseIdleConnections()

in-between the stopServer() and startServer(true) calls in the test.

Thanks for the response. I don’t have to call CloseIdleConnections if the server and client are in two different processes. I am wondering why the behavior is different if server and client are in same process vs in two different processes.

I see the same behavior using two processes that I see in a single process.

For the two process setup I ran the server with:

func main() {
	port = 8839
	startServer(false)

	// keep the server running
	c := make(chan struct{})
	<-c
}

after modifying the handler to switch from http to https after the first request:

func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.Write([]byte("This is an example server.\n"))

	go func() {
		stopServer()
		startServer(true)
	}()
}

For the client I used:

func main() {
	port = 8839
	url := "http://localhost:8839/hello"
	netClient := getClient()
	fmt.Println("Sending request to ", url)
	req, err := getRequest(url)
	resp, err := netClient.Do(req)
	if err != nil {
		fmt.Println("Error was received: ", err)
		os.Exit(1)
	}
	readResponse(resp)

	time.Sleep(10 * time.Second)

	// netClient.Transport.(*http.Transport).CloseIdleConnections()

	fmt.Println("Sending request to ", url)
	resp, err = netClient.Do(req)
	if err != nil {
		fmt.Println("Error was received: ", err)
		os.Exit(1)
	}
	readResponse(resp)
}

Running the client I get:

$ go run client.go
Sending request to  http://localhost:8839/hello
Response:  This is an example server.

Sending request to  http://localhost:8839/hello
Response:  This is an example server.

After uncommenting CloseIdleConnections (and restarting the server), I get:

go run client.go
Sending request to  http://localhost:8839/hello
Response:  This is an example server.

Sending request to  http://localhost:8839/hello
Error was received:  Post http://localhost:8839/hello: malformed HTTP response "\x15\x03\x01\x00\x02\x02\x16"
exit status 1

The behavior seems consistent to me.

OK. I was starting the server without security, killing the process and then starting it with security. You are keeping the process up but stopping and starting the server. I guess that is the key difference.

In my original post, i mentioned that if the response stream is not read, then I don’t have to call CloseIdleConnections for things to work as expected. Do you know why?

Thank you.
Regards,
Anil

If the first response body is not read, the response (and its associated connection) is still alive when the second request is made. Since that connection is being used, netClient has to make a new connection.

If the first response body is read, the connection is no longer needed to service that response, so it is marked as idle. As the connection is now idle, the second request uses it (for better performance).

Thanks again for the response. One last question. Why is that listener.Close() not closing all the open connections , there by invalidating the connections held in the client’s connection pool? If this has happened, it will prevent client using stale connections.

A listener is more like a queue of connections to be handled. Once a connection is accepted, it is removed from the listener’s queue and becomes the responsibility of whatever accepted the connection.

For what it’s worth, some of this is mitigated by using a new server instance on a random port whenever you start a new test. There are helpers for this in the net/http/httptest package. There is no possibility of connection reuse in that case.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.