git » gofer » commit a94f402

http: Implement automatic SSL certificates (ACME) support

author Alberto Bertogli
2022-09-29 19:23:58 UTC
committer Alberto Bertogli
2022-10-09 11:34:34 UTC
parent 49127f1e6b8eab53ac22760e0d1ef1b9532d977a

http: Implement automatic SSL certificates (ACME) support

This patch implements support for getting SSL certificates
automatically, using an ACME provider such as Let's Encrypt.

It adds new configuration options to enable it, and uses
golang.org/x/crypto/acme/autocert to obtain the certificates.

config/config.go +13 -5
etc/gofer.schema.cue +8 -2
etc/gofer.yaml +22 -1
go.mod +6 -0
go.sum +7 -0
server/http.go +5 -9
server/raw.go +1 -1
test/01-fe.yaml +9 -0
test/test.sh +25 -4
test/util/acmesrv/acmesrv.go +338 -0
test/util/lib.sh +9 -1
util/testdata/badcerts/badcerts.com/fullchain.pem +1 -0
util/testdata/badcerts/badcerts.com/privkey.pem +1 -0
util/testdata/certs/nofullchain.com/README +1 -0
util/testdata/certs/noprivkey.com/README +1 -0
util/testdata/certs/noprivkey.com/fullchain.pem +0 -0
util/testdata/certs/notadir.com +1 -0
util/testdata/empty/README +1 -0
util/util.go +97 -3
util/util_test.go +103 -0

diff --git a/config/config.go b/config/config.go
index 0dd84a2..b4498cf 100644
--- a/config/config.go
+++ b/config/config.go
@@ -32,8 +32,16 @@ type HTTP struct {
 }
 
 type HTTPS struct {
-	HTTP  `yaml:",inline"`
-	Certs string `yaml:",omitempty"`
+	HTTP      `yaml:",inline"`
+	Certs     string    `yaml:",omitempty"`
+	AutoCerts AutoCerts `yaml:"autocerts,omitempty"`
+}
+
+type AutoCerts struct {
+	Hosts    []string `yaml:",omitempty"`
+	CacheDir string   `yaml:",omitempty"`
+	Email    string   `yaml:",omitempty"`
+	AcmeURL  string   `yaml:",omitempty"`
 }
 
 type Route struct {
@@ -87,10 +95,10 @@ func (c Config) Check() []error {
 	for addr, h := range c.HTTPS {
 		errs = append(errs, h.Check(c, addr)...)
 
-		// Certs must be set for HTTPS.
-		if h.Certs == "" {
+		// For HTTPS, either Certs or AutoCerts must be set.
+		if h.Certs == "" && len(h.AutoCerts.Hosts) == 0 {
 			errs = append(errs,
-				fmt.Errorf("%q: certs must be set", addr))
+				fmt.Errorf("%q: certs or autocerts must be set", addr))
 		}
 	}
 
diff --git a/etc/gofer.schema.cue b/etc/gofer.schema.cue
index 239d528..ecf783a 100644
--- a/etc/gofer.schema.cue
+++ b/etc/gofer.schema.cue
@@ -19,7 +19,14 @@ http?:
 
 https?:
 	[string]: close(#http & {
-		certs: string
+		certs?: string
+
+		autocerts?: {
+			hosts: [string, ...string]
+			cachedir?: string
+			email?:    string
+			acmeurl?:  string
+		}
 	})
 
 #http: {
@@ -42,7 +49,6 @@ https?:
 		if diropts != _|_ {
 			dir: string
 		}
-
 	}
 
 	auth?: [string]: string
diff --git a/etc/gofer.yaml b/etc/gofer.yaml
index e908a24..998bb9e 100644
--- a/etc/gofer.yaml
+++ b/etc/gofer.yaml
@@ -86,8 +86,29 @@ http:
 # HTTPS servers.
 https:
   "&https":
+    # Automatically get TLS certificates.
+    # Using this implies acceptance of LetsEncrypt's terms of service (or the
+    # selected CA).
+    autocerts:
+      # Hosts to get certificates for.
+      hosts: ["mysite.com", "www.mysite.com"]
+
+      # Where to cache the certificates.
+      # Default: $HOME/.cache/golang-autocert.
+      #cachedir: "/var/cache/gofer/autocerts"
+
+      # Contact email address. The CA can use this to notify about problems.
+      # Optional.
+      #email: "me@myhost.com"
+
+      # ACME directory URL to use.
+      # Default: LetsEncrypt's.
+      #acmeurl: "https://acme-v02.api.letsencrypt.org/directory"
+
     # Location of the certificates, for TLS.
-    certs: "/etc/letsencrypt/live/"
+    # Use this instead of `autocerts` if you get the certificates externally.
+    # If you set this, `autocerts` is ignored.
+    #certs: "/etc/letsencrypt/live/"
 
     # The rest of the fields are the same as for http above.
     routes:
diff --git a/go.mod b/go.mod
index bb59d83..308f282 100644
--- a/go.mod
+++ b/go.mod
@@ -8,3 +8,9 @@ require (
 	github.com/google/go-cmp v0.4.1
 	gopkg.in/yaml.v3 v3.0.1
 )
+
+require (
+	golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be // indirect
+	golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
+	golang.org/x/text v0.3.6 // indirect
+)
diff --git a/go.sum b/go.sum
index ae7c7a9..dbefc73 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,13 @@ blitiri.com.ar/go/systemd v1.1.0 h1:AMr7Ce/5CkvLZvGxsn/ZOagzFf3zU13rcgWdlbWMQ+Y=
 blitiri.com.ar/go/systemd v1.1.0/go.mod h1:0D9Ttrh+TX+WuKQ/dJpdhFND7NYy505v6jhsWrihmPY=
 github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0=
 github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
+golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/server/http.go b/server/http.go
index bed2160..87aaf1b 100644
--- a/server/http.go
+++ b/server/http.go
@@ -133,16 +133,16 @@ func HTTP(addr string, conf config.HTTP) {
 	if err != nil {
 		log.Fatalf("%s error listening: %v", addr, err)
 	}
-	log.Infof("%s http proxy starting on %q", addr, lis.Addr())
+	log.Infof("%s http starting on %q", addr, lis.Addr())
 	err = srv.Serve(lis)
-	log.Fatalf("%s http proxy exited: %v", addr, err)
+	log.Fatalf("%s http exited: %v", addr, err)
 }
 
 func HTTPS(addr string, conf config.HTTPS) {
 	var err error
 	srv := httpServer(addr, conf.HTTP)
 
-	srv.TLSConfig, err = util.LoadCerts(conf.Certs)
+	srv.TLSConfig, err = util.LoadCertsForHTTPS(conf)
 	if err != nil {
 		log.Fatalf("%s error loading certs: %v", addr, err)
 	}
@@ -152,15 +152,11 @@ func HTTPS(addr string, conf config.HTTPS) {
 		log.Fatalf("%s error listening: %v", addr, err)
 	}
 
-	// We need to set the NextProtos manually before creating the TLS
-	// listener, the library cannot help us with this.
-	srv.TLSConfig.NextProtos = append(srv.TLSConfig.NextProtos,
-		"h2", "http/1.1")
 	lis := tls.NewListener(rawLis, srv.TLSConfig)
 
-	log.Infof("%s https proxy starting on %q", addr, lis.Addr())
+	log.Infof("%s https starting on %q", addr, lis.Addr())
 	err = srv.Serve(lis)
-	log.Fatalf("%s https proxy exited: %v", addr, err)
+	log.Fatalf("%s https exited: %v", addr, err)
 }
 
 // joinPath joins to HTTP paths. We can't use path.Join because it strips the
diff --git a/server/raw.go b/server/raw.go
index 0e6cea3..37196ba 100644
--- a/server/raw.go
+++ b/server/raw.go
@@ -19,7 +19,7 @@ func Raw(addr string, conf config.Raw) {
 
 	var tlsConfig *tls.Config
 	if conf.Certs != "" {
-		tlsConfig, err = util.LoadCerts(conf.Certs)
+		tlsConfig, err = util.LoadCertsFromDir(conf.Certs)
 		if err != nil {
 			log.Fatalf("error loading certs: %v", err)
 		}
diff --git a/test/01-fe.yaml b/test/01-fe.yaml
index d9294f1..2073b27 100644
--- a/test/01-fe.yaml
+++ b/test/01-fe.yaml
@@ -38,6 +38,15 @@ https:
     reqlog:
       "/": "requests"
 
+  ":8443":
+    autocerts:
+      hosts: ["miau.com"]
+      acmeurl: "http://localhost:8460/directory"
+      cachedir: ".autocerts-cache"
+    routes: *routes
+    reqlog:
+      "/": "requests"
+
 
 # Raw proxy to the same backend.
 raw:
diff --git a/test/test.sh b/test/test.sh
index 7f6732b..ec992fc 100755
--- a/test/test.sh
+++ b/test/test.sh
@@ -13,22 +13,25 @@ build
 # Remove old request log files, since we will be checking their contents.
 rm -f .01-fe.requests.log .01-be.requests.log
 
+# Make sure we don't accidentally use this from the caller.
+unset CACERT
+
 # Launch the backend serving static files and CGI.
-gofer_bg -v=3 -logfile=.01-be.log -configfile=01-be.yaml
+gofer_bg -v=1 -logfile=.01-be.log -configfile=01-be.yaml
 BE_PID=$PID
 wait_until_ready 8450
 
 # Launch the test instance.
 generate_certs
-gofer_bg -v=3 -logfile=.01-fe.log -configfile=01-fe.yaml
+gofer_bg -v=1 -logfile=.01-fe.log -configfile=01-fe.yaml
 FE_PID=$PID
 wait_until_ready 8441  # http
-wait_until_ready 8442  # https
+wait_until_ready 8442  # https (cert files)
+wait_until_ready 8443  # https (autocert)
 wait_until_ready 8445  # raw
 
 snoop
 
-
 #
 # Test cases.
 #
@@ -158,6 +161,24 @@ do
 done
 
 
+echo "### Autocert"
+# Launch the test ACME server.
+acmesrv &
+wait_until_ready 8460
+
+# exp takes the CA cert from this variable.
+# It is generated by acmesrv on startup.
+CACERT=".acmesrv.cert"
+
+# miau.com is what we configure the frontend to serve and request a cert for.
+base="https://miau.com:8443"
+
+exp $base/file -forcelocalhost -body "ñaca\n"
+exp $base/dir/ñaca -forcelocalhost -body "tracañaca\n"
+
+unset CACERT
+
+
 echo "### Request log"
 function logtest() {
 	exp http://localhost:8441/cgi/logtest
diff --git a/test/util/acmesrv/acmesrv.go b/test/util/acmesrv/acmesrv.go
new file mode 100644
index 0000000..33f23f5
--- /dev/null
+++ b/test/util/acmesrv/acmesrv.go
@@ -0,0 +1,338 @@
+// ACME (RFC 8555) server, for testing purposes only.
+package main
+
+import (
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"flag"
+	"fmt"
+	"math/big"
+	"net"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var (
+	addr = flag.String("addr", "", "address to listen on")
+
+	caCertFile = flag.String("cacert_file", ".acmesrv.cert",
+		"file to write the CA certificate to")
+)
+
+type Server struct {
+	lis net.Listener
+
+	caKey  *ecdsa.PrivateKey
+	caCert []byte
+	caTmpl *x509.Certificate
+
+	orderID int
+
+	// Order ID -> Certificate.
+	orderCert map[int][]byte
+}
+
+func NewServer(addr string) (*Server, error) {
+	lis, err := net.Listen("tcp", addr)
+	if err != nil {
+		return nil, err
+	}
+
+	s := &Server{
+		lis:       lis,
+		orderCert: map[int][]byte{},
+
+		// Start with a high order ID to make debugging easier.
+		orderID: 2000,
+	}
+
+	// Generate root.
+	s.caKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+	s.caTmpl = &x509.Certificate{
+		IsCA:         true,
+		SerialNumber: big.NewInt(time.Now().UnixNano()),
+		Subject: pkix.Name{
+			Organization: []string{"acmesrv"},
+			CommonName:   "acmesrv CA",
+		},
+		BasicConstraintsValid: true,
+		KeyUsage:              x509.KeyUsageCertSign,
+
+		// Make this live longer than the leaf certs.
+		NotBefore: time.Now(),
+		NotAfter:  time.Now().Add(365 * 24 * time.Hour),
+	}
+
+	s.caCert, err = x509.CreateCertificate(
+		rand.Reader, s.caTmpl, s.caTmpl, &(s.caKey.PublicKey), s.caKey)
+	if err != nil {
+		return nil, err
+	}
+
+	return s, nil
+}
+
+func (s *Server) url() string {
+	return "http://" + s.lis.Addr().String()
+}
+
+func (s *Server) orderurl(path string, id int) string {
+	return fmt.Sprintf("%s/%s/%d", s.url(), path, id)
+}
+
+// Get an order ID from the request's URL.
+func getOID(r *http.Request) int {
+	// Example: http://blah/order/1234
+	id, err := strconv.Atoi(strings.Split(r.URL.Path, "/")[2])
+	if err != nil {
+		panic(err)
+	}
+	return id
+}
+
+func (s *Server) directory(w http.ResponseWriter, r *http.Request) {
+	// https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1
+	url := s.url()
+	resp := &struct {
+		NewNonce   string `json:"newNonce"`
+		NewAccount string `json:"newAccount"`
+		NewOrder   string `json:"newOrder"`
+		NewAuthz   string `json:"newAuthz"`
+	}{
+		NewNonce:   url + "/new-nonce",
+		NewAccount: url + "/new-acct",
+		NewOrder:   url + "/new-order",
+		NewAuthz:   url + "/new-authz", // Not needed.
+	}
+	json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) newNonce(w http.ResponseWriter, r *http.Request) {
+	// https://www.rfc-editor.org/rfc/rfc8555#section-7.2
+	w.Header().Set("Replay-Nonce", "test-nonce")
+}
+
+func (s *Server) newAccount(w http.ResponseWriter, r *http.Request) {
+	logPayload(r)
+
+	// https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3
+	w.Header().Set("Replay-Nonce", "test-nonce")
+	w.Header().Set("Location", s.url()+"/acct/a1111")
+	w.WriteHeader(http.StatusCreated)
+	w.Write([]byte("{}"))
+
+}
+
+func (s *Server) newOrder(w http.ResponseWriter, r *http.Request) {
+	// https://www.rfc-editor.org/rfc/rfc8555#section-7.4
+	logPayload(r)
+
+	oid := s.orderID
+	s.orderID++
+
+	w.Header().Set("Replay-Nonce", "test-nonce")
+	w.Header().Set("Location", s.orderurl("orders", oid))
+	w.WriteHeader(http.StatusCreated)
+
+	resp := struct {
+		Status   string   `json:"status"`
+		Auths    []string `json:"authorizations"`
+		Finalize string   `json:"finalize"`
+	}{
+		Status:   "pending",
+		Auths:    append([]string{}, s.orderurl("auth", oid)),
+		Finalize: s.orderurl("finalize", oid),
+	}
+	json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) auth(w http.ResponseWriter, r *http.Request) {
+	// https://www.rfc-editor.org/rfc/rfc8555#section-7.5
+	logPayload(r)
+
+	w.Header().Set("Replay-Nonce", "test-nonce")
+
+	resp := struct {
+		Status string `json:"status"`
+	}{
+		Status: "valid",
+	}
+	json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) orders(w http.ResponseWriter, r *http.Request) {
+	logPayload(r)
+
+	oid := getOID(r)
+	w.Header().Set("Replay-Nonce", "test-nonce")
+	resp := struct {
+		Status   string `json:"status"`
+		Finalize string `json:"finalize"`
+		Cert     string `json:"certificate"`
+	}{
+		Status:   "valid",
+		Finalize: s.orderurl("finalize", oid),
+		Cert:     s.orderurl("cert", oid),
+	}
+	json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) finalize(w http.ResponseWriter, r *http.Request) {
+	// https://www.rfc-editor.org/rfc/rfc8555#section-7.4
+	oid := getOID(r)
+	req := struct {
+		CSR string
+	}{}
+	decodePayload(r, &req)
+	b, _ := base64.RawURLEncoding.DecodeString(req.CSR)
+	csr, err := x509.ParseCertificateRequest(b)
+	if err != nil {
+		panic(err)
+	}
+	fmt.Printf("  csr for %v\n", csr.DNSNames)
+	s.generateCert(oid, csr)
+
+	w.Header().Set("Replay-Nonce", "test-nonce")
+	resp := struct {
+		Status   string `json:"status"`
+		Finalize string `json:"finalize"`
+		Cert     string `json:"certificate"`
+	}{
+		Status:   "valid",
+		Finalize: s.orderurl("finalize", oid),
+		Cert:     s.orderurl("cert", oid),
+	}
+	json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) generateCert(oid int, csr *x509.CertificateRequest) {
+	leaf := &x509.Certificate{
+		SerialNumber: big.NewInt(int64(oid)),
+		Subject:      pkix.Name{Organization: []string{"acmesrv"}},
+		KeyUsage:     x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		DNSNames:     csr.DNSNames,
+
+		// Make the certificate long-lived, otherwise we may hit autocert's
+		// renewal window, and cause it to continuously renew the certificates.
+		NotBefore: time.Now(),
+		NotAfter:  time.Now().Add(90 * 24 * time.Hour),
+
+		BasicConstraintsValid: true,
+	}
+
+	cert, err := x509.CreateCertificate(
+		rand.Reader, leaf, s.caTmpl, csr.PublicKey, s.caKey)
+	if err != nil {
+		panic(err)
+	}
+	s.orderCert[oid] = cert
+}
+
+func (s *Server) cert(w http.ResponseWriter, r *http.Request) {
+	// https://www.rfc-editor.org/rfc/rfc8555#section-7.4.2
+	logPayload(r)
+	oid := getOID(r)
+	w.Header().Set("Replay-Nonce", "test-nonce")
+	w.Header().Set("Content-Type", "application/pem-certificate-chain")
+	pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: s.orderCert[oid]})
+	pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: s.caCert})
+}
+
+func (s *Server) writeCACert() {
+	f, err := os.Create(*caCertFile)
+	if err != nil {
+		panic(err)
+	}
+	defer f.Close()
+	pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: s.caCert})
+}
+
+func (s *Server) newAuthz(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Replay-Nonce", "test-nonce")
+}
+
+func (s *Server) root(w http.ResponseWriter, r *http.Request) {
+	http.Error(w, "not found", http.StatusNotFound)
+}
+
+func (s *Server) Serve() {
+	s.writeCACert()
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/directory", s.directory)
+	mux.HandleFunc("/new-nonce", s.newNonce)
+	mux.HandleFunc("/new-acct", s.newAccount)
+	mux.HandleFunc("/new-order", s.newOrder)
+	mux.HandleFunc("/auth/", s.auth)
+	mux.HandleFunc("/orders/", s.orders)
+	mux.HandleFunc("/finalize/", s.finalize)
+	mux.HandleFunc("/cert/", s.cert)
+	mux.HandleFunc("/", s.root)
+
+	http.Serve(s.lis, withLogging(mux))
+}
+
+func withLogging(parent http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Printf("%s %s %s %s %s\n",
+			time.Now().Format(time.StampMilli),
+			r.RemoteAddr, r.Proto, r.Method, r.URL.String())
+		parent.ServeHTTP(w, r)
+	})
+}
+
+func readPayload(r *http.Request) []byte {
+	// Body has a JSON with a "payload" message, which is a base64-encoded
+	// JSON message with the actual content.
+	req := struct{ Payload string }{}
+	err := json.NewDecoder(r.Body).Decode(&req)
+	if err != nil {
+		panic(err)
+	}
+
+	payload, err := base64.RawURLEncoding.DecodeString(req.Payload)
+	if err != nil {
+		panic(err)
+	}
+
+	return payload
+}
+
+func decodePayload(r *http.Request, v interface{}) {
+	payload := readPayload(r)
+	err := json.Unmarshal(payload, v)
+	if err != nil {
+		panic(err)
+	}
+}
+
+func logPayload(r *http.Request) {
+	payload := readPayload(r)
+	fmt.Printf("  %s\n", payload)
+}
+
+func main() {
+	flag.Parse()
+
+	srv, err := NewServer(*addr)
+	if err != nil {
+		panic(err)
+	}
+
+	fmt.Printf("%s/directory\n", srv.url())
+	fmt.Printf("CA SN: %#v\n", srv.caTmpl.SerialNumber)
+	srv.Serve()
+}
diff --git a/test/util/lib.sh b/test/util/lib.sh
index 067d1fa..4a90893 100644
--- a/test/util/lib.sh
+++ b/test/util/lib.sh
@@ -51,6 +51,14 @@ function gofer_bg() {
 	PID=$!
 }
 
+function acmesrv() {
+	# Remove the cache before launching the ACME server, otherwise clients
+	# won't reach out to it.
+	rm -rf .autocerts-cache/
+	go run ${UTILDIR}/acmesrv/acmesrv.go \
+		-addr=localhost:8460 > .acmesrv.log
+}
+
 # Wait until there's something listening on the given port.
 function wait_until_ready() {
 	PORT=$1
@@ -77,7 +85,7 @@ function exp() {
 
 	${UTILDIR}/exp/exp "$@" \
 		$VF \
-		-cacert=".certs/localhost/fullchain.pem"
+		-cacert="${CACERT:-.certs/localhost/fullchain.pem}"
 }
 
 function snoop() {
diff --git a/util/testdata/badcerts/badcerts.com/fullchain.pem b/util/testdata/badcerts/badcerts.com/fullchain.pem
new file mode 100644
index 0000000..e00f21d
--- /dev/null
+++ b/util/testdata/badcerts/badcerts.com/fullchain.pem
@@ -0,0 +1 @@
+This is a bad file
diff --git a/util/testdata/badcerts/badcerts.com/privkey.pem b/util/testdata/badcerts/badcerts.com/privkey.pem
new file mode 100644
index 0000000..e00f21d
--- /dev/null
+++ b/util/testdata/badcerts/badcerts.com/privkey.pem
@@ -0,0 +1 @@
+This is a bad file
diff --git a/util/testdata/certs/nofullchain.com/README b/util/testdata/certs/nofullchain.com/README
new file mode 100644
index 0000000..6cfc360
--- /dev/null
+++ b/util/testdata/certs/nofullchain.com/README
@@ -0,0 +1 @@
+This directory lacks fullchain.pem
diff --git a/util/testdata/certs/noprivkey.com/README b/util/testdata/certs/noprivkey.com/README
new file mode 100644
index 0000000..72de014
--- /dev/null
+++ b/util/testdata/certs/noprivkey.com/README
@@ -0,0 +1 @@
+This directory lacks privkey.pem
diff --git a/util/testdata/certs/noprivkey.com/fullchain.pem b/util/testdata/certs/noprivkey.com/fullchain.pem
new file mode 100644
index 0000000..e69de29
diff --git a/util/testdata/certs/notadir.com b/util/testdata/certs/notadir.com
new file mode 100644
index 0000000..48d0672
--- /dev/null
+++ b/util/testdata/certs/notadir.com
@@ -0,0 +1 @@
+This is not a directory
diff --git a/util/testdata/empty/README b/util/testdata/empty/README
new file mode 100644
index 0000000..25d5aa2
--- /dev/null
+++ b/util/testdata/empty/README
@@ -0,0 +1 @@
+Empty directory
diff --git a/util/util.go b/util/util.go
index 361924a..e5aca8d 100644
--- a/util/util.go
+++ b/util/util.go
@@ -2,6 +2,7 @@
 package util
 
 import (
+	"context"
 	"crypto/tls"
 	"fmt"
 	"io"
@@ -9,11 +10,104 @@ import (
 	"os"
 	"path/filepath"
 	"sync/atomic"
+
+	"blitiri.com.ar/go/gofer/config"
+	"blitiri.com.ar/go/gofer/trace"
+	"golang.org/x/crypto/acme"
+	"golang.org/x/crypto/acme/autocert"
 )
 
-// LoadCerts loads certificates from the given directory, and returns a TLS
-// config including them.
-func LoadCerts(certDir string) (*tls.Config, error) {
+// LoadCertsForHTTPS returns a TLS configuration based on the given HTTPS
+// config.
+func LoadCertsForHTTPS(conf config.HTTPS) (*tls.Config, error) {
+	if conf.Certs != "" {
+		tlsConfig, err := LoadCertsFromDir(conf.Certs)
+		if err != nil {
+			return nil, err
+		}
+
+		// We need to set the NextProtos manually before creating the TLS
+		// listener, the library cannot help us with this.
+		// For autocert, this is not needed because autocert.Manager does it
+		// for us.
+		tlsConfig.NextProtos = append(tlsConfig.NextProtos,
+			"h2", "http/1.1")
+		return tlsConfig, err
+	}
+
+	m := &autocert.Manager{
+		// As indicated in the documentation, configuring autocerts
+		// implies accepting the CA's TOS.
+		Prompt:     autocert.AcceptTOS,
+		Email:      conf.AutoCerts.Email,
+		HostPolicy: autocert.HostWhitelist(conf.AutoCerts.Hosts...),
+		Cache:      autocert.DirCache(cachePath(conf.AutoCerts.CacheDir)),
+	}
+
+	// Make sure we can write to the cache, to make it easier to detect and
+	// troubleshoot permission issues.
+	err := m.Cache.Put(context.Background(), "__gofer_check", []byte("test"))
+	if err != nil {
+		return nil, fmt.Errorf("error writing to the autocert cache %q: %v",
+			m.Cache, err)
+	}
+
+	if conf.AutoCerts.AcmeURL != "" {
+		m.Client = &acme.Client{
+			DirectoryURL: conf.AutoCerts.AcmeURL,
+			// Note that Key is generated by the Manager, we don't need to
+			// fill it in here.
+		}
+	}
+
+	// Wrap the TLSConfig.GetCertificate so we can log errors, otherwise
+	// they're invisible and difficult to debug.
+	tlsConf := m.TLSConfig()
+	getCert := tlsConf.GetCertificate
+	tlsConf.GetCertificate = func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
+		tr := trace.New("autocerts", h.Conn.RemoteAddr().String())
+		defer tr.Finish()
+
+		cert, err := getCert(h)
+		if err != nil {
+			// We want to mark this as an error so it's easy to find in the
+			// traces, but don't want to log it as such, because these can
+			// also be harmless and add a lot of noise (e.g. a user requesting
+			// a non-whitelisted domain).
+			tr.Printf("request for %q -> %v", h.ServerName, err)
+			tr.SetError()
+		}
+		return cert, err
+	}
+
+	return tlsConf, nil
+}
+
+func cachePath(confDir string) string {
+	if confDir != "" {
+		return confDir
+	}
+
+	base := "gofer-autocert-cache"
+
+	// systemd sets this variable if CacheDirectory= is set.
+	if cd := os.Getenv("CACHE_DIRECTORY"); cd != "" {
+		return filepath.Join(cd, base)
+	}
+
+	// System default (e.g. $HOME/.cache/).
+	cd, err := os.UserCacheDir()
+	if err == nil {
+		return filepath.Join(cd, base)
+	}
+
+	// Last resort: relative path.
+	return base
+}
+
+// LoadCertsFromDir loads certificates from the given directory, and returns a
+// TLS config including them.
+func LoadCertsFromDir(certDir string) (*tls.Config, error) {
 	tlsConfig := &tls.Config{}
 
 	infos, err := ioutil.ReadDir(certDir)
diff --git a/util/util_test.go b/util/util_test.go
new file mode 100644
index 0000000..d62279e
--- /dev/null
+++ b/util/util_test.go
@@ -0,0 +1,103 @@
+package util
+
+import (
+	"os"
+	"strings"
+	"testing"
+
+	"blitiri.com.ar/go/gofer/config"
+)
+
+func TestLoadCertsFromDir(t *testing.T) {
+	// The data in testdata/ is crafted to test some of the corner cases of
+	// LoadCertsFromDir.
+
+	// Incorrect/missing some of the files.
+	c, err := LoadCertsFromDir("testdata/certs/")
+	if c != nil {
+		t.Errorf("expected nil config, got %v", c)
+	}
+	if err == nil || !strings.Contains(err.Error(), "no certificates found") {
+		t.Errorf("expected 'no certificates found' error, got: %v", err)
+	}
+
+	// Invalid PEM certificates.
+	c, err = LoadCertsFromDir("testdata/badcerts/")
+	if c != nil {
+		t.Errorf("expected nil config, got %v", c)
+	}
+	if err == nil || !strings.Contains(err.Error(), "error loading pair") {
+		t.Errorf("expected 'error loading pair' error, got: %v", err)
+	}
+
+	// Empty directory.
+	c, err = LoadCertsFromDir("testdata/empty/")
+	if c != nil {
+		t.Errorf("expected nil config, got %v", c)
+	}
+	if err == nil || !strings.Contains(err.Error(), "no certificates found") {
+		t.Errorf("expected 'no certificates found' error, got: %v", err)
+	}
+
+	// Non-existent directory.
+	c, err = LoadCertsFromDir("testdata/doesnotexist/")
+	if c != nil {
+		t.Errorf("expected nil config, got %v", c)
+	}
+	if err == nil || !strings.Contains(err.Error(), "ReadDir") {
+		t.Errorf("expected ReadDir error, got: %v", err)
+	}
+}
+
+func TestCacheIsWriteableCheck(t *testing.T) {
+	conf := config.HTTPS{
+		AutoCerts: config.AutoCerts{
+			CacheDir: "/proc/should/not/be/allowed",
+		},
+	}
+	c, err := LoadCertsForHTTPS(conf)
+	if err == nil || !strings.Contains(err.Error(), "error writing") {
+		t.Errorf("expected 'error writing to the autocert cache', got: %v / %v",
+			c, err)
+	}
+
+	conf.AutoCerts.CacheDir = "testdata/.TestCacheIsWriteableCheck_dir"
+	_, err = LoadCertsForHTTPS(conf)
+	if err != nil {
+		t.Errorf("failed to write on test directory: %v", err)
+	}
+}
+
+func TestCachePath(t *testing.T) {
+	checkEq := func(desc, cd string, expected string) {
+		if c := cachePath(cd); c != expected {
+			t.Errorf("%s: expected %q, got %q", desc, expected, c)
+		}
+	}
+
+	checkEq("config dir is set", "/some/path/", "/some/path/")
+
+	{
+		orig := os.Getenv("CACHE_DIRECTORY")
+		os.Setenv("CACHE_DIRECTORY", "/my/cache")
+		checkEq("using $CACHE_DIRECTORY", "", "/my/cache/gofer-autocert-cache")
+		os.Setenv("CACHE_DIRECTORY", orig)
+	}
+
+	{
+		orig := os.Getenv("XDG_CACHE_HOME")
+		os.Setenv("XDG_CACHE_HOME", "/xdg/cache")
+		checkEq("using os.UserCacheDir", "", "/xdg/cache/gofer-autocert-cache")
+		os.Setenv("XDG_CACHE_HOME", orig)
+	}
+
+	{
+		origxdg := os.Getenv("XDG_CACHE_HOME")
+		os.Unsetenv("XDG_CACHE_HOME")
+		orighome := os.Getenv("HOME")
+		os.Unsetenv("HOME")
+		checkEq("last resort", "", "gofer-autocert-cache")
+		os.Setenv("HOME", orighome)
+		os.Setenv("XDG_CACHE_HOME", origxdg)
+	}
+}