TLS is best known as the layer that secures HTTPS, but its name β€” Transport Layer Security β€” describes a broader role: it encrypts and signs arbitrary socket traffic, making it a secure version of TCP rather than of HTTP specifically. Protocols such as gRPC rely on it for security. The same machinery used to build HTTPS servers in Go can be used to wrap a custom network protocol, with the socket-level code differing only in the listen and dial calls.

An encrypted echo server

The server below accepts concurrent client connections and mirrors back everything a client sends, until the client closes its side of the connection. Structurally it matches a plain TCP server, except that net.Listen becomes tls.Listen, which takes an accompanying tls.Config.

func main() {
  port := flag.String("port", "4040", "listening port")
  certFile := flag.String("cert", "cert.pem", "certificate PEM file")
  keyFile := flag.String("key", "key.pem", "key PEM file")
  flag.Parse()

  cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
  if err != nil {
    log.Fatal(err)
  }
  config := &tls.Config{Certificates: []tls.Certificate{cert}}

  log.Printf("listening on port %s\n", *port)
  l, err := tls.Listen("tcp", ":"+*port, config)
  if err != nil {
    log.Fatal(err)
  }
  defer l.Close()

  for {
    conn, err := l.Accept()
    if err != nil {
      log.Fatal(err)
    }
    log.Printf("accepted connection from %s\n", conn.RemoteAddr())

    go func(c net.Conn) {
      io.Copy(c, c)
      c.Close()
      log.Printf("closing connection from %s\n", conn.RemoteAddr())
    }(conn)
  }
}

The certificate/key pair the config refers to can come from the certificate-generating tooling used for HTTPS servers or from mkcert.

The client side

The client is likewise a near-copy of its non-TLS counterpart, with net.Dial swapped for tls.Dial and a tls.Config populated with a certificate the client is willing to trust. That certificate may be the server's own, the certificate of a CA that signed the server's certificate, or anything else in the chain of trust.

func main() {
  port := flag.String("port", "4040", "port to connect")
  certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
  flag.Parse()

  cert, err := os.ReadFile(*certFile)
  if err != nil {
    log.Fatal(err)
  }
  certPool := x509.NewCertPool()
  if ok := certPool.AppendCertsFromPEM(cert); !ok {
    log.Fatalf("unable to parse cert from %s", *certFile)
  }
  config := &tls.Config{RootCAs: certPool}

  conn, err := tls.Dial("tcp", "localhost:"+*port, config)
  if err != nil {
    log.Fatal(err)
  }

  _, err = io.WriteString(conn, "Hello simple secure Server\n")
  if err != nil {
    log.Fatal("client write error:", err)
  }
  if err = conn.CloseWrite(); err != nil {
    log.Fatal(err)
  }

  buf := make([]byte, 256)
  n, err := conn.Read(buf)
  if err != nil && err != io.EOF {
    log.Fatal(err)
  }

  fmt.Println("client read:", string(buf[:n]))
  conn.Close()
}

Inspecting certificate chains with tls-dial-port

A small utility called tls-dial-port opens a TLS connection to a given IP address and prints the certificates the server presents:

func main() {
  addr := flag.String("addr", "localhost:4040", "dial address")
  flag.Parse()

  cfg := tls.Config{}
  conn, err := tls.Dial("tcp", *addr, &cfg)
  if err != nil {
    log.Fatal("TLS connection failed: " + err.Error())
  }
  defer conn.Close()

  certChain := conn.ConnectionState().PeerCertificates
  for i, cert := range certChain {
    fmt.Println(i)
    fmt.Println("Issuer:", cert.Issuer)
    fmt.Println("Subject:", cert.Subject)
    fmt.Println("Version:", cert.Version)
    fmt.Println("NotAfter:", cert.NotAfter)
    fmt.Println("DNS names:", cert.DNSNames)
    fmt.Println("")
  }
}

The program trusts only the system defaults, so a self-signed server certificate will be rejected unless the certificate is in the system root store β€” as one generated by mkcert would be.

Start the TLS socket server using an mkcert-generated certificate:

$ mkcert localhost

Created a new certificate valid for the following names πŸ“œ
 - "localhost"

The certificate is at "./localhost.pem" and the key at "./localhost-key.pem" βœ…

It will expire on 7 July 2023 πŸ—“

$ go run tls-socket-server.go -cert localhost.pem -key localhost-key.pem
2021/04/07 06:27:20 listening on port 4040

Then, from another terminal, run tls-dial-port:

$ go run tls-dial-port.go -addr localhost:4040
0
Issuer: CN=mkcert eliben@salami (Eli Bendersky),OU=eliben@salami (Eli Bendersky),O=mkcert development CA
Subject: OU=eliben@salami (Eli Bendersky),O=mkcert development certificate
Version: 3
NotAfter: 2023-07-07 13:27:12 +0000 UTC
DNS names: [localhost]

The output shows the certificate mkcert produced. Because mkcert installs its certificate into the machine's system root store, the default settings of tls.Dial trust it.

The same utility can be aimed at port 443 on public sites:

$ go run tls-dial-port.go -addr reddit.com:443
0
Issuer: CN=DigiCert TLS RSA SHA256 2020 CA1,O=DigiCert Inc,C=US
Subject: CN=*.reddit.com,O=Reddit Inc.,L=San Francisco,ST=California,C=US
Version: 3
NotAfter: 2021-07-06 23:59:59 +0000 UTC
DNS names: [reddit.com *.reddit.com]

1
Issuer: CN=DigiCert Global Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US
Subject: CN=DigiCert TLS RSA SHA256 2020 CA1,O=DigiCert Inc,C=US
Version: 3
NotAfter: 2030-09-23 23:59:59 +0000 UTC
DNS names: []
[1]Prior to the TLS standards, an older version of the protocol was called SSL - Secure Socket Layer, which is even more suggestive of this use.
[2]Note that here I show the machinery for a client to trust the server, not the other way around. In the HTTPS post we've also seen how to set up mTLS for mutual authentication, and the same technique can easily be applied here.