diff --git a/README.md b/README.md
index 669664f9fdf95a49b92d408ea1b22deec560ea2d..0932128e6aa5d5287be754bd6be9beb270b558e1 100644
--- a/README.md
+++ b/README.md
@@ -3,3 +3,34 @@ acmeserver
 
 Runs a daemon to manage a set of SSL certificates using the ACME protocol.
 
+There are many similar tools, why another one? Well we need a few
+unique features:
+
+* custom output code for certificates and private keys, so we can
+  write them to [replds](https://git.autistici.org/ai3/replds) and
+  have them replicated to all front-ends;
+* support for our DNS setup for *dns-01* challenges, by sending RFC
+  2136 updates to all DNS servers in parallel.
+
+For the rest it's a fairly common ACME automation tool, it supports
+the *http-01* and *dns-01* challenges (no *tls-sni-01* because the
+tool is meant to be run behind a HTTPS proxy so it can't directly
+control the serving certificates).
+
+Since this is a particularly critical piece of software, a few extra
+cautions are necessary in its development:
+
+* do not implement any ACME-specific code but use a well-maintained
+  library instead
+  (like [golang.org/x/crypto/acme](https://golang.org/x/crypto/acme))
+* try to be robust against ACME high-level protocol changes by keeping
+  this tool replaceable with *certbot* and a bunch of shell
+  scripts. In particular we can do this by:
+  * keeping a directory structure for the output that's compatible
+    with certbot
+  * having a way to independently push content to replds (which we do,
+    by way of the *replds* command itself)
+    
+  So the advantage of *acmeserver* becomes just the integration
+  between the various components in a single package / binary (and
+  monitoring, etc).
diff --git a/acme.go b/acme.go
index fe52a6b931a317849214e4674a3b384be8308d1e..51eccf9d94251b2aa484b1b38bba53f43338c20e 100644
--- a/acme.go
+++ b/acme.go
@@ -14,22 +14,31 @@ import (
 	"log"
 	"net/http"
 	"path/filepath"
-	"strings"
-	"sync"
 
 	"golang.org/x/crypto/acme"
 )
 
-// ACME handles the certificate creation/renewal workflow using acme
-// http-01 challenges. It serves validation tokens under the
-// /.well-known/acme-challenge path when used as an HTTP handler.
+const (
+	http01Challenge = "http-01"
+	dns01Challenge  = "dns-01"
+)
+
+type validator interface {
+	Fulfill(context.Context, *acme.Client, string, *acme.Challenge) (func(), error)
+}
+
+// ACME is a simple wrapper for an acme.Client that handles the
+// certificate creation/renewal workflow using http-01 or dns-01
+// challenges.
 type ACME struct {
 	email          string
 	accountKeyPath string
+	directoryURL   string
 	client         *acme.Client
 
-	mx         sync.Mutex
-	httpTokens map[string][]byte
+	handler              http.Handler
+	validators           map[string]validator
+	defaultChallengeType string
 }
 
 // NewACME returns a new ACME object with the provided config.
@@ -38,14 +47,46 @@ func NewACME(config *Config) (*ACME, error) {
 		return nil, errors.New("configuration parameter 'email' is unset")
 	}
 
+	var h http.Handler
+	v := make(map[string]validator)
+	if config.HTTP.Enabled {
+		httpv := newHTTPValidator()
+		h = httpv
+		v[http01Challenge] = httpv
+	}
+	if config.DNS.Enabled {
+		dnsv, err := newDNSValidator(config)
+		if err != nil {
+			return nil, err
+		}
+		v[dns01Challenge] = dnsv
+	}
+
+	defaultChalType := config.DefaultChallengeType
+	if defaultChalType == "" {
+		defaultChalType = http01Challenge
+	}
+	if _, ok := v[defaultChalType]; !ok {
+		return nil, fmt.Errorf("the default challenge type '%s' is not configured", defaultChalType)
+	}
+
+	directoryURL := config.DirectoryURL
+	if directoryURL == "" {
+		directoryURL = acme.LetsEncryptURL
+	}
+
 	return &ACME{
-		email:          config.Email,
-		accountKeyPath: filepath.Join(config.Dir, "account.key"),
-		httpTokens:     make(map[string][]byte),
+		email:                config.Email,
+		accountKeyPath:       filepath.Join(config.Dir, "account.key"),
+		directoryURL:         directoryURL,
+		handler:              h,
+		validators:           v,
+		defaultChallengeType: defaultChalType,
 	}, nil
 }
 
-func (a *ACME) accountKey(ctx context.Context) (crypto.Signer, error) {
+// Return the account key, possibly initializing it if it does not exist.
+func (a *ACME) accountKey() (crypto.Signer, error) {
 	if key, err := parsePrivateKeyFromFile(a.accountKeyPath); err == nil {
 		return key, err
 	}
@@ -66,15 +107,18 @@ func (a *ACME) accountKey(ctx context.Context) (crypto.Signer, error) {
 	return eckey, err
 }
 
+// Return a new acme.Client, possibly initializing the account key if
+// it does not exist yet. The Client is created on the first call and
+// cached thereafter.
 func (a *ACME) acmeClient(ctx context.Context) (*acme.Client, error) {
 	if a.client != nil {
 		return a.client, nil
 	}
 
 	client := &acme.Client{
-		DirectoryURL: acme.LetsEncryptURL,
+		DirectoryURL: a.directoryURL,
 	}
-	key, err := a.accountKey(ctx)
+	key, err := a.accountKey()
 	if err != nil {
 		return nil, err
 	}
@@ -82,6 +126,10 @@ func (a *ACME) acmeClient(ctx context.Context) (*acme.Client, error) {
 	ac := &acme.Account{
 		Contact: []string{"mailto:" + a.email},
 	}
+
+	// Register the account (accept TOS) if necessary. If the
+	// account is already registered we get a StatusConflict,
+	// which we can ignore.
 	_, err = client.Register(ctx, ac, func(_ string) bool { return true })
 	if ae, ok := err.(*acme.Error); err == nil || ok && ae.StatusCode == http.StatusConflict {
 		a.client = client
@@ -93,17 +141,17 @@ func (a *ACME) acmeClient(ctx context.Context) (*acme.Client, error) {
 // GetCertificate returns a certificate chain (and the parsed leaf
 // cert), given a private key and a list of domains. The first domain
 // will be the subject CN, the others will be added as subjectAltNames.
-func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, domains []string) (der [][]byte, leaf *x509.Certificate, err error) {
+func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, c *certConfig) (der [][]byte, leaf *x509.Certificate, err error) {
 	client, err := a.acmeClient(ctx)
 	if err != nil {
 		return nil, nil, err
 	}
 
-	if err = a.verifyAll(ctx, client, domains); err != nil {
+	if err = a.verifyAll(ctx, client, c); err != nil {
 		return nil, nil, err
 	}
 
-	csr, err := certRequest(key, domains)
+	csr, err := certRequest(key, c.Names)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -111,28 +159,29 @@ func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, domains []
 	if err != nil {
 		return nil, nil, err
 	}
-	leaf, err = validCert(domains, der, key)
+	leaf, err = validCert(c.Names, der, key)
 	if err != nil {
 		return nil, nil, err
 	}
 	return der, leaf, nil
 }
 
-func (a *ACME) verifyAll(ctx context.Context, client *acme.Client, domains []string) error {
-	for _, domain := range domains {
-		if err := a.verify(ctx, client, domain); err != nil {
+func (a *ACME) verifyAll(ctx context.Context, client *acme.Client, c *certConfig) error {
+	for _, domain := range c.Names {
+		if err := a.verify(ctx, client, c, domain); err != nil {
 			return err
 		}
 	}
 	return nil
 }
 
-func (a *ACME) verify(ctx context.Context, client *acme.Client, domain string) error {
+func (a *ACME) verify(ctx context.Context, client *acme.Client, c *certConfig, domain string) error {
+	// Make an authorization request to the ACME server, and
+	// verify that it returns a valid response with challenges.
 	authz, err := client.Authorize(ctx, domain)
 	if err != nil {
 		return err
 	}
-
 	switch authz.Status {
 	case acme.StatusValid:
 		return nil // already authorized
@@ -140,16 +189,28 @@ func (a *ACME) verify(ctx context.Context, client *acme.Client, domain string) e
 		return fmt.Errorf("invalid authorization %q", authz.URI)
 	}
 
-	chal := pickChallenge("http-01", authz.Challenges)
+	// Pick a challenge that matches our preferences and the
+	// available validators. The validator fulfills the challenge,
+	// and returns a cleanup function that we're going to call
+	// before we return. All steps are sequential and idempotent.
+	chal := a.pickChallenge(authz.Challenges, c)
 	if chal == nil {
 		return fmt.Errorf("unable to authorize %q", domain)
 	}
-	cleanup, err := a.fulfill(ctx, client, chal)
+	v, ok := a.validators[chal.Type]
+	if !ok {
+		return fmt.Errorf("challenge type '%s' is not available", chal.Type)
+	}
+	cleanup, err := v.Fulfill(ctx, client, domain, chal)
 	if err != nil {
 		return err
 	}
 	defer cleanup()
 
+	// Tell the ACME server that we've accepted the challenge, and
+	// then wait, possibly for some time, until there is an
+	// authorization response (either successful or not) from the
+	// server.
 	if _, err = client.Accept(ctx, chal); err != nil {
 		return err
 	}
@@ -157,56 +218,25 @@ func (a *ACME) verify(ctx context.Context, client *acme.Client, domain string) e
 	return err
 }
 
-func (a *ACME) fulfill(ctx context.Context, client *acme.Client, chal *acme.Challenge) (cleanup func(), err error) {
-	if chal.Type != "http-01" {
-		return nil, errors.New("unsupported challenge type")
-	}
-
-	resp, err := client.HTTP01ChallengeResponse(chal.Token)
-	if err != nil {
-		return nil, err
-	}
-	p := client.HTTP01ChallengePath(chal.Token)
-	a.putHTTPToken(ctx, p, resp)
-	return func() { go a.deleteHTTPToken(p) }, nil
-}
-
-func (a *ACME) httpToken(ctx context.Context, tokenPath string) ([]byte, error) {
-	a.mx.Lock()
-	defer a.mx.Unlock()
-
-	if v, ok := a.httpTokens[tokenPath]; ok {
-		return v, nil
+// Pick a challenge with the right type from the Challenge response
+// returned by the ACME server. We have a pretty specific idea of the
+// challenge type we want (either the default, or the ChallengeType
+// specified in the cert config), so we just search for that one.
+func (a *ACME) pickChallenge(chal []*acme.Challenge, c *certConfig) *acme.Challenge {
+	ctype := a.defaultChallengeType
+	if c.ChallengeType != "" {
+		ctype = c.ChallengeType
+	}
+	for _, ch := range chal {
+		if ch.Type == ctype {
+			return ch
+		}
 	}
-	return nil, errors.New("token not found")
-}
-
-func (a *ACME) putHTTPToken(ctx context.Context, tokenPath, val string) {
-	a.mx.Lock()
-	a.httpTokens[tokenPath] = []byte(val)
-	a.mx.Unlock()
-}
-
-func (a *ACME) deleteHTTPToken(tokenPath string) {
-	a.mx.Lock()
-	delete(a.httpTokens, tokenPath)
-	a.mx.Unlock()
+	return nil
 }
 
-func (a *ACME) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
-		http.NotFound(w, r)
-		return
-	}
-
-	a.mx.Lock()
-	defer a.mx.Unlock()
-
-	data, err := a.httpToken(r.Context(), r.URL.Path)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusNotFound)
-		return
-	}
-
-	w.Write(data)
+// Handler returns the http.Handler that is used to satisfy the
+// http-01 validation requests.
+func (a *ACME) Handler() http.Handler {
+	return a.handler
 }
diff --git a/cmd/acmeserver/acmeserver.go b/cmd/acmeserver/acmeserver.go
index 46accd3ba5ec38a6b48d4b5882b344a8b5ba482e..31e4d0c8aaefc1f5d0fdef34dc46bdd84ed4d77c 100644
--- a/cmd/acmeserver/acmeserver.go
+++ b/cmd/acmeserver/acmeserver.go
@@ -5,6 +5,7 @@ import (
 	"flag"
 	"io/ioutil"
 	"log"
+	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
@@ -19,6 +20,8 @@ var (
 	configFile = flag.String("config", "/etc/acmeserver/config.yml", "configuration `file`")
 )
 
+// Config ties together the acmeserver Config and the standard
+// serverutil HTTP server configuration.
 type Config struct {
 	ACME   *acmeserver.Config       `yaml:",inline"`
 	Server *serverutil.ServerConfig `yaml:"http_server"`
@@ -26,7 +29,7 @@ type Config struct {
 
 func loadConfig(path string) (*Config, error) {
 	// Read YAML config.
-	data, err := ioutil.ReadFile(path)
+	data, err := ioutil.ReadFile(path) // nolint: gosec
 	if err != nil {
 		return nil, err
 	}
@@ -46,22 +49,41 @@ func main() {
 		log.Fatal(err)
 	}
 
-	acme, err := acmeserver.NewACME(config.ACME)
-	if err != nil {
-		log.Fatal(err)
+	var h http.Handler
+	var cg acmeserver.CertGenerator
+	if !config.ACME.Testing {
+		acme, err := acmeserver.NewACME(config.ACME) // nolint: vetshadow
+		if err != nil {
+			log.Fatal(err)
+		}
+		cg = acme
+		h = acme.Handler()
+
+	} else {
+		// When testing=true there's no content to serve over
+		// HTTP. Metrics and other debug pages are added
+		// automatically by the serverutil package.
+		cg = acmeserver.NewSelfSignedCertGenerator()
+		h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			http.NotFound(w, r)
+		})
 	}
 
-	m, err := acmeserver.NewManager(config.ACME, acme)
+	m, err := acmeserver.NewManager(config.ACME, cg)
 	if err != nil {
 		log.Fatal(err)
 	}
 
-	// Start the acmeserver.Manager, and set up a SIGHUP handler to reload its config.
-	if err := m.Start(context.Background()); err != nil {
+	// Start the acmeserver.Manager in the background. The
+	// serverutil package installs SIGTERM handlers that stop the
+	// HTTP server, so when it terminates we also kill the context
+	// attached to the Manager.
+	ctx, cancel := context.WithCancel(context.Background())
+	if err := m.Start(ctx); err != nil {
 		log.Fatal(err)
 	}
-	defer m.Stop()
 
+	// Set up a SIGHUP handler to reload the configuration.
 	hupCh := make(chan os.Signal, 1)
 	go func() {
 		for {
@@ -71,7 +93,10 @@ func main() {
 	}()
 	signal.Notify(hupCh, syscall.SIGHUP)
 
-	if err := serverutil.Serve(acme, config.Server, *addr); err != nil {
+	if err := serverutil.Serve(h, config.Server, *addr); err != nil {
 		log.Fatal(err)
 	}
+
+	cancel()
+	m.Wait()
 }
diff --git a/config.go b/config.go
index f367a5684b3b954755e5074b6554d883f9399859..b009de492cfacac1ff3b3d691e50ef71ca375800 100644
--- a/config.go
+++ b/config.go
@@ -1,6 +1,8 @@
 package acmeserver
 
 import (
+	"errors"
+	"fmt"
 	"io/ioutil"
 	"log"
 	"path/filepath"
@@ -11,58 +13,92 @@ import (
 )
 
 // Config holds the configuration for an acmeserver instance.
+//
+// nolint: maligned
 type Config struct {
 	Addr string `yaml:"addr"`
 
-	Email  string                    `yaml:"email"`
-	UseRSA bool                      `yaml:"use_rsa"`
+	Testing              bool   `yaml:"testing"`
+	DirectoryURL         string `yaml:"directory_url"`
+	DefaultChallengeType string `yaml:"default_challenge"`
+	UseRSA               bool   `yaml:"use_rsa"`
+	RenewalDays          int    `yaml:"renewal_days"`
+	Email                string `yaml:"email"`
+
 	Dir    string                    `yaml:"cert_dir"`
 	ReplDS *clientutil.BackendConfig `yaml:"replds"`
+
+	HTTP struct {
+		Enabled bool `yaml:"enabled"`
+	} `yaml:"http"`
+
+	DNS struct {
+		Enabled       bool     `yaml:"enabled"`
+		Nameservers   []string `yaml:"nameservers"`
+		TSIGKeyName   string   `yaml:"tsig_key_name"`
+		TSIGKeyAlgo   string   `yaml:"tsig_key_algo"`
+		TSIGKeySecret string   `yaml:"tsig_key_secret"`
+	} `yaml:"dns"`
 }
 
-// Definition of a single certificate: the main CN, and optionally a
-// list of subjectAltName entries.
+// This is all the configuration we need to generate a certificate.
 type certConfig struct {
-	CN       string   `yaml:"cn"`
-	AltNames []string `yaml:"alt_names"`
+	// List of names for this certificate. The first one will be
+	// the certificate CN, all of them will be subjectAltNames.
+	Names []string `yaml:"names"`
+
+	// Challenge type to use for this domain. If empty, the
+	// defaults will be applied.
+	ChallengeType string `yaml:"challenge,omitempty"`
 }
 
-func decodeCertConfig(data []byte) ([][]string, error) {
-	var cc []certConfig
-	if err := yaml.Unmarshal(data, &cc); err != nil {
-		return nil, err
+func (c *certConfig) check() error {
+	if len(c.Names) == 0 {
+		return errors.New("empty names list")
 	}
-
-	var domains [][]string
-	for _, c := range cc {
-		d := []string{c.CN}
-		d = append(d, c.AltNames...)
-		domains = append(domains, d)
+	// We can't check here if the challenge type is actually
+	// configured or not, but at least we can report unknown /
+	// unsupported types as syntax errors.
+	switch c.ChallengeType {
+	case "", dns01Challenge, http01Challenge:
+	default:
+		return fmt.Errorf("unkown or unsupported challenge type '%s'", c.ChallengeType)
 	}
-	return domains, nil
+	return nil
 }
 
-func readCertConfig(path string) ([][]string, error) {
-	data, err := ioutil.ReadFile(path)
+func readCertConfigs(path string) ([]*certConfig, error) {
+	data, err := ioutil.ReadFile(path) // nolint: gosec
 	if err != nil {
 		return nil, err
 	}
-	return decodeCertConfig(data)
+	var cc []*certConfig
+	if err := yaml.Unmarshal(data, &cc); err != nil {
+		return nil, err
+	}
+	return cc, nil
 }
 
-func readCertConfigsFromDir(dir string) ([][]string, error) {
+func readCertConfigsFromDir(dir string) ([]*certConfig, error) {
 	files, err := filepath.Glob(filepath.Join(dir, "*.yml"))
 	if err != nil {
 		return nil, err
 	}
-	var domains [][]string
+	var out []*certConfig
 	for _, f := range files {
-		d, err := readCertConfig(f)
+		cc, err := readCertConfigs(f)
 		if err != nil {
 			log.Printf("error reading %s: %v", f, err)
 			continue
 		}
-		domains = append(domains, d...)
+		// Validate the cert configs, skip ones with errors.
+		for _, c := range cc {
+			if err := c.check(); err != nil {
+				log.Printf("configuration error in %s: %v", f, err)
+				continue
+			}
+			out = append(out, c)
+		}
 	}
-	return domains, nil
+	return out, nil
 }
diff --git a/dns_challenge.go b/dns_challenge.go
new file mode 100644
index 0000000000000000000000000000000000000000..af04a08b29f1f728749ecf2fcc4150525943d971
--- /dev/null
+++ b/dns_challenge.go
@@ -0,0 +1,156 @@
+package acmeserver
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log"
+	"strings"
+	"time"
+
+	"github.com/miekg/dns"
+	"golang.org/x/crypto/acme"
+)
+
+const (
+	rfc2136Timeout   = 600
+	tsigFudgeSeconds = 300
+)
+
+type dnsValidator struct {
+	nameservers []string
+	enableTSIG  bool
+	keyName     string
+	keyAlgo     string
+	keySecret   string
+}
+
+func newDNSValidator(config *Config) (*dnsValidator, error) {
+	if len(config.DNS.Nameservers) == 0 {
+		return nil, errors.New("no nameservers configured")
+	}
+
+	// Check that the TSIG parameters are consistent, if provided at all.
+	n := 0
+	if config.DNS.TSIGKeyName != "" {
+		n++
+	}
+	if config.DNS.TSIGKeyAlgo != "" {
+		n++
+	}
+	if config.DNS.TSIGKeySecret != "" {
+		n++
+	}
+	if n != 0 && n != 3 {
+		return nil, errors.New("either none or all of 'tsig_key_name', 'tsig_key_algo' and 'tsig_key_secret' must be set")
+	}
+
+	return &dnsValidator{
+		nameservers: config.DNS.Nameservers,
+		enableTSIG:  n > 0,
+		keyName:     dns.Fqdn(config.DNS.TSIGKeyName),
+		keyAlgo:     config.DNS.TSIGKeyAlgo,
+		keySecret:   config.DNS.TSIGKeySecret,
+	}, nil
+}
+
+func (d *dnsValidator) makeRR(fqdn, value string, ttl int) []dns.RR {
+	rr := new(dns.TXT)
+	rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}
+	rr.Txt = []string{value}
+	return []dns.RR{rr}
+}
+
+func (d *dnsValidator) makeMsg(zone string, rrs []dns.RR, remove bool) *dns.Msg {
+	m := new(dns.Msg)
+	m.SetUpdate(zone)
+	if remove {
+		m.Remove(rrs)
+	} else {
+		m.RemoveRRset(rrs)
+		m.Insert(rrs)
+	}
+	if d.enableTSIG {
+		m.SetTsig(d.keyName, d.keyAlgo, tsigFudgeSeconds, time.Now().Unix())
+	}
+	return m
+}
+
+func (d *dnsValidator) client() *dns.Client {
+	c := new(dns.Client)
+	c.SingleInflight = true
+	if d.enableTSIG {
+		// TSIG authentication / msg signing.
+		c.TsigSecret = map[string]string{d.keyName: d.keySecret}
+	}
+	return c
+}
+
+func (d *dnsValidator) Fulfill(ctx context.Context, client *acme.Client, domain string, chal *acme.Challenge) (func(), error) {
+	zone := domain[strings.Index(domain, ".")+1:]
+	fqdn := dns.Fqdn(domain)
+	value, err := client.DNS01ChallengeRecord(chal.Token)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := d.onAllNS(ctx, func(ns string) error {
+		return d.updateNS(ctx, ns, zone, fqdn, value, false)
+	}); err != nil {
+		return nil, err
+	}
+
+	return func() {
+		// nolint
+		d.onAllNS(ctx, func(ns string) error {
+			return d.updateNS(ctx, ns, zone, fqdn, value, true)
+		})
+	}, nil
+}
+
+func (d *dnsValidator) updateNS(ctx context.Context, ns, zone, fqdn, value string, remove bool) error {
+	rrs := d.makeRR(fqdn, value, rfc2136Timeout)
+	m := d.makeMsg(zone, rrs, remove)
+	c := d.client()
+
+	// Send the query
+	reply, _, err := c.Exchange(m, ns)
+	if err != nil {
+		return err
+	}
+	if reply != nil && reply.Rcode != dns.RcodeSuccess {
+		return fmt.Errorf("DNS server replied: %s", dns.RcodeToString[reply.Rcode])
+	}
+
+	return nil
+}
+
+// Run a function for each configured nameserver.
+func (d *dnsValidator) onAllNS(ctx context.Context, f func(string) error) error {
+	ch := make(chan error, len(d.nameservers))
+	defer close(ch)
+
+	for _, ns := range d.nameservers {
+		if !strings.Contains(ns, ":") {
+			ns += ":53"
+		}
+		go func(ns string) {
+			err := f(ns)
+			if err != nil {
+				log.Printf("error updating DNS server %s: %v", ns, err)
+			}
+			ch <- err
+		}(ns)
+	}
+
+	var ok bool
+	for i := 0; i < len(d.nameservers); i++ {
+		if err := <-ch; err == nil {
+			ok = true
+		}
+	}
+	if !ok {
+		return errors.New("all nameservers failed")
+	}
+	return nil
+}
diff --git a/http_challenge.go b/http_challenge.go
new file mode 100644
index 0000000000000000000000000000000000000000..f99a28af87645f4aa41b1f08d9426d85d3616282
--- /dev/null
+++ b/http_challenge.go
@@ -0,0 +1,58 @@
+package acmeserver
+
+import (
+	"context"
+	"net/http"
+	"sync"
+
+	"golang.org/x/crypto/acme"
+)
+
+// httpValidator can validate http-01 challenges. It serves
+// validation tokens under the /.well-known/acme-challenge path when
+// used as an HTTP handler.
+type httpValidator struct {
+	mx     sync.Mutex
+	tokens map[string][]byte
+}
+
+func newHTTPValidator() *httpValidator {
+	return &httpValidator{
+		tokens: make(map[string][]byte),
+	}
+}
+
+func (h *httpValidator) Fulfill(_ context.Context, client *acme.Client, _ string, chal *acme.Challenge) (func(), error) {
+	resp, err := client.HTTP01ChallengeResponse(chal.Token)
+	if err != nil {
+		return nil, err
+	}
+	path := client.HTTP01ChallengePath(chal.Token)
+
+	h.mx.Lock()
+	h.tokens[path] = []byte(resp)
+	h.mx.Unlock()
+
+	return func() {
+		go func() {
+			h.mx.Lock()
+			delete(h.tokens, path)
+			h.mx.Unlock()
+		}()
+	}, nil
+}
+
+func (h *httpValidator) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// No defer because it's not polite to Write() while holding the lock.
+	h.mx.Lock()
+	data, ok := h.tokens[r.URL.Path]
+	h.mx.Unlock()
+
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+
+	// Not interested in errors writing the response.
+	w.Write(data) // nolint
+}
diff --git a/instrumentation.go b/instrumentation.go
new file mode 100644
index 0000000000000000000000000000000000000000..3e1ce04bdd999db0238721e207a0b10a59bdfb0a
--- /dev/null
+++ b/instrumentation.go
@@ -0,0 +1,47 @@
+package acmeserver
+
+import (
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+var (
+	certExpirationTimes = prometheus.NewGaugeVec(
+		prometheus.GaugeOpts{
+			Name: "cert_expiration_time",
+			Help: "Certificate expiration time.",
+		},
+		[]string{"cn"},
+	)
+	certOk = prometheus.NewGaugeVec(
+		prometheus.GaugeOpts{
+			Name: "cert_ok",
+			Help: "Do we have a valid certificate.",
+		},
+		[]string{"cn"},
+	)
+	certRenewalCount = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "cert_renewal_total",
+			Help: "Total certificate renewal attempts.",
+		},
+		[]string{"cn"},
+	)
+	certRenewalErrorCount = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "cert_renewal_errors",
+			Help: "Errors while renewing certificates.",
+		},
+		[]string{"cn"},
+	)
+)
+
+func init() {
+	prometheus.MustRegister(certExpirationTimes, certOk, certRenewalErrorCount)
+}
+
+func b2f(b bool) float64 {
+	if b {
+		return 1
+	}
+	return 0
+}
diff --git a/server.go b/manager.go
similarity index 61%
rename from server.go
rename to manager.go
index 79d25a48dcfcc70a77669d444a4eddbd5d22df98..76910d12f52f92609396b37e8c879b9d300128a2 100644
--- a/server.go
+++ b/manager.go
@@ -11,7 +11,6 @@ import (
 	"crypto/x509/pkix"
 	"encoding/pem"
 	"errors"
-	"fmt"
 	"io"
 	"log"
 	"path/filepath"
@@ -19,17 +18,46 @@ import (
 	"time"
 
 	"git.autistici.org/ai3/go-common/clientutil"
-	"golang.org/x/crypto/acme"
+	"github.com/prometheus/client_golang/prometheus"
 )
 
+// certInfo represents what we know about the state of the certificate
+// at runtime.
 type certInfo struct {
-	domains       []string
+	config        *certConfig
 	retryDeadline time.Time
 
-	// A write-only attribute (not part of the logic) to indicate
-	// whether we think we have a valid certificate or not. Used
-	// for monitoring and debugging.
-	valid bool
+	// Write-only attributes (not part of the renewal logic) to
+	// indicate whether we think we have a valid certificate or
+	// not. Used for monitoring and debugging.
+	valid     bool
+	expiresAt time.Time
+}
+
+func (i *certInfo) cn() string {
+	return i.config.Names[0]
+}
+
+func (i *certInfo) setOk(cert *x509.Certificate, renewalDays int) {
+	i.retryDeadline = cert.NotAfter.AddDate(0, 0, -renewalDays)
+	i.expiresAt = cert.NotAfter
+	i.valid = true
+
+	l := prometheus.Labels{"cn": i.cn()}
+	certOk.With(l).Set(1)
+}
+
+func (i *certInfo) setErr() {
+	now := time.Now()
+	i.retryDeadline = now.Add(errorRetryTimeout)
+	i.valid = false
+	if !i.expiresAt.IsZero() {
+		i.valid = i.expiresAt.After(now)
+	}
+
+	l := prometheus.Labels{"cn": i.cn()}
+	certRenewalErrorCount.With(l).Inc()
+	certOk.With(l).Set(b2f(i.valid))
 }
 
 type certStorage interface {
@@ -37,23 +65,24 @@ type certStorage interface {
 	PutCert(string, [][]byte, crypto.Signer) error
 }
 
+// CertGenerator is an interface to something that can generate a new
+// certificate, given a list of domains and a private key. The ACME
+// type implements this interface.
 type CertGenerator interface {
-	GetCertificate(context.Context, crypto.Signer, []string) ([][]byte, *x509.Certificate, error)
+	GetCertificate(context.Context, crypto.Signer, *certConfig) ([][]byte, *x509.Certificate, error)
 }
 
 // Manager periodically renews certificates before they expire, and
 // responds to http-01 validation requests.
 type Manager struct {
-	//email          string
-	//accountKeyPath string
-	configDir string
-	useRSA    bool
-	storage   certStorage
-	certs     []*certInfo
-	certGen   CertGenerator
-
-	configCh chan [][]string
-	stopCh   chan bool
+	configDir   string
+	useRSA      bool
+	storage     certStorage
+	certs       []*certInfo
+	certGen     CertGenerator
+	renewalDays int
+
+	configCh chan []*certConfig
 	doneCh   chan bool
 }
 
@@ -65,12 +94,15 @@ func NewManager(config *Config, certGen CertGenerator) (*Manager, error) {
 	}
 
 	m := &Manager{
-		useRSA:    config.UseRSA,
-		configDir: filepath.Join(config.Dir, "config"),
-		stopCh:    make(chan bool),
-		doneCh:    make(chan bool),
-		configCh:  make(chan [][]string, 1),
-		certGen:   certGen,
+		useRSA:      config.UseRSA,
+		configDir:   filepath.Join(config.Dir, "config"),
+		doneCh:      make(chan bool),
+		configCh:    make(chan []*certConfig, 1),
+		certGen:     certGen,
+		renewalDays: config.RenewalDays,
+	}
+	if m.renewalDays <= 0 {
+		m.renewalDays = 15
 	}
 
 	ds := &dirStorage{root: filepath.Join(config.Dir, "certs")}
@@ -91,7 +123,8 @@ func NewManager(config *Config, certGen CertGenerator) (*Manager, error) {
 }
 
 // Start the renewal processes. Canceling the provided context will
-// cause background processing to stop.
+// cause background processing to stop, interrupting all running
+// updates.
 func (m *Manager) Start(ctx context.Context) error {
 	domains, err := readCertConfigsFromDir(m.configDir)
 	if err != nil {
@@ -105,9 +138,8 @@ func (m *Manager) Start(ctx context.Context) error {
 	return nil
 }
 
-// Stop any pending operation and release all resources.
-func (m *Manager) Stop() {
-	close(m.stopCh)
+// Wait for the Manager to terminate once it has been canceled.
+func (m *Manager) Wait() {
 	<-m.doneCh
 }
 
@@ -140,19 +172,21 @@ func (m *Manager) updateAllCerts(ctx context.Context, certs []*certInfo) {
 		default:
 		}
 
+		certRenewalCount.With(prometheus.Labels{"cn": certInfo.cn()}).Inc()
 		uctx, cancel := context.WithTimeout(ctx, renewalTimeout)
-		err := m.updateCert(uctx, certInfo)
+		leaf, err := m.updateCert(uctx, certInfo)
 		cancel()
-		if err != nil {
-			log.Printf("error updating %s: %v", certInfo.domains[0], err)
-			// Retry in a little while.
-			certInfo.retryDeadline = time.Now().Add(errorRetryTimeout)
-			certInfo.valid = false
+		if err == nil {
+			log.Printf("successfully renewed %s", certInfo.cn())
+			certInfo.setOk(leaf, m.renewalDays)
+		} else {
+			log.Printf("error updating %s: %v", certInfo.cn(), err)
+			certInfo.setErr()
 		}
 	}
 }
 
-func (m *Manager) updateCert(ctx context.Context, certInfo *certInfo) error {
+func (m *Manager) updateCert(ctx context.Context, certInfo *certInfo) (*x509.Certificate, error) {
 	// Create a new private key.
 	var (
 		err error
@@ -164,49 +198,45 @@ func (m *Manager) updateCert(ctx context.Context, certInfo *certInfo) error {
 		key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
 	}
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	der, leaf, err := m.certGen.GetCertificate(ctx, key, certInfo.domains)
+	der, leaf, err := m.certGen.GetCertificate(ctx, key, certInfo.config)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	if err := m.storage.PutCert(certInfo.domains[0], der, key); err != nil {
-		return err
+	if err := m.storage.PutCert(certInfo.cn(), der, key); err != nil {
+		return nil, err
 	}
 
-	certInfo.retryDeadline = renewalDeadline(leaf)
-	certInfo.valid = true
-
-	return nil
+	return leaf, nil
 }
 
 // Replace the current configuration.
-func (m *Manager) loadConfig(certDomains [][]string) []*certInfo {
-	var certs []*certInfo
-	for _, domains := range certDomains {
-		cn := domains[0]
+func (m *Manager) loadConfig(certs []*certConfig) []*certInfo {
+	var out []*certInfo
+	for _, c := range certs {
+		cn := c.Names[0]
 		certInfo := &certInfo{
-			domains: domains,
+			config: c,
 		}
 		pub, priv, err := m.storage.GetCert(cn)
 		if err != nil {
 			log.Printf("cert for %s is missing", cn)
 		} else {
 			// By calling validCert we catch things like subjectAltName changes.
-			leaf, err := validCert(domains, pub, priv)
+			leaf, err := validCert(c.Names, pub, priv)
 			if err == nil {
 				log.Printf("cert for %s loaded from storage", cn)
-				certInfo.retryDeadline = renewalDeadline(leaf)
-				certInfo.valid = true
+				certInfo.setOk(leaf, m.renewalDays)
 			} else {
 				log.Printf("cert for %s loaded from storage but parameters have changed", cn)
 			}
 		}
-		certs = append(certs, certInfo)
+		out = append(out, certInfo)
 	}
-	return certs
+	return out
 }
 
 // This channel is used by the testing code to trigger an update,
@@ -233,6 +263,8 @@ func (m *Manager) loop(ctx context.Context) {
 		return cancel
 	}
 
+	// Cancel the running update, if any. Called on config
+	// updates, when exiting.
 	cancelUpdate := func() {
 		if upCancel != nil {
 			upCancel()
@@ -242,6 +274,7 @@ func (m *Manager) loop(ctx context.Context) {
 	defer cancelUpdate()
 
 	tick := time.NewTicker(5 * time.Minute)
+	defer tick.Stop()
 	for {
 		select {
 		case <-tick.C:
@@ -251,20 +284,12 @@ func (m *Manager) loop(ctx context.Context) {
 		case certDomains := <-m.configCh:
 			cancelUpdate()
 			m.certs = m.loadConfig(certDomains)
-		case <-m.stopCh:
-			return
 		case <-ctx.Done():
 			return
 		}
 	}
 }
 
-var renewalDays = 15
-
-func renewalDeadline(cert *x509.Certificate) time.Time {
-	return cert.NotAfter.AddDate(0, 0, -renewalDays)
-}
-
 func concatDER(der [][]byte) []byte {
 	// Append DERs to a single []byte buffer and parse the results.
 	var n int
@@ -279,54 +304,6 @@ func concatDER(der [][]byte) []byte {
 	return out
 }
 
-func validCert(domains []string, der [][]byte, key crypto.Signer) (leaf *x509.Certificate, err error) {
-	x509Cert, err := x509.ParseCertificates(concatDER(der))
-	if err != nil {
-		return nil, err
-	}
-	if len(x509Cert) == 0 {
-		return nil, errors.New("no public key found")
-	}
-	leaf = x509Cert[0]
-
-	// verify the leaf is not expired and matches the given domains
-	now := time.Now()
-	if now.Before(leaf.NotBefore) {
-		return nil, errors.New("certificate isn't valid yet")
-	}
-	if now.After(leaf.NotAfter) {
-		return nil, errors.New("certificate expired")
-	}
-	for _, domain := range domains {
-		if err := leaf.VerifyHostname(domain); err != nil {
-			return nil, fmt.Errorf("certificate does not match domain %q", domain)
-		}
-	}
-
-	// ensure the leaf corresponds to the private key
-	switch pub := leaf.PublicKey.(type) {
-	case *rsa.PublicKey:
-		prv, ok := key.(*rsa.PrivateKey)
-		if !ok {
-			return nil, errors.New("private key type does not match public key type")
-		}
-		if pub.N.Cmp(prv.N) != 0 {
-			return nil, errors.New("private key does not match public key")
-		}
-	case *ecdsa.PublicKey:
-		prv, ok := key.(*ecdsa.PrivateKey)
-		if !ok {
-			return nil, errors.New("private key type does not match public key type")
-		}
-		if pub.X.Cmp(prv.X) != 0 || pub.Y.Cmp(prv.Y) != 0 {
-			return nil, errors.New("private key does not match public key")
-		}
-	default:
-		return nil, errors.New("unsupported public key algorithm")
-	}
-	return leaf, nil
-}
-
 func certRequest(key crypto.Signer, domains []string) ([]byte, error) {
 	req := &x509.CertificateRequest{
 		Subject: pkix.Name{CommonName: domains[0]},
@@ -366,12 +343,3 @@ func encodeECDSAKey(w io.Writer, key *ecdsa.PrivateKey) error {
 	pb := &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
 	return pem.Encode(w, pb)
 }
-
-func pickChallenge(typ string, chal []*acme.Challenge) *acme.Challenge {
-	for _, c := range chal {
-		if c.Type == typ {
-			return c
-		}
-	}
-	return nil
-}
diff --git a/server_test.go b/manager_test.go
similarity index 53%
rename from server_test.go
rename to manager_test.go
index 377c1bd3521fcd7125dd0ca8ad9896937041871a..ced4b0cbab0079d05bac84e667294f6014b83465 100644
--- a/server_test.go
+++ b/manager_test.go
@@ -3,77 +3,33 @@ package acmeserver
 import (
 	"context"
 	"crypto"
-	"crypto/ecdsa"
-	"crypto/rand"
-	"crypto/rsa"
 	"crypto/x509"
-	"crypto/x509/pkix"
+	"errors"
 	"io/ioutil"
-	"log"
-	"math/big"
 	"os"
 	"path/filepath"
 	"testing"
 	"time"
 )
 
-type fakeACME struct {
-}
-
-func (f *fakeACME) GetCertificate(_ context.Context, priv crypto.Signer, domains []string) ([][]byte, *x509.Certificate, error) {
-	notBefore := time.Now()
-	notAfter := notBefore.AddDate(1, 0, 0)
-
-	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
-	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	template := x509.Certificate{
-		SerialNumber: serialNumber,
-		Subject: pkix.Name{
-			CommonName: domains[0],
-		},
-		NotBefore: notBefore,
-		NotAfter:  notAfter,
-
-		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
-		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
-		BasicConstraintsValid: true,
-	}
-
-	if len(domains) > 1 {
-		template.DNSNames = domains[1:]
-	}
-
-	der, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv)
-	if err != nil {
-		return nil, nil, err
-	}
+// A CertGenerator that is just very slow (and will return an error
+// in any case).
+type slowACME struct{}
 
-	cert, err := x509.ParseCertificate(der)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	log.Printf("created certificate for %s", domains[0])
-
-	return [][]byte{der}, cert, nil
-}
-
-func publicKey(priv interface{}) interface{} {
-	switch k := priv.(type) {
-	case *rsa.PrivateKey:
-		return &k.PublicKey
-	case *ecdsa.PrivateKey:
-		return &k.PublicKey
-	default:
-		return nil
+func (s *slowACME) GetCertificate(ctx context.Context, _ crypto.Signer, _ *certConfig) ([][]byte, *x509.Certificate, error) {
+	t := time.After(60 * time.Second)
+	select {
+	case <-t:
+		return nil, nil, errors.New("timed out")
+	case <-ctx.Done():
+		return nil, nil, ctx.Err()
 	}
 }
 
-func newTestManager(t testing.TB) (func(), *Manager) {
+// Create a new test function.
+// The first function returned is a cleanup callback.
+// The second function returned is the cancel callback.
+func newTestManager(t testing.TB, g CertGenerator) (func(), context.CancelFunc, *Manager) {
 	dir, err := ioutil.TempDir("", "")
 	if err != nil {
 		t.Fatal(err)
@@ -82,19 +38,20 @@ func newTestManager(t testing.TB) (func(), *Manager) {
 	os.Mkdir(filepath.Join(dir, "config"), 0700)
 	ioutil.WriteFile(
 		filepath.Join(dir, "config", "test.yml"),
-		[]byte("- { cn: example.com }\n"),
+		[]byte("- { names: [example.com] }\n"),
 		0644,
 	)
 
 	m, err := NewManager(&Config{
 		Dir:   dir,
 		Email: "test@example.com",
-	}, &fakeACME{})
+	}, g)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	if err := m.Start(context.Background()); err != nil {
+	ctx, cancel := context.WithCancel(context.Background())
+	if err := m.Start(ctx); err != nil {
 		t.Fatal("Start:", err)
 	}
 
@@ -102,21 +59,22 @@ func newTestManager(t testing.TB) (func(), *Manager) {
 	time.Sleep(50 * time.Millisecond)
 
 	return func() {
-		m.Stop()
+		cancel()
+		m.Wait()
 		os.RemoveAll(dir)
-	}, m
+	}, cancel, m
 }
 
 func TestManager_Reload(t *testing.T) {
-	cleanup, m := newTestManager(t)
+	cleanup, _, m := newTestManager(t, NewSelfSignedCertGenerator())
 	defer cleanup()
 
 	// Data race: we read data owned by another goroutine!
 	if len(m.certs) < 1 {
 		t.Fatal("configuration not loaded?")
 	}
-	if m.certs[0].domains[0] != "example.com" {
-		t.Fatalf("certs[0].domains[0] is %s, expected example.com", m.certs[0].domains[0])
+	if m.certs[0].cn() != "example.com" {
+		t.Fatalf("certs[0].cn() is %s, expected example.com", m.certs[0].cn())
 	}
 
 	// Try a reload, catch obvious errors.
@@ -126,13 +84,13 @@ func TestManager_Reload(t *testing.T) {
 	if len(m.certs) != 1 {
 		t.Fatalf("certs count is %d, expected 1", len(m.certs))
 	}
-	if m.certs[0].domains[0] != "example.com" {
-		t.Fatalf("certs[0].domains[0] is %s, expected example.com", m.certs[0].domains[0])
+	if m.certs[0].cn() != "example.com" {
+		t.Fatalf("certs[0].cn() is %s, expected example.com", m.certs[0].cn())
 	}
 }
 
 func TestManager_NewCert(t *testing.T) {
-	cleanup, m := newTestManager(t)
+	cleanup, _, m := newTestManager(t, NewSelfSignedCertGenerator())
 	defer cleanup()
 
 	now := time.Now()
@@ -175,3 +133,17 @@ func TestManager_NewCert(t *testing.T) {
 		t.Fatal("certificate is invalid after a reload")
 	}
 }
+
+func TestManager_CancelUpdate(t *testing.T) {
+	start := time.Now()
+	cleanup, cancel, m := newTestManager(t, &slowACME{})
+	defer cleanup()
+
+	time.Sleep(100 * time.Millisecond)
+	cancel()
+	m.Wait()
+	elapsed := time.Since(start)
+	if elapsed > 1*time.Second {
+		t.Fatalf("too much time elapsed (%fs), canceling didn't work?", elapsed.Seconds())
+	}
+}
diff --git a/selfsigned.go b/selfsigned.go
new file mode 100644
index 0000000000000000000000000000000000000000..888ab84e19008176acabd4b8e3a6343c34d249f1
--- /dev/null
+++ b/selfsigned.go
@@ -0,0 +1,73 @@
+package acmeserver
+
+import (
+	"context"
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"log"
+	"math/big"
+	"time"
+)
+
+type selfSignedGenerator struct{}
+
+// NewSelfSignedCertGenerator returns a CertGenerator that will create
+// self-signed certificates for every request. Primarily useful for
+// testing acmeserver as a functional component in integration tests.
+func NewSelfSignedCertGenerator() CertGenerator {
+	return &selfSignedGenerator{}
+}
+
+func (g *selfSignedGenerator) GetCertificate(_ context.Context, priv crypto.Signer, c *certConfig) ([][]byte, *x509.Certificate, error) {
+	notBefore := time.Now()
+	notAfter := notBefore.AddDate(1, 0, 0)
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			CommonName: c.Names[0],
+		},
+		DNSNames:  c.Names,
+		NotBefore: notBefore,
+		NotAfter:  notAfter,
+
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	cert, err := x509.ParseCertificate(der)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	log.Printf("created certificate for %s", c.Names[0])
+
+	return [][]byte{der}, cert, nil
+}
+
+func publicKey(priv interface{}) interface{} {
+	switch k := priv.(type) {
+	case *rsa.PrivateKey:
+		return &k.PublicKey
+	case *ecdsa.PrivateKey:
+		return &k.PublicKey
+	default:
+		return nil
+	}
+}
diff --git a/storage.go b/storage.go
index f0c5f703e7996bb00e2bef1c59a8eb4ab6cafc74..b94d6545b5df8a2941bbf6d11e1801411f24d742 100644
--- a/storage.go
+++ b/storage.go
@@ -47,7 +47,7 @@ func (d *dirStorage) PutCert(cn string, der [][]byte, key crypto.Signer) error {
 
 	dir := filepath.Join(d.root, cn)
 	if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
-		if err = os.MkdirAll(dir, 0755); err != nil {
+		if err = os.MkdirAll(dir, 0750); err != nil {
 			return err
 		}
 	}
@@ -126,7 +126,7 @@ func (d *replStorage) PutCert(cn string, der [][]byte, key crypto.Signer) error
 }
 
 func parseCertsFromFile(path string) ([][]byte, error) {
-	data, err := ioutil.ReadFile(path)
+	data, err := ioutil.ReadFile(path) // nolint: gosec
 	if err != nil {
 		return nil, err
 	}
@@ -144,7 +144,7 @@ func parseCertsFromFile(path string) ([][]byte, error) {
 }
 
 func parsePrivateKeyFromFile(path string) (crypto.Signer, error) {
-	data, err := ioutil.ReadFile(path)
+	data, err := ioutil.ReadFile(path) // nolint: gosec
 	if err != nil {
 		return nil, err
 	}
diff --git a/valid_cert.go b/valid_cert.go
new file mode 100644
index 0000000000000000000000000000000000000000..bce5bcf585e6dc00b1dca2b1e1ecae424b8bf104
--- /dev/null
+++ b/valid_cert.go
@@ -0,0 +1,92 @@
+package acmeserver
+
+import (
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/rsa"
+	"crypto/x509"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// Verify that a certificate is valid according both to the current
+// time and to the specified parameters.
+func validCert(domains []string, der [][]byte, key crypto.Signer) (*x509.Certificate, error) {
+	leaf, err := leafCertFromDER(der)
+	if err != nil {
+		return nil, err
+	}
+
+	// Verify the leaf is not expired.
+	if err := isCertExpired(leaf); err != nil {
+		return nil, err
+	}
+
+	// Verify that it matches the given domains.
+	if err := certMatchesDomains(leaf, domains); err != nil {
+		return nil, err
+	}
+
+	// Verify that it matches the private key.
+	if err := certMatchesPrivateKey(leaf, key); err != nil {
+		return nil, err
+	}
+
+	return leaf, nil
+}
+
+func leafCertFromDER(der [][]byte) (*x509.Certificate, error) {
+	x509Cert, err := x509.ParseCertificates(concatDER(der))
+	if err != nil {
+		return nil, err
+	}
+	if len(x509Cert) == 0 {
+		return nil, errors.New("no public key found")
+	}
+	return x509Cert[0], nil
+}
+
+func isCertExpired(cert *x509.Certificate) error {
+	now := time.Now()
+	if now.Before(cert.NotBefore) {
+		return errors.New("certificate isn't valid yet")
+	}
+	if now.After(cert.NotAfter) {
+		return errors.New("certificate expired")
+	}
+	return nil
+}
+
+func certMatchesDomains(cert *x509.Certificate, domains []string) error {
+	for _, domain := range domains {
+		if err := cert.VerifyHostname(domain); err != nil {
+			return fmt.Errorf("certificate does not match domain %q", domain)
+		}
+	}
+	return nil
+}
+
+func certMatchesPrivateKey(cert *x509.Certificate, key crypto.Signer) error {
+	switch pub := cert.PublicKey.(type) {
+	case *rsa.PublicKey:
+		prv, ok := key.(*rsa.PrivateKey)
+		if !ok {
+			return errors.New("private key type does not match public key type")
+		}
+		if pub.N.Cmp(prv.N) != 0 {
+			return errors.New("private key does not match public key")
+		}
+	case *ecdsa.PublicKey:
+		prv, ok := key.(*ecdsa.PrivateKey)
+		if !ok {
+			return errors.New("private key type does not match public key type")
+		}
+		if pub.X.Cmp(prv.X) != 0 || pub.Y.Cmp(prv.Y) != 0 {
+			return errors.New("private key does not match public key")
+		}
+	default:
+		return errors.New("unsupported public key algorithm")
+	}
+	return nil
+}