1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
24 "golang.org/x/crypto/acme"
25 "golang.org/x/crypto/acme/autocert"
28 const stagingDirectoryURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
30 var errInvalidHost = errors.New("unrecognized target host in incoming TLS request")
32 type createCertificates struct{}
34 func (createCertificates) String() string {
38 func (createCertificates) Run(ctx context.Context, fail func(error), super *Supervisor) error {
39 if super.cluster.TLS.Automatic {
40 return bootAutoCert(ctx, fail, super)
41 } else if super.cluster.TLS.Key == "" && super.cluster.TLS.Certificate == "" {
42 return createSelfSignedCert(ctx, fail, super)
48 // bootAutoCert uses Let's Encrypt to get certificates for all the
49 // domains appearing in ExternalURLs, writes them to files where Nginx
50 // can load them, and updates super.cluster.TLS fields (Key and
51 // Certificiate) to point to those files.
53 // It also runs a background task to keep the files up to date.
55 // After bootAutoCert returns, other service components will get the
56 // certificates they need by reading these files or by using a
57 // read-only autocert cache.
59 // Currently this only works when port 80 of every ExternalURL domain
60 // is routed to this host, i.e., on a single-node cluster. Wildcard
61 // domains [for WebDAV] are not supported.
62 func bootAutoCert(ctx context.Context, fail func(error), super *Supervisor) error {
63 hosts := map[string]bool{}
64 for _, svc := range super.cluster.Services.Map() {
65 u := url.URL(svc.ExternalURL)
66 if u.Scheme == "https" || u.Scheme == "wss" {
67 hosts[strings.ToLower(u.Hostname())] = true
70 mgr := &autocert.Manager{
71 Cache: autocert.DirCache(super.tempdir + "/autocert"),
72 Prompt: autocert.AcceptTOS,
73 HostPolicy: func(ctx context.Context, host string) error {
74 if hosts[strings.ToLower(host)] {
81 if super.cluster.TLS.Staging {
82 mgr.Client = &acme.Client{DirectoryURL: stagingDirectoryURL}
85 err := http.ListenAndServe(":80", mgr.HTTPHandler(nil))
86 fail(fmt.Errorf("autocert http-01 challenge handler stopped: %w", err))
88 u := url.URL(super.cluster.Services.Controller.ExternalURL)
89 extHost := u.Hostname()
90 update := func() error {
91 for h := range hosts {
92 cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
97 err = writeCert(super.tempdir, "server.key", "server.crt", cert)
110 for range time.NewTicker(time.Hour).C {
113 super.logger.WithError(err).Error("error getting certificate from autocert")
117 super.cluster.TLS.Key = "file://" + super.tempdir + "/server.key"
118 super.cluster.TLS.Certificate = "file://" + super.tempdir + "/server.crt"
122 // Save cert chain and key in a format Nginx can read.
123 func writeCert(outdir, keyfile, certfile string, cert *tls.Certificate) error {
124 keytmp, err := os.CreateTemp(outdir, keyfile+".tmp.*")
129 defer os.Remove(keytmp.Name())
131 certtmp, err := os.CreateTemp(outdir, certfile+".tmp.*")
135 defer certtmp.Close()
136 defer os.Remove(certtmp.Name())
138 switch privkey := cert.PrivateKey.(type) {
139 case *rsa.PrivateKey:
140 err = pem.Encode(keytmp, &pem.Block{
141 Type: "RSA PRIVATE KEY",
142 Bytes: x509.MarshalPKCS1PrivateKey(privkey),
148 buf, err := x509.MarshalPKCS8PrivateKey(privkey)
152 err = pem.Encode(keytmp, &pem.Block{
165 for _, cert := range cert.Certificate {
166 err = pem.Encode(certtmp, &pem.Block{
174 err = certtmp.Close()
179 err = os.Rename(keytmp.Name(), filepath.Join(outdir, keyfile))
183 err = os.Rename(certtmp.Name(), filepath.Join(outdir, certfile))
190 // Create a root CA key and use it to make a new server
191 // certificate+key pair.
193 // In future we'll make one root CA key per host instead of one per
194 // cluster, so it only needs to be imported to a browser once for
195 // ongoing dev/test usage.
196 func createSelfSignedCert(ctx context.Context, fail func(error), super *Supervisor) error {
197 san := "DNS:localhost,DNS:localhost.localdomain"
198 if net.ParseIP(super.ListenHost) != nil {
199 san += fmt.Sprintf(",IP:%s", super.ListenHost)
201 san += fmt.Sprintf(",DNS:%s", super.ListenHost)
203 hostname, err := os.Hostname()
205 return fmt.Errorf("hostname: %w", err)
207 if hostname != super.ListenHost {
208 san += ",DNS:" + hostname
212 err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "genrsa", "-out", "rootCA.key", "4096")
216 // Generate a self-signed root certificate
217 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")
221 // Generate server key
222 err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "genrsa", "-out", "server.key", "2048")
226 // Build config file for signing request
227 defaultconf, err := ioutil.ReadFile("/etc/ssl/openssl.cnf")
231 conf := append(defaultconf, []byte(fmt.Sprintf("\n[SAN]\nsubjectAltName=%s\n", san))...)
232 err = ioutil.WriteFile(filepath.Join(super.tempdir, "server.cfg"), conf, 0644)
236 // Generate signing request
237 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")
242 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")
246 super.cluster.TLS.Key = "file://" + super.tempdir + "/server.key"
247 super.cluster.TLS.Certificate = "file://" + super.tempdir + "/server.crt"