forked from TrueCloudLab/lego
72914df00f
Introduces a new command line switch `--must-staple` to `run` and `renew`. Using this switch will add the must staple TLS extension to the CSR generated by lego and thus also to the generated certificate. This does not work with user specified CSRs! Fixes #270
463 lines
13 KiB
Go
463 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/urfave/cli"
|
|
"github.com/xenolf/lego/acme"
|
|
"github.com/xenolf/lego/providers/dns/auroradns"
|
|
"github.com/xenolf/lego/providers/dns/cloudflare"
|
|
"github.com/xenolf/lego/providers/dns/digitalocean"
|
|
"github.com/xenolf/lego/providers/dns/dnsimple"
|
|
"github.com/xenolf/lego/providers/dns/dnsmadeeasy"
|
|
"github.com/xenolf/lego/providers/dns/dyn"
|
|
"github.com/xenolf/lego/providers/dns/gandi"
|
|
"github.com/xenolf/lego/providers/dns/googlecloud"
|
|
"github.com/xenolf/lego/providers/dns/linode"
|
|
"github.com/xenolf/lego/providers/dns/namecheap"
|
|
"github.com/xenolf/lego/providers/dns/ns1"
|
|
"github.com/xenolf/lego/providers/dns/ovh"
|
|
"github.com/xenolf/lego/providers/dns/pdns"
|
|
"github.com/xenolf/lego/providers/dns/rfc2136"
|
|
"github.com/xenolf/lego/providers/dns/route53"
|
|
"github.com/xenolf/lego/providers/dns/vultr"
|
|
"github.com/xenolf/lego/providers/http/memcached"
|
|
"github.com/xenolf/lego/providers/http/webroot"
|
|
)
|
|
|
|
func checkFolder(path string) error {
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return os.MkdirAll(path, 0700)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
|
|
|
|
if c.GlobalIsSet("http-timeout") {
|
|
acme.HTTPClient = http.Client{Timeout: time.Duration(c.GlobalInt("http-timeout")) * time.Second}
|
|
}
|
|
|
|
if c.GlobalIsSet("dns-timeout") {
|
|
acme.DNSTimeout = time.Duration(c.GlobalInt("dns-timeout")) * time.Second
|
|
}
|
|
|
|
if len(c.GlobalStringSlice("dns-resolvers")) > 0 {
|
|
resolvers := []string{}
|
|
for _, resolver := range c.GlobalStringSlice("dns-resolvers") {
|
|
if !strings.Contains(resolver, ":") {
|
|
resolver += ":53"
|
|
}
|
|
resolvers = append(resolvers, resolver)
|
|
}
|
|
acme.RecursiveNameservers = resolvers
|
|
}
|
|
|
|
err := checkFolder(c.GlobalString("path"))
|
|
if err != nil {
|
|
logger().Fatalf("Could not check/create path: %s", err.Error())
|
|
}
|
|
|
|
conf := NewConfiguration(c)
|
|
if len(c.GlobalString("email")) == 0 {
|
|
logger().Fatal("You have to pass an account (email address) to the program using --email or -m")
|
|
}
|
|
|
|
//TODO: move to account struct? Currently MUST pass email.
|
|
acc := NewAccount(c.GlobalString("email"), conf)
|
|
|
|
keyType, err := conf.KeyType()
|
|
if err != nil {
|
|
logger().Fatal(err.Error())
|
|
}
|
|
|
|
client, err := acme.NewClient(c.GlobalString("server"), acc, keyType)
|
|
if err != nil {
|
|
logger().Fatalf("Could not create client: %s", err.Error())
|
|
}
|
|
|
|
if len(c.GlobalStringSlice("exclude")) > 0 {
|
|
client.ExcludeChallenges(conf.ExcludedSolvers())
|
|
}
|
|
|
|
if c.GlobalIsSet("webroot") {
|
|
provider, err := webroot.NewHTTPProvider(c.GlobalString("webroot"))
|
|
if err != nil {
|
|
logger().Fatal(err)
|
|
}
|
|
|
|
client.SetChallengeProvider(acme.HTTP01, provider)
|
|
|
|
// --webroot=foo indicates that the user specifically want to do a HTTP challenge
|
|
// infer that the user also wants to exclude all other challenges
|
|
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01})
|
|
}
|
|
if c.GlobalIsSet("memcached-host") {
|
|
provider, err := memcached.NewMemcachedProvider(c.GlobalStringSlice("memcached-host"))
|
|
if err != nil {
|
|
logger().Fatal(err)
|
|
}
|
|
|
|
client.SetChallengeProvider(acme.HTTP01, provider)
|
|
|
|
// --memcached-host=foo:11211 indicates that the user specifically want to do a HTTP challenge
|
|
// infer that the user also wants to exclude all other challenges
|
|
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01})
|
|
}
|
|
if c.GlobalIsSet("http") {
|
|
if strings.Index(c.GlobalString("http"), ":") == -1 {
|
|
logger().Fatalf("The --http switch only accepts interface:port or :port for its argument.")
|
|
}
|
|
client.SetHTTPAddress(c.GlobalString("http"))
|
|
}
|
|
|
|
if c.GlobalIsSet("tls") {
|
|
if strings.Index(c.GlobalString("tls"), ":") == -1 {
|
|
logger().Fatalf("The --tls switch only accepts interface:port or :port for its argument.")
|
|
}
|
|
client.SetTLSAddress(c.GlobalString("tls"))
|
|
}
|
|
|
|
if c.GlobalIsSet("dns") {
|
|
var err error
|
|
var provider acme.ChallengeProvider
|
|
switch c.GlobalString("dns") {
|
|
case "auroradns":
|
|
provider, err = auroradns.NewDNSProvider()
|
|
case "cloudflare":
|
|
provider, err = cloudflare.NewDNSProvider()
|
|
case "digitalocean":
|
|
provider, err = digitalocean.NewDNSProvider()
|
|
case "dnsimple":
|
|
provider, err = dnsimple.NewDNSProvider()
|
|
case "dnsmadeeasy":
|
|
provider, err = dnsmadeeasy.NewDNSProvider()
|
|
case "dyn":
|
|
provider, err = dyn.NewDNSProvider()
|
|
case "gandi":
|
|
provider, err = gandi.NewDNSProvider()
|
|
case "gcloud":
|
|
provider, err = googlecloud.NewDNSProvider()
|
|
case "linode":
|
|
provider, err = linode.NewDNSProvider()
|
|
case "manual":
|
|
provider, err = acme.NewDNSProviderManual()
|
|
case "namecheap":
|
|
provider, err = namecheap.NewDNSProvider()
|
|
case "route53":
|
|
provider, err = route53.NewDNSProvider()
|
|
case "rfc2136":
|
|
provider, err = rfc2136.NewDNSProvider()
|
|
case "vultr":
|
|
provider, err = vultr.NewDNSProvider()
|
|
case "ovh":
|
|
provider, err = ovh.NewDNSProvider()
|
|
case "pdns":
|
|
provider, err = pdns.NewDNSProvider()
|
|
case "ns1":
|
|
provider, err = ns1.NewDNSProvider()
|
|
}
|
|
|
|
if err != nil {
|
|
logger().Fatal(err)
|
|
}
|
|
|
|
client.SetChallengeProvider(acme.DNS01, provider)
|
|
|
|
// --dns=foo indicates that the user specifically want to do a DNS challenge
|
|
// infer that the user also wants to exclude all other challenges
|
|
client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01})
|
|
}
|
|
|
|
return conf, acc, client
|
|
}
|
|
|
|
func saveCertRes(certRes acme.CertificateResource, conf *Configuration) {
|
|
// We store the certificate, private key and metadata in different files
|
|
// as web servers would not be able to work with a combined file.
|
|
certOut := path.Join(conf.CertPath(), certRes.Domain+".crt")
|
|
privOut := path.Join(conf.CertPath(), certRes.Domain+".key")
|
|
pemOut := path.Join(conf.CertPath(), certRes.Domain+".pem")
|
|
metaOut := path.Join(conf.CertPath(), certRes.Domain+".json")
|
|
|
|
err := ioutil.WriteFile(certOut, certRes.Certificate, 0600)
|
|
if err != nil {
|
|
logger().Fatalf("Unable to save Certificate for domain %s\n\t%s", certRes.Domain, err.Error())
|
|
}
|
|
|
|
if certRes.PrivateKey != nil {
|
|
// if we were given a CSR, we don't know the private key
|
|
err = ioutil.WriteFile(privOut, certRes.PrivateKey, 0600)
|
|
if err != nil {
|
|
logger().Fatalf("Unable to save PrivateKey for domain %s\n\t%s", certRes.Domain, err.Error())
|
|
}
|
|
|
|
if conf.context.GlobalBool("pem") {
|
|
err = ioutil.WriteFile(pemOut, bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil), 0600)
|
|
if err != nil {
|
|
logger().Fatalf("Unable to save Certificate and PrivateKey in .pem for domain %s\n\t%s", certRes.Domain, err.Error())
|
|
}
|
|
}
|
|
|
|
} else if conf.context.GlobalBool("pem") {
|
|
// we don't have the private key; can't write the .pem file
|
|
logger().Fatalf("Unable to save pem without private key for domain %s\n\t%s; are you using a CSR?", certRes.Domain, err.Error())
|
|
}
|
|
|
|
jsonBytes, err := json.MarshalIndent(certRes, "", "\t")
|
|
if err != nil {
|
|
logger().Fatalf("Unable to marshal CertResource for domain %s\n\t%s", certRes.Domain, err.Error())
|
|
}
|
|
|
|
err = ioutil.WriteFile(metaOut, jsonBytes, 0600)
|
|
if err != nil {
|
|
logger().Fatalf("Unable to save CertResource for domain %s\n\t%s", certRes.Domain, err.Error())
|
|
}
|
|
}
|
|
|
|
func handleTOS(c *cli.Context, client *acme.Client, acc *Account) {
|
|
// Check for a global accept override
|
|
if c.GlobalBool("accept-tos") {
|
|
err := client.AgreeToTOS()
|
|
if err != nil {
|
|
logger().Fatalf("Could not agree to TOS: %s", err.Error())
|
|
}
|
|
|
|
acc.Save()
|
|
return
|
|
}
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
logger().Printf("Please review the TOS at %s", acc.Registration.TosURL)
|
|
|
|
for {
|
|
logger().Println("Do you accept the TOS? Y/n")
|
|
text, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
logger().Fatalf("Could not read from console: %s", err.Error())
|
|
}
|
|
|
|
text = strings.Trim(text, "\r\n")
|
|
|
|
if text == "n" {
|
|
logger().Fatal("You did not accept the TOS. Unable to proceed.")
|
|
}
|
|
|
|
if text == "Y" || text == "y" || text == "" {
|
|
err = client.AgreeToTOS()
|
|
if err != nil {
|
|
logger().Fatalf("Could not agree to TOS: %s", err.Error())
|
|
}
|
|
acc.Save()
|
|
break
|
|
}
|
|
|
|
logger().Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.")
|
|
}
|
|
}
|
|
|
|
func readCSRFile(filename string) (*x509.CertificateRequest, error) {
|
|
bytes, err := ioutil.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw := bytes
|
|
|
|
// see if we can find a PEM-encoded CSR
|
|
var p *pem.Block
|
|
rest := bytes
|
|
for {
|
|
// decode a PEM block
|
|
p, rest = pem.Decode(rest)
|
|
|
|
// did we fail?
|
|
if p == nil {
|
|
break
|
|
}
|
|
|
|
// did we get a CSR?
|
|
if p.Type == "CERTIFICATE REQUEST" {
|
|
raw = p.Bytes
|
|
}
|
|
}
|
|
|
|
// no PEM-encoded CSR
|
|
// assume we were given a DER-encoded ASN.1 CSR
|
|
// (if this assumption is wrong, parsing these bytes will fail)
|
|
return x509.ParseCertificateRequest(raw)
|
|
}
|
|
|
|
func run(c *cli.Context) error {
|
|
conf, acc, client := setup(c)
|
|
if acc.Registration == nil {
|
|
reg, err := client.Register()
|
|
if err != nil {
|
|
logger().Fatalf("Could not complete registration\n\t%s", err.Error())
|
|
}
|
|
|
|
acc.Registration = reg
|
|
acc.Save()
|
|
|
|
logger().Print("!!!! HEADS UP !!!!")
|
|
logger().Printf(`
|
|
Your account credentials have been saved in your Let's Encrypt
|
|
configuration directory at "%s".
|
|
You should make a secure backup of this folder now. This
|
|
configuration directory will also contain certificates and
|
|
private keys obtained from Let's Encrypt so making regular
|
|
backups of this folder is ideal.`, conf.AccountPath(c.GlobalString("email")))
|
|
|
|
}
|
|
|
|
// If the agreement URL is empty, the account still needs to accept the LE TOS.
|
|
if acc.Registration.Body.Agreement == "" {
|
|
handleTOS(c, client, acc)
|
|
}
|
|
|
|
// we require either domains or csr, but not both
|
|
hasDomains := len(c.GlobalStringSlice("domains")) > 0
|
|
hasCsr := len(c.GlobalString("csr")) > 0
|
|
if hasDomains && hasCsr {
|
|
logger().Fatal("Please specify either --domains/-d or --csr/-c, but not both")
|
|
}
|
|
if !hasDomains && !hasCsr {
|
|
logger().Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)")
|
|
}
|
|
|
|
var cert acme.CertificateResource
|
|
var failures map[string]error
|
|
|
|
if hasDomains {
|
|
// obtain a certificate, generating a new private key
|
|
cert, failures = client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil, c.Bool("must-staple"))
|
|
} else {
|
|
// read the CSR
|
|
csr, err := readCSRFile(c.GlobalString("csr"))
|
|
if err != nil {
|
|
// we couldn't read the CSR
|
|
failures = map[string]error{"csr": err}
|
|
} else {
|
|
// obtain a certificate for this CSR
|
|
cert, failures = client.ObtainCertificateForCSR(*csr, !c.Bool("no-bundle"))
|
|
}
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
for k, v := range failures {
|
|
logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error())
|
|
}
|
|
|
|
// Make sure to return a non-zero exit code if ObtainSANCertificate
|
|
// returned at least one error. Due to us not returning partial
|
|
// certificate we can just exit here instead of at the end.
|
|
os.Exit(1)
|
|
}
|
|
|
|
err := checkFolder(conf.CertPath())
|
|
if err != nil {
|
|
logger().Fatalf("Could not check/create path: %s", err.Error())
|
|
}
|
|
|
|
saveCertRes(cert, conf)
|
|
|
|
return nil
|
|
}
|
|
|
|
func revoke(c *cli.Context) error {
|
|
|
|
conf, _, client := setup(c)
|
|
|
|
err := checkFolder(conf.CertPath())
|
|
if err != nil {
|
|
logger().Fatalf("Could not check/create path: %s", err.Error())
|
|
}
|
|
|
|
for _, domain := range c.GlobalStringSlice("domains") {
|
|
logger().Printf("Trying to revoke certificate for domain %s", domain)
|
|
|
|
certPath := path.Join(conf.CertPath(), domain+".crt")
|
|
certBytes, err := ioutil.ReadFile(certPath)
|
|
|
|
err = client.RevokeCertificate(certBytes)
|
|
if err != nil {
|
|
logger().Fatalf("Error while revoking the certificate for domain %s\n\t%s", domain, err.Error())
|
|
} else {
|
|
logger().Print("Certificate was revoked.")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func renew(c *cli.Context) error {
|
|
conf, _, client := setup(c)
|
|
|
|
if len(c.GlobalStringSlice("domains")) <= 0 {
|
|
logger().Fatal("Please specify at least one domain.")
|
|
}
|
|
|
|
domain := c.GlobalStringSlice("domains")[0]
|
|
|
|
// load the cert resource from files.
|
|
// We store the certificate, private key and metadata in different files
|
|
// as web servers would not be able to work with a combined file.
|
|
certPath := path.Join(conf.CertPath(), domain+".crt")
|
|
privPath := path.Join(conf.CertPath(), domain+".key")
|
|
metaPath := path.Join(conf.CertPath(), domain+".json")
|
|
|
|
certBytes, err := ioutil.ReadFile(certPath)
|
|
if err != nil {
|
|
logger().Fatalf("Error while loading the certificate for domain %s\n\t%s", domain, err.Error())
|
|
}
|
|
|
|
if c.IsSet("days") {
|
|
expTime, err := acme.GetPEMCertExpiration(certBytes)
|
|
if err != nil {
|
|
logger().Printf("Could not get Certification expiration for domain %s", domain)
|
|
}
|
|
|
|
if int(expTime.Sub(time.Now()).Hours()/24.0) > c.Int("days") {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
metaBytes, err := ioutil.ReadFile(metaPath)
|
|
if err != nil {
|
|
logger().Fatalf("Error while loading the meta data for domain %s\n\t%s", domain, err.Error())
|
|
}
|
|
|
|
var certRes acme.CertificateResource
|
|
err = json.Unmarshal(metaBytes, &certRes)
|
|
if err != nil {
|
|
logger().Fatalf("Error while marshalling the meta data for domain %s\n\t%s", domain, err.Error())
|
|
}
|
|
|
|
if c.Bool("reuse-key") {
|
|
keyBytes, err := ioutil.ReadFile(privPath)
|
|
if err != nil {
|
|
logger().Fatalf("Error while loading the private key for domain %s\n\t%s", domain, err.Error())
|
|
}
|
|
certRes.PrivateKey = keyBytes
|
|
}
|
|
|
|
certRes.Certificate = certBytes
|
|
|
|
newCert, err := client.RenewCertificate(certRes, !c.Bool("no-bundle"), c.Bool("must-staple"))
|
|
if err != nil {
|
|
logger().Fatalf("%s", err.Error())
|
|
}
|
|
|
|
saveCertRes(newCert, conf)
|
|
|
|
return nil
|
|
}
|