import (
"context"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
"fmt"
"io/ioutil"
"net"
+ "net/http"
+ "net/url"
+ "os"
"path/filepath"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/acme"
+ "golang.org/x/crypto/acme/autocert"
)
-// Create a root CA key and use it to make a new server
-// certificate+key pair.
-//
-// In future we'll make one root CA key per host instead of one per
-// cluster, so it only needs to be imported to a browser once for
-// ongoing dev/test usage.
+const stagingDirectoryURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
+
+var errInvalidHost = errors.New("unrecognized target host in incoming TLS request")
+
type createCertificates struct{}
func (createCertificates) String() string {
}
func (createCertificates) Run(ctx context.Context, fail func(error), super *Supervisor) error {
- var san string
+ if super.cluster.TLS.ACME.Server != "" {
+ return bootAutoCert(ctx, fail, super)
+ } else if super.cluster.TLS.Key == "" && super.cluster.TLS.Certificate == "" {
+ return createSelfSignedCert(ctx, fail, super)
+ } else {
+ return nil
+ }
+}
+
+// bootAutoCert uses Let's Encrypt to get certificates for all the
+// domains appearing in ExternalURLs, writes them to files where Nginx
+// can load them, and updates super.cluster.TLS fields (Key and
+// Certificiate) to point to those files.
+//
+// It also runs a background task to keep the files up to date.
+//
+// After bootAutoCert returns, other service components will get the
+// certificates they need by reading these files or by using a
+// read-only autocert cache.
+//
+// Currently this only works when port 80 of every ExternalURL domain
+// is routed to this host, i.e., on a single-node cluster. Wildcard
+// domains [for WebDAV] are not supported.
+func bootAutoCert(ctx context.Context, fail func(error), super *Supervisor) error {
+ hosts := map[string]bool{}
+ for _, svc := range super.cluster.Services.Map() {
+ u := url.URL(svc.ExternalURL)
+ if u.Scheme == "https" || u.Scheme == "wss" {
+ hosts[strings.ToLower(u.Hostname())] = true
+ }
+ }
+ mgr := &autocert.Manager{
+ Cache: autocert.DirCache(super.tempdir + "/autocert"),
+ Prompt: autocert.AcceptTOS,
+ HostPolicy: func(ctx context.Context, host string) error {
+ if hosts[strings.ToLower(host)] {
+ return nil
+ } else {
+ return errInvalidHost
+ }
+ },
+ }
+ if srv := super.cluster.TLS.ACME.Server; srv == "LE" {
+ // Leaving mgr.Client == nil means use Let's Encrypt
+ // production environment
+ } else if srv == "LE-staging" {
+ mgr.Client = &acme.Client{DirectoryURL: stagingDirectoryURL}
+ } else if strings.HasPrefix(srv, "https://") {
+ mgr.Client = &acme.Client{DirectoryURL: srv}
+ } else {
+ return fmt.Errorf("autocert setup: invalid directory URL in TLS.ACME.Server: %q", srv)
+ }
+ go func() {
+ err := http.ListenAndServe(":80", mgr.HTTPHandler(nil))
+ fail(fmt.Errorf("autocert http-01 challenge handler stopped: %w", err))
+ }()
+ u := url.URL(super.cluster.Services.Controller.ExternalURL)
+ extHost := u.Hostname()
+ update := func() error {
+ for h := range hosts {
+ cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
+ if err != nil {
+ return err
+ }
+ if h == extHost {
+ err = writeCert(super.tempdir, "server.key", "server.crt", cert)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ }
+ err := update()
+ if err != nil {
+ return err
+ }
+ go func() {
+ for range time.NewTicker(time.Hour).C {
+ err := update()
+ if err != nil {
+ super.logger.WithError(err).Error("error getting certificate from autocert")
+ }
+ }
+ }()
+ super.cluster.TLS.Key = "file://" + super.tempdir + "/server.key"
+ super.cluster.TLS.Certificate = "file://" + super.tempdir + "/server.crt"
+ return nil
+}
+
+// Save cert chain and key in a format Nginx can read.
+func writeCert(outdir, keyfile, certfile string, cert *tls.Certificate) error {
+ keytmp, err := os.CreateTemp(outdir, keyfile+".tmp.*")
+ if err != nil {
+ return err
+ }
+ defer keytmp.Close()
+ defer os.Remove(keytmp.Name())
+
+ certtmp, err := os.CreateTemp(outdir, certfile+".tmp.*")
+ if err != nil {
+ return err
+ }
+ defer certtmp.Close()
+ defer os.Remove(certtmp.Name())
+
+ switch privkey := cert.PrivateKey.(type) {
+ case *rsa.PrivateKey:
+ err = pem.Encode(keytmp, &pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: x509.MarshalPKCS1PrivateKey(privkey),
+ })
+ if err != nil {
+ return err
+ }
+ default:
+ buf, err := x509.MarshalPKCS8PrivateKey(privkey)
+ if err != nil {
+ return err
+ }
+ err = pem.Encode(keytmp, &pem.Block{
+ Type: "PRIVATE KEY",
+ Bytes: buf,
+ })
+ if err != nil {
+ return err
+ }
+ }
+ err = keytmp.Close()
+ if err != nil {
+ return err
+ }
+
+ for _, cert := range cert.Certificate {
+ err = pem.Encode(certtmp, &pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: cert,
+ })
+ if err != nil {
+ return err
+ }
+ }
+ err = certtmp.Close()
+ if err != nil {
+ return err
+ }
+
+ err = os.Rename(keytmp.Name(), filepath.Join(outdir, keyfile))
+ if err != nil {
+ return err
+ }
+ err = os.Rename(certtmp.Name(), filepath.Join(outdir, certfile))
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// Create a root CA key and use it to make a new server
+// certificate+key pair.
+//
+// In future we'll make one root CA key per host instead of one per
+// cluster, so it only needs to be imported to a browser once for
+// ongoing dev/test usage.
+func createSelfSignedCert(ctx context.Context, fail func(error), super *Supervisor) error {
+ san := "DNS:localhost,DNS:localhost.localdomain"
if net.ParseIP(super.ListenHost) != nil {
- san = fmt.Sprintf("IP:%s", super.ListenHost)
+ san += fmt.Sprintf(",IP:%s", super.ListenHost)
} else {
- san = fmt.Sprintf("DNS:%s", super.ListenHost)
+ san += fmt.Sprintf(",DNS:%s", super.ListenHost)
+ }
+ hostname, err := os.Hostname()
+ if err != nil {
+ return fmt.Errorf("hostname: %w", err)
+ }
+ if hostname != super.ListenHost {
+ san += ",DNS:" + hostname
}
// Generate root key
- err := super.RunProgram(ctx, super.tempdir, nil, nil, "openssl", "genrsa", "-out", "rootCA.key", "4096")
+ err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "genrsa", "-out", "rootCA.key", "4096")
if err != nil {
return err
}
// Generate a self-signed root certificate
- err = super.RunProgram(ctx, super.tempdir, nil, nil, "openssl", "req", "-x509", "-new", "-nodes", "-key", "rootCA.key", "-sha256", "-days", "3650", "-out", "rootCA.crt", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost")
+ err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "req", "-x509", "-new", "-nodes", "-key", "rootCA.key", "-sha256", "-days", "3650", "-out", "rootCA.crt", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost")
if err != nil {
return err
}
// Generate server key
- err = super.RunProgram(ctx, super.tempdir, nil, nil, "openssl", "genrsa", "-out", "server.key", "2048")
+ err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "genrsa", "-out", "server.key", "2048")
if err != nil {
return err
}
if err != nil {
return err
}
- err = ioutil.WriteFile(filepath.Join(super.tempdir, "server.cfg"), append(defaultconf, []byte(fmt.Sprintf("\n[SAN]\nsubjectAltName=DNS:localhost,DNS:localhost.localdomain,%s\n", san))...), 0644)
+ conf := append(defaultconf, []byte(fmt.Sprintf("\n[SAN]\nsubjectAltName=%s\n", san))...)
+ err = ioutil.WriteFile(filepath.Join(super.tempdir, "server.cfg"), conf, 0644)
if err != nil {
return err
}
// Generate signing request
- err = super.RunProgram(ctx, super.tempdir, nil, nil, "openssl", "req", "-new", "-sha256", "-key", "server.key", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost", "-reqexts", "SAN", "-config", "server.cfg", "-out", "server.csr")
+ err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "req", "-new", "-sha256", "-key", "server.key", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost", "-reqexts", "SAN", "-config", "server.cfg", "-out", "server.csr")
if err != nil {
return err
}
// Sign certificate
- err = super.RunProgram(ctx, super.tempdir, nil, nil, "openssl", "x509", "-req", "-in", "server.csr", "-CA", "rootCA.crt", "-CAkey", "rootCA.key", "-CAcreateserial", "-out", "server.crt", "-extfile", "server.cfg", "-extensions", "SAN", "-days", "3650", "-sha256")
+ err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "x509", "-req", "-in", "server.csr", "-CA", "rootCA.crt", "-CAkey", "rootCA.key", "-CAcreateserial", "-out", "server.crt", "-extfile", "server.cfg", "-extensions", "SAN", "-days", "3650", "-sha256")
if err != nil {
return err
}
+ super.cluster.TLS.Key = "file://" + super.tempdir + "/server.key"
+ super.cluster.TLS.Certificate = "file://" + super.tempdir + "/server.crt"
return nil
}