Merge remote-tracking branch 'refs/remotes/xenolf/master'

This commit is contained in:
Adrien Carbonne 2016-03-14 11:20:15 +01:00
commit eb773f17d2
39 changed files with 1172 additions and 617 deletions

View file

@ -1 +1,14 @@
language: go
go:
- 1.4.3
- 1.5.3
- tip
install:
- go get -t ./...
- go get golang.org/x/tools/cmd/vet
script:
- go vet ./...
- go test -v ./...

View file

@ -4,6 +4,7 @@
### Added:
- CLI: The `--dns` switch. To include the DNS challenge for consideration. Supported are the following solvers: cloudflare, digitalocean, dnsimple, route53, rfc2136 and manual.
- CLI: The `--accept-tos` switch. Indicates your acceptance of the Let's Encrypt terms of service without prompting you.
- lib: A new type for challenge identifiers `Challenge`
- lib: A new interface for custom challenge providers `ChallengeProvider`
- lib: SetChallengeProvider function. Pass a challenge identifier and a Provider to replace the default behaviour of a challenge.

32
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,32 @@
# How to contribute to lego
Contributions in the form of patches and proposals are essential to keep lego great and to make it even better.
To ensure a great and easy experience for everyone, please review the few guidelines in this document.
## Bug reports
- Use the issue search to see if the issue has already been reported.
- Also look for closed issues to see if your issue has already been fixed.
- If both of the above do not apply create a new issue and include as much information as possible.
Bug reports should include all information a person could need to reproduce your problem without the need to
follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behaviour and the actual behaviour.
## Feature proposals and requests
Feature requests are welcome and should be discussed in an issue.
Please keep proposals focused on one thing at a time and be as detailed as possible.
It is up to you to make a strong point about your proposal and convince us of the merits and the added complexity of this feature.
## Pull requests
Patches, new features and improvements are a great way to help the project.
Please keep them focused on one thing and do not include unrelated commits.
All pull requests which alter the behaviour of the program, add new behaviour or somehow alter code in a non-trivial way should **always** include tests.
If you want to contribute a significant pull request (with a non-trivial workload for you) please **ask first**. We do not want you to spend
a lot of time on something the project's developers might not want to merge into the project.
**IMPORTANT**: By submitting a patch, you agree to allow the project
owners to license your work under the terms of the [MIT License](LICENSE).

View file

@ -91,7 +91,7 @@ GLOBAL OPTIONS:
--webroot Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge
--http Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port
--tls Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port
--dns Enable the DNS challenge for solving using a provider.
--dns Solve a DNS challenge using the specified provider. Disables all other solvers.
Credentials for providers have to be passed through environment variables.
For a more detailed explanation of the parameters, please see the online docs.
Valid providers:
@ -99,7 +99,7 @@ GLOBAL OPTIONS:
digitalocean: DO_AUTH_TOKEN
dnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY
route53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
rfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_NAMESERVER, RFC2136_ZONE
rfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER
manual: none
--help, -h show help
--version, -v print the version
@ -127,9 +127,11 @@ $ lego --email="foo@bar.com" --domains="example.com" renew
Obtain a certificate using the DNS challenge and AWS Route 53:
```bash
$ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego --email="foo@bar.com" --domains="example.com" --dns="route53" --exclude="http-01" --exclude="tls-sni-01" run
$ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego --email="foo@bar.com" --domains="example.com" --dns="route53" run
```
Note that `--dns=foo` implies `--exclude=http-01` and `--exclude=tls-sni-01`. lego will not attempt other challenges if you've told it to use DNS instead.
lego defaults to communicating with the production Let's Encrypt ACME server. If you'd like to test something without issuing real certificates, consider using the staging endpoint instead:
```bash

View file

@ -1,7 +1,7 @@
package main
import (
"crypto/rsa"
"crypto"
"encoding/json"
"io/ioutil"
"os"
@ -13,7 +13,7 @@ import (
// Account represents a users local saved credentials
type Account struct {
Email string `json:"email"`
key *rsa.PrivateKey
key crypto.PrivateKey
Registration *acme.RegistrationResource `json:"registration"`
conf *Configuration
@ -28,16 +28,18 @@ func NewAccount(email string, conf *Configuration) *Account {
logger().Fatalf("Could not check/create directory for account %s: %v", email, err)
}
var privKey *rsa.PrivateKey
var privKey crypto.PrivateKey
if _, err := os.Stat(accKeyPath); os.IsNotExist(err) {
logger().Printf("No key found for account %s. Generating a %v bit key.", email, conf.RsaBits())
privKey, err = generateRsaKey(conf.RsaBits(), accKeyPath)
logger().Printf("No key found for account %s. Generating a curve P384 EC key.", email)
privKey, err = generatePrivateKey(accKeyPath)
if err != nil {
logger().Fatalf("Could not generate RSA private account key for account %s: %v", email, err)
}
logger().Printf("Saved key to %s", accKeyPath)
} else {
privKey, err = loadRsaKey(accKeyPath)
privKey, err = loadPrivateKey(accKeyPath)
if err != nil {
logger().Fatalf("Could not load RSA private key from file %s: %v", accKeyPath, err)
}
@ -73,7 +75,7 @@ func (a *Account) GetEmail() string {
}
// GetPrivateKey returns the private RSA account key.
func (a *Account) GetPrivateKey() *rsa.PrivateKey {
func (a *Account) GetPrivateKey() crypto.PrivateKey {
return a.key
}

View file

@ -1,5 +1,6 @@
package acme
// Challenge is a string that identifies a particular type and version of ACME challenge.
type Challenge string
const (

View file

@ -3,7 +3,6 @@ package acme
import (
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
@ -38,7 +37,7 @@ func logf(format string, args ...interface{}) {
type User interface {
GetEmail() string
GetRegistration() *RegistrationResource
GetPrivateKey() *rsa.PrivateKey
GetPrivateKey() crypto.PrivateKey
}
// Interface for all challenge solvers to implement.
@ -53,7 +52,7 @@ type Client struct {
directory directory
user User
jws *jws
keyBits int
keyType KeyType
issuerCert []byte
solvers map[Challenge]solver
}
@ -61,16 +60,12 @@ type Client struct {
// 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. It will
// generate private keys for certificates of size keyBits.
func NewClient(caDirURL string, user User, keyBits int) (*Client, error) {
func NewClient(caDirURL string, user User, keyType KeyType) (*Client, error) {
privKey := user.GetPrivateKey()
if privKey == nil {
return nil, errors.New("private key was nil")
}
if err := privKey.Validate(); err != nil {
return nil, fmt.Errorf("invalid private key: %v", err)
}
var dir directory
if _, err := getJSON(caDirURL, &dir); err != nil {
return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err)
@ -95,10 +90,10 @@ func NewClient(caDirURL string, user User, keyBits int) (*Client, error) {
// 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}
solvers[TLSSNI01] = &tlsSNIChallenge{jws: jws, validate: validate}
solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}}
solvers[TLSSNI01] = &tlsSNIChallenge{jws: jws, validate: validate, provider: &TLSProviderServer{}}
return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil
return &Client{directory: dir, user: user, jws: jws, keyType: keyType, solvers: solvers}, nil
}
// SetChallengeProvider specifies a custom provider that will make the solution available
@ -126,7 +121,7 @@ func (c *Client) SetHTTPAddress(iface string) error {
}
if chlng, ok := c.solvers[HTTP01]; ok {
chlng.(*httpChallenge).provider = &httpChallengeServer{iface: host, port: port}
chlng.(*httpChallenge).provider = NewHTTPProviderServer(host, port)
}
return nil
@ -142,7 +137,7 @@ func (c *Client) SetTLSAddress(iface string) error {
}
if chlng, ok := c.solvers[TLSSNI01]; ok {
chlng.(*tlsSNIChallenge).provider = &tlsSNIChallengeServer{iface: host, port: port}
chlng.(*tlsSNIChallenge).provider = NewTLSProviderServer(host, port)
}
return nil
}
@ -197,8 +192,10 @@ func (c *Client) Register() (*RegistrationResource, error) {
// AgreeToTOS updates the Client registration and sends the agreement to
// the server.
func (c *Client) AgreeToTOS() error {
c.user.GetRegistration().Body.Agreement = c.user.GetRegistration().TosURL
c.user.GetRegistration().Body.Resource = "reg"
reg := c.user.GetRegistration()
reg.Body.Agreement = c.user.GetRegistration().TosURL
reg.Body.Resource = "reg"
_, err := postJSON(c.jws, c.user.GetRegistration().URI, c.user.GetRegistration().Body, nil)
return err
}
@ -316,13 +313,12 @@ func (c *Client) RenewCertificate(cert CertificateResource, bundle bool) (Certif
links := parseLinks(resp.Header["Link"])
issuerCert, err := c.getIssuerCertificate(links["up"])
if err != nil {
// If we fail to aquire the issuer cert, return the issued certificate - do not fail.
// If we fail to acquire the issuer cert, return the issued certificate - do not fail.
logf("[ERROR][%s] acme: Could not bundle issuer certificate: %v", cert.Domain, err)
} else {
// Success - append the issuer cert to the issued cert.
issuerCert = pemEncode(derCertificateBytes(issuerCert))
issuedCert = append(issuedCert, issuerCert...)
cert.Certificate = issuedCert
}
}
@ -457,7 +453,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool,
commonName := authz[0]
var err error
if privKey == nil {
privKey, err = generatePrivateKey(rsakey, c.keyBits)
privKey, err = generatePrivateKey(c.keyType)
if err != nil {
return CertificateResource{}, err
}
@ -471,7 +467,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool,
}
// TODO: should the CSR be customizable?
csr, err := generateCsr(privKey.(*rsa.PrivateKey), commonName.Domain, san)
csr, err := generateCsr(privKey, commonName.Domain, san)
if err != nil {
return CertificateResource{}, err
}
@ -508,6 +504,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool,
if len(cert) > 0 {
cerRes.CertStableURL = resp.Header.Get("Content-Location")
cerRes.AccountRef = c.user.GetRegistration().URI
issuedCert := pemEncode(derCertificateBytes(cert))
// If bundle is true, we want to return a certificate bundle.
@ -518,7 +515,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool,
links := parseLinks(resp.Header["Link"])
issuerCert, err := c.getIssuerCertificate(links["up"])
if err != nil {
// If we fail to aquire the issuer cert, return the issued certificate - do not fail.
// 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", commonName.Domain, err)
} else {
// Success - append the issuer cert to the issued cert.

View file

@ -1,6 +1,7 @@
package acme
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/json"
@ -13,6 +14,7 @@ import (
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)
@ -28,7 +30,7 @@ func TestNewClient(t *testing.T) {
w.Write(data)
}))
client, err := NewClient(ts.URL, user, keyBits)
client, err := NewClient(ts.URL, user, keyType)
if err != nil {
t.Fatalf("Could not create client: %v", err)
}
@ -40,8 +42,8 @@ func TestNewClient(t *testing.T) {
t.Errorf("Expected jws.privKey to be %p but was %p", expected, actual)
}
if client.keyBits != keyBits {
t.Errorf("Expected keyBits to be %d but was %d", keyBits, client.keyBits)
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 {
@ -68,7 +70,7 @@ func TestClientOptPort(t *testing.T) {
optPort := "1234"
optHost := ""
client, err := NewClient(ts.URL, user, keyBits)
client, err := NewClient(ts.URL, user, RSA2048)
if err != nil {
t.Fatalf("Could not create client: %v", err)
}
@ -82,10 +84,10 @@ func TestClientOptPort(t *testing.T) {
if httpSolver.jws != client.jws {
t.Error("Expected http-01 to have same jws as client")
}
if got := httpSolver.provider.(*httpChallengeServer).port; got != optPort {
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.(*httpChallengeServer).iface; got != optHost {
if got := httpSolver.provider.(*HTTPProviderServer).iface; got != optHost {
t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got)
}
@ -96,10 +98,10 @@ func TestClientOptPort(t *testing.T) {
if httpsSolver.jws != client.jws {
t.Error("Expected tls-sni-01 to have same jws as client")
}
if got := httpsSolver.provider.(*tlsSNIChallengeServer).port; got != optPort {
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.(*tlsSNIChallengeServer).iface; got != optHost {
if got := httpsSolver.provider.(*TLSProviderServer).iface; got != optHost {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, got)
}
@ -108,10 +110,10 @@ func TestClientOptPort(t *testing.T) {
client.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
client.SetTLSAddress(net.JoinHostPort(optHost, optPort))
if got := httpSolver.provider.(*httpChallengeServer).iface; got != optHost {
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.(*tlsSNIChallengeServer).port; got != optPort {
if got := httpsSolver.provider.(*TLSProviderServer).port; got != optPort {
t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got)
}
}
@ -140,8 +142,8 @@ func TestValidate(t *testing.T) {
}))
defer ts.Close()
privKey, _ := generatePrivateKey(rsakey, 512)
j := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL}
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey, directoryURL: ts.URL}
tsts := []struct {
name string
@ -193,4 +195,4 @@ type mockUser struct {
func (u mockUser) GetEmail() string { return u.email }
func (u mockUser) GetRegistration() *RegistrationResource { return u.regres }
func (u mockUser) GetPrivateKey() *rsa.PrivateKey { return u.privatekey }
func (u mockUser) GetPrivateKey() crypto.PrivateKey { return u.privatekey }

View file

@ -10,7 +10,6 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/binary"
"encoding/pem"
"errors"
"fmt"
@ -22,15 +21,19 @@ import (
"time"
"golang.org/x/crypto/ocsp"
"golang.org/x/crypto/sha3"
)
type keyType int
// 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 (
eckey keyType = iota
rsakey
EC256 = KeyType("P256")
EC384 = KeyType("P384")
RSA2048 = KeyType("2048")
RSA4096 = KeyType("4096")
RSA8192 = KeyType("8192")
)
const (
@ -56,14 +59,22 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
return nil, nil, err
}
// We only got one certificate, means we have no issuer certificate - get it.
// 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(certificates[0].IssuingCertificateURL) == 0 {
if len(issuedCert.IssuingCertificateURL) == 0 {
return nil, nil, errors.New("no issuing certificate URL")
}
resp, err := httpGet(certificates[0].IssuingCertificateURL[0])
resp, err := httpGet(issuedCert.IssuingCertificateURL[0])
if err != nil {
return nil, nil, err
}
@ -83,17 +94,8 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
// We want it ordered right SRV CRT -> CA
certificates = append(certificates, issuerCert)
}
// We expect the certificate slice to be ordered downwards the chain.
// SRV CRT -> CA. We need to pull the cert and issuer cert out of it,
// which should always be the last two certificates.
issuedCert := certificates[0]
issuerCert := certificates[1]
if len(issuedCert.OCSPServer) == 0 {
return nil, nil, errors.New("no OCSP server specified in cert")
}
// Finally kick off the OCSP request.
ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil)
if err != nil {
@ -124,8 +126,16 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
}
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 := keyAsJWK(key)
jwk := keyAsJWK(publicKey)
if jwk == nil {
return "", errors.New("Could not generate JWK from key.")
}
@ -144,39 +154,6 @@ func getKeyAuthorization(token string, key interface{}) (string, error) {
return token + "." + keyThumb, nil
}
// Derive the shared secret according to acme spec 5.6
func performECDH(priv *ecdsa.PrivateKey, pub *ecdsa.PublicKey, outLen int, label string) []byte {
// Derive Z from the private and public keys according to SEC 1 Ver. 2.0 - 3.3.1
Z, _ := priv.PublicKey.ScalarMult(pub.X, pub.Y, priv.D.Bytes())
if len(Z.Bytes())+len(label)+4 > 384 {
return nil
}
if outLen < 384*(2^32-1) {
return nil
}
// Derive the shared secret key using the ANS X9.63 KDF - SEC 1 Ver. 2.0 - 3.6.1
hasher := sha3.New384()
buffer := make([]byte, outLen)
bufferLen := 0
for i := 0; i < outLen/384; i++ {
hasher.Reset()
// Ki = Hash(Z || Counter || [SharedInfo])
hasher.Write(Z.Bytes())
binary.Write(hasher, binary.BigEndian, i)
hasher.Write([]byte(label))
hash := hasher.Sum(nil)
copied := copy(buffer[bufferLen:], hash)
bufferLen += copied
}
return buffer
}
// 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) {
@ -218,18 +195,25 @@ func parsePEMPrivateKey(key []byte) (crypto.PrivateKey, error) {
}
}
func generatePrivateKey(t keyType, keyLength int) (crypto.PrivateKey, error) {
switch t {
case eckey:
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 rsakey:
return rsa.GenerateKey(rand.Reader, keyLength)
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: %d", t)
return nil, fmt.Errorf("Invalid KeyType: %s", keyType)
}
func generateCsr(privateKey *rsa.PrivateKey, domain string, san []string) ([]byte, error) {
func generateCsr(privateKey crypto.PrivateKey, domain string, san []string) ([]byte, error) {
template := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: domain,
@ -246,6 +230,9 @@ func generateCsr(privateKey *rsa.PrivateKey, domain string, san []string) ([]byt
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

View file

@ -2,13 +2,14 @@ package acme
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
func TestGeneratePrivateKey(t *testing.T) {
key, err := generatePrivateKey(rsakey, 32)
key, err := generatePrivateKey(RSA2048)
if err != nil {
t.Error("Error generating private key:", err)
}
@ -18,12 +19,12 @@ func TestGeneratePrivateKey(t *testing.T) {
}
func TestGenerateCSR(t *testing.T) {
key, err := generatePrivateKey(rsakey, 512)
key, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatal("Error generating private key:", err)
}
csr, err := generateCsr(key.(*rsa.PrivateKey), "fizz.buzz", nil)
csr, err := generateCsr(key, "fizz.buzz", nil)
if err != nil {
t.Error("Error generating CSR:", err)
}
@ -52,7 +53,7 @@ func TestPEMEncode(t *testing.T) {
}
func TestPEMCertExpiration(t *testing.T) {
privKey, err := generatePrivateKey(rsakey, 2048)
privKey, err := generatePrivateKey(RSA2048)
if err != nil {
t.Fatal("Error generating private key:", err)
}

View file

@ -6,17 +6,22 @@ import (
"errors"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/miekg/dns"
"golang.org/x/net/publicsuffix"
)
type preCheckDNSFunc func(domain, fqdn string) bool
type preCheckDNSFunc func(fqdn, value string) (bool, error)
var preCheckDNS preCheckDNSFunc = checkDNS
var (
preCheckDNS preCheckDNSFunc = checkDNSPropagation
fqdnToZone = map[string]string{}
)
var preCheckDNSFallbackCount = 5
var recursiveNameserver = "google-public-dns-a.google.com:53"
// DNS01Record returns a DNS record which will fulfill the `dns-01` challenge
func DNS01Record(domain, keyAuth string) (fqdn string, value string, ttl int) {
@ -44,7 +49,7 @@ func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
}
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey)
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
if err != nil {
return err
}
@ -60,54 +65,182 @@ func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
}
}()
fqdn, _, _ := DNS01Record(domain, keyAuth)
fqdn, value, _ := DNS01Record(domain, keyAuth)
preCheckDNS(domain, fqdn)
logf("[INFO][%s] Checking DNS record propagation...", domain)
err = WaitFor(60*time.Second, 2*time.Second, func() (bool, error) {
return preCheckDNS(fqdn, value)
})
if err != nil {
return err
}
return s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
}
func checkDNS(domain, fqdn string) bool {
// check if the expected DNS entry was created. If not wait for some time and try again.
m := new(dns.Msg)
m.SetQuestion(domain+".", dns.TypeSOA)
c := new(dns.Client)
in, _, err := c.Exchange(m, "google-public-dns-a.google.com:53")
// 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, recursiveNameserver, true)
if err != nil {
return false
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
}
}
}
}
var authorativeNS string
for _, answ := range in.Answer {
soa := answ.(*dns.SOA)
authorativeNS = soa.Ns
authoritativeNss, err := lookupNameservers(fqdn)
if err != nil {
return false, err
}
fallbackCnt := 0
for fallbackCnt < preCheckDNSFallbackCount {
m.SetQuestion(fqdn, dns.TypeTXT)
in, _, err = c.Exchange(m, authorativeNS+":53")
if err != nil {
return false
}
if len(in.Answer) > 0 {
return true
}
fallbackCnt++
if fallbackCnt >= preCheckDNSFallbackCount {
return false
}
time.Sleep(time.Second * time.Duration(fallbackCnt))
}
return false
return checkAuthoritativeNss(fqdn, value, authoritativeNss)
}
// toFqdn converts the name into a fqdn appending a trailing dot.
func toFqdn(name string) string {
// 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, 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 sends a DNS query to the given nameserver.
// The nameserver should include a port, to facilitate testing where we talk to a mock dns server.
func dnsQuery(fqdn string, rtype uint16, nameserver 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
}
in, err = dns.Exchange(m, nameserver)
if err == dns.ErrTruncated {
tcp := &dns.Client{Net: "tcp"}
in, _, err = tcp.Exchange(m, nameserver)
}
return
}
// lookupNameservers returns the authoritative nameservers for the given fqdn.
func lookupNameservers(fqdn string) ([]string, error) {
var authoritativeNss []string
zone, err := FindZoneByFqdn(fqdn, recursiveNameserver)
if err != nil {
return nil, err
}
r, err := dnsQuery(zone, dns.TypeNS, recursiveNameserver, 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 of the given fqdn
func FindZoneByFqdn(fqdn, nameserver string) (string, error) {
// Do we have it cached?
if zone, ok := fqdnToZone[fqdn]; ok {
return zone, nil
}
// Query the authorative nameserver for a hopefully non-existing SOA record,
// in the authority section of the reply it will have the SOA of the
// containing zone. rfc2308 has this to say on the subject:
// Name servers authoritative for a zone MUST include the SOA record of
// the zone in the authority section of the response when reporting an
// NXDOMAIN or indicating that no data (NODATA) of the requested type exists
in, err := dnsQuery(fqdn, dns.TypeSOA, nameserver, true)
if err != nil {
return "", err
}
if in.Rcode != dns.RcodeNameError {
if in.Rcode != dns.RcodeSuccess {
return "", fmt.Errorf("NS %s returned %s for %s", nameserver, dns.RcodeToString[in.Rcode], fqdn)
}
// We have a success, so one of the answers has to be a SOA RR
for _, ans := range in.Answer {
if soa, ok := ans.(*dns.SOA); ok {
zone := soa.Hdr.Name
// If we ended up on one of the TLDs, it means the domain did not exist.
publicsuffix, _ := publicsuffix.PublicSuffix(UnFqdn(zone))
if publicsuffix == UnFqdn(zone) {
return "", fmt.Errorf("Could not determine zone authoritatively")
}
fqdnToZone[fqdn] = zone
return zone, nil
}
}
// Or it is NODATA, fall through to NXDOMAIN
}
// Search the authority section for our precious SOA RR
for _, ns := range in.Ns {
if soa, ok := ns.(*dns.SOA); ok {
zone := soa.Hdr.Name
// If we ended up on one of the TLDs, it means the domain did not exist.
publicsuffix, _ := publicsuffix.PublicSuffix(UnFqdn(zone))
if publicsuffix == UnFqdn(zone) {
return "", fmt.Errorf("Could not determine zone authoritatively")
}
fqdnToZone[fqdn] = zone
return zone, nil
}
}
return "", fmt.Errorf("NS %s did not return the expected SOA record in the authority section", nameserver)
}
// 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
@ -115,31 +248,11 @@ func toFqdn(name string) string {
return name + "."
}
// unFqdn converts the fqdn into a name removing the trailing dot.
func unFqdn(name string) string {
// 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
}
// waitFor polls the given function 'f', once per second, up to 'timeout' seconds.
func waitFor(timeout int, f func() (bool, error)) error {
start := time.Now().Second()
for {
time.Sleep(1 * time.Second)
if delta := time.Now().Second() - start; delta >= timeout {
return fmt.Errorf("Time limit exceeded (%d seconds)", delta)
}
stop, err := f()
if err != nil {
return err
}
if stop {
return nil
}
}
}

View file

@ -1,145 +0,0 @@
package acme
import (
"fmt"
"os"
"strings"
"github.com/crackcomm/cloudflare"
"golang.org/x/net/context"
)
// DNSProviderCloudFlare is an implementation of the DNSProvider interface
type DNSProviderCloudFlare struct {
client *cloudflare.Client
ctx context.Context
}
// NewDNSProviderCloudFlare returns a DNSProviderCloudFlare instance with a configured cloudflare client.
// Authentication is either done using the passed credentials or - when empty - using the environment
// variables CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY.
func NewDNSProviderCloudFlare(cloudflareEmail, cloudflareKey string) (*DNSProviderCloudFlare, error) {
if cloudflareEmail == "" || cloudflareKey == "" {
cloudflareEmail, cloudflareKey = cloudflareEnvAuth()
if cloudflareEmail == "" || cloudflareKey == "" {
return nil, fmt.Errorf("CloudFlare credentials missing")
}
}
c := &DNSProviderCloudFlare{
client: cloudflare.New(&cloudflare.Options{cloudflareEmail, cloudflareKey}),
ctx: context.Background(),
}
return c, nil
}
// Present creates a TXT record to fulfil the dns-01 challenge
func (c *DNSProviderCloudFlare) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth)
zoneID, err := c.getHostedZoneID(fqdn)
if err != nil {
return err
}
record := newTxtRecord(zoneID, fqdn, value, ttl)
err = c.client.Records.Create(c.ctx, record)
if err != nil {
return fmt.Errorf("CloudFlare API call failed: %v", err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters
func (c *DNSProviderCloudFlare) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := DNS01Record(domain, keyAuth)
records, err := c.findTxtRecords(fqdn)
if err != nil {
return err
}
for _, rec := range records {
err := c.client.Records.Delete(c.ctx, rec.ZoneID, rec.ID)
if err != nil {
return err
}
}
return nil
}
func (c *DNSProviderCloudFlare) findTxtRecords(fqdn string) ([]*cloudflare.Record, error) {
zoneID, err := c.getHostedZoneID(fqdn)
if err != nil {
return nil, err
}
var records []*cloudflare.Record
result, err := c.client.Records.List(c.ctx, zoneID)
if err != nil {
return records, fmt.Errorf("CloudFlare API call has failed: %v", err)
}
name := unFqdn(fqdn)
for _, rec := range result {
if rec.Name == name && rec.Type == "TXT" {
records = append(records, rec)
}
}
return records, nil
}
func (c *DNSProviderCloudFlare) getHostedZoneID(fqdn string) (string, error) {
zones, err := c.client.Zones.List(c.ctx)
if err != nil {
return "", fmt.Errorf("CloudFlare API call failed: %v", err)
}
var hostedZone cloudflare.Zone
for _, zone := range zones {
name := toFqdn(zone.Name)
if strings.HasSuffix(fqdn, name) {
if len(zone.Name) > len(hostedZone.Name) {
hostedZone = *zone
}
}
}
if hostedZone.ID == "" {
return "", fmt.Errorf("No matching CloudFlare zone found for domain %s", fqdn)
}
return hostedZone.ID, nil
}
func newTxtRecord(zoneID, fqdn, value string, ttl int) *cloudflare.Record {
name := unFqdn(fqdn)
return &cloudflare.Record{
Type: "TXT",
Name: name,
Content: value,
TTL: sanitizeTTL(ttl),
ZoneID: zoneID,
}
}
// TTL must be between 120 and 86400 seconds
func sanitizeTTL(ttl int) int {
switch {
case ttl < 120:
return 120
case ttl > 86400:
return 86400
default:
return ttl
}
}
func cloudflareEnvAuth() (email, apiKey string) {
email = os.Getenv("CLOUDFLARE_EMAIL")
apiKey = os.Getenv("CLOUDFLARE_API_KEY")
if len(email) == 0 || len(apiKey) == 0 {
return "", ""
}
return
}

View file

@ -1,90 +0,0 @@
package acme
import (
"fmt"
"github.com/miekg/dns"
"time"
)
// DNSProviderRFC2136 is an implementation of the ChallengeProvider interface that
// uses dynamic DNS updates (RFC 2136) to create TXT records on a nameserver.
type DNSProviderRFC2136 struct {
nameserver string
zone string
tsigKey string
tsigSecret string
records map[string]string
}
// NewDNSProviderRFC2136 returns a new DNSProviderRFC2136 instance.
// To disable TSIG authentication 'tsigKey' and 'tsigSecret' must be set to the empty string.
// 'nameserver' must be a network address in the the form "host:port". 'zone' must be the fully
// qualified name of the zone.
func NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret string) (*DNSProviderRFC2136, error) {
d := &DNSProviderRFC2136{
nameserver: nameserver,
zone: zone,
records: make(map[string]string),
}
if len(tsigKey) > 0 && len(tsigSecret) > 0 {
d.tsigKey = tsigKey
d.tsigSecret = tsigSecret
}
return d, nil
}
// Present creates a TXT record using the specified parameters
func (r *DNSProviderRFC2136) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth)
r.records[fqdn] = value
return r.changeRecord("INSERT", fqdn, value, ttl)
}
// CleanUp removes the TXT record matching the specified parameters
func (r *DNSProviderRFC2136) CleanUp(domain, token, keyAuth string) error {
fqdn, _, ttl := DNS01Record(domain, keyAuth)
value := r.records[fqdn]
return r.changeRecord("REMOVE", fqdn, value, ttl)
}
func (r *DNSProviderRFC2136) changeRecord(action, fqdn, value string, ttl int) error {
// Create RR
rr := new(dns.TXT)
rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}
rr.Txt = []string{value}
rrs := make([]dns.RR, 1)
rrs[0] = rr
// Create dynamic update packet
m := new(dns.Msg)
m.SetUpdate(dns.Fqdn(r.zone))
switch action {
case "INSERT":
m.Insert(rrs)
case "REMOVE":
m.Remove(rrs)
default:
return fmt.Errorf("Unexpected action: %s", action)
}
// Setup client
c := new(dns.Client)
c.SingleInflight = true
// TSIG authentication / msg signing
if len(r.tsigKey) > 0 && len(r.tsigSecret) > 0 {
m.SetTsig(dns.Fqdn(r.tsigKey), dns.HmacMD5, 300, time.Now().Unix())
c.TsigSecret = map[string]string{dns.Fqdn(r.tsigKey): r.tsigSecret}
}
// Send the query
reply, _, err := c.Exchange(m, r.nameserver)
if err != nil {
return fmt.Errorf("DNS update failed: %v", err)
}
if reply != nil && reply.Rcode != dns.RcodeSuccess {
return fmt.Errorf("DNS update failed. Server replied: %s", dns.RcodeToString[reply.Rcode])
}
return nil
}

View file

@ -2,19 +2,86 @@ 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 zone authoritatively",
},
// invalid domain
{"_null.com.",
"Could not determine zone authoritatively",
},
// invalid domain
{"in-valid.co.uk.",
"Could not determine zone authoritatively",
},
}
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",
},
}
func TestDNSValidServerResponse(t *testing.T) {
preCheckDNS = func(domain, fqdn string) bool {
return true
preCheckDNS = func(fqdn, value string) (bool, error) {
return true, nil
}
privKey, _ := generatePrivateKey(rsakey, 512)
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Replay-Nonce", "12345")
@ -22,7 +89,7 @@ func TestDNSValidServerResponse(t *testing.T) {
}))
manualProvider, _ := NewDNSProviderManual()
jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL}
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"}
@ -39,7 +106,60 @@ func TestDNSValidServerResponse(t *testing.T) {
}
func TestPreCheckDNS(t *testing.T) {
if !preCheckDNS("api.letsencrypt.org", "acme-staging.api.letsencrypt.org") {
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 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
}
}
}

View file

@ -8,11 +8,15 @@ import (
"net/http"
"runtime"
"strings"
"time"
)
// UserAgent (if non-empty) will be tacked onto the User-Agent string in requests.
var UserAgent string
// defaultClient is an HTTP client with a reasonable timeout value.
var defaultClient = http.Client{Timeout: 10 * 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.
@ -32,7 +36,7 @@ func httpHead(url string) (resp *http.Response, err error) {
req.Header.Set("User-Agent", userAgent())
resp, err = http.DefaultClient.Do(req)
resp, err = defaultClient.Do(req)
if err != nil {
return resp, err
}
@ -50,7 +54,7 @@ func httpPost(url string, bodyType string, body io.Reader) (resp *http.Response,
req.Header.Set("Content-Type", bodyType)
req.Header.Set("User-Agent", userAgent())
return http.DefaultClient.Do(req)
return defaultClient.Do(req)
}
// httpGet performs a GET request with a proper User-Agent string.
@ -62,7 +66,7 @@ func httpGet(url string) (resp *http.Response, err error) {
}
req.Header.Set("User-Agent", userAgent())
return http.DefaultClient.Do(req)
return defaultClient.Do(req)
}
// getJSON performs an HTTP GET request and parses the response body

View file

@ -21,15 +21,11 @@ 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.PublicKey)
keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey)
if err != nil {
return err
}
if s.provider == nil {
s.provider = &httpChallengeServer{}
}
err = s.provider.Present(domain, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("[%s] error presenting token: %v", domain, err)

View file

@ -7,16 +7,25 @@ import (
"strings"
)
// httpChallengeServer implements ChallengeProvider for `http-01` challenge
type httpChallengeServer struct {
// 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
}
// Present makes the token available at `HTTP01ChallengePath(token)`
func (s *httpChallengeServer) Present(domain, token, keyAuth string) error {
// 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"
}
@ -32,7 +41,8 @@ func (s *httpChallengeServer) Present(domain, token, keyAuth string) error {
return nil
}
func (s *httpChallengeServer) CleanUp(domain, token, keyAuth string) error {
// 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
}
@ -41,7 +51,7 @@ func (s *httpChallengeServer) CleanUp(domain, token, keyAuth string) error {
return nil
}
func (s *httpChallengeServer) serve(domain, token, keyAuth string) {
func (s *HTTPProviderServer) serve(domain, token, keyAuth string) {
path := HTTP01ChallengePath(token)
// The handler validates the HOST header and request type.

View file

@ -1,6 +1,7 @@
package acme
import (
"crypto/rand"
"crypto/rsa"
"io/ioutil"
"os"
@ -9,8 +10,8 @@ import (
)
func TestHTTPChallenge(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 512)
j := &jws{privKey: privKey.(*rsa.PrivateKey)}
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
@ -36,7 +37,7 @@ func TestHTTPChallenge(t *testing.T) {
return nil
}
solver := &httpChallenge{jws: j, validate: mockValidate, provider: &httpChallengeServer{port: "23457"}}
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)
@ -44,10 +45,10 @@ func TestHTTPChallenge(t *testing.T) {
}
func TestHTTPChallengeInvalidPort(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 128)
j := &jws{privKey: privKey.(*rsa.PrivateKey)}
privKey, _ := rsa.GenerateKey(rand.Reader, 128)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: HTTP01, Token: "http2"}
solver := &httpChallenge{jws: j, validate: stubValidate, provider: &httpChallengeServer{port: "123456"}}
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)

View file

@ -2,7 +2,9 @@ package acme
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"fmt"
"net/http"
@ -12,7 +14,7 @@ import (
type jws struct {
directoryURL string
privKey *rsa.PrivateKey
privKey crypto.PrivateKey
nonces []string
}
@ -46,8 +48,20 @@ func (j *jws) post(url string, content []byte) (*http.Response, error) {
}
func (j *jws) signContent(content []byte) (*jose.JsonWebSignature, error) {
// TODO: support other algorithms - RS512
signer, err := jose.NewSigner(jose.RS256, j.privKey)
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
}
}
signer, err := jose.NewSigner(alg, j.privKey)
if err != nil {
return nil, err
}

View file

@ -28,17 +28,13 @@ type registrationMessage struct {
// Registration is returned by the ACME server after the registration
// The client implementation should save this registration somewhere.
type Registration struct {
Resource string `json:"resource,omitempty"`
ID int `json:"id"`
Key struct {
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"key"`
Contact []string `json:"contact"`
Agreement string `json:"agreement,omitempty"`
Authorizations string `json:"authorizations,omitempty"`
Certificates string `json:"certificates,omitempty"`
Resource string `json:"resource,omitempty"`
ID int `json:"id"`
Key jose.JsonWebKey `json:"key"`
Contact []string `json:"contact"`
Agreement string `json:"agreement,omitempty"`
Authorizations string `json:"authorizations,omitempty"`
Certificates string `json:"certificates,omitempty"`
// RecoveryKey recoveryKeyMessage `json:"recoveryKey,omitempty"`
}
@ -113,6 +109,7 @@ 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:"-"`
}

View file

@ -22,15 +22,11 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error {
logf("[INFO][%s] acme: Trying to solve TLS-SNI-01", domain)
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, &t.jws.privKey.PublicKey)
keyAuth, err := getKeyAuthorization(chlng.Token, t.jws.privKey)
if err != nil {
return err
}
if t.provider == nil {
t.provider = &tlsSNIChallengeServer{}
}
err = t.provider.Present(domain, chlng.Token, keyAuth)
if err != nil {
return fmt.Errorf("[%s] error presenting token: %v", domain, err)
@ -47,7 +43,7 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error {
// TLSSNI01ChallengeCert returns a certificate for the `tls-sni-01` challenge
func TLSSNI01ChallengeCert(keyAuth string) (tls.Certificate, error) {
// generate a new RSA key for the certificates
tempPrivKey, err := generatePrivateKey(rsakey, 2048)
tempPrivKey, err := generatePrivateKey(RSA2048)
if err != nil {
return tls.Certificate{}, err
}

View file

@ -7,16 +7,25 @@ import (
"net/http"
)
// tlsSNIChallengeServer implements ChallengeProvider for `TLS-SNI-01` challenge
type tlsSNIChallengeServer struct {
// TLSProviderServer implements ChallengeProvider for `TLS-SNI-01` challenge
// It may be instantiated without using the NewTLSProviderServer function if
// you want only to use the default values.
type TLSProviderServer struct {
iface string
port string
done chan bool
listener net.Listener
}
// NewTLSProviderServer creates a new TLSProviderServer 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 443 respectively.
func NewTLSProviderServer(iface, port string) *TLSProviderServer {
return &TLSProviderServer{iface: iface, port: port}
}
// Present makes the keyAuth available as a cert
func (s *tlsSNIChallengeServer) Present(domain, token, keyAuth string) error {
func (s *TLSProviderServer) Present(domain, token, keyAuth string) error {
if s.port == "" {
s.port = "443"
}
@ -42,7 +51,8 @@ func (s *tlsSNIChallengeServer) Present(domain, token, keyAuth string) error {
return nil
}
func (s *tlsSNIChallengeServer) CleanUp(domain, token, keyAuth string) error {
// CleanUp closes the HTTP server.
func (s *TLSProviderServer) CleanUp(domain, token, keyAuth string) error {
if s.listener == nil {
return nil
}

View file

@ -1,6 +1,7 @@
package acme
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
@ -11,8 +12,8 @@ import (
)
func TestTLSSNIChallenge(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 512)
j := &jws{privKey: privKey.(*rsa.PrivateKey)}
privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni1"}
mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{
@ -43,7 +44,7 @@ func TestTLSSNIChallenge(t *testing.T) {
return nil
}
solver := &tlsSNIChallenge{jws: j, validate: mockValidate, provider: &tlsSNIChallengeServer{port: "23457"}}
solver := &tlsSNIChallenge{jws: j, validate: mockValidate, provider: &TLSProviderServer{port: "23457"}}
if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
t.Errorf("Solve error: got %v, want nil", err)
@ -51,10 +52,10 @@ func TestTLSSNIChallenge(t *testing.T) {
}
func TestTLSSNIChallengeInvalidPort(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 128)
j := &jws{privKey: privKey.(*rsa.PrivateKey)}
privKey, _ := rsa.GenerateKey(rand.Reader, 128)
j := &jws{privKey: privKey}
clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni2"}
solver := &tlsSNIChallenge{jws: j, validate: stubValidate, provider: &tlsSNIChallengeServer{port: "123456"}}
solver := &tlsSNIChallenge{jws: j, validate: stubValidate, provider: &TLSProviderServer{port: "123456"}}
if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil {
t.Errorf("Solve error: got %v, want error", err)

29
acme/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
acme/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)
}
}
}

26
cli.go
View file

@ -50,6 +50,12 @@ func main() {
Name: "run",
Usage: "Register an account, then create and install a certificate",
Action: run,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "no-bundle",
Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.",
},
},
},
{
Name: "revoke",
@ -70,6 +76,10 @@ func main() {
Name: "reuse-key",
Usage: "Used to indicate you want to reuse your current private key for the new certificate.",
},
cli.BoolFlag{
Name: "no-bundle",
Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.",
},
},
},
}
@ -88,10 +98,14 @@ func main() {
Name: "email, m",
Usage: "Email used for registration and recovery contact.",
},
cli.IntFlag{
Name: "rsa-key-size, B",
Value: 2048,
Usage: "Size of the RSA key.",
cli.BoolFlag{
Name: "accept-tos, a",
Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.",
},
cli.StringFlag{
Name: "key-type, k",
Value: "rsa2048",
Usage: "Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384",
},
cli.StringFlag{
Name: "path",
@ -116,7 +130,7 @@ func main() {
},
cli.StringFlag{
Name: "dns",
Usage: "Enable the DNS challenge for solving using a provider." +
Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges." +
"\n\tCredentials for providers have to be passed through environment variables." +
"\n\tFor a more detailed explanation of the parameters, please see the online docs." +
"\n\tValid providers:" +
@ -124,7 +138,7 @@ func main() {
"\n\tdigitalocean: DO_AUTH_TOKEN" +
"\n\tdnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY" +
"\n\troute53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION" +
"\n\trfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_NAMESERVER, RFC2136_ZONE" +
"\n\trfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER" +
"\n\tmanual: none",
},
}

View file

@ -11,6 +11,11 @@ import (
"github.com/codegangsta/cli"
"github.com/xenolf/lego/acme"
"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/rfc2136"
"github.com/xenolf/lego/providers/dns/route53"
)
func checkFolder(path string) error {
@ -23,7 +28,7 @@ func checkFolder(path string) error {
func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
err := checkFolder(c.GlobalString("path"))
if err != nil {
logger().Fatalf("Cound not check/create path: %s", err.Error())
logger().Fatalf("Could not check/create path: %s", err.Error())
}
conf := NewConfiguration(c)
@ -34,7 +39,12 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
//TODO: move to account struct? Currently MUST pass email.
acc := NewAccount(c.GlobalString("email"), conf)
client, err := acme.NewClient(c.GlobalString("server"), acc, conf.RsaBits())
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())
}
@ -52,10 +62,16 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
client.SetChallengeProvider(acme.HTTP01, provider)
}
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"))
}
@ -64,23 +80,23 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
var provider acme.ChallengeProvider
switch c.GlobalString("dns") {
case "cloudflare":
provider, err = acme.NewDNSProviderCloudFlare("", "")
provider, err = cloudflare.NewDNSProvider("", "")
case "digitalocean":
authToken := os.Getenv("DO_AUTH_TOKEN")
provider, err = acme.NewDNSProviderDigitalOcean(authToken)
provider, err = digitalocean.NewDNSProvider(authToken)
case "dnsimple":
provider, err = acme.NewDNSProviderDNSimple("", "")
provider, err = dnsimple.NewDNSProvider("", "")
case "route53":
awsRegion := os.Getenv("AWS_REGION")
provider, err = acme.NewDNSProviderRoute53("", "", awsRegion)
provider, err = route53.NewDNSProvider("", "", awsRegion)
case "rfc2136":
nameserver := os.Getenv("RFC2136_NAMESERVER")
zone := os.Getenv("RFC2136_ZONE")
tsigAlgorithm := os.Getenv("RFC2136_TSIG_ALGORITHM")
tsigKey := os.Getenv("RFC2136_TSIG_KEY")
tsigSecret := os.Getenv("RFC2136_TSIG_SECRET")
provider, err = acme.NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret)
provider, err = rfc2136.NewDNSProvider(nameserver, tsigAlgorithm, tsigKey, tsigSecret)
case "manual":
provider, err = acme.NewDNSProviderManual()
}
@ -90,6 +106,10 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
}
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
@ -123,6 +143,47 @@ func saveCertRes(certRes acme.CertificateResource, conf *Configuration) {
}
}
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 run(c *cli.Context) {
conf, acc, client := setup(c)
if acc.Registration == nil {
@ -145,41 +206,16 @@ func run(c *cli.Context) {
}
// If the agreement URL is empty, the account still needs to accept the LE TOS.
if acc.Registration.Body.Agreement == "" {
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)
}
acc.Save()
break
}
logger().Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.")
}
handleTOS(c, client, acc)
}
if len(c.GlobalStringSlice("domains")) == 0 {
logger().Fatal("Please specify --domains or -d")
}
cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), true, nil)
cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil)
if len(failures) > 0 {
for k, v := range failures {
logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error())
@ -193,7 +229,7 @@ func run(c *cli.Context) {
err := checkFolder(conf.CertPath())
if err != nil {
logger().Fatalf("Cound not check/create path: %s", err.Error())
logger().Fatalf("Could not check/create path: %s", err.Error())
}
saveCertRes(cert, conf)
@ -205,7 +241,7 @@ func revoke(c *cli.Context) {
err := checkFolder(conf.CertPath())
if err != nil {
logger().Fatalf("Cound not check/create path: %s", err.Error())
logger().Fatalf("Could not check/create path: %s", err.Error())
}
for _, domain := range c.GlobalStringSlice("domains") {
@ -276,7 +312,7 @@ func renew(c *cli.Context) {
certRes.Certificate = certBytes
newCert, err := client.RenewCertificate(certRes, true)
newCert, err := client.RenewCertificate(certRes, !c.Bool("no-bundle"))
if err != nil {
logger().Fatalf("%s", err.Error())
}

View file

@ -1,6 +1,7 @@
package main
import (
"fmt"
"net/url"
"os"
"path"
@ -20,11 +21,25 @@ func NewConfiguration(c *cli.Context) *Configuration {
return &Configuration{context: c}
}
// RsaBits returns the current set RSA bit length for private keys
func (c *Configuration) RsaBits() int {
return c.context.GlobalInt("rsa-key-size")
// KeyType the type from which private keys should be generated
func (c *Configuration) KeyType() (acme.KeyType, error) {
switch strings.ToUpper(c.context.GlobalString("key-type")) {
case "RSA2048":
return acme.RSA2048, nil
case "RSA4096":
return acme.RSA4096, nil
case "RSA8192":
return acme.RSA8192, nil
case "EC256":
return acme.EC256, nil
case "EC384":
return acme.EC384, nil
}
return "", fmt.Errorf("Unsupported KeyType: %s", c.context.GlobalString("key-type"))
}
// ExcludedSolvers is a list of solvers that are to be excluded.
func (c *Configuration) ExcludedSolvers() (cc []acme.Challenge) {
for _, s := range c.context.GlobalStringSlice("exclude") {
cc = append(cc, acme.Challenge(s))
@ -39,6 +54,7 @@ func (c *Configuration) ServerPath() string {
return strings.Replace(srvStr, "/", string(os.PathSeparator), -1)
}
// CertPath gets the path for certificates.
func (c *Configuration) CertPath() string {
return path.Join(c.context.GlobalString("path"), "certificates")
}
@ -54,7 +70,7 @@ func (c *Configuration) AccountPath(acc string) string {
return path.Join(c.AccountsPath(), acc)
}
// AccountPath returns the OS dependent path to the keys of a particular account
// AccountKeysPath returns the OS dependent path to the keys of a particular account
func (c *Configuration) AccountKeysPath(acc string) string {
return path.Join(c.AccountPath(acc), "keys")
}

View file

@ -1,21 +1,30 @@
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"io/ioutil"
"os"
)
func generateRsaKey(length int, file string) (*rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, length)
func generatePrivateKey(file string) (crypto.PrivateKey, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, err
}
pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
keyBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return nil, err
}
pemKey := pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}
certOut, err := os.Create(file)
if err != nil {
@ -28,12 +37,20 @@ func generateRsaKey(length int, file string) (*rsa.PrivateKey, error) {
return privateKey, nil
}
func loadRsaKey(file string) (*rsa.PrivateKey, error) {
func loadPrivateKey(file string) (crypto.PrivateKey, error) {
keyBytes, err := ioutil.ReadFile(file)
if err != nil {
return nil, err
}
keyBlock, _ := pem.Decode(keyBytes)
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
switch keyBlock.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(keyBlock.Bytes)
}
return nil, errors.New("Unknown private key type.")
}

View file

@ -0,0 +1,212 @@
// Package cloudflare implements a DNS provider for solving the DNS-01 challenge using cloudflare DNS.
package cloudflare
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/xenolf/lego/acme"
)
// CloudFlareAPIURL represents the API endpoint to call.
// TODO: Unexport?
const CloudFlareAPIURL = "https://api.cloudflare.com/client/v4"
// DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct {
authEmail string
authKey string
}
// NewDNSProvider returns a DNSProvider instance with a configured cloudflare client.
// Credentials can either be passed as arguments or through CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY env vars.
func NewDNSProvider(cloudflareEmail, cloudflareKey string) (*DNSProvider, error) {
if cloudflareEmail == "" || cloudflareKey == "" {
cloudflareEmail, cloudflareKey = cloudflareEnvAuth()
if cloudflareEmail == "" || cloudflareKey == "" {
return nil, fmt.Errorf("CloudFlare credentials missing")
}
}
return &DNSProvider{
authEmail: cloudflareEmail,
authKey: cloudflareKey,
}, nil
}
// Present creates a TXT record to fulfil the dns-01 challenge
func (c *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
zoneID, err := c.getHostedZoneID(fqdn)
if err != nil {
return err
}
rec := cloudFlareRecord{
Type: "TXT",
Name: acme.UnFqdn(fqdn),
Content: value,
TTL: 120,
}
body, err := json.Marshal(rec)
if err != nil {
return err
}
_, err = c.makeRequest("POST", fmt.Sprintf("/zones/%s/dns_records", zoneID), bytes.NewReader(body))
if err != nil {
return err
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters
func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
record, err := c.findTxtRecord(fqdn)
if err != nil {
return err
}
_, err = c.makeRequest("DELETE", fmt.Sprintf("/zones/%s/dns_records/%s", record.ZoneID, record.ID), nil)
if err != nil {
return err
}
return nil
}
func (c *DNSProvider) getHostedZoneID(fqdn string) (string, error) {
// HostedZone represents a CloudFlare DNS zone
type HostedZone struct {
ID string `json:"id"`
Name string `json:"name"`
}
result, err := c.makeRequest("GET", "/zones?per_page=1000", nil)
if err != nil {
return "", err
}
var zones []HostedZone
err = json.Unmarshal(result, &zones)
if err != nil {
return "", err
}
var hostedZone HostedZone
for _, zone := range zones {
name := acme.ToFqdn(zone.Name)
if strings.HasSuffix(fqdn, name) {
if len(zone.Name) > len(hostedZone.Name) {
hostedZone = zone
}
}
}
if hostedZone.ID == "" {
return "", fmt.Errorf("No matching CloudFlare zone found for %s", fqdn)
}
return hostedZone.ID, nil
}
func (c *DNSProvider) findTxtRecord(fqdn string) (*cloudFlareRecord, error) {
zoneID, err := c.getHostedZoneID(fqdn)
if err != nil {
return nil, err
}
result, err := c.makeRequest("GET", fmt.Sprintf("/zones/%s/dns_records?per_page=1000", zoneID), nil)
if err != nil {
return nil, err
}
var records []cloudFlareRecord
err = json.Unmarshal(result, &records)
if err != nil {
return nil, err
}
for _, rec := range records {
if rec.Name == acme.UnFqdn(fqdn) && rec.Type == "TXT" {
return &rec, nil
}
}
return nil, fmt.Errorf("No existing record found for %s", fqdn)
}
func (c *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawMessage, error) {
// APIError contains error details for failed requests
type APIError struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// APIResponse represents a response from CloudFlare API
type APIResponse struct {
Success bool `json:"success"`
Errors []*APIError `json:"errors"`
Result json.RawMessage `json:"result"`
}
req, err := http.NewRequest(method, fmt.Sprintf("%s%s", CloudFlareAPIURL, uri), body)
if err != nil {
return nil, err
}
req.Header.Set("X-Auth-Email", c.authEmail)
req.Header.Set("X-Auth-Key", c.authKey)
//req.Header.Set("User-Agent", userAgent())
client := http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("Error querying API -> %v", err)
}
defer resp.Body.Close()
var r APIResponse
err = json.NewDecoder(resp.Body).Decode(&r)
if err != nil {
return nil, err
}
if !r.Success {
if len(r.Errors) > 0 {
return nil, fmt.Errorf("API error -> %d: %s", r.Errors[0].Code, r.Errors[0].Message)
}
return nil, fmt.Errorf("API error")
}
return r.Result, nil
}
func cloudflareEnvAuth() (email, apiKey string) {
email = os.Getenv("CLOUDFLARE_EMAIL")
apiKey = os.Getenv("CLOUDFLARE_API_KEY")
if len(email) == 0 || len(apiKey) == 0 {
return "", ""
}
return
}
// cloudFlareRecord represents a CloudFlare DNS record
type cloudFlareRecord struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
ID string `json:"id,omitempty"`
TTL int `json:"ttl,omitempty"`
ZoneID string `json:"zone_id,omitempty"`
}

View file

@ -1,4 +1,4 @@
package acme
package cloudflare
import (
"os"
@ -29,26 +29,26 @@ func restoreCloudFlareEnv() {
os.Setenv("CLOUDFLARE_API_KEY", cflareAPIKey)
}
func TestNewDNSProviderCloudFlareValid(t *testing.T) {
func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "")
os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProviderCloudFlare("123", "123")
_, err := NewDNSProvider("123", "123")
assert.NoError(t, err)
restoreCloudFlareEnv()
}
func TestNewDNSProviderCloudFlareValidEnv(t *testing.T) {
func TestNewDNSProviderValidEnv(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "test@example.com")
os.Setenv("CLOUDFLARE_API_KEY", "123")
_, err := NewDNSProviderCloudFlare("", "")
_, err := NewDNSProvider("", "")
assert.NoError(t, err)
restoreCloudFlareEnv()
}
func TestNewDNSProviderCloudFlareMissingCredErr(t *testing.T) {
func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "")
os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProviderCloudFlare("", "")
_, err := NewDNSProvider("", "")
assert.EqualError(t, err, "CloudFlare credentials missing")
restoreCloudFlareEnv()
}
@ -58,7 +58,7 @@ func TestCloudFlarePresent(t *testing.T) {
t.Skip("skipping live test")
}
provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey)
provider, err := NewDNSProvider(cflareEmail, cflareAPIKey)
assert.NoError(t, err)
err = provider.Present(cflareDomain, "", "123d==")
@ -70,9 +70,9 @@ func TestCloudFlareCleanUp(t *testing.T) {
t.Skip("skipping live test")
}
time.Sleep(time.Second * 1)
time.Sleep(time.Second * 2)
provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey)
provider, err := NewDNSProvider(cflareEmail, cflareAPIKey)
assert.NoError(t, err)
err = provider.CleanUp(cflareDomain, "", "123d==")

View file

@ -1,4 +1,5 @@
package acme
// Package digitalocean implements a DNS provider for solving the DNS-01 challenge using digitalocean DNS.
package digitalocean
import (
"bytes"
@ -6,28 +7,30 @@ import (
"fmt"
"net/http"
"sync"
"github.com/xenolf/lego/acme"
)
// DNSProviderDigitalOcean is an implementation of the DNSProvider interface
// DNSProvider is an implementation of the acme.ChallengeProvider interface
// that uses DigitalOcean's REST API to manage TXT records for a domain.
type DNSProviderDigitalOcean struct {
type DNSProvider struct {
apiAuthToken string
recordIDs map[string]int
recordIDsMu sync.Mutex
}
// NewDNSProviderDigitalOcean returns a new DNSProviderDigitalOcean instance.
// NewDNSProvider returns a new DNSProvider instance.
// apiAuthToken is the personal access token created in the DigitalOcean account
// control panel, and it will be sent in bearer authorization headers.
func NewDNSProviderDigitalOcean(apiAuthToken string) (*DNSProviderDigitalOcean, error) {
return &DNSProviderDigitalOcean{
func NewDNSProvider(apiAuthToken string) (*DNSProvider, error) {
return &DNSProvider{
apiAuthToken: apiAuthToken,
recordIDs: make(map[string]int),
}, nil
}
// Present creates a TXT record using the specified parameters
func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// txtRecordRequest represents the request body to DO's API to make a TXT record
type txtRecordRequest struct {
RecordType string `json:"type"`
@ -45,7 +48,7 @@ func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error {
} `json:"domain_record"`
}
fqdn, value, _ := DNS01Record(domain, keyAuth)
fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
reqURL := fmt.Sprintf("%s/v2/domains/%s/records", digitalOceanBaseURL, domain)
reqData := txtRecordRequest{RecordType: "TXT", Name: fqdn, Data: value}
@ -87,8 +90,8 @@ func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error {
}
// CleanUp removes the TXT record matching the specified parameters
func (d *DNSProviderDigitalOcean) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := DNS01Record(domain, keyAuth)
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
// get the record's unique ID from when we created it
d.recordIDsMu.Lock()

View file

@ -1,4 +1,4 @@
package acme
package digitalocean
import (
"fmt"
@ -53,7 +53,7 @@ func TestDigitalOceanPresent(t *testing.T) {
defer mock.Close()
digitalOceanBaseURL = mock.URL
doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth)
doprov, err := NewDNSProvider(fakeDigitalOceanAuth)
if doprov == nil {
t.Fatal("Expected non-nil DigitalOcean provider, but was nil")
}
@ -95,7 +95,7 @@ func TestDigitalOceanCleanUp(t *testing.T) {
defer mock.Close()
digitalOceanBaseURL = mock.URL
doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth)
doprov, err := NewDNSProvider(fakeDigitalOceanAuth)
if doprov == nil {
t.Fatal("Expected non-nil DigitalOcean provider, but was nil")
}

View file

@ -1,4 +1,5 @@
package acme
// Package dnsimple implements a DNS provider for solving the DNS-01 challenge using dnsimple DNS.
package dnsimple
import (
"fmt"
@ -6,34 +7,35 @@ import (
"strings"
"github.com/weppos/dnsimple-go/dnsimple"
"github.com/xenolf/lego/acme"
)
// DNSProviderDNSimple is an implementation of the DNSProvider interface.
type DNSProviderDNSimple struct {
// DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct {
client *dnsimple.Client
}
// NewDNSProviderDNSimple returns a DNSProviderDNSimple instance with a configured dnsimple client.
// NewDNSProvider returns a DNSProvider instance with a configured dnsimple client.
// Authentication is either done using the passed credentials or - when empty - using the environment
// variables DNSIMPLE_EMAIL and DNSIMPLE_API_KEY.
func NewDNSProviderDNSimple(dnsimpleEmail, dnsimpleApiKey string) (*DNSProviderDNSimple, error) {
if dnsimpleEmail == "" || dnsimpleApiKey == "" {
dnsimpleEmail, dnsimpleApiKey = dnsimpleEnvAuth()
if dnsimpleEmail == "" || dnsimpleApiKey == "" {
func NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey string) (*DNSProvider, error) {
if dnsimpleEmail == "" || dnsimpleAPIKey == "" {
dnsimpleEmail, dnsimpleAPIKey = dnsimpleEnvAuth()
if dnsimpleEmail == "" || dnsimpleAPIKey == "" {
return nil, fmt.Errorf("DNSimple credentials missing")
}
}
c := &DNSProviderDNSimple{
client: dnsimple.NewClient(dnsimpleApiKey, dnsimpleEmail),
c := &DNSProvider{
client: dnsimple.NewClient(dnsimpleAPIKey, dnsimpleEmail),
}
return c, nil
}
// Present creates a TXT record to fulfil the dns-01 challenge.
func (c *DNSProviderDNSimple) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth)
func (c *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
zoneID, zoneName, err := c.getHostedZone(domain)
if err != nil {
@ -50,8 +52,8 @@ func (c *DNSProviderDNSimple) Present(domain, token, keyAuth string) error {
}
// CleanUp removes the TXT record matching the specified parameters.
func (c *DNSProviderDNSimple) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := DNS01Record(domain, keyAuth)
func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
records, err := c.findTxtRecords(domain, fqdn)
if err != nil {
@ -67,7 +69,7 @@ func (c *DNSProviderDNSimple) CleanUp(domain, token, keyAuth string) error {
return nil
}
func (c *DNSProviderDNSimple) getHostedZone(domain string) (string, string, error) {
func (c *DNSProvider) getHostedZone(domain string) (string, string, error) {
domains, _, err := c.client.Domains.List()
if err != nil {
return "", "", fmt.Errorf("DNSimple API call failed: %v", err)
@ -88,7 +90,7 @@ func (c *DNSProviderDNSimple) getHostedZone(domain string) (string, string, erro
return fmt.Sprintf("%v", hostedDomain.Id), hostedDomain.Name, nil
}
func (c *DNSProviderDNSimple) findTxtRecords(domain, fqdn string) ([]dnsimple.Record, error) {
func (c *DNSProvider) findTxtRecords(domain, fqdn string) ([]dnsimple.Record, error) {
zoneID, zoneName, err := c.getHostedZone(domain)
if err != nil {
return nil, err
@ -110,7 +112,7 @@ func (c *DNSProviderDNSimple) findTxtRecords(domain, fqdn string) ([]dnsimple.Re
return records, nil
}
func (c *DNSProviderDNSimple) newTxtRecord(zone, fqdn, value string, ttl int) *dnsimple.Record {
func (c *DNSProvider) newTxtRecord(zone, fqdn, value string, ttl int) *dnsimple.Record {
name := c.extractRecordName(fqdn, zone)
return &dnsimple.Record{
@ -121,8 +123,8 @@ func (c *DNSProviderDNSimple) newTxtRecord(zone, fqdn, value string, ttl int) *d
}
}
func (c *DNSProviderDNSimple) extractRecordName(fqdn, domain string) string {
name := unFqdn(fqdn)
func (c *DNSProvider) extractRecordName(fqdn, domain string) string {
name := acme.UnFqdn(fqdn)
if idx := strings.Index(name, "."+domain); idx != -1 {
return name[:idx]
}

View file

@ -1,4 +1,4 @@
package acme
package dnsimple
import (
"os"
@ -29,25 +29,25 @@ func restoreDNSimpleEnv() {
os.Setenv("DNSIMPLE_API_KEY", dnsimpleAPIKey)
}
func TestNewDNSProviderDNSimpleValid(t *testing.T) {
func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("DNSIMPLE_EMAIL", "")
os.Setenv("DNSIMPLE_API_KEY", "")
_, err := NewDNSProviderDNSimple("example@example.com", "123")
_, err := NewDNSProvider("example@example.com", "123")
assert.NoError(t, err)
restoreDNSimpleEnv()
}
func TestNewDNSProviderDNSimpleValidEnv(t *testing.T) {
func TestNewDNSProviderValidEnv(t *testing.T) {
os.Setenv("DNSIMPLE_EMAIL", "example@example.com")
os.Setenv("DNSIMPLE_API_KEY", "123")
_, err := NewDNSProviderDNSimple("", "")
_, err := NewDNSProvider("", "")
assert.NoError(t, err)
restoreDNSimpleEnv()
}
func TestNewDNSProviderDNSimpleMissingCredErr(t *testing.T) {
func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("DNSIMPLE_EMAIL", "")
os.Setenv("DNSIMPLE_API_KEY", "")
_, err := NewDNSProviderDNSimple("", "")
_, err := NewDNSProvider("", "")
assert.EqualError(t, err, "DNSimple credentials missing")
restoreDNSimpleEnv()
}
@ -57,7 +57,7 @@ func TestLiveDNSimplePresent(t *testing.T) {
t.Skip("skipping live test")
}
provider, err := NewDNSProviderDNSimple(dnsimpleEmail, dnsimpleAPIKey)
provider, err := NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey)
assert.NoError(t, err)
err = provider.Present(dnsimpleDomain, "", "123d==")
@ -71,7 +71,7 @@ func TestLiveDNSimpleCleanUp(t *testing.T) {
time.Sleep(time.Second * 1)
provider, err := NewDNSProviderDNSimple(cflareEmail, cflareAPIKey)
provider, err := NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey)
assert.NoError(t, err)
err = provider.CleanUp(dnsimpleDomain, "", "123d==")

View file

@ -0,0 +1,108 @@
// Package rfc2136 implements a DNS provider for solving the DNS-01 challenge using the rfc2136 dynamic update.
package rfc2136
import (
"fmt"
"net"
"strings"
"time"
"github.com/miekg/dns"
"github.com/xenolf/lego/acme"
)
// DNSProvider is an implementation of the acme.ChallengeProvider interface that
// uses dynamic DNS updates (RFC 2136) to create TXT records on a nameserver.
type DNSProvider struct {
nameserver string
tsigAlgorithm string
tsigKey string
tsigSecret string
}
// NewDNSProvider returns a new DNSProvider instance.
// To disable TSIG authentication 'tsigAlgorithm, 'tsigKey' and 'tsigSecret' must be set to the empty string.
// 'nameserver' must be a network address in the the form "host" or "host:port".
func NewDNSProvider(nameserver, tsigAlgorithm, tsigKey, tsigSecret string) (*DNSProvider, error) {
// Append the default DNS port if none is specified.
if _, _, err := net.SplitHostPort(nameserver); err != nil {
if strings.Contains(err.Error(), "missing port") {
nameserver = net.JoinHostPort(nameserver, "53")
} else {
return nil, err
}
}
d := &DNSProvider{
nameserver: nameserver,
}
if tsigAlgorithm == "" {
tsigAlgorithm = dns.HmacMD5
}
d.tsigAlgorithm = tsigAlgorithm
if len(tsigKey) > 0 && len(tsigSecret) > 0 {
d.tsigKey = tsigKey
d.tsigSecret = tsigSecret
}
return d, nil
}
// Present creates a TXT record using the specified parameters
func (r *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
return r.changeRecord("INSERT", fqdn, value, ttl)
}
// CleanUp removes the TXT record matching the specified parameters
func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
return r.changeRecord("REMOVE", fqdn, value, ttl)
}
func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
// Find the zone for the given fqdn
zone, err := acme.FindZoneByFqdn(fqdn, r.nameserver)
if err != nil {
return err
}
// Create RR
rr := new(dns.TXT)
rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}
rr.Txt = []string{value}
rrs := []dns.RR{rr}
// Create dynamic update packet
m := new(dns.Msg)
m.SetUpdate(zone)
switch action {
case "INSERT":
// Always remove old challenge left over from who knows what.
m.RemoveRRset(rrs)
m.Insert(rrs)
case "REMOVE":
m.Remove(rrs)
default:
return fmt.Errorf("Unexpected action: %s", action)
}
// Setup client
c := new(dns.Client)
c.SingleInflight = true
// TSIG authentication / msg signing
if len(r.tsigKey) > 0 && len(r.tsigSecret) > 0 {
m.SetTsig(dns.Fqdn(r.tsigKey), r.tsigAlgorithm, 300, time.Now().Unix())
c.TsigSecret = map[string]string{dns.Fqdn(r.tsigKey): r.tsigSecret}
}
// Send the query
reply, _, err := c.Exchange(m, r.nameserver)
if err != nil {
return fmt.Errorf("DNS update failed: %v", err)
}
if reply != nil && reply.Rcode != dns.RcodeSuccess {
return fmt.Errorf("DNS update failed. Server replied: %s", dns.RcodeToString[reply.Rcode])
}
return nil
}

View file

@ -1,7 +1,8 @@
package acme
package rfc2136
import (
"bytes"
"fmt"
"net"
"strings"
"sync"
@ -9,6 +10,7 @@ import (
"time"
"github.com/miekg/dns"
"github.com/xenolf/lego/acme"
)
var (
@ -25,6 +27,7 @@ var (
var reqChan = make(chan *dns.Msg, 10)
func TestRFC2136CanaryLocalTestServer(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc("example.com.", serverHandlerHello)
defer dns.HandleRemove("example.com.")
@ -48,6 +51,7 @@ func TestRFC2136CanaryLocalTestServer(t *testing.T) {
}
func TestRFC2136ServerSuccess(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
defer dns.HandleRemove(rfc2136TestZone)
@ -57,9 +61,9 @@ func TestRFC2136ServerSuccess(t *testing.T) {
}
defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
provider, err := NewDNSProvider(addrstr, "", "", "")
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err)
}
if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil {
t.Errorf("Expected Present() to return no error but the error was -> %v", err)
@ -67,6 +71,7 @@ func TestRFC2136ServerSuccess(t *testing.T) {
}
func TestRFC2136ServerError(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnErr)
defer dns.HandleRemove(rfc2136TestZone)
@ -76,9 +81,9 @@ func TestRFC2136ServerError(t *testing.T) {
}
defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
provider, err := NewDNSProvider(addrstr, "", "", "")
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err)
}
if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err == nil {
t.Errorf("Expected Present() to return an error but it did not.")
@ -88,6 +93,7 @@ func TestRFC2136ServerError(t *testing.T) {
}
func TestRFC2136TsigClient(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
defer dns.HandleRemove(rfc2136TestZone)
@ -97,9 +103,9 @@ func TestRFC2136TsigClient(t *testing.T) {
}
defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, rfc2136TestTsigKey, rfc2136TestTsigSecret)
provider, err := NewDNSProvider(addrstr, "", rfc2136TestTsigKey, rfc2136TestTsigSecret)
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err)
}
if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil {
t.Errorf("Expected Present() to return no error but the error was -> %v", err)
@ -107,6 +113,7 @@ func TestRFC2136TsigClient(t *testing.T) {
}
func TestRFC2136ValidUpdatePacket(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerPassBackRequest)
defer dns.HandleRemove(rfc2136TestZone)
@ -116,18 +123,11 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) {
}
defer server.Shutdown()
rr := new(dns.TXT)
rr.Hdr = dns.RR_Header{
Name: rfc2136TestFqdn,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: uint32(rfc2136TestTTL),
}
rr.Txt = []string{rfc2136TestValue}
rrs := make([]dns.RR, 1)
rrs[0] = rr
txtRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN TXT %s", rfc2136TestFqdn, rfc2136TestTTL, rfc2136TestValue))
rrs := []dns.RR{txtRR}
m := new(dns.Msg)
m.SetUpdate(dns.Fqdn(rfc2136TestZone))
m.SetUpdate(rfc2136TestZone)
m.RemoveRRset(rrs)
m.Insert(rrs)
expectstr := m.String()
expect, err := m.Pack()
@ -135,9 +135,9 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) {
t.Fatalf("Error packing expect msg: %v", err)
}
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
provider, err := NewDNSProvider(addrstr, "", "", "")
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err)
}
if err := provider.Present(rfc2136TestDomain, "", "1234d=="); err != nil {
@ -198,6 +198,11 @@ func serverHandlerHello(w dns.ResponseWriter, req *dns.Msg) {
func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET {
// Return SOA to appease findZoneByFqdn()
soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", rfc2136TestZone, rfc2136TestTTL, rfc2136TestZone, rfc2136TestZone))
m.Answer = []dns.RR{soaRR}
}
if t := req.IsTsig(); t != nil {
if w.TsigStatus() == nil {
@ -218,6 +223,11 @@ func serverHandlerReturnErr(w dns.ResponseWriter, req *dns.Msg) {
func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET {
// Return SOA to appease findZoneByFqdn()
soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", rfc2136TestZone, rfc2136TestTTL, rfc2136TestZone, rfc2136TestZone))
m.Answer = []dns.RR{soaRR}
}
if t := req.IsTsig(); t != nil {
if w.TsigStatus() == nil {
@ -227,5 +237,8 @@ func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) {
}
w.WriteMsg(m)
reqChan <- req
if req.Opcode != dns.OpcodeQuery || req.Question[0].Qtype != dns.TypeSOA || req.Question[0].Qclass != dns.ClassINET {
// Only talk back when it is not the SOA RR.
reqChan <- req
}
}

View file

@ -1,4 +1,5 @@
package acme
// Package route53 implements a DNS provider for solving the DNS-01 challenge using route53 DNS.
package route53
import (
"fmt"
@ -7,20 +8,21 @@ import (
"github.com/mitchellh/goamz/aws"
"github.com/mitchellh/goamz/route53"
"github.com/xenolf/lego/acme"
)
// DNSProviderRoute53 is an implementation of the DNSProvider interface
type DNSProviderRoute53 struct {
// DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct {
client *route53.Route53
}
// NewDNSProviderRoute53 returns a DNSProviderRoute53 instance with a configured route53 client.
// NewDNSProvider returns a DNSProvider instance with a configured route53 client.
// Authentication is either done using the passed credentials or - when empty - falling back to
// the customary AWS credential mechanisms, including the file refernced by $AWS_CREDENTIAL_FILE
// the customary AWS credential mechanisms, including the file referenced by $AWS_CREDENTIAL_FILE
// (defaulting to $HOME/.aws/credentials) optionally scoped to $AWS_PROFILE, credentials
// supplied by the environment variables AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY [ + AWS_SECURITY_TOKEN ],
// and finally credentials available via the EC2 instance metadata service.
func NewDNSProviderRoute53(awsAccessKey, awsSecretKey, awsRegionName string) (*DNSProviderRoute53, error) {
func NewDNSProvider(awsAccessKey, awsSecretKey, awsRegionName string) (*DNSProvider, error) {
region, ok := aws.Regions[awsRegionName]
if !ok {
return nil, fmt.Errorf("Invalid AWS region name %s", awsRegionName)
@ -32,35 +34,36 @@ func NewDNSProviderRoute53(awsAccessKey, awsSecretKey, awsRegionName string) (*D
// - uses AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY and optionally AWS_SECURITY_TOKEN, if provided
// - uses EC2 instance metadata credentials (http://169.254.169.254/latest/meta-data/…), if available
// ...and otherwise returns an error
if auth, err := aws.GetAuth(awsAccessKey, awsSecretKey); err != nil {
auth, err := aws.GetAuth(awsAccessKey, awsSecretKey)
if err != nil {
return nil, err
} else {
client := route53.New(auth, region)
return &DNSProviderRoute53{client: client}, nil
}
client := route53.New(auth, region)
return &DNSProvider{client: client}, nil
}
// Present creates a TXT record using the specified parameters
func (r *DNSProviderRoute53) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth)
func (r *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"`
return r.changeRecord("UPSERT", fqdn, value, ttl)
}
// CleanUp removes the TXT record matching the specified parameters
func (r *DNSProviderRoute53) CleanUp(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth)
func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"`
return r.changeRecord("DELETE", fqdn, value, ttl)
}
func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) error {
func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
hostedZoneID, err := r.getHostedZoneID(fqdn)
if err != nil {
return err
}
recordSet := newTXTRecordSet(fqdn, value, ttl)
update := route53.Change{action, recordSet}
update := route53.Change{Action: action, Record: recordSet}
changes := []route53.Change{update}
req := route53.ChangeResourceRecordSetsRequest{Comment: "Created by Lego", Changes: changes}
resp, err := r.client.ChangeResourceRecordSets(hostedZoneID, &req)
@ -68,7 +71,7 @@ func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) e
return err
}
return waitFor(90, func() (bool, error) {
return acme.WaitFor(90*time.Second, 5*time.Second, func() (bool, error) {
status, err := r.client.GetChange(resp.ChangeInfo.ID)
if err != nil {
return false, err
@ -80,7 +83,7 @@ func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) e
})
}
func (r *DNSProviderRoute53) getHostedZoneID(fqdn string) (string, error) {
func (r *DNSProvider) getHostedZoneID(fqdn string) (string, error) {
zones := []route53.HostedZone{}
zoneResp, err := r.client.ListHostedZones("", 0)
if err != nil {

View file

@ -1,8 +1,10 @@
package acme
package route53
import (
"net/http"
"os"
"testing"
"time"
"github.com/mitchellh/goamz/aws"
"github.com/mitchellh/goamz/route53"
@ -63,9 +65,9 @@ var GetChangeAnswer = `<?xml version="1.0" encoding="UTF-8"?>
</GetChangeResponse>`
var serverResponseMap = testutil.ResponseMap{
"/2013-04-01/hostedzone/": testutil.Response{200, nil, ListHostedZonesAnswer},
"/2013-04-01/hostedzone/Z2K123214213123/rrset": testutil.Response{200, nil, ChangeResourceRecordSetsAnswer},
"/2013-04-01/change/asdf": testutil.Response{200, nil, GetChangeAnswer},
"/2013-04-01/hostedzone/": testutil.Response{Status: 200, Headers: nil, Body: ListHostedZonesAnswer},
"/2013-04-01/hostedzone/Z2K123214213123/rrset": testutil.Response{Status: 200, Headers: nil, Body: ChangeResourceRecordSetsAnswer},
"/2013-04-01/change/asdf": testutil.Response{Status: 200, Headers: nil, Body: GetChangeAnswer},
}
func init() {
@ -89,40 +91,49 @@ func makeRoute53TestServer() *testutil.HTTPServer {
return testServer
}
func makeRoute53Provider(server *testutil.HTTPServer) *DNSProviderRoute53 {
auth := aws.Auth{"abc", "123", ""}
func makeRoute53Provider(server *testutil.HTTPServer) *DNSProvider {
auth := aws.Auth{AccessKey: "abc", SecretKey: "123", Token: ""}
client := route53.NewWithClient(auth, aws.Region{Route53Endpoint: server.URL}, testutil.DefaultClient)
return &DNSProviderRoute53{client: client}
return &DNSProvider{client: client}
}
func TestNewDNSProviderRoute53Valid(t *testing.T) {
func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("AWS_ACCESS_KEY_ID", "")
os.Setenv("AWS_SECRET_ACCESS_KEY", "")
_, err := NewDNSProviderRoute53("123", "123", "us-east-1")
_, err := NewDNSProvider("123", "123", "us-east-1")
assert.NoError(t, err)
restoreRoute53Env()
}
func TestNewDNSProviderRoute53ValidEnv(t *testing.T) {
func TestNewDNSProviderValidEnv(t *testing.T) {
os.Setenv("AWS_ACCESS_KEY_ID", "123")
os.Setenv("AWS_SECRET_ACCESS_KEY", "123")
_, err := NewDNSProviderRoute53("", "", "us-east-1")
_, err := NewDNSProvider("", "", "us-east-1")
assert.NoError(t, err)
restoreRoute53Env()
}
func TestNewDNSProviderRoute53MissingAuthErr(t *testing.T) {
func TestNewDNSProviderMissingAuthErr(t *testing.T) {
os.Setenv("AWS_ACCESS_KEY_ID", "")
os.Setenv("AWS_SECRET_ACCESS_KEY", "")
os.Setenv("AWS_CREDENTIAL_FILE", "") // in case test machine has this variable set
os.Setenv("HOME", "/") // in case test machine has ~/.aws/credentials
_, err := NewDNSProviderRoute53("", "", "us-east-1")
// The default AWS HTTP client retries three times with a deadline of 10 seconds.
// Replace the default HTTP client with one that does not retry and has a low timeout.
awsClient := aws.RetryingClient
aws.RetryingClient = &http.Client{Timeout: time.Millisecond}
_, err := NewDNSProvider("", "", "us-east-1")
assert.EqualError(t, err, "No valid AWS authentication found")
restoreRoute53Env()
// restore default AWS HTTP client
aws.RetryingClient = awsClient
}
func TestNewDNSProviderRoute53InvalidRegionErr(t *testing.T) {
_, err := NewDNSProviderRoute53("123", "123", "us-east-3")
func TestNewDNSProviderInvalidRegionErr(t *testing.T) {
_, err := NewDNSProvider("123", "123", "us-east-3")
assert.EqualError(t, err, "Invalid AWS region name us-east-3")
}