mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
62bd2f4427
instead of failing the connection because no certificates are available. this may improve interoperability. perhaps the remote smtp client that's doing the delivery will decide they do like the tls cert for our (mx) hostname after all. this only applies to incoming smtp deliveries. for other tls connections (https, imaps/submissions and imap/submission with starttls) we still cause connections for unknown sni hostnames to fail. if case no sni was present, we were already falling back to a cert for the (listener/mx) hostname, that behaviour hasn't changed. for issue #206 by RobSlgm
426 lines
14 KiB
Go
426 lines
14 KiB
Go
// Package autotls automatically configures TLS (for SMTP, IMAP, HTTP) by
|
|
// requesting certificates with ACME, typically from Let's Encrypt.
|
|
package autotls
|
|
|
|
// We do tls-alpn-01, and also http-01. For DNS we would need a third party tool
|
|
// with an API that can make the DNS changes, as we don't want to link in dozens of
|
|
// bespoke API's for DNS record manipulation into mox.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
cryptorand "crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/acme"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
|
|
"github.com/mjl-/autocert"
|
|
|
|
"github.com/mjl-/mox/dns"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/moxvar"
|
|
)
|
|
|
|
var (
|
|
metricCertput = promauto.NewCounter(
|
|
prometheus.CounterOpts{
|
|
Name: "mox_autotls_certput_total",
|
|
Help: "Number of certificate store puts.",
|
|
},
|
|
)
|
|
)
|
|
|
|
// Manager is in charge of a single ACME identity, and automatically requests
|
|
// certificates for allowlisted hosts.
|
|
type Manager struct {
|
|
ACMETLSConfig *tls.Config // For serving HTTPS on port 443, which is required for certificate requests to succeed.
|
|
Manager *autocert.Manager
|
|
|
|
shutdown <-chan struct{}
|
|
|
|
sync.Mutex
|
|
hosts map[dns.Domain]struct{}
|
|
}
|
|
|
|
// Load returns an initialized autotls manager for "name" (used for the ACME key
|
|
// file and requested certs and their keys). All files are stored within acmeDir.
|
|
//
|
|
// contactEmail must be a valid email address to which notifications about ACME can
|
|
// be sent. directoryURL is the ACME starting point.
|
|
//
|
|
// eabKeyID and eabKey are for external account binding when making a new account,
|
|
// which some ACME providers require.
|
|
//
|
|
// getPrivateKey is called to get the private key for the host and key type. It
|
|
// can be used to deliver a specific (e.g. always the same) private key for a
|
|
// host, or a newly generated key.
|
|
//
|
|
// When shutdown is closed, no new TLS connections can be created.
|
|
func 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) {
|
|
if directoryURL == "" {
|
|
return nil, fmt.Errorf("empty ACME directory URL")
|
|
}
|
|
if contactEmail == "" {
|
|
return nil, fmt.Errorf("empty contact email")
|
|
}
|
|
|
|
// Load identity key if it exists. Otherwise, create a new key.
|
|
p := filepath.Join(acmeDir, name+".key")
|
|
var key crypto.Signer
|
|
f, err := os.Open(p)
|
|
if f != nil {
|
|
defer f.Close()
|
|
}
|
|
if err != nil && os.IsNotExist(err) {
|
|
key, err = ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating ecdsa identity key: %s", err)
|
|
}
|
|
der, err := x509.MarshalPKCS8PrivateKey(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal identity key: %s", err)
|
|
}
|
|
block := &pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Headers: map[string]string{
|
|
"Note": fmt.Sprintf("PEM PKCS8 ECDSA private key generated for ACME provider %s by mox", name),
|
|
},
|
|
Bytes: der,
|
|
}
|
|
b := &bytes.Buffer{}
|
|
if err := pem.Encode(b, block); err != nil {
|
|
return nil, fmt.Errorf("pem encode: %s", err)
|
|
} else if err := os.WriteFile(p, b.Bytes(), 0660); err != nil {
|
|
return nil, fmt.Errorf("writing identity key: %s", err)
|
|
}
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("open identity key file: %s", err)
|
|
} else {
|
|
var privKey any
|
|
if buf, err := io.ReadAll(f); err != nil {
|
|
return nil, fmt.Errorf("reading identity key: %s", err)
|
|
} else if p, _ := pem.Decode(buf); p == nil {
|
|
return nil, fmt.Errorf("no pem data")
|
|
} else if p.Type != "PRIVATE KEY" {
|
|
return nil, fmt.Errorf("got PEM block %q, expected \"PRIVATE KEY\"", p.Type)
|
|
} else if privKey, err = x509.ParsePKCS8PrivateKey(p.Bytes); err != nil {
|
|
return nil, fmt.Errorf("parsing PKCS8 private key: %s", err)
|
|
}
|
|
switch k := privKey.(type) {
|
|
case *ecdsa.PrivateKey:
|
|
key = k
|
|
case *rsa.PrivateKey:
|
|
key = k
|
|
default:
|
|
return nil, fmt.Errorf("unsupported private key type %T", key)
|
|
}
|
|
}
|
|
|
|
m := &autocert.Manager{
|
|
Cache: dirCache(filepath.Join(acmeDir, "keycerts", name)),
|
|
Prompt: autocert.AcceptTOS,
|
|
Email: contactEmail,
|
|
Client: &acme.Client{
|
|
DirectoryURL: directoryURL,
|
|
Key: key,
|
|
UserAgent: "mox/" + moxvar.Version,
|
|
},
|
|
GetPrivateKey: getPrivateKey,
|
|
// HostPolicy set below.
|
|
}
|
|
// If external account binding key is provided, use it for registering a new account.
|
|
// 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.
|
|
if eabKeyID != "" {
|
|
m.ExternalAccountBinding = &acme.ExternalAccountBinding{
|
|
KID: eabKeyID,
|
|
Key: eabKey,
|
|
}
|
|
}
|
|
|
|
a := &Manager{
|
|
Manager: m,
|
|
shutdown: shutdown,
|
|
hosts: map[dns.Domain]struct{}{},
|
|
}
|
|
m.HostPolicy = a.HostPolicy
|
|
acmeTLSConfig := *m.TLSConfig()
|
|
acmeTLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
return a.loggingGetCertificate(hello, dns.Domain{}, false, false)
|
|
}
|
|
a.ACMETLSConfig = &acmeTLSConfig
|
|
return a, nil
|
|
}
|
|
|
|
// logigngGetCertificate is a helper to implement crypto/tls.Config.GetCertificate,
|
|
// optionally falling back to a certificate for fallbackHostname in case SNI is
|
|
// absent or for an unknown hostname.
|
|
func (m *Manager) loggingGetCertificate(hello *tls.ClientHelloInfo, fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) (*tls.Certificate, error) {
|
|
log := mlog.New("autotls", nil).WithContext(hello.Context())
|
|
|
|
// If we can't find a certificate (depending on fallback parameters), we return a
|
|
// nil certificate and nil error, which crypto/tls turns into a TLS alert
|
|
// "unrecognized name", which can be interpreted by clients as a hint that they are
|
|
// using the wrong hostname, or a certificate is missing.
|
|
|
|
if hello.ServerName == "" && fallbackNoSNI {
|
|
hello.ServerName = fallbackHostname.ASCII
|
|
}
|
|
|
|
// Handle missing SNI to prevent logging an error below.
|
|
if hello.ServerName == "" {
|
|
log.Debug("tls request without sni servername, rejecting", slog.Any("localaddr", hello.Conn.LocalAddr()), slog.Any("supportedprotos", hello.SupportedProtos))
|
|
return nil, nil
|
|
}
|
|
|
|
cert, err := m.Manager.GetCertificate(hello)
|
|
if err != nil && errors.Is(err, errHostNotAllowed) {
|
|
if !fallbackUnknownSNI {
|
|
log.Debugx("requesting certificate", err, slog.String("host", hello.ServerName))
|
|
return nil, nil
|
|
}
|
|
|
|
log.Debug("certificate for unknown hostname, using fallback hostname", slog.String("host", hello.ServerName))
|
|
hello.ServerName = fallbackHostname.ASCII
|
|
cert, err = m.Manager.GetCertificate(hello)
|
|
if err != nil {
|
|
log.Errorx("requesting certificate for fallback hostname", err, slog.String("host", hello.ServerName))
|
|
} else {
|
|
log.Debugx("requesting certificate for fallback hostname", err, slog.String("host", hello.ServerName))
|
|
}
|
|
return cert, err
|
|
} else if err != nil {
|
|
log.Errorx("requesting certificate", err, slog.String("host", hello.ServerName))
|
|
}
|
|
return cert, err
|
|
}
|
|
|
|
// TLSConfig returns a TLS server config that optionally returns a certificate for
|
|
// fallbackHostname if no SNI was done, or for an unknown hostname.
|
|
//
|
|
// If fallbackNoSNI is set, TLS connections without SNI will use a certificate for
|
|
// fallbackHostname. Otherwise, connections without SNI will fail with a message
|
|
// that no TLS certificate is available.
|
|
//
|
|
// If fallbackUnknownSNI is set, TLS connections with an SNI hostname that is not
|
|
// allowlisted will instead use a certificate for fallbackHostname. Otherwise, such
|
|
// TLS connections will fail.
|
|
func (m *Manager) TLSConfig(fallbackHostname dns.Domain, fallbackNoSNI, fallbackUnknownSNI bool) *tls.Config {
|
|
return &tls.Config{
|
|
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
return m.loggingGetCertificate(hello, fallbackHostname, fallbackNoSNI, fallbackUnknownSNI)
|
|
},
|
|
}
|
|
}
|
|
|
|
// CertAvailable checks whether a non-expired ECDSA certificate is available in the
|
|
// cache for host. No other checks than expiration are done.
|
|
func (m *Manager) CertAvailable(ctx context.Context, log mlog.Log, host dns.Domain) (bool, error) {
|
|
ck := host.ASCII // Would be "+rsa" for rsa keys.
|
|
data, err := m.Manager.Cache.Get(ctx, ck)
|
|
if err != nil && errors.Is(err, autocert.ErrCacheMiss) {
|
|
return false, nil
|
|
} else if err != nil {
|
|
return false, fmt.Errorf("attempt to get certificate from cache: %v", err)
|
|
}
|
|
|
|
// The cached keycert is of the form: private key, leaf certificate, intermediate certificates...
|
|
privb, rem := pem.Decode(data)
|
|
if privb == nil {
|
|
return false, fmt.Errorf("missing private key in cached keycert file")
|
|
}
|
|
pubb, _ := pem.Decode(rem)
|
|
if pubb == nil {
|
|
return false, fmt.Errorf("missing certificate in cached keycert file")
|
|
} else if pubb.Type != "CERTIFICATE" {
|
|
return false, fmt.Errorf("second pem block is %q, expected CERTIFICATE", pubb.Type)
|
|
}
|
|
cert, err := x509.ParseCertificate(pubb.Bytes)
|
|
if err != nil {
|
|
return false, fmt.Errorf("parsing certificate from cached keycert file: %v", err)
|
|
}
|
|
// We assume the certificate has a matching hostname, and is properly CA-signed. We
|
|
// only check the expiration time.
|
|
if time.Until(cert.NotBefore) > 0 || time.Since(cert.NotAfter) > 0 {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// SetAllowedHostnames sets a new list of allowed hostnames for automatic TLS.
|
|
// After setting the host names, a goroutine is start to check that new host names
|
|
// are fully served by publicIPs (only if non-empty and there is no unspecified
|
|
// address in the list). If no, log an error with a warning that ACME validation
|
|
// may fail.
|
|
func (m *Manager) SetAllowedHostnames(log mlog.Log, resolver dns.Resolver, hostnames map[dns.Domain]struct{}, publicIPs []string, checkHosts bool) {
|
|
m.Lock()
|
|
defer m.Unlock()
|
|
|
|
// Log as slice, sorted.
|
|
l := make([]dns.Domain, 0, len(hostnames))
|
|
for d := range hostnames {
|
|
l = append(l, d)
|
|
}
|
|
sort.Slice(l, func(i, j int) bool {
|
|
return l[i].Name() < l[j].Name()
|
|
})
|
|
|
|
log.Debug("autotls setting allowed hostnames", slog.Any("hostnames", l), slog.Any("publicips", publicIPs))
|
|
var added []dns.Domain
|
|
for h := range hostnames {
|
|
if _, ok := m.hosts[h]; !ok {
|
|
added = append(added, h)
|
|
}
|
|
}
|
|
m.hosts = hostnames
|
|
|
|
if checkHosts && len(added) > 0 && len(publicIPs) > 0 {
|
|
for _, ip := range publicIPs {
|
|
if net.ParseIP(ip).IsUnspecified() {
|
|
return
|
|
}
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
publicIPstrs := map[string]struct{}{}
|
|
for _, ip := range publicIPs {
|
|
publicIPstrs[ip] = struct{}{}
|
|
}
|
|
|
|
log.Debug("checking ips of hosts configured for acme tls cert validation")
|
|
for _, h := range added {
|
|
ips, _, err := resolver.LookupIP(ctx, "ip", h.ASCII+".")
|
|
if err != nil {
|
|
log.Errorx("warning: acme tls cert validation for host may fail due to dns lookup error", err, slog.Any("host", h))
|
|
continue
|
|
}
|
|
for _, ip := range ips {
|
|
if _, ok := publicIPstrs[ip.String()]; !ok {
|
|
log.Error("warning: acme tls cert validation for host is likely to fail because not all its ips are being listened on",
|
|
slog.Any("hostname", h),
|
|
slog.Any("listenedips", publicIPs),
|
|
slog.Any("hostips", ips),
|
|
slog.Any("missingip", ip))
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Hostnames returns the allowed host names for use with ACME.
|
|
func (m *Manager) Hostnames() []dns.Domain {
|
|
m.Lock()
|
|
defer m.Unlock()
|
|
var l []dns.Domain
|
|
for h := range m.hosts {
|
|
l = append(l, h)
|
|
}
|
|
return l
|
|
}
|
|
|
|
var errHostNotAllowed = errors.New("autotls: host not in allowlist")
|
|
|
|
// HostPolicy decides if a host is allowed for use with ACME, i.e. whether a
|
|
// certificate will be returned if present and/or will be requested if not yet
|
|
// present. Only hosts added with SetAllowedHostnames are allowed. During shutdown,
|
|
// no new connections are allowed.
|
|
func (m *Manager) HostPolicy(ctx context.Context, host string) (rerr error) {
|
|
log := mlog.New("autotls", nil).WithContext(ctx)
|
|
defer func() {
|
|
log.Debugx("autotls hostpolicy result", rerr, slog.String("host", host))
|
|
}()
|
|
|
|
// Don't request new TLS certs when we are shutting down.
|
|
select {
|
|
case <-m.shutdown:
|
|
return fmt.Errorf("shutting down")
|
|
default:
|
|
}
|
|
|
|
xhost, _, err := net.SplitHostPort(host)
|
|
if err == nil {
|
|
// For http-01, host may include a port number.
|
|
host = xhost
|
|
}
|
|
|
|
d, err := dns.ParseDomain(host)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid host: %v", err)
|
|
}
|
|
|
|
m.Lock()
|
|
defer m.Unlock()
|
|
if _, ok := m.hosts[d]; !ok {
|
|
return fmt.Errorf("%w: %q", errHostNotAllowed, d)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type dirCache autocert.DirCache
|
|
|
|
func (d dirCache) Delete(ctx context.Context, name string) (rerr error) {
|
|
log := mlog.New("autotls", nil).WithContext(ctx)
|
|
defer func() {
|
|
log.Debugx("dircache delete result", rerr, slog.String("name", name))
|
|
}()
|
|
err := autocert.DirCache(d).Delete(ctx, name)
|
|
if err != nil {
|
|
log.Errorx("deleting cert from dir cache", err, slog.String("name", name))
|
|
} else if !strings.HasSuffix(name, "+token") {
|
|
log.Info("autotls cert delete", slog.String("name", name))
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (d dirCache) Get(ctx context.Context, name string) (rbuf []byte, rerr error) {
|
|
log := mlog.New("autotls", nil).WithContext(ctx)
|
|
defer func() {
|
|
log.Debugx("dircache get result", rerr, slog.String("name", name))
|
|
}()
|
|
buf, err := autocert.DirCache(d).Get(ctx, name)
|
|
if err != nil && errors.Is(err, autocert.ErrCacheMiss) {
|
|
log.Infox("getting cert from dir cache", err, slog.String("name", name))
|
|
} else if err != nil {
|
|
log.Errorx("getting cert from dir cache", err, slog.String("name", name))
|
|
} else if !strings.HasSuffix(name, "+token") {
|
|
log.Debug("autotls cert get", slog.String("name", name))
|
|
}
|
|
return buf, err
|
|
}
|
|
|
|
func (d dirCache) Put(ctx context.Context, name string, data []byte) (rerr error) {
|
|
log := mlog.New("autotls", nil).WithContext(ctx)
|
|
defer func() {
|
|
log.Debugx("dircache put result", rerr, slog.String("name", name))
|
|
}()
|
|
metricCertput.Inc()
|
|
err := autocert.DirCache(d).Put(ctx, name, data)
|
|
if err != nil {
|
|
log.Errorx("storing cert in dir cache", err, slog.String("name", name))
|
|
} else if !strings.HasSuffix(name, "+token") {
|
|
log.Info("autotls cert store", slog.String("name", name))
|
|
}
|
|
return err
|
|
}
|