Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • ai3/tools/acmeserver
  • godog/acmeserver
  • svp-bot/acmeserver
3 results
Show changes
Commits on Source (107)
include:
- "https://git.autistici.org/pipelines/debian/raw/master/common.yml"
- "https://git.autistici.org/pipelines/images/test/golang/raw/master/ci.yml"
......@@ -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).
......@@ -13,23 +13,31 @@ import (
"io/ioutil"
"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.
......@@ -37,15 +45,50 @@ func NewACME(config *Config) (*ACME, error) {
if config.Email == "" {
return nil, errors.New("configuration parameter 'email' is unset")
}
if config.AccountKeyPath == "" {
return nil, errors.New("configuration parameter 'account_key_path' 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: config.AccountKeyPath,
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 +109,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,131 +128,135 @@ 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 {
if ae, ok := err.(*acme.Error); err == nil || err == acme.ErrAccountAlreadyExists || (ok && ae.StatusCode == http.StatusConflict) {
a.client = client
err = nil
}
// Fetch account info and display it.
if acct, err := client.GetReg(ctx, ""); err == nil {
log.Printf("ACME account %s", acct.URI)
}
return a.client, err
}
// 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 {
o, err := a.verifyAll(ctx, client, c)
if err != nil {
return nil, nil, err
}
csr, err := certRequest(key, domains)
csr, err := certRequest(key, c.Names)
if err != nil {
return nil, nil, err
}
der, _, err = client.CreateCert(ctx, csr, 0, true)
der, _, err = client.CreateOrderCert(ctx, o.FinalizeURL, csr, true)
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 {
return err
}
}
return nil
}
func (a *ACME) verify(ctx context.Context, client *acme.Client, domain string) error {
authz, err := client.Authorize(ctx, domain)
func (a *ACME) verifyAll(ctx context.Context, client *acme.Client, c *certConfig) (*acme.Order, error) {
// Make an authorization request to the ACME server, and
// verify that it returns a valid response with challenges.
o, err := client.AuthorizeOrder(ctx, acme.DomainIDs(c.Names...))
if err != nil {
return err
return nil, fmt.Errorf("AuthorizeOrder failed: %v", err)
}
switch authz.Status {
case acme.StatusValid:
return nil // already authorized
case acme.StatusInvalid:
return fmt.Errorf("invalid authorization %q", authz.URI)
switch o.Status {
case acme.StatusReady:
return o, nil // already authorized
case acme.StatusPending:
default:
return nil, fmt.Errorf("invalid new order status %q", o.Status)
}
chal := pickChallenge("http-01", authz.Challenges)
if chal == nil {
return fmt.Errorf("unable to authorize %q", domain)
}
cleanup, err := a.fulfill(ctx, client, chal)
if err != nil {
return err
}
defer cleanup()
for _, zurl := range o.AuthzURLs {
z, err := client.GetAuthorization(ctx, zurl)
if err != nil {
return nil, fmt.Errorf("GetAuthorization(%s) failed: %v", zurl, err)
}
if z.Status != acme.StatusPending {
continue
}
// 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(z.Challenges, c)
if chal == nil {
return nil, fmt.Errorf("unable to authorize %q", c.Names)
}
if _, err = client.Accept(ctx, chal); err != nil {
return err
}
_, err = client.WaitAuthorization(ctx, authz.URI)
return err
}
v, ok := a.validators[chal.Type]
if !ok {
return nil, fmt.Errorf("challenge type '%s' is not available", chal.Type)
}
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")
}
log.Printf("attempting fulfillment for %q (identifier: %+v)", c.Names, z.Identifier)
for _, domain := range c.Names {
cleanup, err := v.Fulfill(ctx, client, domain, chal)
if err != nil {
return nil, fmt.Errorf("fulfillment failed: %v", err)
}
defer cleanup()
}
resp, err := client.HTTP01ChallengeResponse(chal.Token)
if err != nil {
return nil, err
if _, err := client.Accept(ctx, chal); err != nil {
return nil, fmt.Errorf("challenge accept failed: %v", err)
}
if _, err := client.WaitAuthorization(ctx, z.URI); err != nil {
return nil, fmt.Errorf("WaitAuthorization(%s) failed: %v", z.URI, 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
// Authorizations are satisfied, wait for the CA
// to update the order status.
if _, err = client.WaitOrder(ctx, o.URI); err != nil {
return nil, err
}
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 o, 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
// 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
}
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
for _, ch := range chal {
if ch.Type == ctype {
return ch
}
}
return nil
}
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
}
......@@ -3,35 +3,38 @@ package main
import (
"context"
"flag"
"io/ioutil"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"git.autistici.org/ai3/acmeserver"
"git.autistici.org/ai3/go-common/serverutil"
"gopkg.in/yaml.v2"
"git.autistici.org/ai3/tools/acmeserver"
"gopkg.in/yaml.v3"
)
var (
addr = flag.String("addr", ":2780", "tcp `address` to listen on")
configFile = flag.String("config", "/etc/acmeserver/config.yml", "configuration `file`")
configFile = flag.String("config", "/etc/acme/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"`
ACME acmeserver.Config `yaml:",inline"`
Server *serverutil.ServerConfig `yaml:"http_server"`
}
func loadConfig(path string) (*Config, error) {
// Read YAML config.
data, err := ioutil.ReadFile(path)
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
if err := yaml.NewDecoder(f).Decode(&config); err != nil {
return nil, err
}
return &config, nil
......@@ -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()
}
package acmeserver
import (
"io/ioutil"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"gopkg.in/yaml.v2"
"gopkg.in/yaml.v3"
"git.autistici.org/ai3/go-common/clientutil"
)
// Config holds the configuration for an acmeserver instance.
//
// The reason for supporting multiple config_dirs is to allow
// integration with third-party automation systems: in some cases,
// automation tools need control of an entire directory in order to
// safely delete entries that no longer exist.
//
// nolint: maligned
type Config struct {
Addr string `yaml:"addr"`
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"`
UseRSA bool `yaml:"use_rsa"`
Dir string `yaml:"cert_dir"`
ReplDS *clientutil.BackendConfig `yaml:"replds"`
AccountKeyPath string `yaml:"account_key_path"`
Email string `yaml:"email"`
Dirs []string `yaml:"config_dirs"`
Output struct {
Path string `yaml:"path"`
ReplDS *clientutil.BackendConfig `yaml:"replds"`
} `yaml:"output"`
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) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
return decodeCertConfig(data)
defer f.Close()
var cc []*certConfig
if err := yaml.NewDecoder(f).Decode(&cc); err != nil {
return nil, err
}
return cc, nil
}
func readCertConfigsFromDir(dir string) ([][]string, error) {
func readCertConfigsFromDir(dir string, certs []*certConfig) ([]*certConfig, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.yml"))
if err != nil {
return nil, err
}
var domains [][]string
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
}
certs = append(certs, c)
}
}
return certs, nil
}
func readCertConfigsFromDirs(dirs []string) ([]*certConfig, error) {
var out []*certConfig
for _, dir := range dirs {
certs, err := readCertConfigsFromDir(dir, out)
if err != nil {
return nil, err
}
out = certs
}
return domains, nil
return out, nil
}
......@@ -7,6 +7,7 @@ User=acmeserver
Group=acmeserver
EnvironmentFile=-/etc/default/acmeserver
ExecStart=/usr/bin/acmeserver --addr $ADDR
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
# Hardening
......@@ -16,6 +17,7 @@ PrivateDevices=yes
ProtectHome=yes
ProtectSystem=full
ReadOnlyDirectories=/
ReadWriteDirectories=/var/lib/acme
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]
......
......@@ -2,4 +2,4 @@ acmeserver (2.0) unstable; urgency=medium
* Initial Release.
-- Autistici/Inventati <debian@autistici.org> Sat, 15 Jun 2018 09:23:40 +0000
-- Autistici/Inventati <debian@autistici.org> Fri, 15 Jun 2018 09:23:40 +0000
10
......@@ -2,12 +2,12 @@ Source: acmeserver
Section: admin
Priority: optional
Maintainer: Autistici/Inventati <debian@autistici.org>
Build-Depends: debhelper (>=9), golang-go, dh-systemd, dh-golang
Build-Depends: debhelper-compat (= 13), golang-any (>= 1.11), dh-golang
Standards-Version: 3.9.6
Package: acmeserver
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Built-Using: ${misc:Built-Using}
Description: ACME server
Automatically manages and renews public SSL certificates.
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: acmeserver
Source: <https://git.autistici.org/ai3/acmeserver>
Source: <https://git.autistici.org/ai3/tools/acmeserver>
Files: *
Copyright: 2018 Autistici/Inventati <info@autistici.org>
......
#!/usr/bin/make -f
export DH_GOPKG = git.autistici.org/ai3/acmeserver
export DH_GOPKG = git.autistici.org/ai3/tools/acmeserver
export DH_GOLANG_EXCLUDES = vendor
export DH_GOLANG_INSTALL_ALL := 1
%:
dh $@ --with systemd --with golang --buildsystem golang
dh $@ --with golang --buildsystem golang
override_dh_install:
rm -fr $(CURDIR)/debian/acmeserver/usr/share/gocode
dh_install
override_dh_auto_install:
dh_auto_install -- --no-source
override_dh_systemd_enable:
dh_systemd_enable --no-enable
override_dh_systemd_start:
dh_systemd_start --no-start
override_dh_installsystemd:
dh_installsystemd --no-enable
override_dh_installsystemd:
dh_installsystemd --no-start
package acmeserver
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/miekg/dns"
"golang.org/x/crypto/acme"
"golang.org/x/net/publicsuffix"
)
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) {
domain = strings.TrimPrefix(domain, "*.")
zone, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
return nil, fmt.Errorf("could not determine effective tld: %w", err)
}
zone = dns.Fqdn(zone)
fqdn := dns.Fqdn("_acme-challenge." + 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 {
log.Printf("updateNS(%s, %s, %s, %s, %v)", ns, zone, fqdn, value, remove)
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
}
module git.autistici.org/ai3/tools/acmeserver
go 1.19
require (
git.autistici.org/ai3/go-common v0.0.0-20230816213645-b3aa3fb514d6
git.autistici.org/ai3/tools/replds v0.0.0-20230923170339-b6e6e3cc032b
github.com/miekg/dns v1.1.50
github.com/prometheus/client_golang v1.12.2
golang.org/x/crypto v0.24.0
golang.org/x/net v0.26.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/openzipkin/zipkin-go v0.4.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.34.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.9.0 // indirect
go.opentelemetry.io/otel v1.10.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.9.0 // indirect
go.opentelemetry.io/otel/metric v0.31.0 // indirect
go.opentelemetry.io/otel/sdk v1.10.0 // indirect
go.opentelemetry.io/otel/trace v1.10.0 // indirect
golang.org/x/mod v0.4.2 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.27.1 // indirect
)
This diff is collapsed.
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
}
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
}
......@@ -11,25 +11,57 @@ import (
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"path/filepath"
"sync"
"time"
"git.autistici.org/ai3/go-common/clientutil"
"golang.org/x/crypto/acme"
"git.autistici.org/ai3/tools/replds"
"github.com/prometheus/client_golang/prometheus"
)
var (
defaultRenewalDays = 21
updateInterval = 1 * time.Minute
)
// 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,53 +69,61 @@ 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.
// Manager periodically renews certificates before they expire.
type Manager struct {
//email string
//accountKeyPath string
configDir string
useRSA bool
storage certStorage
certs []*certInfo
certGen CertGenerator
configCh chan [][]string
stopCh chan bool
configDirs []string
useRSA bool
storage certStorage
certGen CertGenerator
renewalDays int
configCh chan []*certConfig
doneCh chan bool
mx sync.Mutex
certs []*certInfo
}
// NewManager creates a new Manager with the given configuration.
func NewManager(config *Config, certGen CertGenerator) (*Manager, error) {
// Validate the configuration.
if config.Dir == "" {
return nil, errors.New("configuration parameter 'cert_dir' is unset")
if len(config.Dirs) == 0 {
return nil, errors.New("configuration parameter 'config_dirs' is unset")
}
if config.Output.Path == "" {
return nil, errors.New("'output.path' is unset")
}
if config.RenewalDays <= 0 {
config.RenewalDays = defaultRenewalDays
}
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,
configDirs: config.Dirs,
doneCh: make(chan bool),
configCh: make(chan []*certConfig, 1),
certGen: certGen,
renewalDays: config.RenewalDays,
}
ds := &dirStorage{root: filepath.Join(config.Dir, "certs")}
if config.ReplDS == nil {
ds := &dirStorage{root: config.Output.Path}
if config.Output.ReplDS == nil {
m.storage = ds
} else {
be, err := clientutil.NewBackend(config.ReplDS)
r, err := replds.NewPublicClient(config.Output.ReplDS)
if err != nil {
return nil, err
}
m.storage = &replStorage{
dirStorage: ds,
replClient: be,
replClient: r,
}
}
......@@ -91,9 +131,10 @@ 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)
domains, err := readCertConfigsFromDirs(m.configDirs)
if err != nil {
return err
}
......@@ -105,15 +146,14 @@ 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
}
// Reload configuration.
func (m *Manager) Reload() {
domains, err := readCertConfigsFromDir(m.configDir)
domains, err := readCertConfigsFromDirs(m.configDirs)
if err != nil {
log.Printf("error reading config: %v", err)
return
......@@ -124,7 +164,7 @@ func (m *Manager) Reload() {
var (
renewalTimeout = 10 * time.Minute
errorRetryTimeout = 10 * time.Minute
errorRetryTimeout = 6 * time.Hour
)
func (m *Manager) updateAllCerts(ctx context.Context, certs []*certInfo) {
......@@ -140,19 +180,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 +206,57 @@ 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
}
func (m *Manager) getCerts() []*certInfo {
m.mx.Lock()
defer m.mx.Unlock()
return m.certs
}
func (m *Manager) setCerts(certs []*certInfo) {
m.mx.Lock()
m.certs = certs
m.mx.Unlock()
}
// This channel is used by the testing code to trigger an update,
......@@ -214,55 +264,24 @@ func (m *Manager) loadConfig(certDomains [][]string) []*certInfo {
var testUpdateCh = make(chan bool)
func (m *Manager) loop(ctx context.Context) {
// Updates are long-term jobs, so they should be
// interruptible. We run updates in a separate goroutine, and
// cancel them when the configuration is reloaded or on exit.
var upCancel context.CancelFunc
var wg sync.WaitGroup
startUpdate := func(certs []*certInfo) context.CancelFunc {
// Ensure the previous update has finished.
wg.Wait()
upCtx, cancel := context.WithCancel(ctx)
wg.Add(1)
go func() {
m.updateAllCerts(upCtx, certs)
wg.Done()
}()
return cancel
}
cancelUpdate := func() {
if upCancel != nil {
upCancel()
}
wg.Wait()
}
defer cancelUpdate()
tick := time.NewTicker(5 * time.Minute)
for {
select {
case <-tick.C:
upCancel = startUpdate(m.certs)
case <-testUpdateCh:
upCancel = startUpdate(m.certs)
case certDomains := <-m.configCh:
cancelUpdate()
m.certs = m.loadConfig(certDomains)
case <-m.stopCh:
return
case <-ctx.Done():
return
reloadCh := make(chan interface{}, 1)
go func() {
for config := range m.configCh {
certs := m.loadConfig(config)
m.setCerts(certs)
reloadCh <- certs
}
}
}
var renewalDays = 15
}()
func renewalDeadline(cert *x509.Certificate) time.Time {
return cert.NotAfter.AddDate(0, 0, -renewalDays)
runWithUpdates(
ctx,
func(ctx context.Context, value interface{}) {
certs := value.([]*certInfo)
m.updateAllCerts(ctx, certs)
},
reloadCh,
updateInterval,
)
}
func concatDER(der [][]byte) []byte {
......@@ -279,60 +298,10 @@ 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]},
}
if len(domains) > 1 {
req.DNSNames = domains[1:]
Subject: pkix.Name{CommonName: domains[0]},
DNSNames: domains,
}
return x509.CreateCertificateRequest(rand.Reader, req, key)
}
......@@ -366,12 +335,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
}
......@@ -3,77 +3,43 @@ 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,
// A CertGenerator that is just very slow (and will return an error
// in any case).
type slowACME struct{}
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
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()
}
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
}
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 newTestConfig(dir string) *Config {
c := Config{
Dirs: []string{filepath.Join(dir, "config")},
AccountKeyPath: filepath.Join(dir, "account.key"),
Email: "test@example.com",
}
c.Output.Path = filepath.Join(dir, "certs")
return &c
}
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 +48,17 @@ 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{})
m, err := NewManager(newTestConfig(dir), 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,46 +66,55 @@ 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 {
certs := m.getCerts()
if len(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 certs[0].cn() != "example.com" {
t.Fatalf("certs[0].cn() is %s, expected example.com", certs[0].cn())
}
// Try a reload, catch obvious errors.
m.Reload()
time.Sleep(50 * time.Millisecond)
certs = m.getCerts()
if len(m.certs) != 1 {
if len(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 certs[0].cn() != "example.com" {
t.Fatalf("certs[0].cn() is %s, expected example.com", certs[0].cn())
}
}
func TestManager_NewCert(t *testing.T) {
cleanup, m := newTestManager(t)
var oldUpdateInterval time.Duration
oldUpdateInterval, updateInterval = updateInterval, 50*time.Millisecond
defer func() {
updateInterval = oldUpdateInterval
}()
cleanup, _, m := newTestManager(t, NewSelfSignedCertGenerator())
defer cleanup()
now := time.Now()
ci := m.certs[0]
certs := m.getCerts()
ci := certs[0]
if ci.retryDeadline.After(now) {
t.Fatalf("retry deadline is in the future: %v", ci.retryDeadline)
}
testUpdateCh <- true
time.Sleep(100 * time.Millisecond)
// Verify that the retry/renewal timestamp is in the future.
......@@ -156,11 +129,11 @@ func TestManager_NewCert(t *testing.T) {
// Verify that the credentials have successfully been written
// to storage.
p := filepath.Join(m.configDir, "../certs/example.com/cert.pem")
p := filepath.Join(m.configDirs[0], "../certs/example.com/cert.pem")
if _, err := os.Stat(p); err != nil {
t.Fatalf("file not created: %v", err)
}
p = filepath.Join(m.configDir, "../certs/example.com/private_key.pem")
p = filepath.Join(m.configDirs[0], "../certs/example.com/privkey.pem")
if _, err := os.Stat(p); err != nil {
t.Fatalf("file not created: %v", err)
}
......@@ -170,8 +143,22 @@ func TestManager_NewCert(t *testing.T) {
m.Reload()
time.Sleep(50 * time.Millisecond)
ci = m.certs[0]
ci = m.getCerts()[0]
if !ci.valid {
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())
}
}
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}
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
}
}