From 69d65dfa8528350e73409e684844344e3cdb3f16 Mon Sep 17 00:00:00 2001 From: ale <ale@incal.net> Date: Thu, 9 Aug 2018 23:45:22 +0100 Subject: [PATCH] Refactor of the code Add dns-01 support, make the code more readable, add a testing mode that will generate self-signed certificates (for test environments that are not reachable from outside). --- README.md | 31 +++++ acme.go | 176 ++++++++++++++----------- cmd/acmeserver/acmeserver.go | 43 +++++-- config.go | 86 +++++++++---- dns_challenge.go | 156 ++++++++++++++++++++++ http_challenge.go | 58 +++++++++ instrumentation.go | 47 +++++++ server.go => manager.go | 206 +++++++++++++----------------- server_test.go => manager_test.go | 112 ++++++---------- selfsigned.go | 73 +++++++++++ storage.go | 6 +- valid_cert.go | 92 +++++++++++++ 12 files changed, 787 insertions(+), 299 deletions(-) create mode 100644 dns_challenge.go create mode 100644 http_challenge.go create mode 100644 instrumentation.go rename server.go => manager.go (61%) rename server_test.go => manager_test.go (53%) create mode 100644 selfsigned.go create mode 100644 valid_cert.go diff --git a/README.md b/README.md index 669664f9..0932128e 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 fe52a6b9..51eccf9d 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 46accd3b..31e4d0c8 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 f367a568..b009de49 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 00000000..af04a08b --- /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 00000000..f99a28af --- /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 00000000..3e1ce04b --- /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 79d25a48..76910d12 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 377c1bd3..ced4b0cb 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 00000000..888ab84e --- /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 f0c5f703..b94d6545 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 00000000..bce5bcf5 --- /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 +} -- GitLab