github.com/philhug/dnscontrol@v0.2.4-0.20180625181521-921fa9849001/pkg/acme/acme.go (about)

     1  // Package acme provides a means of performing Let's Encrypt DNS challenges via a DNSConfig
     2  package acme
     3  
     4  import (
     5  	"crypto/x509"
     6  	"encoding/json"
     7  	"encoding/pem"
     8  	"fmt"
     9  	"io/ioutil"
    10  	"log"
    11  	"net/url"
    12  	"os"
    13  	"path/filepath"
    14  	"sort"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/StackExchange/dnscontrol/models"
    19  	"github.com/StackExchange/dnscontrol/pkg/nameservers"
    20  	"github.com/xenolf/lego/acmev2"
    21  )
    22  
    23  type CertConfig struct {
    24  	CertName string   `json:"cert_name"`
    25  	Names    []string `json:"names"`
    26  }
    27  
    28  type Client interface {
    29  	IssueOrRenewCert(config *CertConfig, renewUnder int, verbose bool) (bool, error)
    30  }
    31  
    32  type certManager struct {
    33  	directory      string
    34  	email          string
    35  	acmeDirectory  string
    36  	acmeHost       string
    37  	cfg            *models.DNSConfig
    38  	checkedDomains map[string]bool
    39  
    40  	account *account
    41  	client  *acme.Client
    42  }
    43  
    44  const (
    45  	LetsEncryptLive  = "https://acme-v02.api.letsencrypt.org/directory"
    46  	LetsEncryptStage = "https://acme-staging-v02.api.letsencrypt.org/directory"
    47  )
    48  
    49  func New(cfg *models.DNSConfig, directory string, email string, server string) (Client, error) {
    50  	u, err := url.Parse(server)
    51  	if err != nil || u.Host == "" {
    52  		return nil, fmt.Errorf("ACME directory '%s' is not a valid URL", server)
    53  	}
    54  	c := &certManager{
    55  		directory:      directory,
    56  		email:          email,
    57  		acmeDirectory:  server,
    58  		acmeHost:       u.Host,
    59  		cfg:            cfg,
    60  		checkedDomains: map[string]bool{},
    61  	}
    62  
    63  	if err := c.loadOrCreateAccount(); err != nil {
    64  		return nil, err
    65  	}
    66  	c.client.ExcludeChallenges([]acme.Challenge{acme.HTTP01})
    67  	c.client.SetChallengeProvider(acme.DNS01, c)
    68  	return c, nil
    69  }
    70  
    71  // IssueOrRenewCert will obtain a certificate with the given name if it does not exist,
    72  // or renew it if it is close enough to the expiration date.
    73  // It will return true if it issued or updated the certificate.
    74  func (c *certManager) IssueOrRenewCert(cfg *CertConfig, renewUnder int, verbose bool) (bool, error) {
    75  	if !verbose {
    76  		acme.Logger = log.New(ioutil.Discard, "", 0)
    77  	}
    78  
    79  	log.Printf("Checking certificate [%s]", cfg.CertName)
    80  	if err := os.MkdirAll(filepath.Dir(c.certFile(cfg.CertName, "json")), perms); err != nil {
    81  		return false, err
    82  	}
    83  	existing, err := c.readCertificate(cfg.CertName)
    84  	if err != nil {
    85  		return false, err
    86  	}
    87  
    88  	var action = func() (acme.CertificateResource, error) {
    89  		return c.client.ObtainCertificate(cfg.Names, true, nil, true)
    90  	}
    91  
    92  	if existing == nil {
    93  		log.Println("No existing cert found. Issuing new...")
    94  	} else {
    95  		names, daysLeft, err := getCertInfo(existing.Certificate)
    96  		if err != nil {
    97  			return false, err
    98  		}
    99  		log.Printf("Found existing cert. %0.2f days remaining.", daysLeft)
   100  		namesOK := dnsNamesEqual(cfg.Names, names)
   101  		if daysLeft >= float64(renewUnder) && namesOK {
   102  			log.Println("Nothing to do")
   103  			//nothing to do
   104  			return false, nil
   105  		}
   106  		if !namesOK {
   107  			log.Println("DNS Names don't match expected set. Reissuing.")
   108  		} else {
   109  			log.Println("Renewing cert")
   110  			action = func() (acme.CertificateResource, error) {
   111  				return c.client.RenewCertificate(*existing, true, true)
   112  			}
   113  		}
   114  	}
   115  
   116  	certResource, err := action()
   117  	if err != nil {
   118  		return false, err
   119  	}
   120  	fmt.Printf("Obtained certificate for %s\n", cfg.CertName)
   121  	return true, c.writeCertificate(cfg.CertName, &certResource)
   122  }
   123  
   124  // filename for certifiacte / key / json file
   125  func (c *certManager) certFile(name, ext string) string {
   126  	return filepath.Join(c.directory, "certificates", name, name+"."+ext)
   127  }
   128  
   129  func (c *certManager) writeCertificate(name string, cr *acme.CertificateResource) error {
   130  	jDAt, err := json.MarshalIndent(cr, "", "  ")
   131  	if err != nil {
   132  		return err
   133  	}
   134  	if err = ioutil.WriteFile(c.certFile(name, "json"), jDAt, perms); err != nil {
   135  		return err
   136  	}
   137  	if err = ioutil.WriteFile(c.certFile(name, "crt"), cr.Certificate, perms); err != nil {
   138  		return err
   139  	}
   140  	return ioutil.WriteFile(c.certFile(name, "key"), cr.PrivateKey, perms)
   141  }
   142  
   143  func getCertInfo(pemBytes []byte) (names []string, remaining float64, err error) {
   144  	block, _ := pem.Decode(pemBytes)
   145  	if block == nil {
   146  		return nil, 0, fmt.Errorf("Invalid certificate pem data")
   147  	}
   148  	cert, err := x509.ParseCertificate(block.Bytes)
   149  	if err != nil {
   150  		return nil, 0, err
   151  	}
   152  	var daysLeft = float64(cert.NotAfter.Sub(time.Now())) / float64(time.Hour*24)
   153  	return cert.DNSNames, daysLeft, nil
   154  }
   155  
   156  // checks two lists of sans to make sure they have all the same names in them.
   157  func dnsNamesEqual(a []string, b []string) bool {
   158  	if len(a) != len(b) {
   159  		return false
   160  	}
   161  	sort.Strings(a)
   162  	sort.Strings(b)
   163  	for i, s := range a {
   164  		if b[i] != s {
   165  			return false
   166  		}
   167  	}
   168  	return true
   169  }
   170  
   171  func (c *certManager) readCertificate(name string) (*acme.CertificateResource, error) {
   172  	f, err := os.Open(c.certFile(name, "json"))
   173  	if err != nil && os.IsNotExist(err) {
   174  		// if json does not exist, nothing does
   175  		return nil, nil
   176  	}
   177  	if err != nil {
   178  		return nil, err
   179  	}
   180  	defer f.Close()
   181  	dec := json.NewDecoder(f)
   182  	cr := &acme.CertificateResource{}
   183  	if err = dec.Decode(cr); err != nil {
   184  		return nil, err
   185  	}
   186  	// load cert
   187  	crtBytes, err := ioutil.ReadFile(c.certFile(name, "crt"))
   188  	if err != nil {
   189  		return nil, err
   190  	}
   191  	cr.Certificate = crtBytes
   192  	return cr, nil
   193  }
   194  
   195  func (c *certManager) Present(domain, token, keyAuth string) (e error) {
   196  	d := c.cfg.DomainContainingFQDN(domain)
   197  	// fix NS records for this domain's DNS providers
   198  	// only need to do this once per domain
   199  	const metaKey = "x-fixed-nameservers"
   200  	if d.Metadata[metaKey] == "" {
   201  		nsList, err := nameservers.DetermineNameservers(d)
   202  		if err != nil {
   203  			return err
   204  		}
   205  		d.Nameservers = nsList
   206  		nameservers.AddNSRecords(d)
   207  		d.Metadata[metaKey] = "true"
   208  	}
   209  	// copy now so we can add txt record safely, and just run unmodified version later to cleanup
   210  	d, err := d.Copy()
   211  	if err != nil {
   212  		return err
   213  	}
   214  	if err := c.ensureNoPendingCorrections(d); err != nil {
   215  		return err
   216  	}
   217  	fqdn, val, _ := acme.DNS01Record(domain, keyAuth)
   218  	fmt.Println(fqdn, val)
   219  	txt := &models.RecordConfig{Type: "TXT"}
   220  	txt.SetTargetTXT(val)
   221  	txt.SetLabelFromFQDN(fqdn, d.Name)
   222  	d.Records = append(d.Records, txt)
   223  
   224  	return getAndRunCorrections(d)
   225  }
   226  
   227  func (c *certManager) ensureNoPendingCorrections(d *models.DomainConfig) error {
   228  	// only need to check a domain once per app run
   229  	if c.checkedDomains[d.Name] {
   230  		return nil
   231  	}
   232  	corrections, err := getCorrections(d)
   233  	if err != nil {
   234  		return err
   235  	}
   236  	if len(corrections) != 0 {
   237  		// TODO: maybe allow forcing through this check.
   238  		for _, c := range corrections {
   239  			fmt.Println(c.Msg)
   240  		}
   241  		return fmt.Errorf("Found %d pending corrections for %s. Not going to proceed issuing certificates", len(corrections), d.Name)
   242  	}
   243  	return nil
   244  }
   245  
   246  // IgnoredProviders is a lit of provider names that should not be used to fill challenges.
   247  var IgnoredProviders = map[string]bool{}
   248  
   249  func getCorrections(d *models.DomainConfig) ([]*models.Correction, error) {
   250  	cs := []*models.Correction{}
   251  	for _, p := range d.DNSProviderInstances {
   252  		if IgnoredProviders[p.Name] {
   253  			continue
   254  		}
   255  		dc, err := d.Copy()
   256  		if err != nil {
   257  			return nil, err
   258  		}
   259  		corrections, err := p.Driver.GetDomainCorrections(dc)
   260  		if err != nil {
   261  			return nil, err
   262  		}
   263  		for _, c := range corrections {
   264  			c.Msg = fmt.Sprintf("[%s] %s", p.Name, strings.TrimSpace(c.Msg))
   265  		}
   266  		cs = append(cs, corrections...)
   267  	}
   268  	return cs, nil
   269  }
   270  
   271  func getAndRunCorrections(d *models.DomainConfig) error {
   272  	cs, err := getCorrections(d)
   273  	if err != nil {
   274  		return err
   275  	}
   276  	fmt.Printf("%d corrections\n", len(cs))
   277  	for _, c := range cs {
   278  		fmt.Printf("Running [%s]\n", c.Msg)
   279  		err = c.F()
   280  		if err != nil {
   281  			return err
   282  		}
   283  	}
   284  	return nil
   285  }
   286  
   287  func (c *certManager) CleanUp(domain, token, keyAuth string) error {
   288  	d := c.cfg.DomainContainingFQDN(domain)
   289  	return getAndRunCorrections(d)
   290  }