1// Package autotls automatically configures TLS (for SMTP, IMAP, HTTP) by
2// requesting certificates with ACME, typically from Let's Encrypt.
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.
15 cryptorand "crypto/rand"
32 "golang.org/x/crypto/acme"
34 "github.com/prometheus/client_golang/prometheus"
35 "github.com/prometheus/client_golang/prometheus/promauto"
37 "github.com/mjl-/autocert"
39 "github.com/mjl-/mox/dns"
40 "github.com/mjl-/mox/mlog"
41 "github.com/mjl-/mox/moxvar"
45 metricMissingServerName = promauto.NewCounter(
46 prometheus.CounterOpts{
47 Name: "mox_autotls_missing_servername_total",
48 Help: "Number of failed TLS connection attempts with missing SNI where no fallback hostname was configured.",
51 metricUnknownServerName = promauto.NewCounter(
52 prometheus.CounterOpts{
53 Name: "mox_autotls_unknown_servername_total",
54 Help: "Number of failed TLS connection attempts with an unrecognized SNI name where no fallback hostname was configured.",
57 metricCertRequestErrors = promauto.NewCounter(
58 prometheus.CounterOpts{
59 Name: "mox_autotls_cert_request_errors_total",
60 Help: "Number of errors trying to retrieve a certificate for a hostname, possibly ACME verification errors.",
63 metricCertput = promauto.NewCounter(
64 prometheus.CounterOpts{
65 Name: "mox_autotls_certput_total",
66 Help: "Number of certificate store puts.",
71// Manager is in charge of a single ACME identity, and automatically requests
72// certificates for allowlisted hosts.
74 ACMETLSConfig *tls.Config // For serving HTTPS on port 443, which is required for certificate requests to succeed.
75 Manager *autocert.Manager
77 shutdown <-chan struct{}
80 hosts map[dns.Domain]struct{}
83// Load returns an initialized autotls manager for "name" (used for the ACME key
84// file and requested certs and their keys). All files are stored within acmeDir.
86// contactEmail must be a valid email address to which notifications about ACME can
87// be sent. directoryURL is the ACME starting point.
89// eabKeyID and eabKey are for external account binding when making a new account,
90// which some ACME providers require.
92// getPrivateKey is called to get the private key for the host and key type. It
93// can be used to deliver a specific (e.g. always the same) private key for a
94// host, or a newly generated key.
96// When shutdown is closed, no new TLS connections can be created.
97func Load(log mlog.Log, name, acmeDir, contactEmail, directoryURL string, eabKeyID string, eabKey []byte, getPrivateKey func(host string, keyType autocert.KeyType) (crypto.Signer, error), shutdown <-chan struct{}) (*Manager, error) {
98 if directoryURL == "" {
99 return nil, fmt.Errorf("empty ACME directory URL")
101 if contactEmail == "" {
102 return nil, fmt.Errorf("empty contact email")
105 // Load identity key if it exists. Otherwise, create a new key.
106 p := filepath.Join(acmeDir, name+".key")
107 var key crypto.Signer
112 log.Check(err, "closing identify key file")
115 if err != nil && os.IsNotExist(err) {
116 key, err = ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
118 return nil, fmt.Errorf("generating ecdsa identity key: %s", err)
120 der, err := x509.MarshalPKCS8PrivateKey(key)
122 return nil, fmt.Errorf("marshal identity key: %s", err)
126 Headers: map[string]string{
127 "Note": fmt.Sprintf("PEM PKCS8 ECDSA private key generated for ACME provider %s by mox", name),
132 if err := pem.Encode(b, block); err != nil {
133 return nil, fmt.Errorf("pem encode: %s", err)
134 } else if err := os.WriteFile(p, b.Bytes(), 0660); err != nil {
135 return nil, fmt.Errorf("writing identity key: %s", err)
137 } else if err != nil {
138 return nil, fmt.Errorf("open identity key file: %s", err)
141 if buf, err := io.ReadAll(f); err != nil {
142 return nil, fmt.Errorf("reading identity key: %s", err)
143 } else if p, _ := pem.Decode(buf); p == nil {
144 return nil, fmt.Errorf("no pem data")
145 } else if p.Type != "PRIVATE KEY" {
146 return nil, fmt.Errorf("got PEM block %q, expected \"PRIVATE KEY\"", p.Type)
147 } else if privKey, err = x509.ParsePKCS8PrivateKey(p.Bytes); err != nil {
148 return nil, fmt.Errorf("parsing PKCS8 private key: %s", err)
150 switch k := privKey.(type) {
151 case *ecdsa.PrivateKey:
153 case *rsa.PrivateKey:
156 return nil, fmt.Errorf("unsupported private key type %T", key)
160 m := &autocert.Manager{
161 Cache: dirCache(filepath.Join(acmeDir, "keycerts", name)),
162 Prompt: autocert.AcceptTOS,
164 Client: &acme.Client{
165 DirectoryURL: directoryURL,
167 UserAgent: "mox/" + moxvar.Version,
169 GetPrivateKey: getPrivateKey,
170 // HostPolicy set below.
172 // If external account binding key is provided, use it for registering a new account.
173 // 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.
175 m.ExternalAccountBinding = &acme.ExternalAccountBinding{
184 hosts: map[dns.Domain]struct{}{},
186 m.HostPolicy = a.HostPolicy
187 acmeTLSConfig := *m.TLSConfig()
188 acmeTLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
189 return a.loggingGetCertificate(hello, dns.Domain{}, false, false)
191 a.ACMETLSConfig = &acmeTLSConfig
195// loggingGetCertificate is a helper to implement crypto/tls.Config.GetCertificate,
196// optionally falling back to a certificate for fallbackHostname in case SNI is
197// absent or for an unknown hostname.
198func (m *Manager) loggingGetCertificate(hello *tls.ClientHelloInfo, fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) (*tls.Certificate, error) {
199 log := mlog.New("autotls", nil).WithContext(hello.Context()).With(
200 slog.Any("localaddr", hello.Conn.LocalAddr()),
201 slog.Any("supportedprotos", hello.SupportedProtos),
202 slog.String("servername", hello.ServerName),
205 // If we can't find a certificate (depending on fallback parameters), we return a
206 // nil certificate and nil error, which crypto/tls turns into a TLS alert
207 // "unrecognized name", which can be interpreted by clients as a hint that they are
210 // IP addresses for ServerName are not allowed, but happen in practice. If we
211 // should be lenient (fallbackUnknownSNI), we switch to the fallback hostname,
212 // otherwise we return an error. We don't want to pass IP addresses to
213 // GetCertificate because it will return an error for IPv6 addresses.
215 if net.ParseIP(hello.ServerName) != nil {
216 if fallbackUnknownSNI {
217 hello.ServerName = fallbackHostname.ASCII
218 log = log.With(slog.String("servername", hello.ServerName))
220 log.Debug("tls request with ip for server name, rejecting")
221 return nil, fmt.Errorf("invalid ip address for sni server name")
225 if hello.ServerName == "" && fallbackNoSNI {
226 hello.ServerName = fallbackHostname.ASCII
227 log = log.With(slog.String("servername", hello.ServerName))
230 // Handle missing SNI to prevent logging an error below.
231 if hello.ServerName == "" {
232 metricMissingServerName.Inc()
233 log.Debug("tls request without sni server name, rejecting")
237 // Names without dot (e.g. "443" as seen in practice) are rejected with an error by
238 // autocert, so handle it early instead of causing error logging/alerting.
239 if !strings.Contains(strings.Trim(hello.ServerName, "."), ".") {
240 if fallbackUnknownSNI {
241 hello.ServerName = fallbackHostname.ASCII
242 log = log.With(slog.String("servername", hello.ServerName))
244 log.Debug("tls request for server name without dot, rejecting")
245 return nil, fmt.Errorf("invalid sni server name without dot")
249 cert, err := m.Manager.GetCertificate(hello)
250 if err != nil && errors.Is(err, errHostNotAllowed) {
251 if !fallbackUnknownSNI {
252 metricUnknownServerName.Inc()
253 log.Debugx("requesting certificate", err)
257 // Some legitimate email deliveries over SMTP use an unknown SNI, e.g. a bare
258 // domain instead of the MX hostname. We "should" return an error, but that would
259 // break email delivery, so we use the fallback name if it is configured.
262 hello.ServerName = fallbackHostname.ASCII
263 log = log.With(slog.String("servername", hello.ServerName))
264 log.Debug("certificate for unknown hostname, using fallback hostname")
265 cert, err = m.Manager.GetCertificate(hello)
267 metricCertRequestErrors.Inc()
268 log.Errorx("requesting certificate for fallback hostname", err)
270 log.Debug("using certificate for fallback hostname")
273 } else if err != nil {
274 metricCertRequestErrors.Inc()
275 log.Errorx("requesting certificate", err)
280// TLSConfig returns a TLS server config that optionally returns a certificate for
281// fallbackHostname if no SNI was done, or for an unknown hostname.
283// If fallbackNoSNI is set, TLS connections without SNI will use a certificate for
284// fallbackHostname. Otherwise, connections without SNI will fail with a message
285// that no TLS certificate is available.
287// If fallbackUnknownSNI is set, TLS connections with an SNI hostname that is not
288// allowlisted will instead use a certificate for fallbackHostname. Otherwise, such
289// TLS connections will fail.
290func (m *Manager) TLSConfig(fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) *tls.Config {
292 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
293 return m.loggingGetCertificate(hello, fallbackHostname, fallbackNoSNI, fallbackUnknownSNI)
298// CertAvailable checks whether a non-expired ECDSA certificate is available in the
299// cache for host. No other checks than expiration are done.
300func (m *Manager) CertAvailable(ctx context.Context, log mlog.Log, host dns.Domain) (bool, error) {
301 ck := host.ASCII // Would be "+rsa" for rsa keys.
302 data, err := m.Manager.Cache.Get(ctx, ck)
303 if err != nil && errors.Is(err, autocert.ErrCacheMiss) {
305 } else if err != nil {
306 return false, fmt.Errorf("attempt to get certificate from cache: %v", err)
309 // The cached keycert is of the form: private key, leaf certificate, intermediate certificates...
310 privb, rem := pem.Decode(data)
312 return false, fmt.Errorf("missing private key in cached keycert file")
314 pubb, _ := pem.Decode(rem)
316 return false, fmt.Errorf("missing certificate in cached keycert file")
317 } else if pubb.Type != "CERTIFICATE" {
318 return false, fmt.Errorf("second pem block is %q, expected CERTIFICATE", pubb.Type)
320 cert, err := x509.ParseCertificate(pubb.Bytes)
322 return false, fmt.Errorf("parsing certificate from cached keycert file: %v", err)
324 // We assume the certificate has a matching hostname, and is properly CA-signed. We
325 // only check the expiration time.
326 if time.Until(cert.NotBefore) > 0 || time.Since(cert.NotAfter) > 0 {
332// SetAllowedHostnames sets a new list of allowed hostnames for automatic TLS.
333// After setting the host names, a goroutine is start to check that new host names
334// are fully served by publicIPs (only if non-empty and there is no unspecified
335// address in the list). If no, log an error with a warning that ACME validation
337func (m *Manager) SetAllowedHostnames(log mlog.Log, resolver dns.Resolver, hostnames map[dns.Domain]struct{}, publicIPs []string, checkHosts bool) {
341 // Log as slice, sorted.
342 l := make([]dns.Domain, 0, len(hostnames))
343 for d := range hostnames {
346 sort.Slice(l, func(i, j int) bool {
347 return l[i].Name() < l[j].Name()
350 log.Debug("autotls setting allowed hostnames", slog.Any("hostnames", l), slog.Any("publicips", publicIPs))
351 var added []dns.Domain
352 for h := range hostnames {
353 if _, ok := m.hosts[h]; !ok {
354 added = append(added, h)
359 if checkHosts && len(added) > 0 && len(publicIPs) > 0 {
360 for _, ip := range publicIPs {
361 if net.ParseIP(ip).IsUnspecified() {
366 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
369 publicIPstrs := map[string]struct{}{}
370 for _, ip := range publicIPs {
371 publicIPstrs[ip] = struct{}{}
374 log.Debug("checking ips of hosts configured for acme tls cert validation")
375 for _, h := range added {
376 ips, _, err := resolver.LookupIP(ctx, "ip", h.ASCII+".")
378 log.Warnx("acme tls cert validation for host may fail due to dns lookup error", err, slog.Any("host", h))
381 for _, ip := range ips {
382 if _, ok := publicIPstrs[ip.String()]; !ok {
383 log.Warn("acme tls cert validation for host is likely to fail because not all its ips are being listened on",
384 slog.Any("hostname", h),
385 slog.Any("listenedips", publicIPs),
386 slog.Any("hostips", ips),
387 slog.Any("missingip", ip))
395// Hostnames returns the allowed host names for use with ACME.
396func (m *Manager) Hostnames() []dns.Domain {
400 for h := range m.hosts {
406var errHostNotAllowed = errors.New("autotls: host not in allowlist")
408// HostPolicy decides if a host is allowed for use with ACME, i.e. whether a
409// certificate will be returned if present and/or will be requested if not yet
410// present. Only hosts added with SetAllowedHostnames are allowed. During shutdown,
411// no new connections are allowed.
412func (m *Manager) HostPolicy(ctx context.Context, host string) (rerr error) {
413 log := mlog.New("autotls", nil).WithContext(ctx)
415 log.Debugx("autotls hostpolicy result", rerr, slog.String("host", host))
418 // Don't request new TLS certs when we are shutting down.
421 return fmt.Errorf("shutting down")
425 xhost, _, err := net.SplitHostPort(host)
427 // For http-01, host may include a port number.
431 d, err := dns.ParseDomain(host)
433 return fmt.Errorf("invalid host: %v", err)
438 if _, ok := m.hosts[d]; !ok {
439 return fmt.Errorf("%w: %q", errHostNotAllowed, d)
444type dirCache autocert.DirCache
446func (d dirCache) Delete(ctx context.Context, name string) (rerr error) {
447 log := mlog.New("autotls", nil).WithContext(ctx)
449 log.Debugx("dircache delete result", rerr, slog.String("name", name))
451 err := autocert.DirCache(d).Delete(ctx, name)
453 log.Errorx("deleting cert from dir cache", err, slog.String("name", name))
454 } else if !strings.HasSuffix(name, "+token") {
455 log.Info("autotls cert delete", slog.String("name", name))
460func (d dirCache) Get(ctx context.Context, name string) (rbuf []byte, rerr error) {
461 log := mlog.New("autotls", nil).WithContext(ctx)
463 log.Debugx("dircache get result", rerr, slog.String("name", name))
465 buf, err := autocert.DirCache(d).Get(ctx, name)
466 if err != nil && errors.Is(err, autocert.ErrCacheMiss) {
467 log.Infox("getting cert from dir cache", err, slog.String("name", name))
468 } else if err != nil {
469 log.Errorx("getting cert from dir cache", err, slog.String("name", name))
470 } else if !strings.HasSuffix(name, "+token") {
471 log.Debug("autotls cert get", slog.String("name", name))
476func (d dirCache) Put(ctx context.Context, name string, data []byte) (rerr error) {
477 log := mlog.New("autotls", nil).WithContext(ctx)
479 log.Debugx("dircache put result", rerr, slog.String("name", name))
482 err := autocert.DirCache(d).Put(ctx, name, data)
484 log.Errorx("storing cert in dir cache", err, slog.String("name", name))
485 } else if !strings.HasSuffix(name, "+token") {
486 log.Info("autotls cert store", slog.String("name", name))