author | Alberto Bertogli
<albertito@blitiri.com.ar> 2022-09-29 19:23:58 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2022-10-09 11:34:34 UTC |
parent | 49127f1e6b8eab53ac22760e0d1ef1b9532d977a |
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) + } +}