Initial ACMEv2 support WIP

This commit is contained in:
xenolf 2018-03-14 02:04:09 +01:00
parent 06a8e7c475
commit 3d582c0169
26 changed files with 2979 additions and 46 deletions

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ lego.exe
lego lego
.lego .lego
.idea .idea
dist/
.vscode/

View file

@ -7,7 +7,7 @@ import (
"os" "os"
"path" "path"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acmev2"
) )
// Account represents a users local saved credentials // Account represents a users local saved credentials
@ -64,8 +64,14 @@ func NewAccount(email string, conf *Configuration) *Account {
acc.key = privKey acc.key = privKey
acc.conf = conf acc.conf = conf
if acc.Registration == nil { if acc.Registration == nil || acc.Registration.Body.Status == "" {
logger().Fatalf("Could not load account for %s. Registration is nil.", email) reg, err := tryRecoverAccount(privKey, conf)
if err != nil {
logger().Fatalf("Could not load account for %s. Registration is nil -> %#v", email, err)
}
acc.Registration = reg
acc.Save()
} }
if acc.conf == nil { if acc.conf == nil {
@ -75,6 +81,21 @@ func NewAccount(email string, conf *Configuration) *Account {
return &acc return &acc
} }
func tryRecoverAccount(privKey crypto.PrivateKey, conf *Configuration) (*acme.RegistrationResource, error) {
// couldn't load account but got a key. Try to look the account up.
serverURL := conf.context.GlobalString("server")
client, err := acme.NewClient(serverURL, &Account{key: privKey, conf: conf}, acme.RSA2048)
if err != nil {
return nil, err
}
reg, err := client.ResolveAccountByKey()
if err != nil {
return nil, err
}
return reg, nil
}
/** Implementation of the acme.User interface **/ /** Implementation of the acme.User interface **/
// GetEmail returns the email address for the account // GetEmail returns the email address for the account

13
acmev2/challenges.go Normal file
View file

@ -0,0 +1,13 @@
package acme
// Challenge is a string that identifies a particular type and version of ACME challenge.
type Challenge string
const (
// HTTP01 is the "http-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#http
// Note: HTTP01ChallengePath returns the URL path to fulfill this challenge
HTTP01 = Challenge("http-01")
// DNS01 is the "dns-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#dns
// Note: DNS01Record returns a DNS record which will fulfill this challenge
DNS01 = Challenge("dns-01")
)

799
acmev2/client.go Normal file
View file

@ -0,0 +1,799 @@
// Package acme implements the ACME protocol for Let's Encrypt and other conforming providers.
package acme
import (
"crypto"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"log"
"net"
"regexp"
"strconv"
"strings"
"time"
)
var (
// Logger is an optional custom logger.
Logger *log.Logger
)
const (
// maxBodySize is the maximum size of body that we will read.
maxBodySize = 1024 * 1024
// overallRequestLimit is the overall number of request per second limited on the
// “new-reg”, “new-authz” and “new-cert” endpoints. From the documentation the
// limitation is 20 requests per second, but using 20 as value doesn't work but 18 do
overallRequestLimit = 18
)
// logf writes a log entry. It uses Logger if not
// nil, otherwise it uses the default log.Logger.
func logf(format string, args ...interface{}) {
if Logger != nil {
Logger.Printf(format, args...)
} else {
log.Printf(format, args...)
}
}
// User interface is to be implemented by users of this library.
// It is used by the client type to get user specific information.
type User interface {
GetEmail() string
GetRegistration() *RegistrationResource
GetPrivateKey() crypto.PrivateKey
}
// Interface for all challenge solvers to implement.
type solver interface {
Solve(challenge challenge, domain string) error
}
type validateFunc func(j *jws, domain, uri string, chlng challenge) error
// Client is the user-friendy way to ACME
type Client struct {
directory directory
user User
jws *jws
keyType KeyType
solvers map[Challenge]solver
}
// NewClient creates a new ACME client on behalf of the user. The client will depend on
// the ACME directory located at caDirURL for the rest of its actions. A private
// key of type keyType (see KeyType contants) will be generated when requesting a new
// certificate if one isn't provided.
func NewClient(caDirURL string, user User, keyType KeyType) (*Client, error) {
privKey := user.GetPrivateKey()
if privKey == nil {
return nil, errors.New("private key was nil")
}
var dir directory
if _, err := getJSON(caDirURL, &dir); err != nil {
return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err)
}
if dir.NewAccountURL == "" {
return nil, errors.New("directory missing new registration URL")
}
if dir.NewOrderURL == "" {
return nil, errors.New("directory missing new order URL")
}
/*if dir.RevokeCertURL == "" {
return nil, errors.New("directory missing revoke certificate URL")
}*/
jws := &jws{privKey: privKey, getNonceURL: dir.NewNonceURL}
if reg := user.GetRegistration(); reg != nil {
jws.kid = reg.URI
}
// REVIEW: best possibility?
// Add all available solvers with the right index as per ACME
// spec to this map. Otherwise they won`t be found.
solvers := make(map[Challenge]solver)
solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}}
return &Client{directory: dir, user: user, jws: jws, keyType: keyType, solvers: solvers}, nil
}
// SetChallengeProvider specifies a custom provider p that can solve the given challenge type.
func (c *Client) SetChallengeProvider(challenge Challenge, p ChallengeProvider) error {
switch challenge {
case HTTP01:
c.solvers[challenge] = &httpChallenge{jws: c.jws, validate: validate, provider: p}
case DNS01:
c.solvers[challenge] = &dnsChallenge{jws: c.jws, validate: validate, provider: p}
default:
return fmt.Errorf("Unknown challenge %v", challenge)
}
return nil
}
// SetHTTPAddress specifies a custom interface:port to be used for HTTP based challenges.
// If this option is not used, the default port 80 and all interfaces will be used.
// To only specify a port and no interface use the ":port" notation.
//
// NOTE: This REPLACES any custom HTTP provider previously set by calling
// c.SetChallengeProvider with the default HTTP challenge provider.
func (c *Client) SetHTTPAddress(iface string) error {
host, port, err := net.SplitHostPort(iface)
if err != nil {
return err
}
if chlng, ok := c.solvers[HTTP01]; ok {
chlng.(*httpChallenge).provider = NewHTTPProviderServer(host, port)
}
return nil
}
// ExcludeChallenges explicitly removes challenges from the pool for solving.
func (c *Client) ExcludeChallenges(challenges []Challenge) {
// Loop through all challenges and delete the requested one if found.
for _, challenge := range challenges {
delete(c.solvers, challenge)
}
}
// GetToSURL returns the current ToS URL from the Directory
func (c *Client) GetToSURL() string {
return c.directory.Meta.TermsOfService
}
// Register the current account to the ACME server.
func (c *Client) Register(tosAgreed bool) (*RegistrationResource, error) {
if c == nil || c.user == nil {
return nil, errors.New("acme: cannot register a nil client or user")
}
logf("[INFO] acme: Registering account for %s", c.user.GetEmail())
accMsg := accountMessage{}
if c.user.GetEmail() != "" {
accMsg.Contact = []string{"mailto:" + c.user.GetEmail()}
} else {
accMsg.Contact = []string{}
}
accMsg.TermsOfServiceAgreed = tosAgreed
var serverReg accountMessage
hdr, err := postJSON(c.jws, c.directory.NewAccountURL, accMsg, &serverReg)
if err != nil {
remoteErr, ok := err.(RemoteError)
if ok && remoteErr.StatusCode == 409 {
} else {
return nil, err
}
}
reg := &RegistrationResource{
URI: hdr.Get("Location"),
Body: serverReg,
}
c.jws.kid = reg.URI
return reg, nil
}
// ResolveAccountByKey will attempt to look up an account using the given account key
// and return its registration resource.
func (c *Client) ResolveAccountByKey() (*RegistrationResource, error) {
logf("[INFO] acme: Trying to resolve account by key")
acc := accountMessage{OnlyReturnExisting: true}
hdr, err := postJSON(c.jws, c.directory.NewAccountURL, acc, &acc)
if err != nil {
return nil, err
}
accountLink := hdr.Get("Location")
if accountLink == "" {
return nil, errors.New("Server did not return the account link")
}
var retAccount accountMessage
c.jws.kid = accountLink
hdr, err = postJSON(c.jws, accountLink, accountMessage{}, &retAccount)
if err != nil {
return nil, err
}
return &RegistrationResource{URI: accountLink, Body: retAccount}, nil
}
// DeleteRegistration deletes the client's user registration from the ACME
// server.
func (c *Client) DeleteRegistration() error {
if c == nil || c.user == nil {
return errors.New("acme: cannot unregister a nil client or user")
}
logf("[INFO] acme: Deleting account for %s", c.user.GetEmail())
accMsg := accountMessage{
Status: "deactivated",
}
_, err := postJSON(c.jws, c.user.GetRegistration().URI, accMsg, nil)
if err != nil {
return err
}
return nil
}
// QueryRegistration runs a POST request on the client's registration and
// returns the result.
//
// This is similar to the Register function, but acting on an existing
// registration link and resource.
func (c *Client) QueryRegistration() (*RegistrationResource, error) {
if c == nil || c.user == nil {
return nil, errors.New("acme: cannot query the registration of a nil client or user")
}
// Log the URL here instead of the email as the email may not be set
logf("[INFO] acme: Querying account for %s", c.user.GetRegistration().URI)
accMsg := accountMessage{}
var serverReg accountMessage
_, err := postJSON(c.jws, c.user.GetRegistration().URI, accMsg, &serverReg)
if err != nil {
return nil, err
}
reg := &RegistrationResource{Body: serverReg}
// Location: header is not returned so this needs to be populated off of
// existing URI
reg.URI = c.user.GetRegistration().URI
return reg, nil
}
// ObtainCertificateForCSR tries to obtain a certificate matching the CSR passed into it.
// The domains are inferred from the CommonName and SubjectAltNames, if any. The private key
// for this CSR is not required.
// If bundle is true, the []byte contains both the issuer certificate and
// your issued certificate as a bundle.
// This function will never return a partial certificate. If one domain in the list fails,
// the whole certificate will fail.
func (c *Client) ObtainCertificateForCSR(csr x509.CertificateRequest, bundle bool) (CertificateResource, map[string]error) {
// figure out what domains it concerns
// start with the common name
domains := []string{csr.Subject.CommonName}
// loop over the SubjectAltName DNS names
DNSNames:
for _, sanName := range csr.DNSNames {
for _, existingName := range domains {
if existingName == sanName {
// duplicate; skip this name
continue DNSNames
}
}
// name is unique
domains = append(domains, sanName)
}
if bundle {
logf("[INFO][%s] acme: Obtaining bundled SAN certificate given a CSR", strings.Join(domains, ", "))
} else {
logf("[INFO][%s] acme: Obtaining SAN certificate given a CSR", strings.Join(domains, ", "))
}
order, err := c.createOrderForIdentifiers(domains)
if err != nil {
identErrors := make(map[string]error)
for _, auth := range order.Identifiers {
identErrors[auth.Value] = err
}
}
authz, failures := c.getAuthzForOrder(order)
// If any challenge fails - return. Do not generate partial SAN certificates.
if len(failures) > 0 {
/*for _, auth := range authz {
c.disableAuthz(auth)
}*/
return CertificateResource{}, failures
}
errs := c.solveChallengeForAuthz(authz)
// If any challenge fails - return. Do not generate partial SAN certificates.
if len(errs) > 0 {
return CertificateResource{}, errs
}
logf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
cert, err := c.requestCertificateForCsr(order, bundle, csr.Raw, nil)
if err != nil {
for _, chln := range authz {
failures[chln.Identifier.Value] = err
}
}
// Add the CSR to the certificate so that it can be used for renewals.
cert.CSR = pemEncode(&csr)
return cert, failures
}
// ObtainCertificate tries to obtain a single certificate using all domains passed into it.
// The first domain in domains is used for the CommonName field of the certificate, all other
// domains are added using the Subject Alternate Names extension. A new private key is generated
// for every invocation of this function. If you do not want that you can supply your own private key
// in the privKey parameter. If this parameter is non-nil it will be used instead of generating a new one.
// If bundle is true, the []byte contains both the issuer certificate and
// your issued certificate as a bundle.
// This function will never return a partial certificate. If one domain in the list fails,
// the whole certificate will fail.
func (c *Client) ObtainCertificate(domains []string, bundle bool, privKey crypto.PrivateKey, mustStaple bool) (CertificateResource, map[string]error) {
if bundle {
logf("[INFO][%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", "))
} else {
logf("[INFO][%s] acme: Obtaining SAN certificate", strings.Join(domains, ", "))
}
order, err := c.createOrderForIdentifiers(domains)
if err != nil {
identErrors := make(map[string]error)
for _, auth := range order.Identifiers {
identErrors[auth.Value] = err
}
}
authz, failures := c.getAuthzForOrder(order)
// If any challenge fails - return. Do not generate partial SAN certificates.
if len(failures) > 0 {
/*for _, auth := range authz {
c.disableAuthz(auth)
}*/
return CertificateResource{}, failures
}
errs := c.solveChallengeForAuthz(authz)
// If any challenge fails - return. Do not generate partial SAN certificates.
if len(errs) > 0 {
return CertificateResource{}, errs
}
logf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
cert, err := c.requestCertificateForOrder(order, bundle, privKey, mustStaple)
if err != nil {
for _, auth := range authz {
failures[auth.Identifier.Value] = err
}
}
return cert, failures
}
// RevokeCertificate takes a PEM encoded certificate or bundle and tries to revoke it at the CA.
func (c *Client) RevokeCertificate(certificate []byte) error {
certificates, err := parsePEMBundle(certificate)
if err != nil {
return err
}
x509Cert := certificates[0]
if x509Cert.IsCA {
return fmt.Errorf("Certificate bundle starts with a CA certificate")
}
encodedCert := base64.URLEncoding.EncodeToString(x509Cert.Raw)
_, err = postJSON(c.jws, c.directory.RevokeCertURL, revokeCertMessage{Certificate: encodedCert}, nil)
return err
}
// RenewCertificate takes a CertificateResource and tries to renew the certificate.
// If the renewal process succeeds, the new certificate will ge returned in a new CertResource.
// Please be aware that this function will return a new certificate in ANY case that is not an error.
// If the server does not provide us with a new cert on a GET request to the CertURL
// this function will start a new-cert flow where a new certificate gets generated.
// If bundle is true, the []byte contains both the issuer certificate and
// your issued certificate as a bundle.
// For private key reuse the PrivateKey property of the passed in CertificateResource should be non-nil.
func (c *Client) RenewCertificate(cert CertificateResource, bundle, mustStaple bool) (CertificateResource, error) {
// Input certificate is PEM encoded. Decode it here as we may need the decoded
// cert later on in the renewal process. The input may be a bundle or a single certificate.
certificates, err := parsePEMBundle(cert.Certificate)
if err != nil {
return CertificateResource{}, err
}
x509Cert := certificates[0]
if x509Cert.IsCA {
return CertificateResource{}, fmt.Errorf("[%s] Certificate bundle starts with a CA certificate", cert.Domain)
}
// This is just meant to be informal for the user.
timeLeft := x509Cert.NotAfter.Sub(time.Now().UTC())
logf("[INFO][%s] acme: Trying renewal with %d hours remaining", cert.Domain, int(timeLeft.Hours()))
// We always need to request a new certificate to renew.
// Start by checking to see if the certificate was based off a CSR, and
// use that if it's defined.
if len(cert.CSR) > 0 {
csr, err := pemDecodeTox509CSR(cert.CSR)
if err != nil {
return CertificateResource{}, err
}
newCert, failures := c.ObtainCertificateForCSR(*csr, bundle)
return newCert, failures[cert.Domain]
}
var privKey crypto.PrivateKey
if cert.PrivateKey != nil {
privKey, err = parsePEMPrivateKey(cert.PrivateKey)
if err != nil {
return CertificateResource{}, err
}
}
var domains []string
var failures map[string]error
// check for SAN certificate
if len(x509Cert.DNSNames) > 1 {
domains = append(domains, x509Cert.Subject.CommonName)
for _, sanDomain := range x509Cert.DNSNames {
if sanDomain == x509Cert.Subject.CommonName {
continue
}
domains = append(domains, sanDomain)
}
} else {
domains = append(domains, x509Cert.Subject.CommonName)
}
newCert, failures := c.ObtainCertificate(domains, bundle, privKey, mustStaple)
return newCert, failures[cert.Domain]
}
func (c *Client) createOrderForIdentifiers(domains []string) (orderResource, error) {
var identifiers []identifier
for _, domain := range domains {
identifiers = append(identifiers, identifier{Type: "dns", Value: domain})
}
order := orderMessage{
Identifiers: identifiers,
}
var response orderMessage
hdr, err := postJSON(c.jws, c.directory.NewOrderURL, order, &response)
if err != nil {
return orderResource{}, err
}
orderRes := orderResource{
URL: hdr.Get("Location"),
orderMessage: response,
}
return orderRes, nil
}
// Looks through the challenge combinations to find a solvable match.
// Then solves the challenges in series and returns.
func (c *Client) solveChallengeForAuthz(authorizations []authorization) map[string]error {
// loop through the resources, basically through the domains.
failures := make(map[string]error)
for _, authz := range authorizations {
if authz.Status == "valid" {
// Boulder might recycle recent validated authz (see issue #267)
logf("[INFO][%s] acme: Authorization already valid; skipping challenge", authz.Identifier.Value)
continue
}
// no solvers - no solving
if i, solver := c.chooseSolver(authz, authz.Identifier.Value); solver != nil {
err := solver.Solve(authz.Challenges[i], authz.Identifier.Value)
if err != nil {
//c.disableAuthz(authz.Identifier)
failures[authz.Identifier.Value] = err
}
} else {
//c.disableAuthz(authz)
failures[authz.Identifier.Value] = fmt.Errorf("[%s] acme: Could not determine solvers", authz.Identifier.Value)
}
}
return failures
}
// Checks all challenges from the server in order and returns the first matching solver.
func (c *Client) chooseSolver(auth authorization, domain string) (int, solver) {
for i, challenge := range auth.Challenges {
if solver, ok := c.solvers[Challenge(challenge.Type)]; ok {
return i, solver
}
logf("[INFO][%s] acme: Could not find solver for: %s", domain, challenge.Type)
}
return 0, nil
}
// Get the challenges needed to proof our identifier to the ACME server.
func (c *Client) getAuthzForOrder(order orderResource) ([]authorization, map[string]error) {
resc, errc := make(chan authorization), make(chan domainError)
delay := time.Second / overallRequestLimit
for _, authzURL := range order.Authorizations {
time.Sleep(delay)
go func(authzURL string) {
var authz authorization
_, err := getJSON(authzURL, &authz)
if err != nil {
errc <- domainError{Domain: authz.Identifier.Value, Error: err}
return
}
resc <- authz
}(authzURL)
}
var responses []authorization
failures := make(map[string]error)
for i := 0; i < len(order.Authorizations); i++ {
select {
case res := <-resc:
responses = append(responses, res)
case err := <-errc:
failures[err.Domain] = err.Error
}
}
logAuthz(order)
close(resc)
close(errc)
return responses, failures
}
func logAuthz(order orderResource) {
for i, auth := range order.Authorizations {
logf("[INFO][%s] AuthURL: %s", order.Identifiers[i].Value, auth)
}
}
// cleanAuthz loops through the passed in slice and disables any auths which are not "valid"
func (c *Client) disableAuthz(authURL string) error {
var disabledAuth authorization
_, err := postJSON(c.jws, authURL, deactivateAuthMessage{Status: "deactivated"}, &disabledAuth)
return err
}
func (c *Client) requestCertificateForOrder(order orderResource, bundle bool, privKey crypto.PrivateKey, mustStaple bool) (CertificateResource, error) {
var err error
if privKey == nil {
privKey, err = generatePrivateKey(c.keyType)
if err != nil {
return CertificateResource{}, err
}
}
// determine certificate name(s) based on the authorization resources
commonName := order.Identifiers[0].Value
var san []string
for _, auth := range order.Identifiers {
san = append(san, auth.Value)
}
// TODO: should the CSR be customizable?
csr, err := generateCsr(privKey, commonName, san, mustStaple)
if err != nil {
return CertificateResource{}, err
}
return c.requestCertificateForCsr(order, bundle, csr, pemEncode(privKey))
}
func (c *Client) requestCertificateForCsr(order orderResource, bundle bool, csr []byte, privateKeyPem []byte) (CertificateResource, error) {
commonName := order.Identifiers[0].Value
var authURLs []string
for _, auth := range order.Identifiers[1:] {
authURLs = append(authURLs, auth.Value)
}
csrString := base64.RawURLEncoding.EncodeToString(csr)
var retOrder orderMessage
_, error := postJSON(c.jws, order.Finalize, csrMessage{Csr: csrString}, &retOrder)
if error != nil {
return CertificateResource{}, error
}
if retOrder.Status == "invalid" {
return CertificateResource{}, error
}
certRes := CertificateResource{
Domain: commonName,
CertURL: retOrder.Certificate,
PrivateKey: privateKeyPem,
}
if retOrder.Status == "valid" {
// if the certificate is available right away, short cut!
ok, err := c.checkCertResponse(retOrder, &certRes, bundle)
if err != nil {
return CertificateResource{}, err
}
if ok {
return certRes, nil
}
}
maxChecks := 1000
for i := 0; i < maxChecks; i++ {
_, err := getJSON(order.URL, &retOrder)
if err != nil {
return CertificateResource{}, err
}
done, err := c.checkCertResponse(retOrder, &certRes, bundle)
if err != nil {
return CertificateResource{}, err
}
if done {
break
}
if i == maxChecks-1 {
return CertificateResource{}, fmt.Errorf("polled for certificate %d times; giving up", i)
}
}
return certRes, nil
}
// checkCertResponse checks to see if the certificate is ready and a link is contained in the
// response. if so, loads it into certRes and returns true. If the cert
// is not yet ready, it returns false. The certRes input
// should already have the Domain (common name) field populated. If bundle is
// true, the certificate will be bundled with the issuer's cert.
func (c *Client) checkCertResponse(order orderMessage, certRes *CertificateResource, bundle bool) (bool, error) {
switch order.Status {
case "valid":
resp, err := httpGet(order.Certificate)
if err != nil {
return false, err
}
cert, err := ioutil.ReadAll(limitReader(resp.Body, maxBodySize))
if err != nil {
return false, err
}
// The issuer certificate link is always supplied via an "up" link
// in the response headers of a new certificate.
links := parseLinks(resp.Header["Link"])
if link, ok := links["up"]; ok {
issuerCert, err := c.getIssuerCertificate(link)
if err != nil {
// If we fail to acquire the issuer cert, return the issued certificate - do not fail.
logf("[WARNING][%s] acme: Could not bundle issuer certificate: %v", certRes.Domain, err)
} else {
issuerCert = pemEncode(derCertificateBytes(issuerCert))
// If bundle is true, we want to return a certificate bundle.
// To do this, we append the issuer cert to the issued cert.
if bundle {
cert = append(cert, issuerCert...)
}
certRes.IssuerCertificate = issuerCert
}
}
certRes.Certificate = cert
certRes.CertURL = order.Certificate
certRes.CertStableURL = order.Certificate
logf("[INFO][%s] Server responded with a certificate.", certRes.Domain)
return true, nil
case "processing":
return false, nil
case "invalid":
return false, errors.New("Order has invalid state: invalid")
}
return false, nil
}
// getIssuerCertificate requests the issuer certificate
func (c *Client) getIssuerCertificate(url string) ([]byte, error) {
logf("[INFO] acme: Requesting issuer cert from %s", url)
resp, err := httpGet(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, maxBodySize))
if err != nil {
return nil, err
}
_, err = x509.ParseCertificate(issuerBytes)
if err != nil {
return nil, err
}
return issuerBytes, err
}
func parseLinks(links []string) map[string]string {
aBrkt := regexp.MustCompile("[<>]")
slver := regexp.MustCompile("(.+) *= *\"(.+)\"")
linkMap := make(map[string]string)
for _, link := range links {
link = aBrkt.ReplaceAllString(link, "")
parts := strings.Split(link, ";")
matches := slver.FindStringSubmatch(parts[1])
if len(matches) > 0 {
linkMap[matches[2]] = parts[0]
}
}
return linkMap
}
// validate makes the ACME server start validating a
// challenge response, only returning once it is done.
func validate(j *jws, domain, uri string, c challenge) error {
var chlng challenge
hdr, err := postJSON(j, uri, c, &chlng)
if err != nil {
return err
}
// After the path is sent, the ACME server will access our server.
// Repeatedly check the server for an updated status on our request.
for {
switch chlng.Status {
case "valid":
logf("[INFO][%s] The server validated our request", domain)
return nil
case "pending":
break
case "invalid":
return handleChallengeError(chlng)
default:
return errors.New("The server returned an unexpected state")
}
ra, err := strconv.Atoi(hdr.Get("Retry-After"))
if err != nil {
// The ACME server MUST return a Retry-After.
// If it doesn't, we'll just poll hard.
ra = 5
}
time.Sleep(time.Duration(ra) * time.Second)
hdr, err = getJSON(uri, &chlng)
if err != nil {
return err
}
}
}

269
acmev2/client_test.go Normal file
View file

@ -0,0 +1,269 @@
package acme
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestNewClient(t *testing.T) {
keyBits := 32 // small value keeps test fast
keyType := RSA2048
key, err := rsa.GenerateKey(rand.Reader, keyBits)
if err != nil {
t.Fatal("Could not generate test key:", err)
}
user := mockUser{
email: "test@test.com",
regres: new(RegistrationResource),
privatekey: key,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := json.Marshal(directory{NewAuthzURL: "http://test", NewCertURL: "http://test", NewRegURL: "http://test", RevokeCertURL: "http://test"})
w.Write(data)
}))
client, err := NewClient(ts.URL, user, keyType)
if err != nil {
t.Fatalf("Could not create client: %v", err)
}
if client.jws == nil {
t.Fatalf("Expected client.jws to not be nil")
}
if expected, actual := key, client.jws.privKey; actual != expected {
t.Errorf("Expected jws.privKey to be %p but was %p", expected, actual)
}
if client.keyType != keyType {
t.Errorf("Expected keyType to be %s but was %s", keyType, client.keyType)
}
if expected, actual := 2, len(client.solvers); actual != expected {
t.Fatalf("Expected %d solver(s), got %d", expected, actual)
}
}
func TestClientOptPort(t *testing.T) {
keyBits := 32 // small value keeps test fast
key, err := rsa.GenerateKey(rand.Reader, keyBits)
if err != nil {
t.Fatal("Could not generate test key:", err)
}
user := mockUser{
email: "test@test.com",
regres: new(RegistrationResource),
privatekey: key,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := json.Marshal(directory{NewAuthzURL: "http://test", NewCertURL: "http://test", NewRegURL: "http://test", RevokeCertURL: "http://test"})
w.Write(data)
}))
optPort := "1234"
optHost := ""
client, err := NewClient(ts.URL, user, RSA2048)
if err != nil {
t.Fatalf("Could not create client: %v", err)
}
client.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
client.SetTLSAddress(net.JoinHostPort(optHost, optPort))
httpSolver, ok := client.solvers[HTTP01].(*httpChallenge)
if !ok {
t.Fatal("Expected http-01 solver to be httpChallenge type")
}
if httpSolver.jws != client.jws {
t.Error("Expected http-01 to have same jws as client")
}
if got := httpSolver.provider.(*HTTPProviderServer).port; got != optPort {
t.Errorf("Expected http-01 to have port %s but was %s", optPort, got)
}
if got := httpSolver.provider.(*HTTPProviderServer).iface; got != optHost {
t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got)
}
httpsSolver, ok := client.solvers[TLSSNI01].(*tlsSNIChallenge)
if !ok {
t.Fatal("Expected tls-sni-01 solver to be httpChallenge type")
}
if httpsSolver.jws != client.jws {
t.Error("Expected tls-sni-01 to have same jws as client")
}
if got := httpsSolver.provider.(*TLSProviderServer).port; got != optPort {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got)
}
if got := httpsSolver.provider.(*TLSProviderServer).iface; got != optHost {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, got)
}
// test setting different host
optHost = "127.0.0.1"
client.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
client.SetTLSAddress(net.JoinHostPort(optHost, optPort))
if got := httpSolver.provider.(*HTTPProviderServer).iface; got != optHost {
t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got)
}
if got := httpsSolver.provider.(*TLSProviderServer).port; got != optPort {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got)
}
}
func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(250 * time.Millisecond)
w.Header().Add("Replay-Nonce", "12345")
w.Header().Add("Retry-After", "0")
writeJSONResponse(w, &challenge{Type: "http-01", Status: "Valid", URI: "http://example.com/", Token: "token"})
}))
defer ts.Close()
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey, directoryURL: ts.URL}
ch := make(chan bool)
resultCh := make(chan bool)
go func() {
j.Nonce()
ch <- true
}()
go func() {
j.Nonce()
ch <- true
}()
go func() {
<-ch
<-ch
resultCh <- true
}()
select {
case <-resultCh:
case <-time.After(400 * time.Millisecond):
t.Fatal("JWS is probably holding a lock while making HTTP request")
}
}
func TestValidate(t *testing.T) {
var statuses []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Minimal stub ACME server for validation.
w.Header().Add("Replay-Nonce", "12345")
w.Header().Add("Retry-After", "0")
switch r.Method {
case "HEAD":
case "POST":
st := statuses[0]
statuses = statuses[1:]
writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"})
case "GET":
st := statuses[0]
statuses = statuses[1:]
writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"})
default:
http.Error(w, r.Method, http.StatusMethodNotAllowed)
}
}))
defer ts.Close()
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey, directoryURL: ts.URL}
tsts := []struct {
name string
statuses []string
want string
}{
{"POST-unexpected", []string{"weird"}, "unexpected"},
{"POST-valid", []string{"valid"}, ""},
{"POST-invalid", []string{"invalid"}, "Error Detail"},
{"GET-unexpected", []string{"pending", "weird"}, "unexpected"},
{"GET-valid", []string{"pending", "valid"}, ""},
{"GET-invalid", []string{"pending", "invalid"}, "Error Detail"},
}
for _, tst := range tsts {
statuses = tst.statuses
if err := validate(j, "example.com", ts.URL, challenge{Type: "http-01", Token: "token"}); err == nil && tst.want != "" {
t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want)
} else if err != nil && !strings.Contains(err.Error(), tst.want) {
t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want)
}
}
}
func TestGetChallenges(t *testing.T) {
var ts *httptest.Server
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET", "HEAD":
w.Header().Add("Replay-Nonce", "12345")
w.Header().Add("Retry-After", "0")
writeJSONResponse(w, directory{NewAuthzURL: ts.URL, NewCertURL: ts.URL, NewRegURL: ts.URL, RevokeCertURL: ts.URL})
case "POST":
writeJSONResponse(w, authorization{})
}
}))
defer ts.Close()
keyBits := 512 // small value keeps test fast
keyType := RSA2048
key, err := rsa.GenerateKey(rand.Reader, keyBits)
if err != nil {
t.Fatal("Could not generate test key:", err)
}
user := mockUser{
email: "test@test.com",
regres: &RegistrationResource{NewAuthzURL: ts.URL},
privatekey: key,
}
client, err := NewClient(ts.URL, user, keyType)
if err != nil {
t.Fatalf("Could not create client: %v", err)
}
_, failures := client.getChallenges([]string{"example.com"})
if failures["example.com"] == nil {
t.Fatal("Expecting \"Server did not provide next link to proceed\" error, got nil")
}
}
// writeJSONResponse marshals the body as JSON and writes it to the response.
func writeJSONResponse(w http.ResponseWriter, body interface{}) {
bs, err := json.Marshal(body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(bs); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// stubValidate is like validate, except it does nothing.
func stubValidate(j *jws, domain, uri string, chlng challenge) error {
return nil
}
type mockUser struct {
email string
regres *RegistrationResource
privatekey *rsa.PrivateKey
}
func (u mockUser) GetEmail() string { return u.email }
func (u mockUser) GetRegistration() *RegistrationResource { return u.regres }
func (u mockUser) GetPrivateKey() crypto.PrivateKey { return u.privatekey }

343
acmev2/crypto.go Normal file
View file

@ -0,0 +1,343 @@
package acme
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"io/ioutil"
"math/big"
"net/http"
"time"
"encoding/asn1"
"golang.org/x/crypto/ocsp"
jose "gopkg.in/square/go-jose.v2"
)
// KeyType represents the key algo as well as the key size or curve to use.
type KeyType string
type derCertificateBytes []byte
// Constants for all key types we support.
const (
EC256 = KeyType("P256")
EC384 = KeyType("P384")
RSA2048 = KeyType("2048")
RSA4096 = KeyType("4096")
RSA8192 = KeyType("8192")
)
const (
// OCSPGood means that the certificate is valid.
OCSPGood = ocsp.Good
// OCSPRevoked means that the certificate has been deliberately revoked.
OCSPRevoked = ocsp.Revoked
// OCSPUnknown means that the OCSP responder doesn't know about the certificate.
OCSPUnknown = ocsp.Unknown
// OCSPServerFailed means that the OCSP responder failed to process the request.
OCSPServerFailed = ocsp.ServerFailed
)
// Constants for OCSP must staple
var (
tlsFeatureExtensionOID = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}
ocspMustStapleFeature = []byte{0x30, 0x03, 0x02, 0x01, 0x05}
)
// GetOCSPForCert takes a PEM encoded cert or cert bundle returning the raw OCSP response,
// the parsed response, and an error, if any. The returned []byte can be passed directly
// into the OCSPStaple property of a tls.Certificate. If the bundle only contains the
// issued certificate, this function will try to get the issuer certificate from the
// IssuingCertificateURL in the certificate. If the []byte and/or ocsp.Response return
// values are nil, the OCSP status may be assumed OCSPUnknown.
func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
certificates, err := parsePEMBundle(bundle)
if err != nil {
return nil, nil, err
}
// We expect the certificate slice to be ordered downwards the chain.
// SRV CRT -> CA. We need to pull the leaf and issuer certs out of it,
// which should always be the first two certificates. If there's no
// OCSP server listed in the leaf cert, there's nothing to do. And if
// we have only one certificate so far, we need to get the issuer cert.
issuedCert := certificates[0]
if len(issuedCert.OCSPServer) == 0 {
return nil, nil, errors.New("no OCSP server specified in cert")
}
if len(certificates) == 1 {
// TODO: build fallback. If this fails, check the remaining array entries.
if len(issuedCert.IssuingCertificateURL) == 0 {
return nil, nil, errors.New("no issuing certificate URL")
}
resp, err := httpGet(issuedCert.IssuingCertificateURL[0])
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
issuerBytes, err := ioutil.ReadAll(limitReader(resp.Body, 1024*1024))
if err != nil {
return nil, nil, err
}
issuerCert, err := x509.ParseCertificate(issuerBytes)
if err != nil {
return nil, nil, err
}
// Insert it into the slice on position 0
// We want it ordered right SRV CRT -> CA
certificates = append(certificates, issuerCert)
}
issuerCert := certificates[1]
// Finally kick off the OCSP request.
ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil)
if err != nil {
return nil, nil, err
}
reader := bytes.NewReader(ocspReq)
req, err := httpPost(issuedCert.OCSPServer[0], "application/ocsp-request", reader)
if err != nil {
return nil, nil, err
}
defer req.Body.Close()
ocspResBytes, err := ioutil.ReadAll(limitReader(req.Body, 1024*1024))
ocspRes, err := ocsp.ParseResponse(ocspResBytes, issuerCert)
if err != nil {
return nil, nil, err
}
return ocspResBytes, ocspRes, nil
}
func getKeyAuthorization(token string, key interface{}) (string, error) {
var publicKey crypto.PublicKey
switch k := key.(type) {
case *ecdsa.PrivateKey:
publicKey = k.Public()
case *rsa.PrivateKey:
publicKey = k.Public()
}
// Generate the Key Authorization for the challenge
jwk := &jose.JSONWebKey{Key: publicKey}
if jwk == nil {
return "", errors.New("Could not generate JWK from key")
}
thumbBytes, err := jwk.Thumbprint(crypto.SHA256)
if err != nil {
return "", err
}
// unpad the base64URL
keyThumb := base64.RawURLEncoding.EncodeToString(thumbBytes)
return token + "." + keyThumb, nil
}
// parsePEMBundle parses a certificate bundle from top to bottom and returns
// a slice of x509 certificates. This function will error if no certificates are found.
func parsePEMBundle(bundle []byte) ([]*x509.Certificate, error) {
var certificates []*x509.Certificate
var certDERBlock *pem.Block
for {
certDERBlock, bundle = pem.Decode(bundle)
if certDERBlock == nil {
break
}
if certDERBlock.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
return nil, err
}
certificates = append(certificates, cert)
}
}
if len(certificates) == 0 {
return nil, errors.New("No certificates were found while parsing the bundle")
}
return certificates, nil
}
func parsePEMPrivateKey(key []byte) (crypto.PrivateKey, error) {
keyBlock, _ := pem.Decode(key)
switch keyBlock.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(keyBlock.Bytes)
default:
return nil, errors.New("Unknown PEM header value")
}
}
func generatePrivateKey(keyType KeyType) (crypto.PrivateKey, error) {
switch keyType {
case EC256:
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case EC384:
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case RSA2048:
return rsa.GenerateKey(rand.Reader, 2048)
case RSA4096:
return rsa.GenerateKey(rand.Reader, 4096)
case RSA8192:
return rsa.GenerateKey(rand.Reader, 8192)
}
return nil, fmt.Errorf("Invalid KeyType: %s", keyType)
}
func generateCsr(privateKey crypto.PrivateKey, domain string, san []string, mustStaple bool) ([]byte, error) {
template := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: domain,
},
}
if len(san) > 0 {
template.DNSNames = san
}
if mustStaple {
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
Id: tlsFeatureExtensionOID,
Value: ocspMustStapleFeature,
})
}
return x509.CreateCertificateRequest(rand.Reader, &template, privateKey)
}
func pemEncode(data interface{}) []byte {
var pemBlock *pem.Block
switch key := data.(type) {
case *ecdsa.PrivateKey:
keyBytes, _ := x509.MarshalECPrivateKey(key)
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}
case *rsa.PrivateKey:
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
break
case *x509.CertificateRequest:
pemBlock = &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: key.Raw}
break
case derCertificateBytes:
pemBlock = &pem.Block{Type: "CERTIFICATE", Bytes: []byte(data.(derCertificateBytes))}
}
return pem.EncodeToMemory(pemBlock)
}
func pemDecode(data []byte) (*pem.Block, error) {
pemBlock, _ := pem.Decode(data)
if pemBlock == nil {
return nil, fmt.Errorf("Pem decode did not yield a valid block. Is the certificate in the right format?")
}
return pemBlock, nil
}
func pemDecodeTox509(pem []byte) (*x509.Certificate, error) {
pemBlock, err := pemDecode(pem)
if pemBlock == nil {
return nil, err
}
return x509.ParseCertificate(pemBlock.Bytes)
}
func pemDecodeTox509CSR(pem []byte) (*x509.CertificateRequest, error) {
pemBlock, err := pemDecode(pem)
if pemBlock == nil {
return nil, err
}
if pemBlock.Type != "CERTIFICATE REQUEST" {
return nil, fmt.Errorf("PEM block is not a certificate request")
}
return x509.ParseCertificateRequest(pemBlock.Bytes)
}
// GetPEMCertExpiration returns the "NotAfter" date of a PEM encoded certificate.
// The certificate has to be PEM encoded. Any other encodings like DER will fail.
func GetPEMCertExpiration(cert []byte) (time.Time, error) {
pemBlock, err := pemDecode(cert)
if pemBlock == nil {
return time.Time{}, err
}
return getCertExpiration(pemBlock.Bytes)
}
// getCertExpiration returns the "NotAfter" date of a DER encoded certificate.
func getCertExpiration(cert []byte) (time.Time, error) {
pCert, err := x509.ParseCertificate(cert)
if err != nil {
return time.Time{}, err
}
return pCert.NotAfter, nil
}
func generatePemCert(privKey *rsa.PrivateKey, domain string) ([]byte, error) {
derBytes, err := generateDerCert(privKey, time.Time{}, domain)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}), nil
}
func generateDerCert(privKey *rsa.PrivateKey, expiration time.Time, domain string) ([]byte, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, err
}
if expiration.IsZero() {
expiration = time.Now().Add(365)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "ACME Challenge TEMP",
},
NotBefore: time.Now(),
NotAfter: expiration,
KeyUsage: x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
DNSNames: []string{domain},
}
return x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
}
func limitReader(rd io.ReadCloser, numBytes int64) io.ReadCloser {
return http.MaxBytesReader(nil, rd, numBytes)
}

93
acmev2/crypto_test.go Normal file
View file

@ -0,0 +1,93 @@
package acme
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
func TestGeneratePrivateKey(t *testing.T) {
key, err := generatePrivateKey(RSA2048)
if err != nil {
t.Error("Error generating private key:", err)
}
if key == nil {
t.Error("Expected key to not be nil, but it was")
}
}
func TestGenerateCSR(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatal("Error generating private key:", err)
}
csr, err := generateCsr(key, "fizz.buzz", nil, true)
if err != nil {
t.Error("Error generating CSR:", err)
}
if csr == nil || len(csr) == 0 {
t.Error("Expected CSR with data, but it was nil or length 0")
}
}
func TestPEMEncode(t *testing.T) {
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
reader := MockRandReader{b: buf}
key, err := rsa.GenerateKey(reader, 32)
if err != nil {
t.Fatal("Error generating private key:", err)
}
data := pemEncode(key)
if data == nil {
t.Fatal("Expected result to not be nil, but it was")
}
if len(data) != 127 {
t.Errorf("Expected PEM encoding to be length 127, but it was %d", len(data))
}
}
func TestPEMCertExpiration(t *testing.T) {
privKey, err := generatePrivateKey(RSA2048)
if err != nil {
t.Fatal("Error generating private key:", err)
}
expiration := time.Now().Add(365)
expiration = expiration.Round(time.Second)
certBytes, err := generateDerCert(privKey.(*rsa.PrivateKey), expiration, "test.com")
if err != nil {
t.Fatal("Error generating cert:", err)
}
buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
// Some random string should return an error.
if ctime, err := GetPEMCertExpiration(buf.Bytes()); err == nil {
t.Errorf("Expected getCertExpiration to return an error for garbage string but returned %v", ctime)
}
// A DER encoded certificate should return an error.
if _, err := GetPEMCertExpiration(certBytes); err == nil {
t.Errorf("Expected getCertExpiration to return an error for DER certificates but returned none.")
}
// A PEM encoded certificate should work ok.
pemCert := pemEncode(derCertificateBytes(certBytes))
if ctime, err := GetPEMCertExpiration(pemCert); err != nil || !ctime.Equal(expiration.UTC()) {
t.Errorf("Expected getCertExpiration to return %v but returned %v. Error: %v", expiration, ctime, err)
}
}
type MockRandReader struct {
b *bytes.Buffer
}
func (r MockRandReader) Read(p []byte) (int, error) {
return r.b.Read(p)
}

309
acmev2/dns_challenge.go Normal file
View file

@ -0,0 +1,309 @@
package acme
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/miekg/dns"
)
type preCheckDNSFunc func(fqdn, value string) (bool, error)
var (
// PreCheckDNS checks DNS propagation before notifying ACME that
// the DNS challenge is ready.
PreCheckDNS preCheckDNSFunc = checkDNSPropagation
fqdnToZone = map[string]string{}
)
const defaultResolvConf = "/etc/resolv.conf"
var defaultNameservers = []string{
"google-public-dns-a.google.com:53",
"google-public-dns-b.google.com:53",
}
// RecursiveNameservers are used to pre-check DNS propagations
var RecursiveNameservers = getNameservers(defaultResolvConf, defaultNameservers)
// DNSTimeout is used to override the default DNS timeout of 10 seconds.
var DNSTimeout = 10 * time.Second
// getNameservers attempts to get systems nameservers before falling back to the defaults
func getNameservers(path string, defaults []string) []string {
config, err := dns.ClientConfigFromFile(path)
if err != nil || len(config.Servers) == 0 {
return defaults
}
systemNameservers := []string{}
for _, server := range config.Servers {
// ensure all servers have a port number
if _, _, err := net.SplitHostPort(server); err != nil {
systemNameservers = append(systemNameservers, net.JoinHostPort(server, "53"))
} else {
systemNameservers = append(systemNameservers, server)
}
}
return systemNameservers
}
// DNS01Record returns a DNS record which will fulfill the `dns-01` challenge
func DNS01Record(domain, keyAuth string) (fqdn string, value string, ttl int) {
keyAuthShaBytes := sha256.Sum256([]byte(keyAuth))
// base64URL encoding without padding
value = base64.RawURLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size])
ttl = 120
fqdn = fmt.Sprintf("_acme-challenge.%s.", domain)
return
}
// dnsChallenge implements the dns-01 challenge according to ACME 7.5
type dnsChallenge struct {
jws *jws
validate validateFunc
provider ChallengeProvider
}
func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
logf("[INFO][%s] acme: Trying to solve DNS-01", domain)
if s.provider == nil {
return errors.New("No DNS Provider configured")
}
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
if err != nil {
return err
}
err = s.provider.Present(domain, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("Error presenting token: %s", err)
}
defer func() {
err := s.provider.CleanUp(domain, chlng.Token, keyAuth)
if err != nil {
log.Printf("Error cleaning up %s: %v ", domain, err)
}
}()
fqdn, value, _ := DNS01Record(domain, keyAuth)
logf("[INFO][%s] Checking DNS record propagation using %+v", domain, RecursiveNameservers)
var timeout, interval time.Duration
switch provider := s.provider.(type) {
case ChallengeProviderTimeout:
timeout, interval = provider.Timeout()
default:
timeout, interval = 60*time.Second, 2*time.Second
}
err = WaitFor(timeout, interval, func() (bool, error) {
return PreCheckDNS(fqdn, value)
})
if err != nil {
return err
}
return s.validate(s.jws, domain, chlng.URL, challenge{Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
}
// checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers.
func checkDNSPropagation(fqdn, value string) (bool, error) {
// Initial attempt to resolve at the recursive NS
r, err := dnsQuery(fqdn, dns.TypeTXT, RecursiveNameservers, true)
if err != nil {
return false, err
}
if r.Rcode == dns.RcodeSuccess {
// If we see a CNAME here then use the alias
for _, rr := range r.Answer {
if cn, ok := rr.(*dns.CNAME); ok {
if cn.Hdr.Name == fqdn {
fqdn = cn.Target
break
}
}
}
}
authoritativeNss, err := lookupNameservers(fqdn)
if err != nil {
return false, err
}
return checkAuthoritativeNss(fqdn, value, authoritativeNss)
}
// checkAuthoritativeNss queries each of the given nameservers for the expected TXT record.
func checkAuthoritativeNss(fqdn, value string, nameservers []string) (bool, error) {
for _, ns := range nameservers {
r, err := dnsQuery(fqdn, dns.TypeTXT, []string{net.JoinHostPort(ns, "53")}, false)
if err != nil {
return false, err
}
if r.Rcode != dns.RcodeSuccess {
return false, fmt.Errorf("NS %s returned %s for %s", ns, dns.RcodeToString[r.Rcode], fqdn)
}
var found bool
for _, rr := range r.Answer {
if txt, ok := rr.(*dns.TXT); ok {
if strings.Join(txt.Txt, "") == value {
found = true
break
}
}
}
if !found {
return false, fmt.Errorf("NS %s did not return the expected TXT record", ns)
}
}
return true, nil
}
// dnsQuery will query a nameserver, iterating through the supplied servers as it retries
// The nameserver should include a port, to facilitate testing where we talk to a mock dns server.
func dnsQuery(fqdn string, rtype uint16, nameservers []string, recursive bool) (in *dns.Msg, err error) {
m := new(dns.Msg)
m.SetQuestion(fqdn, rtype)
m.SetEdns0(4096, false)
if !recursive {
m.RecursionDesired = false
}
// Will retry the request based on the number of servers (n+1)
for i := 1; i <= len(nameservers)+1; i++ {
ns := nameservers[i%len(nameservers)]
udp := &dns.Client{Net: "udp", Timeout: DNSTimeout}
in, _, err = udp.Exchange(m, ns)
if err == dns.ErrTruncated {
tcp := &dns.Client{Net: "tcp", Timeout: DNSTimeout}
// If the TCP request succeeds, the err will reset to nil
in, _, err = tcp.Exchange(m, ns)
}
if err == nil {
break
}
}
return
}
// lookupNameservers returns the authoritative nameservers for the given fqdn.
func lookupNameservers(fqdn string) ([]string, error) {
var authoritativeNss []string
zone, err := FindZoneByFqdn(fqdn, RecursiveNameservers)
if err != nil {
return nil, fmt.Errorf("Could not determine the zone: %v", err)
}
r, err := dnsQuery(zone, dns.TypeNS, RecursiveNameservers, true)
if err != nil {
return nil, err
}
for _, rr := range r.Answer {
if ns, ok := rr.(*dns.NS); ok {
authoritativeNss = append(authoritativeNss, strings.ToLower(ns.Ns))
}
}
if len(authoritativeNss) > 0 {
return authoritativeNss, nil
}
return nil, fmt.Errorf("Could not determine authoritative nameservers")
}
// FindZoneByFqdn determines the zone apex for the given fqdn by recursing up the
// domain labels until the nameserver returns a SOA record in the answer section.
func FindZoneByFqdn(fqdn string, nameservers []string) (string, error) {
// Do we have it cached?
if zone, ok := fqdnToZone[fqdn]; ok {
return zone, nil
}
labelIndexes := dns.Split(fqdn)
for _, index := range labelIndexes {
domain := fqdn[index:]
in, err := dnsQuery(domain, dns.TypeSOA, nameservers, true)
if err != nil {
return "", err
}
// Any response code other than NOERROR and NXDOMAIN is treated as error
if in.Rcode != dns.RcodeNameError && in.Rcode != dns.RcodeSuccess {
return "", fmt.Errorf("Unexpected response code '%s' for %s",
dns.RcodeToString[in.Rcode], domain)
}
// Check if we got a SOA RR in the answer section
if in.Rcode == dns.RcodeSuccess {
// CNAME records cannot/should not exist at the root of a zone.
// So we skip a domain when a CNAME is found.
if dnsMsgContainsCNAME(in) {
continue
}
for _, ans := range in.Answer {
if soa, ok := ans.(*dns.SOA); ok {
zone := soa.Hdr.Name
fqdnToZone[fqdn] = zone
return zone, nil
}
}
}
}
return "", fmt.Errorf("Could not find the start of authority")
}
// dnsMsgContainsCNAME checks for a CNAME answer in msg
func dnsMsgContainsCNAME(msg *dns.Msg) bool {
for _, ans := range msg.Answer {
if _, ok := ans.(*dns.CNAME); ok {
return true
}
}
return false
}
// ClearFqdnCache clears the cache of fqdn to zone mappings. Primarily used in testing.
func ClearFqdnCache() {
fqdnToZone = map[string]string{}
}
// ToFqdn converts the name into a fqdn appending a trailing dot.
func ToFqdn(name string) string {
n := len(name)
if n == 0 || name[n-1] == '.' {
return name
}
return name + "."
}
// UnFqdn converts the fqdn into a name removing the trailing dot.
func UnFqdn(name string) string {
n := len(name)
if n != 0 && name[n-1] == '.' {
return name[:n-1]
}
return name
}

View file

@ -0,0 +1,53 @@
package acme
import (
"bufio"
"fmt"
"os"
)
const (
dnsTemplate = "%s %d IN TXT \"%s\""
)
// DNSProviderManual is an implementation of the ChallengeProvider interface
type DNSProviderManual struct{}
// NewDNSProviderManual returns a DNSProviderManual instance.
func NewDNSProviderManual() (*DNSProviderManual, error) {
return &DNSProviderManual{}, nil
}
// Present prints instructions for manually creating the TXT record
func (*DNSProviderManual) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth)
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value)
authZone, err := FindZoneByFqdn(fqdn, RecursiveNameservers)
if err != nil {
return err
}
logf("[INFO] acme: Please create the following TXT record in your %s zone:", authZone)
logf("[INFO] acme: %s", dnsRecord)
logf("[INFO] acme: Press 'Enter' when you are done")
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
return nil
}
// CleanUp prints instructions for manually removing the TXT record
func (*DNSProviderManual) CleanUp(domain, token, keyAuth string) error {
fqdn, _, ttl := DNS01Record(domain, keyAuth)
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, "...")
authZone, err := FindZoneByFqdn(fqdn, RecursiveNameservers)
if err != nil {
return err
}
logf("[INFO] acme: You can now remove this TXT record from your %s zone:", authZone)
logf("[INFO] acme: %s", dnsRecord)
return nil
}

View file

@ -0,0 +1,200 @@
package acme
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"os"
"reflect"
"sort"
"strings"
"testing"
"time"
)
var lookupNameserversTestsOK = []struct {
fqdn string
nss []string
}{
{"books.google.com.ng.",
[]string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
},
{"www.google.com.",
[]string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
},
{"physics.georgetown.edu.",
[]string{"ns1.georgetown.edu.", "ns2.georgetown.edu.", "ns3.georgetown.edu."},
},
}
var lookupNameserversTestsErr = []struct {
fqdn string
error string
}{
// invalid tld
{"_null.n0n0.",
"Could not determine the zone",
},
}
var findZoneByFqdnTests = []struct {
fqdn string
zone string
}{
{"mail.google.com.", "google.com."}, // domain is a CNAME
{"foo.google.com.", "google.com."}, // domain is a non-existent subdomain
{"example.com.ac.", "ac."}, // domain is a eTLD
{"cross-zone-example.assets.sh.", "assets.sh."}, // domain is a cross-zone CNAME
}
var checkAuthoritativeNssTests = []struct {
fqdn, value string
ns []string
ok bool
}{
// TXT RR w/ expected value
{"8.8.8.8.asn.routeviews.org.", "151698.8.8.024", []string{"asnums.routeviews.org."},
true,
},
// No TXT RR
{"ns1.google.com.", "", []string{"ns2.google.com."},
false,
},
}
var checkAuthoritativeNssTestsErr = []struct {
fqdn, value string
ns []string
error string
}{
// TXT RR /w unexpected value
{"8.8.8.8.asn.routeviews.org.", "fe01=", []string{"asnums.routeviews.org."},
"did not return the expected TXT record",
},
// No TXT RR
{"ns1.google.com.", "fe01=", []string{"ns2.google.com."},
"did not return the expected TXT record",
},
}
var checkResolvConfServersTests = []struct {
fixture string
expected []string
defaults []string
}{
{"testdata/resolv.conf.1", []string{"10.200.3.249:53", "10.200.3.250:5353", "[2001:4860:4860::8844]:53", "[10.0.0.1]:5353"}, []string{"127.0.0.1:53"}},
{"testdata/resolv.conf.nonexistant", []string{"127.0.0.1:53"}, []string{"127.0.0.1:53"}},
}
func TestDNSValidServerResponse(t *testing.T) {
PreCheckDNS = func(fqdn, value string) (bool, error) {
return true, nil
}
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Replay-Nonce", "12345")
w.Write([]byte("{\"type\":\"dns01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}"))
}))
manualProvider, _ := NewDNSProviderManual()
jws := &jws{privKey: privKey, directoryURL: ts.URL}
solver := &dnsChallenge{jws: jws, validate: validate, provider: manualProvider}
clientChallenge := challenge{Type: "dns01", Status: "pending", URI: ts.URL, Token: "http8"}
go func() {
time.Sleep(time.Second * 2)
f := bufio.NewWriter(os.Stdout)
defer f.Flush()
f.WriteString("\n")
}()
if err := solver.Solve(clientChallenge, "example.com"); err != nil {
t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err)
}
}
func TestPreCheckDNS(t *testing.T) {
ok, err := PreCheckDNS("acme-staging.api.letsencrypt.org", "fe01=")
if err != nil || !ok {
t.Errorf("preCheckDNS failed for acme-staging.api.letsencrypt.org")
}
}
func TestLookupNameserversOK(t *testing.T) {
for _, tt := range lookupNameserversTestsOK {
nss, err := lookupNameservers(tt.fqdn)
if err != nil {
t.Fatalf("#%s: got %q; want nil", tt.fqdn, err)
}
sort.Strings(nss)
sort.Strings(tt.nss)
if !reflect.DeepEqual(nss, tt.nss) {
t.Errorf("#%s: got %v; want %v", tt.fqdn, nss, tt.nss)
}
}
}
func TestLookupNameserversErr(t *testing.T) {
for _, tt := range lookupNameserversTestsErr {
_, err := lookupNameservers(tt.fqdn)
if err == nil {
t.Fatalf("#%s: expected %q (error); got <nil>", tt.fqdn, tt.error)
}
if !strings.Contains(err.Error(), tt.error) {
t.Errorf("#%s: expected %q (error); got %q", tt.fqdn, tt.error, err)
continue
}
}
}
func TestFindZoneByFqdn(t *testing.T) {
for _, tt := range findZoneByFqdnTests {
res, err := FindZoneByFqdn(tt.fqdn, RecursiveNameservers)
if err != nil {
t.Errorf("FindZoneByFqdn failed for %s: %v", tt.fqdn, err)
}
if res != tt.zone {
t.Errorf("%s: got %s; want %s", tt.fqdn, res, tt.zone)
}
}
}
func TestCheckAuthoritativeNss(t *testing.T) {
for _, tt := range checkAuthoritativeNssTests {
ok, _ := checkAuthoritativeNss(tt.fqdn, tt.value, tt.ns)
if ok != tt.ok {
t.Errorf("%s: got %t; want %t", tt.fqdn, ok, tt.ok)
}
}
}
func TestCheckAuthoritativeNssErr(t *testing.T) {
for _, tt := range checkAuthoritativeNssTestsErr {
_, err := checkAuthoritativeNss(tt.fqdn, tt.value, tt.ns)
if err == nil {
t.Fatalf("#%s: expected %q (error); got <nil>", tt.fqdn, tt.error)
}
if !strings.Contains(err.Error(), tt.error) {
t.Errorf("#%s: expected %q (error); got %q", tt.fqdn, tt.error, err)
continue
}
}
}
func TestResolveConfServers(t *testing.T) {
for _, tt := range checkResolvConfServersTests {
result := getNameservers(tt.fixture, tt.defaults)
sort.Strings(result)
sort.Strings(tt.expected)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("#%s: expected %q; got %q", tt.fixture, tt.expected, result)
}
}
}

78
acmev2/error.go Normal file
View file

@ -0,0 +1,78 @@
package acme
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
)
const (
tosAgreementError = "Terms of service have changed"
invalidNonceError = "urn:ietf:params:acme:error:badNonce"
)
// RemoteError is the base type for all errors specific to the ACME protocol.
type RemoteError struct {
StatusCode int `json:"status,omitempty"`
Type string `json:"type"`
Detail string `json:"detail"`
}
func (e RemoteError) Error() string {
return fmt.Sprintf("acme: Error %d - %s - %s", e.StatusCode, e.Type, e.Detail)
}
// TOSError represents the error which is returned if the user needs to
// accept the TOS.
// TODO: include the new TOS url if we can somehow obtain it.
type TOSError struct {
RemoteError
}
// NonceError represents the error which is returned if the
// nonce sent by the client was not accepted by the server.
type NonceError struct {
RemoteError
}
type domainError struct {
Domain string
Error error
}
func handleHTTPError(resp *http.Response) error {
var errorDetail RemoteError
contentType := resp.Header.Get("Content-Type")
if contentType == "application/json" || strings.HasPrefix(contentType, "application/problem+json") {
err := json.NewDecoder(resp.Body).Decode(&errorDetail)
if err != nil {
return err
}
} else {
detailBytes, err := ioutil.ReadAll(limitReader(resp.Body, maxBodySize))
if err != nil {
return err
}
errorDetail.Detail = string(detailBytes)
}
errorDetail.StatusCode = resp.StatusCode
// Check for errors we handle specifically
if errorDetail.StatusCode == http.StatusForbidden && errorDetail.Detail == tosAgreementError {
return TOSError{errorDetail}
}
if errorDetail.StatusCode == http.StatusBadRequest && errorDetail.Type == invalidNonceError {
return NonceError{errorDetail}
}
return errorDetail
}
func handleChallengeError(chlng challenge) error {
return chlng.Error
}

160
acmev2/http.go Normal file
View file

@ -0,0 +1,160 @@
package acme
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"runtime"
"strings"
"time"
)
// UserAgent (if non-empty) will be tacked onto the User-Agent string in requests.
var UserAgent string
// HTTPClient is an HTTP client with a reasonable timeout value.
var HTTPClient = http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).Dial,
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
const (
// defaultGoUserAgent is the Go HTTP package user agent string. Too
// bad it isn't exported. If it changes, we should update it here, too.
defaultGoUserAgent = "Go-http-client/1.1"
// ourUserAgent is the User-Agent of this underlying library package.
ourUserAgent = "xenolf-acme"
)
// httpHead performs a HEAD request with a proper User-Agent string.
// The response body (resp.Body) is already closed when this function returns.
func httpHead(url string) (resp *http.Response, err error) {
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to head %q: %v", url, err)
}
req.Header.Set("User-Agent", userAgent())
resp, err = HTTPClient.Do(req)
if err != nil {
return resp, fmt.Errorf("failed to do head %q: %v", url, err)
}
resp.Body.Close()
return resp, err
}
// httpPost performs a POST request with a proper User-Agent string.
// Callers should close resp.Body when done reading from it.
func httpPost(url string, bodyType string, body io.Reader) (resp *http.Response, err error) {
req, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, fmt.Errorf("failed to post %q: %v", url, err)
}
req.Header.Set("Content-Type", bodyType)
req.Header.Set("User-Agent", userAgent())
return HTTPClient.Do(req)
}
// httpGet performs a GET request with a proper User-Agent string.
// Callers should close resp.Body when done reading from it.
func httpGet(url string) (resp *http.Response, err error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to get %q: %v", url, err)
}
req.Header.Set("User-Agent", userAgent())
return HTTPClient.Do(req)
}
// getJSON performs an HTTP GET request and parses the response body
// as JSON, into the provided respBody object.
func getJSON(uri string, respBody interface{}) (http.Header, error) {
resp, err := httpGet(uri)
if err != nil {
return nil, fmt.Errorf("failed to get json %q: %v", uri, err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return resp.Header, handleHTTPError(resp)
}
return resp.Header, json.NewDecoder(resp.Body).Decode(respBody)
}
// postJSON performs an HTTP POST request and parses the response body
// as JSON, into the provided respBody object.
func postJSON(j *jws, uri string, reqBody, respBody interface{}) (http.Header, error) {
jsonBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, errors.New("Failed to marshal network message")
}
resp, err := j.post(uri, jsonBytes)
if err != nil {
return nil, fmt.Errorf("Failed to post JWS message. -> %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
err := handleHTTPError(resp)
switch err.(type) {
case NonceError:
// Retry once if the nonce was invalidated
retryResp, err := j.post(uri, jsonBytes)
if err != nil {
return nil, fmt.Errorf("Failed to post JWS message. -> %v", err)
}
defer retryResp.Body.Close()
if retryResp.StatusCode >= http.StatusBadRequest {
return retryResp.Header, handleHTTPError(retryResp)
}
if respBody == nil {
return retryResp.Header, nil
}
return retryResp.Header, json.NewDecoder(retryResp.Body).Decode(respBody)
default:
return resp.Header, err
}
}
if respBody == nil {
return resp.Header, nil
}
return resp.Header, json.NewDecoder(resp.Body).Decode(respBody)
}
// userAgent builds and returns the User-Agent string to use in requests.
func userAgent() string {
ua := fmt.Sprintf("%s (%s; %s) %s %s", defaultGoUserAgent, runtime.GOOS, runtime.GOARCH, ourUserAgent, UserAgent)
return strings.TrimSpace(ua)
}

41
acmev2/http_challenge.go Normal file
View file

@ -0,0 +1,41 @@
package acme
import (
"fmt"
"log"
)
type httpChallenge struct {
jws *jws
validate validateFunc
provider ChallengeProvider
}
// HTTP01ChallengePath returns the URL path for the `http-01` challenge
func HTTP01ChallengePath(token string) string {
return "/.well-known/acme-challenge/" + token
}
func (s *httpChallenge) Solve(chlng challenge, domain string) error {
logf("[INFO][%s] acme: Trying to solve HTTP-01", domain)
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
if err != nil {
return err
}
err = s.provider.Present(domain, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("[%s] error presenting token: %v", domain, err)
}
defer func() {
err := s.provider.CleanUp(domain, chlng.Token, keyAuth)
if err != nil {
log.Printf("[%s] error cleaning up: %v", domain, err)
}
}()
return s.validate(s.jws, domain, chlng.URL, challenge{Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
}

View file

@ -0,0 +1,79 @@
package acme
import (
"fmt"
"net"
"net/http"
"strings"
)
// HTTPProviderServer implements ChallengeProvider for `http-01` challenge
// It may be instantiated without using the NewHTTPProviderServer function if
// you want only to use the default values.
type HTTPProviderServer struct {
iface string
port string
done chan bool
listener net.Listener
}
// NewHTTPProviderServer creates a new HTTPProviderServer on the selected interface and port.
// Setting iface and / or port to an empty string will make the server fall back to
// the "any" interface and port 80 respectively.
func NewHTTPProviderServer(iface, port string) *HTTPProviderServer {
return &HTTPProviderServer{iface: iface, port: port}
}
// Present starts a web server and makes the token available at `HTTP01ChallengePath(token)` for web requests.
func (s *HTTPProviderServer) Present(domain, token, keyAuth string) error {
if s.port == "" {
s.port = "80"
}
var err error
s.listener, err = net.Listen("tcp", net.JoinHostPort(s.iface, s.port))
if err != nil {
return fmt.Errorf("Could not start HTTP server for challenge -> %v", err)
}
s.done = make(chan bool)
go s.serve(domain, token, keyAuth)
return nil
}
// CleanUp closes the HTTP server and removes the token from `HTTP01ChallengePath(token)`
func (s *HTTPProviderServer) CleanUp(domain, token, keyAuth string) error {
if s.listener == nil {
return nil
}
s.listener.Close()
<-s.done
return nil
}
func (s *HTTPProviderServer) serve(domain, token, keyAuth string) {
path := HTTP01ChallengePath(token)
// The handler validates the HOST header and request type.
// For validation it then writes the token the server returned with the challenge
mux := http.NewServeMux()
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Host, domain) && r.Method == "GET" {
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte(keyAuth))
logf("[INFO][%s] Served key authentication", domain)
} else {
logf("[WARN] Received request for domain %s with method %s but the domain did not match any challenge. Please ensure your are passing the HOST header properly.", r.Host, r.Method)
w.Write([]byte("TEST"))
}
})
httpServer := &http.Server{
Handler: mux,
}
// Once httpServer is shut down we don't want any lingering
// connections, so disable KeepAlives.
httpServer.SetKeepAlivesEnabled(false)
httpServer.Serve(s.listener)
s.done <- true
}

View file

@ -0,0 +1,57 @@
package acme
import (
"crypto/rand"
"crypto/rsa"
"io/ioutil"
"strings"
"testing"
)
func TestHTTPChallenge(t *testing.T) {
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: HTTP01, Token: "http1"}
mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token
resp, err := httpGet(uri)
if err != nil {
return err
}
defer resp.Body.Close()
if want := "text/plain"; resp.Header.Get("Content-Type") != want {
t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization)
}
return nil
}
solver := &httpChallenge{jws: j, validate: mockValidate, provider: &HTTPProviderServer{port: "23457"}}
if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
t.Errorf("Solve error: got %v, want nil", err)
}
}
func TestHTTPChallengeInvalidPort(t *testing.T) {
privKey, _ := rsa.GenerateKey(rand.Reader, 128)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: HTTP01, Token: "http2"}
solver := &httpChallenge{jws: j, validate: stubValidate, provider: &HTTPProviderServer{port: "123456"}}
if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil {
t.Errorf("Solve error: got %v, want error", err)
} else if want, want18 := "invalid port 123456", "123456: invalid port"; !strings.HasSuffix(err.Error(), want) && !strings.HasSuffix(err.Error(), want18) {
t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want)
}
}

100
acmev2/http_test.go Normal file
View file

@ -0,0 +1,100 @@
package acme
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHTTPHeadUserAgent(t *testing.T) {
var ua, method string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
defer ts.Close()
_, err := httpHead(ts.URL)
if err != nil {
t.Fatal(err)
}
if method != "HEAD" {
t.Errorf("Expected method to be HEAD, got %s", method)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua)
}
}
func TestHTTPGetUserAgent(t *testing.T) {
var ua, method string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
defer ts.Close()
res, err := httpGet(ts.URL)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if method != "GET" {
t.Errorf("Expected method to be GET, got %s", method)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua)
}
}
func TestHTTPPostUserAgent(t *testing.T) {
var ua, method string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
defer ts.Close()
res, err := httpPost(ts.URL, "text/plain", strings.NewReader("falalalala"))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if method != "POST" {
t.Errorf("Expected method to be POST, got %s", method)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected User-Agent to contain '%s', got: '%s'", ourUserAgent, ua)
}
}
func TestUserAgent(t *testing.T) {
ua := userAgent()
if !strings.Contains(ua, defaultGoUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", defaultGoUserAgent, ua)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", ourUserAgent, ua)
}
if strings.HasSuffix(ua, " ") {
t.Errorf("UA should not have trailing spaces; got '%s'", ua)
}
// customize the UA by appending a value
UserAgent = "MyApp/1.2.3"
ua = userAgent()
if !strings.Contains(ua, defaultGoUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", defaultGoUserAgent, ua)
}
if !strings.Contains(ua, ourUserAgent) {
t.Errorf("Expected UA to contain %s, got '%s'", ourUserAgent, ua)
}
if !strings.Contains(ua, UserAgent) {
t.Errorf("Expected custom UA to contain %s, got '%s'", UserAgent, ua)
}
}

138
acmev2/jws.go Normal file
View file

@ -0,0 +1,138 @@
package acme
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"fmt"
"net/http"
"sync"
"gopkg.in/square/go-jose.v2"
)
type jws struct {
getNonceURL string
privKey crypto.PrivateKey
kid string
nonces nonceManager
}
// Posts a JWS signed message to the specified URL.
// It does NOT close the response body, so the caller must
// do that if no error was returned.
func (j *jws) post(url string, content []byte) (*http.Response, error) {
signedContent, err := j.signContent(url, content)
if err != nil {
return nil, fmt.Errorf("Failed to sign content -> %s", err.Error())
}
data := bytes.NewBuffer([]byte(signedContent.FullSerialize()))
resp, err := httpPost(url, "application/jose+json", data)
if err != nil {
return nil, fmt.Errorf("Failed to HTTP POST to %s -> %s", url, err.Error())
}
nonce, nonceErr := getNonceFromResponse(resp)
if nonceErr == nil {
j.nonces.Push(nonce)
}
return resp, nil
}
func (j *jws) signContent(url string, content []byte) (*jose.JSONWebSignature, error) {
var alg jose.SignatureAlgorithm
switch k := j.privKey.(type) {
case *rsa.PrivateKey:
alg = jose.RS256
case *ecdsa.PrivateKey:
if k.Curve == elliptic.P256() {
alg = jose.ES256
} else if k.Curve == elliptic.P384() {
alg = jose.ES384
}
}
jsonKey := jose.JSONWebKey{
Key: j.privKey,
KeyID: j.kid,
}
signKey := jose.SigningKey{
Algorithm: alg,
Key: jsonKey,
}
options := jose.SignerOptions{
NonceSource: j,
ExtraHeaders: make(map[jose.HeaderKey]interface{}),
}
options.ExtraHeaders["url"] = url
if j.kid == "" {
options.EmbedJWK = true
}
signer, err := jose.NewSigner(signKey, &options)
if err != nil {
return nil, fmt.Errorf("Failed to create jose signer -> %s", err.Error())
}
signed, err := signer.Sign(content)
if err != nil {
return nil, fmt.Errorf("Failed to sign content -> %s", err.Error())
}
return signed, nil
}
func (j *jws) Nonce() (string, error) {
if nonce, ok := j.nonces.Pop(); ok {
return nonce, nil
}
return getNonce(j.getNonceURL)
}
type nonceManager struct {
nonces []string
sync.Mutex
}
func (n *nonceManager) Pop() (string, bool) {
n.Lock()
defer n.Unlock()
if len(n.nonces) == 0 {
return "", false
}
nonce := n.nonces[len(n.nonces)-1]
n.nonces = n.nonces[:len(n.nonces)-1]
return nonce, true
}
func (n *nonceManager) Push(nonce string) {
n.Lock()
defer n.Unlock()
n.nonces = append(n.nonces, nonce)
}
func getNonce(url string) (string, error) {
resp, err := httpHead(url)
if err != nil {
return "", fmt.Errorf("Failed to get nonce from HTTP HEAD -> %s", err.Error())
}
return getNonceFromResponse(resp)
}
func getNonceFromResponse(resp *http.Response) (string, error) {
nonce := resp.Header.Get("Replay-Nonce")
if nonce == "" {
return "", fmt.Errorf("Server did not respond with a proper nonce header")
}
return nonce, nil
}

103
acmev2/messages.go Normal file
View file

@ -0,0 +1,103 @@
package acme
import (
"time"
)
// RegistrationResource represents all important informations about a registration
// of which the client needs to keep track itself.
type RegistrationResource struct {
Body accountMessage `json:"body,omitempty"`
URI string `json:"uri,omitempty"`
}
type directory struct {
NewNonceURL string `json:"newNonce"`
NewAccountURL string `json:"newAccount"`
NewOrderURL string `json:"newOrder"`
RevokeCertURL string `json:"revokeCert"`
KeyChangeURL string `json:"keyChange"`
Meta struct {
TermsOfService string `json:"termsOfService"`
Website string `json:"website"`
CaaIdentities []string `json:"caaIdentities"`
ExternalAccountRequired bool `json:"externalAccountRequired"`
} `json:"meta"`
}
type accountMessage struct {
Status string `json:"status,omitempty"`
Contact []string `json:"contact,omitempty"`
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"`
Orders string `json:"orders,omitempty"`
OnlyReturnExisting bool `json:"onlyReturnExisting,omitempty"`
}
type orderResource struct {
URL string `json:"url,omitempty"`
orderMessage `json:"body,omitempty"`
}
type orderMessage struct {
Status string `json:"status,omitempty"`
Expires string `json:"expires,omitempty"`
Identifiers []identifier `json:"identifiers"`
NotBefore string `json:"notBefore,omitempty"`
NotAfter string `json:"notAfter,omitempty"`
Authorizations []string `json:"authorizations,omitempty"`
Finalize string `json:"finalize,omitempty"`
Certificate string `json:"certificate,omitempty"`
}
type authorization struct {
Status string `json:"status"`
Expires time.Time `json:"expires"`
Identifier identifier `json:"identifier"`
Challenges []challenge `json:"challenges"`
}
type identifier struct {
Type string `json:"type"`
Value string `json:"value"`
}
type challenge struct {
URL string `json:"url"`
Type string `json:"type"`
Status string `json:"status"`
Token string `json:"token"`
Validated time.Time `json:"validated"`
KeyAuthorization string `json:"keyAuthorization"`
Error RemoteError `json:"error"`
}
type csrMessage struct {
Csr string `json:"csr"`
}
type emptyObjectMessage struct {
}
type revokeCertMessage struct {
Certificate string `json:"certificate"`
}
type deactivateAuthMessage struct {
Status string `jsom:"status"`
}
// CertificateResource represents a CA issued certificate.
// PrivateKey, Certificate and IssuerCertificate are all
// already PEM encoded and can be directly written to disk.
// Certificate may be a certificate bundle, depending on the
// options supplied to create it.
type CertificateResource struct {
Domain string `json:"domain"`
CertURL string `json:"certUrl"`
CertStableURL string `json:"certStableUrl"`
AccountRef string `json:"accountRef,omitempty"`
PrivateKey []byte `json:"-"`
Certificate []byte `json:"-"`
IssuerCertificate []byte `json:"-"`
CSR []byte `json:"-"`
}

1
acmev2/pop_challenge.go Normal file
View file

@ -0,0 +1 @@
package acme

28
acmev2/provider.go Normal file
View file

@ -0,0 +1,28 @@
package acme
import "time"
// ChallengeProvider enables implementing a custom challenge
// provider. Present presents the solution to a challenge available to
// be solved. CleanUp will be called by the challenge if Present ends
// in a non-error state.
type ChallengeProvider interface {
Present(domain, token, keyAuth string) error
CleanUp(domain, token, keyAuth string) error
}
// ChallengeProviderTimeout allows for implementing a
// ChallengeProvider where an unusually long timeout is required when
// waiting for an ACME challenge to be satisfied, such as when
// checking for DNS record progagation. If an implementor of a
// ChallengeProvider provides a Timeout method, then the return values
// of the Timeout method will be used when appropriate by the acme
// package. The interval value is the time between checks.
//
// The default values used for timeout and interval are 60 seconds and
// 2 seconds respectively. These are used when no Timeout method is
// defined for the ChallengeProvider.
type ChallengeProviderTimeout interface {
ChallengeProvider
Timeout() (timeout, interval time.Duration)
}

5
acmev2/testdata/resolv.conf.1 vendored Normal file
View file

@ -0,0 +1,5 @@
domain company.com
nameserver 10.200.3.249
nameserver 10.200.3.250:5353
nameserver 2001:4860:4860::8844
nameserver [10.0.0.1]:5353

29
acmev2/utils.go Normal file
View file

@ -0,0 +1,29 @@
package acme
import (
"fmt"
"time"
)
// WaitFor polls the given function 'f', once every 'interval', up to 'timeout'.
func WaitFor(timeout, interval time.Duration, f func() (bool, error)) error {
var lastErr string
timeup := time.After(timeout)
for {
select {
case <-timeup:
return fmt.Errorf("Time limit exceeded. Last error: %s", lastErr)
default:
}
stop, err := f()
if stop {
return nil
}
if err != nil {
lastErr = err.Error()
}
time.Sleep(interval)
}
}

26
acmev2/utils_test.go Normal file
View file

@ -0,0 +1,26 @@
package acme
import (
"testing"
"time"
)
func TestWaitForTimeout(t *testing.T) {
c := make(chan error)
go func() {
err := WaitFor(3*time.Second, 1*time.Second, func() (bool, error) {
return false, nil
})
c <- err
}()
timeout := time.After(4 * time.Second)
select {
case <-timeout:
t.Fatal("timeout exceeded")
case err := <-c:
if err == nil {
t.Errorf("expected timeout error; got %v", err)
}
}
}

6
cli.go
View file

@ -11,7 +11,7 @@ import (
"text/tabwriter" "text/tabwriter"
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acmev2"
) )
// Logger is used to log errors; if nil, the default log.Logger is used. // Logger is used to log errors; if nil, the default log.Logger is used.
@ -154,10 +154,6 @@ func main() {
Name: "http", Name: "http",
Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port", Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port",
}, },
cli.StringFlag{
Name: "tls",
Usage: "Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port",
},
cli.StringFlag{ cli.StringFlag{
Name: "dns", Name: "dns",
Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.", Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.",

View file

@ -6,6 +6,7 @@ import (
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os" "os"
@ -14,7 +15,7 @@ import (
"time" "time"
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acmev2"
"github.com/xenolf/lego/providers/dns" "github.com/xenolf/lego/providers/dns"
"github.com/xenolf/lego/providers/http/memcached" "github.com/xenolf/lego/providers/http/memcached"
"github.com/xenolf/lego/providers/http/webroot" "github.com/xenolf/lego/providers/http/webroot"
@ -66,6 +67,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
logger().Fatal(err.Error()) logger().Fatal(err.Error())
} }
acme.UserAgent = fmt.Sprintf("le-go/cli %s", c.App.Version)
client, err := acme.NewClient(c.GlobalString("server"), acc, keyType) client, err := acme.NewClient(c.GlobalString("server"), acc, keyType)
if err != nil { if err != nil {
logger().Fatalf("Could not create client: %s", err.Error()) logger().Fatalf("Could not create client: %s", err.Error())
@ -85,7 +88,7 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
// --webroot=foo indicates that the user specifically want to do a HTTP challenge // --webroot=foo indicates that the user specifically want to do a HTTP challenge
// infer that the user also wants to exclude all other challenges // infer that the user also wants to exclude all other challenges
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) client.ExcludeChallenges([]acme.Challenge{acme.DNS01})
} }
if c.GlobalIsSet("memcached-host") { if c.GlobalIsSet("memcached-host") {
provider, err := memcached.NewMemcachedProvider(c.GlobalStringSlice("memcached-host")) provider, err := memcached.NewMemcachedProvider(c.GlobalStringSlice("memcached-host"))
@ -97,7 +100,7 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
// --memcached-host=foo:11211 indicates that the user specifically want to do a HTTP challenge // --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 // infer that the user also wants to exclude all other challenges
client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) client.ExcludeChallenges([]acme.Challenge{acme.DNS01})
} }
if c.GlobalIsSet("http") { if c.GlobalIsSet("http") {
if strings.Index(c.GlobalString("http"), ":") == -1 { if strings.Index(c.GlobalString("http"), ":") == -1 {
@ -106,13 +109,6 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
client.SetHTTPAddress(c.GlobalString("http")) 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") { if c.GlobalIsSet("dns") {
provider, err := dns.NewDNSChallengeProviderByName(c.GlobalString("dns")) provider, err := dns.NewDNSChallengeProviderByName(c.GlobalString("dns"))
if err != nil { if err != nil {
@ -123,20 +119,23 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
// --dns=foo indicates that the user specifically want to do a DNS challenge // --dns=foo indicates that the user specifically want to do a DNS challenge
// infer that the user also wants to exclude all other challenges // infer that the user also wants to exclude all other challenges
client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01}) client.ExcludeChallenges([]acme.Challenge{acme.HTTP01})
} }
return conf, acc, client return conf, acc, client
} }
func saveCertRes(certRes acme.CertificateResource, conf *Configuration) { func saveCertRes(certRes acme.CertificateResource, conf *Configuration) {
// make sure no funny chars are in the cert names (like wildcards ;))
domainName := strings.Replace(certRes.Domain, "*", "_", -1)
// We store the certificate, private key and metadata in different 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. // as web servers would not be able to work with a combined file.
certOut := path.Join(conf.CertPath(), certRes.Domain+".crt") certOut := path.Join(conf.CertPath(), domainName+".crt")
privOut := path.Join(conf.CertPath(), certRes.Domain+".key") privOut := path.Join(conf.CertPath(), domainName+".key")
pemOut := path.Join(conf.CertPath(), certRes.Domain+".pem") pemOut := path.Join(conf.CertPath(), domainName+".pem")
metaOut := path.Join(conf.CertPath(), certRes.Domain+".json") metaOut := path.Join(conf.CertPath(), domainName+".json")
issuerOut := path.Join(conf.CertPath(), certRes.Domain+".issuer.crt") issuerOut := path.Join(conf.CertPath(), domainName+".issuer.crt")
err := ioutil.WriteFile(certOut, certRes.Certificate, 0600) err := ioutil.WriteFile(certOut, certRes.Certificate, 0600)
if err != nil { if err != nil {
@ -180,20 +179,14 @@ func saveCertRes(certRes acme.CertificateResource, conf *Configuration) {
} }
} }
func handleTOS(c *cli.Context, client *acme.Client, acc *Account) { func handleTOS(c *cli.Context, client *acme.Client) bool {
// Check for a global accept override // Check for a global accept override
if c.GlobalBool("accept-tos") { if c.GlobalBool("accept-tos") {
err := client.AgreeToTOS() return true
if err != nil {
logger().Fatalf("Could not agree to TOS: %s", err.Error())
}
acc.Save()
return
} }
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
logger().Printf("Please review the TOS at %s", acc.Registration.TosURL) logger().Printf("Please review the TOS at %s", client.GetToSURL())
for { for {
logger().Println("Do you accept the TOS? Y/n") logger().Println("Do you accept the TOS? Y/n")
@ -209,16 +202,13 @@ func handleTOS(c *cli.Context, client *acme.Client, acc *Account) {
} }
if text == "Y" || text == "y" || text == "" { if text == "Y" || text == "y" || text == "" {
err = client.AgreeToTOS() return true
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.") logger().Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.")
} }
return false
} }
func readCSRFile(filename string) (*x509.CertificateRequest, error) { func readCSRFile(filename string) (*x509.CertificateRequest, error) {
@ -255,7 +245,12 @@ func readCSRFile(filename string) (*x509.CertificateRequest, error) {
func run(c *cli.Context) error { func run(c *cli.Context) error {
conf, acc, client := setup(c) conf, acc, client := setup(c)
if acc.Registration == nil { if acc.Registration == nil {
reg, err := client.Register() accepted := handleTOS(c, client)
if !accepted {
logger().Fatal("You did not accept the TOS. Unable to proceed.")
}
reg, err := client.Register(accepted)
if err != nil { if err != nil {
logger().Fatalf("Could not complete registration\n\t%s", err.Error()) logger().Fatalf("Could not complete registration\n\t%s", err.Error())
} }
@ -274,11 +269,6 @@ func run(c *cli.Context) error {
} }
// 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 // we require either domains or csr, but not both
hasDomains := len(c.GlobalStringSlice("domains")) > 0 hasDomains := len(c.GlobalStringSlice("domains")) > 0
hasCsr := len(c.GlobalString("csr")) > 0 hasCsr := len(c.GlobalString("csr")) > 0

View file

@ -8,7 +8,7 @@ import (
"strings" "strings"
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acmev2"
) )
// Configuration type from CLI and config files. // Configuration type from CLI and config files.