X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/37ab0eedec5eaf99c27b6b64fd04cc9248081713..0f5b0542513b572959e39400bae42e69aeb1a7b6:/lib/boot/cert.go diff --git a/lib/boot/cert.go b/lib/boot/cert.go index 508605bb7a..175a350803 100644 --- a/lib/boot/cert.go +++ b/lib/boot/cert.go @@ -6,16 +6,29 @@ package boot 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 { @@ -23,18 +36,197 @@ func (createCertificates) String() string { } func (createCertificates) Run(ctx context.Context, fail func(error), super *Supervisor) error { + 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) + } else { + 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 } @@ -43,22 +235,22 @@ func (createCertificates) Run(ctx context.Context, fail func(error), super *Supe if err != nil { return err } - err = ioutil.WriteFile(filepath.Join(super.tempdir, "server.cfg"), append(defaultconf, []byte(` -[SAN] -subjectAltName=DNS:localhost,DNS:localhost.localdomain -`)...), 0777) + 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", "-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 }