mirror of
https://github.com/mjl-/mox.git
synced 2025-07-13 06:54:38 +03:00
mox!
This commit is contained in:
279
autotls/autotls.go
Normal file
279
autotls/autotls.go
Normal file
@ -0,0 +1,279 @@
|
||||
// Package autotls automatically configures TLS (for SMTP, IMAP, HTTP) by
|
||||
// requesting certificates with ACME, typically from Let's Encrypt.
|
||||
package autotls
|
||||
|
||||
// We only do tls-alpn-01. For http-01, we would have to start another
|
||||
// listener. 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. We can do http-01 relatively easily. It could
|
||||
// be useful to not depend on a single mechanism.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/moxvar"
|
||||
)
|
||||
|
||||
var xlog = mlog.New("autotls")
|
||||
|
||||
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.
|
||||
TLSConfig *tls.Config // For all TLS servers not used for validating ACME requests. Like SMTP and HTTPS on ports other than 443.
|
||||
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. When shutdown is closed, no
|
||||
// new TLS connections can be created.
|
||||
func Load(name, acmeDir, contactEmail, directoryURL string, 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(acmeDir + "/keycerts/" + name),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Email: contactEmail,
|
||||
Client: &acme.Client{
|
||||
DirectoryURL: directoryURL,
|
||||
Key: key,
|
||||
UserAgent: "mox/" + moxvar.Version,
|
||||
},
|
||||
// HostPolicy set below.
|
||||
}
|
||||
|
||||
loggingGetCertificate := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
log := xlog.WithContext(hello.Context())
|
||||
|
||||
// Handle missing SNI to prevent logging an error below.
|
||||
// At startup, during config initialization, we already adjust the tls config to
|
||||
// inject the listener hostname if there isn't one in the TLS client hello. This is
|
||||
// common for SMTP STARTTLS connections, which often do not care about the
|
||||
// validation of the certificate.
|
||||
if hello.ServerName == "" {
|
||||
log.Debug("tls request without sni servername, rejecting")
|
||||
return nil, fmt.Errorf("sni server name required")
|
||||
}
|
||||
|
||||
cert, err := m.GetCertificate(hello)
|
||||
if err != nil {
|
||||
if errors.Is(err, errHostNotAllowed) {
|
||||
log.Debugx("requesting certificate", err, mlog.Field("host", hello.ServerName))
|
||||
} else {
|
||||
log.Errorx("requesting certificate", err, mlog.Field("host", hello.ServerName))
|
||||
}
|
||||
}
|
||||
return cert, err
|
||||
}
|
||||
|
||||
acmeTLSConfig := *m.TLSConfig()
|
||||
acmeTLSConfig.GetCertificate = loggingGetCertificate
|
||||
|
||||
tlsConfig := tls.Config{
|
||||
GetCertificate: loggingGetCertificate,
|
||||
}
|
||||
|
||||
a := &Manager{
|
||||
ACMETLSConfig: &acmeTLSConfig,
|
||||
TLSConfig: &tlsConfig,
|
||||
Manager: m,
|
||||
shutdown: shutdown,
|
||||
hosts: map[dns.Domain]struct{}{},
|
||||
}
|
||||
m.HostPolicy = a.HostPolicy
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// AllowHostname adds hostname for use with ACME.
|
||||
func (m *Manager) AllowHostname(hostname dns.Domain) {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
xlog.Debug("autotls add hostname", mlog.Field("hostname", hostname))
|
||||
m.hosts[hostname] = struct{}{}
|
||||
}
|
||||
|
||||
// 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 AllowHostname are allowed. During shutdown, no
|
||||
// new connections are allowed.
|
||||
func (m *Manager) HostPolicy(ctx context.Context, host string) (rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
defer func() {
|
||||
log.WithContext(ctx).Debugx("autotls hostpolicy result", rerr, mlog.Field("host", host))
|
||||
}()
|
||||
|
||||
// Don't request new TLS certs when we are shutting down.
|
||||
select {
|
||||
case <-m.shutdown:
|
||||
return fmt.Errorf("shutting down")
|
||||
default:
|
||||
}
|
||||
|
||||
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 := xlog.WithContext(ctx)
|
||||
defer func() {
|
||||
log.Debugx("dircache delete result", rerr, mlog.Field("name", name))
|
||||
}()
|
||||
err := autocert.DirCache(d).Delete(ctx, name)
|
||||
if err != nil {
|
||||
log.Errorx("deleting cert from dir cache", err, mlog.Field("name", name))
|
||||
} else if !strings.HasSuffix(name, "+token") {
|
||||
log.Info("autotls cert delete", mlog.Field("name", name))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d dirCache) Get(ctx context.Context, name string) (rbuf []byte, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
defer func() {
|
||||
log.Debugx("dircache get result", rerr, mlog.Field("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, mlog.Field("name", name))
|
||||
} else if err != nil {
|
||||
log.Errorx("getting cert from dir cache", err, mlog.Field("name", name))
|
||||
} else if !strings.HasSuffix(name, "+token") {
|
||||
log.Debug("autotls cert get", mlog.Field("name", name))
|
||||
}
|
||||
return buf, err
|
||||
}
|
||||
|
||||
func (d dirCache) Put(ctx context.Context, name string, data []byte) (rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
defer func() {
|
||||
log.Debugx("dircache put result", rerr, mlog.Field("name", name))
|
||||
}()
|
||||
metricCertput.Inc()
|
||||
err := autocert.DirCache(d).Put(ctx, name, data)
|
||||
if err != nil {
|
||||
log.Errorx("storing cert in dir cache", err, mlog.Field("name", name))
|
||||
} else if !strings.HasSuffix(name, "+token") {
|
||||
log.Info("autotls cert store", mlog.Field("name", name))
|
||||
}
|
||||
return err
|
||||
}
|
97
autotls/autotls_test.go
Normal file
97
autotls/autotls_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package autotls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
func TestAutotls(t *testing.T) {
|
||||
os.RemoveAll("../testdata/autotls")
|
||||
os.MkdirAll("../testdata/autotls", 0770)
|
||||
|
||||
shutdown := make(chan struct{})
|
||||
m, err := Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", shutdown)
|
||||
if err != nil {
|
||||
t.Fatalf("load manager: %v", err)
|
||||
}
|
||||
l := m.Hostnames()
|
||||
if len(l) != 0 {
|
||||
t.Fatalf("hostnames, got %v, expected empty list", l)
|
||||
}
|
||||
if err := m.HostPolicy(context.Background(), "mox.example"); err == nil || !errors.Is(err, errHostNotAllowed) {
|
||||
t.Fatalf("hostpolicy, got err %v, expected errHostNotAllowed", err)
|
||||
}
|
||||
m.AllowHostname(dns.Domain{ASCII: "mox.example"})
|
||||
l = m.Hostnames()
|
||||
if !reflect.DeepEqual(l, []dns.Domain{{ASCII: "mox.example"}}) {
|
||||
t.Fatalf("hostnames, got %v, expected single mox.example", l)
|
||||
}
|
||||
if err := m.HostPolicy(context.Background(), "mox.example"); err != nil {
|
||||
t.Fatalf("hostpolicy, got err %v, expected no error", err)
|
||||
}
|
||||
if err := m.HostPolicy(context.Background(), "other.mox.example"); err == nil || !errors.Is(err, errHostNotAllowed) {
|
||||
t.Fatalf("hostpolicy, got err %v, expected errHostNotAllowed", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cache := m.Manager.Cache
|
||||
if _, err := cache.Get(ctx, "mox.example"); err == nil || !errors.Is(err, autocert.ErrCacheMiss) {
|
||||
t.Fatalf("cache get for absent entry: got err %v, expected autocert.ErrCacheMiss", err)
|
||||
}
|
||||
if err := cache.Put(ctx, "mox.example", []byte("test")); err != nil {
|
||||
t.Fatalf("cache put for absent entry: got err %v, expected error", err)
|
||||
}
|
||||
if data, err := cache.Get(ctx, "mox.example"); err != nil || string(data) != "test" {
|
||||
t.Fatalf("cache get: got err %v data %q, expected nil, 'test'", err, data)
|
||||
}
|
||||
if err := cache.Put(ctx, "mox.example", []byte("test2")); err != nil {
|
||||
t.Fatalf("cache put for absent entry: got err %v, expected error", err)
|
||||
}
|
||||
if data, err := cache.Get(ctx, "mox.example"); err != nil || string(data) != "test2" {
|
||||
t.Fatalf("cache get: got err %v data %q, expected nil, 'test2'", err, data)
|
||||
}
|
||||
if err := cache.Delete(ctx, "mox.example"); err != nil {
|
||||
t.Fatalf("cache delete: got err %v, expected no error", err)
|
||||
}
|
||||
if _, err := cache.Get(ctx, "mox.example"); err == nil || !errors.Is(err, autocert.ErrCacheMiss) {
|
||||
t.Fatalf("cache get for absent entry: got err %v, expected autocert.ErrCacheMiss", err)
|
||||
}
|
||||
|
||||
close(shutdown)
|
||||
if err := m.HostPolicy(context.Background(), "mox.example"); err == nil {
|
||||
t.Fatalf("hostpolicy, got err %v, expected error due to shutdown", err)
|
||||
}
|
||||
|
||||
key0 := m.Manager.Client.Key
|
||||
|
||||
m, err = Load("test", "../testdata/autotls", "mox@localhost", "https://localhost/", shutdown)
|
||||
if err != nil {
|
||||
t.Fatalf("load manager again: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Manager.Client.Key, key0) {
|
||||
t.Fatalf("private key changed after reload")
|
||||
}
|
||||
m.shutdown = make(chan struct{})
|
||||
m.AllowHostname(dns.Domain{ASCII: "mox.example"})
|
||||
if err := m.HostPolicy(context.Background(), "mox.example"); err != nil {
|
||||
t.Fatalf("hostpolicy, got err %v, expected no error", err)
|
||||
}
|
||||
|
||||
m2, err := Load("test2", "../testdata/autotls", "mox@localhost", "https://localhost/", shutdown)
|
||||
if err != nil {
|
||||
t.Fatalf("load another manager: %v", err)
|
||||
}
|
||||
if reflect.DeepEqual(m.Manager.Client.Key, m2.Manager.Client.Key) {
|
||||
t.Fatalf("private key reused between managers")
|
||||
}
|
||||
|
||||
// Only remove in case of success.
|
||||
os.RemoveAll("../testdata/autotls")
|
||||
}
|
Reference in New Issue
Block a user