1// Package autotls automatically configures TLS (for SMTP, IMAP, HTTP) by
2// requesting certificates with ACME, typically from Let's Encrypt.
3package autotls
4
5// We do tls-alpn-01, and also http-01. For DNS we would need a third party tool
6// with an API that can make the DNS changes, as we don't want to link in dozens of
7// bespoke API's for DNS record manipulation into mox.
8
9import (
10 "bytes"
11 "context"
12 "crypto"
13 "crypto/ecdsa"
14 "crypto/elliptic"
15 cryptorand "crypto/rand"
16 "crypto/rsa"
17 "crypto/tls"
18 "crypto/x509"
19 "encoding/pem"
20 "errors"
21 "fmt"
22 "io"
23 "log/slog"
24 "net"
25 "os"
26 "path/filepath"
27 "sort"
28 "strings"
29 "sync"
30 "time"
31
32 "golang.org/x/crypto/acme"
33
34 "github.com/prometheus/client_golang/prometheus"
35 "github.com/prometheus/client_golang/prometheus/promauto"
36
37 "github.com/mjl-/autocert"
38
39 "github.com/mjl-/mox/dns"
40 "github.com/mjl-/mox/mlog"
41 "github.com/mjl-/mox/moxvar"
42)
43
44var (
45 metricCertput = promauto.NewCounter(
46 prometheus.CounterOpts{
47 Name: "mox_autotls_certput_total",
48 Help: "Number of certificate store puts.",
49 },
50 )
51)
52
53// Manager is in charge of a single ACME identity, and automatically requests
54// certificates for allowlisted hosts.
55type Manager struct {
56 ACMETLSConfig *tls.Config // For serving HTTPS on port 443, which is required for certificate requests to succeed.
57 Manager *autocert.Manager
58
59 shutdown <-chan struct{}
60
61 sync.Mutex
62 hosts map[dns.Domain]struct{}
63}
64
65// Load returns an initialized autotls manager for "name" (used for the ACME key
66// file and requested certs and their keys). All files are stored within acmeDir.
67//
68// contactEmail must be a valid email address to which notifications about ACME can
69// be sent. directoryURL is the ACME starting point.
70//
71// eabKeyID and eabKey are for external account binding when making a new account,
72// which some ACME providers require.
73//
74// getPrivateKey is called to get the private key for the host and key type. It
75// can be used to deliver a specific (e.g. always the same) private key for a
76// host, or a newly generated key.
77//
78// When shutdown is closed, no new TLS connections can be created.
79func Load(name, acmeDir, contactEmail, directoryURL string, eabKeyID string, eabKey []byte, getPrivateKey func(host string, keyType autocert.KeyType) (crypto.Signer, error), shutdown <-chan struct{}) (*Manager, error) {
80 if directoryURL == "" {
81 return nil, fmt.Errorf("empty ACME directory URL")
82 }
83 if contactEmail == "" {
84 return nil, fmt.Errorf("empty contact email")
85 }
86
87 // Load identity key if it exists. Otherwise, create a new key.
88 p := filepath.Join(acmeDir, name+".key")
89 var key crypto.Signer
90 f, err := os.Open(p)
91 if f != nil {
92 defer f.Close()
93 }
94 if err != nil && os.IsNotExist(err) {
95 key, err = ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
96 if err != nil {
97 return nil, fmt.Errorf("generating ecdsa identity key: %s", err)
98 }
99 der, err := x509.MarshalPKCS8PrivateKey(key)
100 if err != nil {
101 return nil, fmt.Errorf("marshal identity key: %s", err)
102 }
103 block := &pem.Block{
104 Type: "PRIVATE KEY",
105 Headers: map[string]string{
106 "Note": fmt.Sprintf("PEM PKCS8 ECDSA private key generated for ACME provider %s by mox", name),
107 },
108 Bytes: der,
109 }
110 b := &bytes.Buffer{}
111 if err := pem.Encode(b, block); err != nil {
112 return nil, fmt.Errorf("pem encode: %s", err)
113 } else if err := os.WriteFile(p, b.Bytes(), 0660); err != nil {
114 return nil, fmt.Errorf("writing identity key: %s", err)
115 }
116 } else if err != nil {
117 return nil, fmt.Errorf("open identity key file: %s", err)
118 } else {
119 var privKey any
120 if buf, err := io.ReadAll(f); err != nil {
121 return nil, fmt.Errorf("reading identity key: %s", err)
122 } else if p, _ := pem.Decode(buf); p == nil {
123 return nil, fmt.Errorf("no pem data")
124 } else if p.Type != "PRIVATE KEY" {
125 return nil, fmt.Errorf("got PEM block %q, expected \"PRIVATE KEY\"", p.Type)
126 } else if privKey, err = x509.ParsePKCS8PrivateKey(p.Bytes); err != nil {
127 return nil, fmt.Errorf("parsing PKCS8 private key: %s", err)
128 }
129 switch k := privKey.(type) {
130 case *ecdsa.PrivateKey:
131 key = k
132 case *rsa.PrivateKey:
133 key = k
134 default:
135 return nil, fmt.Errorf("unsupported private key type %T", key)
136 }
137 }
138
139 m := &autocert.Manager{
140 Cache: dirCache(filepath.Join(acmeDir, "keycerts", name)),
141 Prompt: autocert.AcceptTOS,
142 Email: contactEmail,
143 Client: &acme.Client{
144 DirectoryURL: directoryURL,
145 Key: key,
146 UserAgent: "mox/" + moxvar.Version,
147 },
148 GetPrivateKey: getPrivateKey,
149 // HostPolicy set below.
150 }
151 // If external account binding key is provided, use it for registering a new account.
152 // todo: ideally the key and its id are provided temporarily by the admin when registering a new account. but we don't do that interactive setup yet. in the future, an interactive setup/quickstart would ask for the key once to register a new acme account.
153 if eabKeyID != "" {
154 m.ExternalAccountBinding = &acme.ExternalAccountBinding{
155 KID: eabKeyID,
156 Key: eabKey,
157 }
158 }
159
160 a := &Manager{
161 Manager: m,
162 shutdown: shutdown,
163 hosts: map[dns.Domain]struct{}{},
164 }
165 m.HostPolicy = a.HostPolicy
166 acmeTLSConfig := *m.TLSConfig()
167 acmeTLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
168 return a.loggingGetCertificate(hello, dns.Domain{}, false, false)
169 }
170 a.ACMETLSConfig = &acmeTLSConfig
171 return a, nil
172}
173
174// logigngGetCertificate is a helper to implement crypto/tls.Config.GetCertificate,
175// optionally falling back to a certificate for fallbackHostname in case SNI is
176// absent or for an unknown hostname.
177func (m *Manager) loggingGetCertificate(hello *tls.ClientHelloInfo, fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) (*tls.Certificate, error) {
178 log := mlog.New("autotls", nil).WithContext(hello.Context())
179
180 // If we can't find a certificate (depending on fallback parameters), we return a
181 // nil certificate and nil error, which crypto/tls turns into a TLS alert
182 // "unrecognized name", which can be interpreted by clients as a hint that they are
183 // using the wrong hostname, or a certificate is missing.
184
185 if hello.ServerName == "" && fallbackNoSNI {
186 hello.ServerName = fallbackHostname.ASCII
187 }
188
189 // Handle missing SNI to prevent logging an error below.
190 if hello.ServerName == "" {
191 log.Debug("tls request without sni servername, rejecting", slog.Any("localaddr", hello.Conn.LocalAddr()), slog.Any("supportedprotos", hello.SupportedProtos))
192 return nil, nil
193 }
194
195 cert, err := m.Manager.GetCertificate(hello)
196 if err != nil && errors.Is(err, errHostNotAllowed) {
197 if !fallbackUnknownSNI {
198 log.Debugx("requesting certificate", err, slog.String("host", hello.ServerName))
199 return nil, nil
200 }
201
202 log.Debug("certificate for unknown hostname, using fallback hostname", slog.String("host", hello.ServerName))
203 hello.ServerName = fallbackHostname.ASCII
204 cert, err = m.Manager.GetCertificate(hello)
205 if err != nil {
206 log.Errorx("requesting certificate for fallback hostname", err, slog.String("host", hello.ServerName))
207 } else {
208 log.Debugx("requesting certificate for fallback hostname", err, slog.String("host", hello.ServerName))
209 }
210 return cert, err
211 } else if err != nil {
212 log.Errorx("requesting certificate", err, slog.String("host", hello.ServerName))
213 }
214 return cert, err
215}
216
217// TLSConfig returns a TLS server config that optionally returns a certificate for
218// fallbackHostname if no SNI was done, or for an unknown hostname.
219//
220// If fallbackNoSNI is set, TLS connections without SNI will use a certificate for
221// fallbackHostname. Otherwise, connections without SNI will fail with a message
222// that no TLS certificate is available.
223//
224// If fallbackUnknownSNI is set, TLS connections with an SNI hostname that is not
225// allowlisted will instead use a certificate for fallbackHostname. Otherwise, such
226// TLS connections will fail.
227func (m *Manager) TLSConfig(fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) *tls.Config {
228 return &tls.Config{
229 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
230 return m.loggingGetCertificate(hello, fallbackHostname, fallbackNoSNI, fallbackUnknownSNI)
231 },
232 SessionTicketsDisabled: true,
233 }
234}
235
236// CertAvailable checks whether a non-expired ECDSA certificate is available in the
237// cache for host. No other checks than expiration are done.
238func (m *Manager) CertAvailable(ctx context.Context, log mlog.Log, host dns.Domain) (bool, error) {
239 ck := host.ASCII // Would be "+rsa" for rsa keys.
240 data, err := m.Manager.Cache.Get(ctx, ck)
241 if err != nil && errors.Is(err, autocert.ErrCacheMiss) {
242 return false, nil
243 } else if err != nil {
244 return false, fmt.Errorf("attempt to get certificate from cache: %v", err)
245 }
246
247 // The cached keycert is of the form: private key, leaf certificate, intermediate certificates...
248 privb, rem := pem.Decode(data)
249 if privb == nil {
250 return false, fmt.Errorf("missing private key in cached keycert file")
251 }
252 pubb, _ := pem.Decode(rem)
253 if pubb == nil {
254 return false, fmt.Errorf("missing certificate in cached keycert file")
255 } else if pubb.Type != "CERTIFICATE" {
256 return false, fmt.Errorf("second pem block is %q, expected CERTIFICATE", pubb.Type)
257 }
258 cert, err := x509.ParseCertificate(pubb.Bytes)
259 if err != nil {
260 return false, fmt.Errorf("parsing certificate from cached keycert file: %v", err)
261 }
262 // We assume the certificate has a matching hostname, and is properly CA-signed. We
263 // only check the expiration time.
264 if time.Until(cert.NotBefore) > 0 || time.Since(cert.NotAfter) > 0 {
265 return false, nil
266 }
267 return true, nil
268}
269
270// SetAllowedHostnames sets a new list of allowed hostnames for automatic TLS.
271// After setting the host names, a goroutine is start to check that new host names
272// are fully served by publicIPs (only if non-empty and there is no unspecified
273// address in the list). If no, log an error with a warning that ACME validation
274// may fail.
275func (m *Manager) SetAllowedHostnames(log mlog.Log, resolver dns.Resolver, hostnames map[dns.Domain]struct{}, publicIPs []string, checkHosts bool) {
276 m.Lock()
277 defer m.Unlock()
278
279 // Log as slice, sorted.
280 l := make([]dns.Domain, 0, len(hostnames))
281 for d := range hostnames {
282 l = append(l, d)
283 }
284 sort.Slice(l, func(i, j int) bool {
285 return l[i].Name() < l[j].Name()
286 })
287
288 log.Debug("autotls setting allowed hostnames", slog.Any("hostnames", l), slog.Any("publicips", publicIPs))
289 var added []dns.Domain
290 for h := range hostnames {
291 if _, ok := m.hosts[h]; !ok {
292 added = append(added, h)
293 }
294 }
295 m.hosts = hostnames
296
297 if checkHosts && len(added) > 0 && len(publicIPs) > 0 {
298 for _, ip := range publicIPs {
299 if net.ParseIP(ip).IsUnspecified() {
300 return
301 }
302 }
303 go func() {
304 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
305 defer cancel()
306
307 publicIPstrs := map[string]struct{}{}
308 for _, ip := range publicIPs {
309 publicIPstrs[ip] = struct{}{}
310 }
311
312 log.Debug("checking ips of hosts configured for acme tls cert validation")
313 for _, h := range added {
314 ips, _, err := resolver.LookupIP(ctx, "ip", h.ASCII+".")
315 if err != nil {
316 log.Errorx("warning: acme tls cert validation for host may fail due to dns lookup error", err, slog.Any("host", h))
317 continue
318 }
319 for _, ip := range ips {
320 if _, ok := publicIPstrs[ip.String()]; !ok {
321 log.Error("warning: acme tls cert validation for host is likely to fail because not all its ips are being listened on",
322 slog.Any("hostname", h),
323 slog.Any("listenedips", publicIPs),
324 slog.Any("hostips", ips),
325 slog.Any("missingip", ip))
326 }
327 }
328 }
329 }()
330 }
331}
332
333// Hostnames returns the allowed host names for use with ACME.
334func (m *Manager) Hostnames() []dns.Domain {
335 m.Lock()
336 defer m.Unlock()
337 var l []dns.Domain
338 for h := range m.hosts {
339 l = append(l, h)
340 }
341 return l
342}
343
344var errHostNotAllowed = errors.New("autotls: host not in allowlist")
345
346// HostPolicy decides if a host is allowed for use with ACME, i.e. whether a
347// certificate will be returned if present and/or will be requested if not yet
348// present. Only hosts added with SetAllowedHostnames are allowed. During shutdown,
349// no new connections are allowed.
350func (m *Manager) HostPolicy(ctx context.Context, host string) (rerr error) {
351 log := mlog.New("autotls", nil).WithContext(ctx)
352 defer func() {
353 log.Debugx("autotls hostpolicy result", rerr, slog.String("host", host))
354 }()
355
356 // Don't request new TLS certs when we are shutting down.
357 select {
358 case <-m.shutdown:
359 return fmt.Errorf("shutting down")
360 default:
361 }
362
363 xhost, _, err := net.SplitHostPort(host)
364 if err == nil {
365 // For http-01, host may include a port number.
366 host = xhost
367 }
368
369 d, err := dns.ParseDomain(host)
370 if err != nil {
371 return fmt.Errorf("invalid host: %v", err)
372 }
373
374 m.Lock()
375 defer m.Unlock()
376 if _, ok := m.hosts[d]; !ok {
377 return fmt.Errorf("%w: %q", errHostNotAllowed, d)
378 }
379 return nil
380}
381
382type dirCache autocert.DirCache
383
384func (d dirCache) Delete(ctx context.Context, name string) (rerr error) {
385 log := mlog.New("autotls", nil).WithContext(ctx)
386 defer func() {
387 log.Debugx("dircache delete result", rerr, slog.String("name", name))
388 }()
389 err := autocert.DirCache(d).Delete(ctx, name)
390 if err != nil {
391 log.Errorx("deleting cert from dir cache", err, slog.String("name", name))
392 } else if !strings.HasSuffix(name, "+token") {
393 log.Info("autotls cert delete", slog.String("name", name))
394 }
395 return err
396}
397
398func (d dirCache) Get(ctx context.Context, name string) (rbuf []byte, rerr error) {
399 log := mlog.New("autotls", nil).WithContext(ctx)
400 defer func() {
401 log.Debugx("dircache get result", rerr, slog.String("name", name))
402 }()
403 buf, err := autocert.DirCache(d).Get(ctx, name)
404 if err != nil && errors.Is(err, autocert.ErrCacheMiss) {
405 log.Infox("getting cert from dir cache", err, slog.String("name", name))
406 } else if err != nil {
407 log.Errorx("getting cert from dir cache", err, slog.String("name", name))
408 } else if !strings.HasSuffix(name, "+token") {
409 log.Debug("autotls cert get", slog.String("name", name))
410 }
411 return buf, err
412}
413
414func (d dirCache) Put(ctx context.Context, name string, data []byte) (rerr error) {
415 log := mlog.New("autotls", nil).WithContext(ctx)
416 defer func() {
417 log.Debugx("dircache put result", rerr, slog.String("name", name))
418 }()
419 metricCertput.Inc()
420 err := autocert.DirCache(d).Put(ctx, name, data)
421 if err != nil {
422 log.Errorx("storing cert in dir cache", err, slog.String("name", name))
423 } else if !strings.HasSuffix(name, "+token") {
424 log.Info("autotls cert store", slog.String("name", name))
425 }
426 return err
427}
428