mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-05-11 05:55:12 +00:00
Let's Encrypt Certificate Generation (#327)
* Manual rebase of get-certs branch * fix endpoints, add verbose flag * more stable pre-check behaviour * start of docs * docs for get-certs * don't require cert for dnscontrol * fix up directory paths * small doc tweaks
This commit is contained in:
290
pkg/acme/acme.go
Normal file
290
pkg/acme/acme.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// Package acme provides a means of performing Let's Encrypt DNS challenges via a DNSConfig
|
||||
package acme
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/models"
|
||||
"github.com/StackExchange/dnscontrol/pkg/nameservers"
|
||||
"github.com/xenolf/lego/acmev2"
|
||||
)
|
||||
|
||||
type CertConfig struct {
|
||||
CertName string `json:"cert_name"`
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
IssueOrRenewCert(config *CertConfig, renewUnder int, verbose bool) (bool, error)
|
||||
}
|
||||
|
||||
type certManager struct {
|
||||
directory string
|
||||
email string
|
||||
acmeDirectory string
|
||||
acmeHost string
|
||||
cfg *models.DNSConfig
|
||||
checkedDomains map[string]bool
|
||||
|
||||
account *account
|
||||
client *acme.Client
|
||||
}
|
||||
|
||||
const (
|
||||
LetsEncryptLive = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
LetsEncryptStage = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
)
|
||||
|
||||
func New(cfg *models.DNSConfig, directory string, email string, server string) (Client, error) {
|
||||
u, err := url.Parse(server)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, fmt.Errorf("ACME directory '%s' is not a valid URL", server)
|
||||
}
|
||||
c := &certManager{
|
||||
directory: directory,
|
||||
email: email,
|
||||
acmeDirectory: server,
|
||||
acmeHost: u.Host,
|
||||
cfg: cfg,
|
||||
checkedDomains: map[string]bool{},
|
||||
}
|
||||
|
||||
if err := c.loadOrCreateAccount(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.client.ExcludeChallenges([]acme.Challenge{acme.HTTP01})
|
||||
c.client.SetChallengeProvider(acme.DNS01, c)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// IssueOrRenewCert will obtain a certificate with the given name if it does not exist,
|
||||
// or renew it if it is close enough to the expiration date.
|
||||
// It will return true if it issued or updated the certificate.
|
||||
func (c *certManager) IssueOrRenewCert(cfg *CertConfig, renewUnder int, verbose bool) (bool, error) {
|
||||
if !verbose {
|
||||
acme.Logger = log.New(ioutil.Discard, "", 0)
|
||||
}
|
||||
|
||||
log.Printf("Checking certificate [%s]", cfg.CertName)
|
||||
if err := os.MkdirAll(filepath.Dir(c.certFile(cfg.CertName, "json")), perms); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing, err := c.readCertificate(cfg.CertName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var action = func() (acme.CertificateResource, error) {
|
||||
return c.client.ObtainCertificate(cfg.Names, true, nil, true)
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
log.Println("No existing cert found. Issuing new...")
|
||||
} else {
|
||||
names, daysLeft, err := getCertInfo(existing.Certificate)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
log.Printf("Found existing cert. %0.2f days remaining.", daysLeft)
|
||||
namesOK := dnsNamesEqual(cfg.Names, names)
|
||||
if daysLeft >= float64(renewUnder) && namesOK {
|
||||
log.Println("Nothing to do")
|
||||
//nothing to do
|
||||
return false, nil
|
||||
}
|
||||
if !namesOK {
|
||||
log.Println("DNS Names don't match expected set. Reissuing.")
|
||||
} else {
|
||||
log.Println("Renewing cert")
|
||||
action = func() (acme.CertificateResource, error) {
|
||||
return c.client.RenewCertificate(*existing, true, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
certResource, err := action()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
fmt.Printf("Obtained certificate for %s\n", cfg.CertName)
|
||||
return true, c.writeCertificate(cfg.CertName, &certResource)
|
||||
}
|
||||
|
||||
// filename for certifiacte / key / json file
|
||||
func (c *certManager) certFile(name, ext string) string {
|
||||
return filepath.Join(c.directory, "certificates", name, name+"."+ext)
|
||||
}
|
||||
|
||||
func (c *certManager) writeCertificate(name string, cr *acme.CertificateResource) error {
|
||||
jDAt, err := json.MarshalIndent(cr, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ioutil.WriteFile(c.certFile(name, "json"), jDAt, perms); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ioutil.WriteFile(c.certFile(name, "crt"), cr.Certificate, perms); err != nil {
|
||||
return err
|
||||
}
|
||||
return ioutil.WriteFile(c.certFile(name, "key"), cr.PrivateKey, perms)
|
||||
}
|
||||
|
||||
func getCertInfo(pemBytes []byte) (names []string, remaining float64, err error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, 0, fmt.Errorf("Invalid certificate pem data")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var daysLeft = float64(cert.NotAfter.Sub(time.Now())) / float64(time.Hour*24)
|
||||
return cert.DNSNames, daysLeft, nil
|
||||
}
|
||||
|
||||
// checks two lists of sans to make sure they have all the same names in them.
|
||||
func dnsNamesEqual(a []string, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
sort.Strings(a)
|
||||
sort.Strings(b)
|
||||
for i, s := range a {
|
||||
if b[i] != s {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *certManager) readCertificate(name string) (*acme.CertificateResource, error) {
|
||||
f, err := os.Open(c.certFile(name, "json"))
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
// if json does not exist, nothing does
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
cr := &acme.CertificateResource{}
|
||||
if err = dec.Decode(cr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// load cert
|
||||
crtBytes, err := ioutil.ReadFile(c.certFile(name, "crt"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cr.Certificate = crtBytes
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
func (c *certManager) Present(domain, token, keyAuth string) (e error) {
|
||||
d := c.cfg.DomainContainingFQDN(domain)
|
||||
// fix NS records for this domain's DNS providers
|
||||
// only need to do this once per domain
|
||||
const metaKey = "x-fixed-nameservers"
|
||||
if d.Metadata[metaKey] == "" {
|
||||
nsList, err := nameservers.DetermineNameservers(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Nameservers = nsList
|
||||
nameservers.AddNSRecords(d)
|
||||
d.Metadata[metaKey] = "true"
|
||||
}
|
||||
// copy now so we can add txt record safely, and just run unmodified version later to cleanup
|
||||
d, err := d.Copy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureNoPendingCorrections(d); err != nil {
|
||||
return err
|
||||
}
|
||||
fqdn, val, _ := acme.DNS01Record(domain, keyAuth)
|
||||
fmt.Println(fqdn, val)
|
||||
txt := &models.RecordConfig{Type: "TXT"}
|
||||
txt.SetTargetTXT(val)
|
||||
txt.SetLabelFromFQDN(fqdn, d.Name)
|
||||
d.Records = append(d.Records, txt)
|
||||
|
||||
return getAndRunCorrections(d)
|
||||
}
|
||||
|
||||
func (c *certManager) ensureNoPendingCorrections(d *models.DomainConfig) error {
|
||||
// only need to check a domain once per app run
|
||||
if c.checkedDomains[d.Name] {
|
||||
return nil
|
||||
}
|
||||
corrections, err := getCorrections(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(corrections) != 0 {
|
||||
// TODO: maybe allow forcing through this check.
|
||||
for _, c := range corrections {
|
||||
fmt.Println(c.Msg)
|
||||
}
|
||||
return fmt.Errorf("Found %d pending corrections for %s. Not going to proceed issuing certificates", len(corrections), d.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IgnoredProviders is a lit of provider names that should not be used to fill challenges.
|
||||
var IgnoredProviders = map[string]bool{}
|
||||
|
||||
func getCorrections(d *models.DomainConfig) ([]*models.Correction, error) {
|
||||
cs := []*models.Correction{}
|
||||
for _, p := range d.DNSProviderInstances {
|
||||
if IgnoredProviders[p.Name] {
|
||||
continue
|
||||
}
|
||||
dc, err := d.Copy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
corrections, err := p.Driver.GetDomainCorrections(dc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range corrections {
|
||||
c.Msg = fmt.Sprintf("[%s] %s", p.Name, strings.TrimSpace(c.Msg))
|
||||
}
|
||||
cs = append(cs, corrections...)
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
func getAndRunCorrections(d *models.DomainConfig) error {
|
||||
cs, err := getCorrections(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%d corrections\n", len(cs))
|
||||
for _, c := range cs {
|
||||
fmt.Printf("Running [%s]\n", c.Msg)
|
||||
err = c.F()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *certManager) CleanUp(domain, token, keyAuth string) error {
|
||||
d := c.cfg.DomainContainingFQDN(domain)
|
||||
return getAndRunCorrections(d)
|
||||
}
|
Reference in New Issue
Block a user