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 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: ### 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 `--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 type for challenge identifiers `Challenge`
- lib: A new interface for custom challenge providers `ChallengeProvider` - 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. - 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 --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 --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 --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. Credentials for providers have to be passed through environment variables.
For a more detailed explanation of the parameters, please see the online docs. For a more detailed explanation of the parameters, please see the online docs.
Valid providers: Valid providers:
@ -99,7 +99,7 @@ GLOBAL OPTIONS:
digitalocean: DO_AUTH_TOKEN digitalocean: DO_AUTH_TOKEN
dnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY dnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY
route53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION 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 manual: none
--help, -h show help --help, -h show help
--version, -v print the version --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: Obtain a certificate using the DNS challenge and AWS Route 53:
```bash ```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: 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 ```bash

View file

@ -1,7 +1,7 @@
package main package main
import ( import (
"crypto/rsa" "crypto"
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"os" "os"
@ -13,7 +13,7 @@ import (
// Account represents a users local saved credentials // Account represents a users local saved credentials
type Account struct { type Account struct {
Email string `json:"email"` Email string `json:"email"`
key *rsa.PrivateKey key crypto.PrivateKey
Registration *acme.RegistrationResource `json:"registration"` Registration *acme.RegistrationResource `json:"registration"`
conf *Configuration 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) 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) { 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 { if err != nil {
logger().Fatalf("Could not generate RSA private account key for account %s: %v", email, err) logger().Fatalf("Could not generate RSA private account key for account %s: %v", email, err)
} }
logger().Printf("Saved key to %s", accKeyPath) logger().Printf("Saved key to %s", accKeyPath)
} else { } else {
privKey, err = loadRsaKey(accKeyPath) privKey, err = loadPrivateKey(accKeyPath)
if err != nil { if err != nil {
logger().Fatalf("Could not load RSA private key from file %s: %v", accKeyPath, err) 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. // GetPrivateKey returns the private RSA account key.
func (a *Account) GetPrivateKey() *rsa.PrivateKey { func (a *Account) GetPrivateKey() crypto.PrivateKey {
return a.key return a.key
} }

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package acme package acme
import ( import (
"crypto"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"encoding/json" "encoding/json"
@ -13,6 +14,7 @@ import (
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
keyBits := 32 // small value keeps test fast keyBits := 32 // small value keeps test fast
keyType := RSA2048
key, err := rsa.GenerateKey(rand.Reader, keyBits) key, err := rsa.GenerateKey(rand.Reader, keyBits)
if err != nil { if err != nil {
t.Fatal("Could not generate test key:", err) t.Fatal("Could not generate test key:", err)
@ -28,7 +30,7 @@ func TestNewClient(t *testing.T) {
w.Write(data) w.Write(data)
})) }))
client, err := NewClient(ts.URL, user, keyBits) client, err := NewClient(ts.URL, user, keyType)
if err != nil { if err != nil {
t.Fatalf("Could not create client: %v", err) 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) t.Errorf("Expected jws.privKey to be %p but was %p", expected, actual)
} }
if client.keyBits != keyBits { if client.keyType != keyType {
t.Errorf("Expected keyBits to be %d but was %d", keyBits, client.keyBits) t.Errorf("Expected keyType to be %s but was %s", keyType, client.keyType)
} }
if expected, actual := 2, len(client.solvers); actual != expected { if expected, actual := 2, len(client.solvers); actual != expected {
@ -68,7 +70,7 @@ func TestClientOptPort(t *testing.T) {
optPort := "1234" optPort := "1234"
optHost := "" optHost := ""
client, err := NewClient(ts.URL, user, keyBits) client, err := NewClient(ts.URL, user, RSA2048)
if err != nil { if err != nil {
t.Fatalf("Could not create client: %v", err) t.Fatalf("Could not create client: %v", err)
} }
@ -82,10 +84,10 @@ func TestClientOptPort(t *testing.T) {
if httpSolver.jws != client.jws { if httpSolver.jws != client.jws {
t.Error("Expected http-01 to have same jws as client") 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) 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) 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 { if httpsSolver.jws != client.jws {
t.Error("Expected tls-sni-01 to have same jws as client") 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) 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) 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.SetHTTPAddress(net.JoinHostPort(optHost, optPort))
client.SetTLSAddress(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) 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) 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() defer ts.Close()
privKey, _ := generatePrivateKey(rsakey, 512) privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} j := &jws{privKey: privKey, directoryURL: ts.URL}
tsts := []struct { tsts := []struct {
name string name string
@ -193,4 +195,4 @@ type mockUser struct {
func (u mockUser) GetEmail() string { return u.email } func (u mockUser) GetEmail() string { return u.email }
func (u mockUser) GetRegistration() *RegistrationResource { return u.regres } 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"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/base64" "encoding/base64"
"encoding/binary"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
@ -22,15 +21,19 @@ import (
"time" "time"
"golang.org/x/crypto/ocsp" "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 type derCertificateBytes []byte
// Constants for all key types we support.
const ( const (
eckey keyType = iota EC256 = KeyType("P256")
rsakey EC384 = KeyType("P384")
RSA2048 = KeyType("2048")
RSA4096 = KeyType("4096")
RSA8192 = KeyType("8192")
) )
const ( const (
@ -56,14 +59,22 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
return nil, nil, err 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 { if len(certificates) == 1 {
// TODO: build fallback. If this fails, check the remaining array entries. // 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") 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -83,17 +94,8 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
// We want it ordered right SRV CRT -> CA // We want it ordered right SRV CRT -> CA
certificates = append(certificates, issuerCert) 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] 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. // Finally kick off the OCSP request.
ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil) ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil)
if err != nil { if err != nil {
@ -124,8 +126,16 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) {
} }
func getKeyAuthorization(token string, key interface{}) (string, 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 // Generate the Key Authorization for the challenge
jwk := keyAsJWK(key) jwk := keyAsJWK(publicKey)
if jwk == nil { if jwk == nil {
return "", errors.New("Could not generate JWK from key.") 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 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 // 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. // a slice of x509 certificates. This function will error if no certificates are found.
func parsePEMBundle(bundle []byte) ([]*x509.Certificate, error) { 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) { func generatePrivateKey(keyType KeyType) (crypto.PrivateKey, error) {
switch t {
case eckey: switch keyType {
case EC256:
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case EC384:
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case rsakey: case RSA2048:
return rsa.GenerateKey(rand.Reader, keyLength) 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{ template := x509.CertificateRequest{
Subject: pkix.Name{ Subject: pkix.Name{
CommonName: domain, CommonName: domain,
@ -246,6 +230,9 @@ func generateCsr(privateKey *rsa.PrivateKey, domain string, san []string) ([]byt
func pemEncode(data interface{}) []byte { func pemEncode(data interface{}) []byte {
var pemBlock *pem.Block var pemBlock *pem.Block
switch key := data.(type) { switch key := data.(type) {
case *ecdsa.PrivateKey:
keyBytes, _ := x509.MarshalECPrivateKey(key)
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}
case *rsa.PrivateKey: case *rsa.PrivateKey:
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
break break

View file

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

View file

@ -6,17 +6,22 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"net"
"strings" "strings"
"time" "time"
"github.com/miekg/dns" "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 // DNS01Record returns a DNS record which will fulfill the `dns-01` challenge
func DNS01Record(domain, keyAuth string) (fqdn string, value string, ttl int) { 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 // 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 { if err != nil {
return err 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}) 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 { // checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers.
// check if the expected DNS entry was created. If not wait for some time and try again. func checkDNSPropagation(fqdn, value string) (bool, error) {
m := new(dns.Msg) // Initial attempt to resolve at the recursive NS
m.SetQuestion(domain+".", dns.TypeSOA) r, err := dnsQuery(fqdn, dns.TypeTXT, recursiveNameserver, true)
c := new(dns.Client)
in, _, err := c.Exchange(m, "google-public-dns-a.google.com:53")
if err != nil { 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 authoritativeNss, err := lookupNameservers(fqdn)
for _, answ := range in.Answer { if err != nil {
soa := answ.(*dns.SOA) return false, err
authorativeNS = soa.Ns
} }
fallbackCnt := 0 return checkAuthoritativeNss(fqdn, value, authoritativeNss)
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
} }
// toFqdn converts the name into a fqdn appending a trailing dot. // checkAuthoritativeNss queries each of the given nameservers for the expected TXT record.
func toFqdn(name string) string { 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) n := len(name)
if n == 0 || name[n-1] == '.' { if n == 0 || name[n-1] == '.' {
return name return name
@ -115,31 +248,11 @@ func toFqdn(name string) string {
return name + "." return name + "."
} }
// unFqdn converts the fqdn into a name removing the trailing dot. // UnFqdn converts the fqdn into a name removing the trailing dot.
func unFqdn(name string) string { func UnFqdn(name string) string {
n := len(name) n := len(name)
if n != 0 && name[n-1] == '.' { if n != 0 && name[n-1] == '.' {
return name[:n-1] return name[:n-1]
} }
return name 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 ( import (
"bufio" "bufio"
"crypto/rand"
"crypto/rsa" "crypto/rsa"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"reflect"
"sort"
"strings"
"testing" "testing"
"time" "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) { func TestDNSValidServerResponse(t *testing.T) {
preCheckDNS = func(domain, fqdn string) bool { preCheckDNS = func(fqdn, value string) (bool, error) {
return true 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) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Replay-Nonce", "12345") w.Header().Add("Replay-Nonce", "12345")
@ -22,7 +89,7 @@ func TestDNSValidServerResponse(t *testing.T) {
})) }))
manualProvider, _ := NewDNSProviderManual() 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} solver := &dnsChallenge{jws: jws, validate: validate, provider: manualProvider}
clientChallenge := challenge{Type: "dns01", Status: "pending", URI: ts.URL, Token: "http8"} 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) { 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") 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" "net/http"
"runtime" "runtime"
"strings" "strings"
"time"
) )
// UserAgent (if non-empty) will be tacked onto the User-Agent string in requests. // UserAgent (if non-empty) will be tacked onto the User-Agent string in requests.
var UserAgent string var UserAgent string
// defaultClient is an HTTP client with a reasonable timeout value.
var defaultClient = http.Client{Timeout: 10 * time.Second}
const ( const (
// defaultGoUserAgent is the Go HTTP package user agent string. Too // defaultGoUserAgent is the Go HTTP package user agent string. Too
// bad it isn't exported. If it changes, we should update it here, 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()) req.Header.Set("User-Agent", userAgent())
resp, err = http.DefaultClient.Do(req) resp, err = defaultClient.Do(req)
if err != nil { if err != nil {
return resp, err 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("Content-Type", bodyType)
req.Header.Set("User-Agent", userAgent()) 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. // 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()) 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 // 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) logf("[INFO][%s] acme: Trying to solve HTTP-01", domain)
// Generate the Key Authorization for the challenge // 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 { if err != nil {
return err return err
} }
if s.provider == nil {
s.provider = &httpChallengeServer{}
}
err = s.provider.Present(domain, chlng.Token, keyAuth) err = s.provider.Present(domain, chlng.Token, keyAuth)
if err != nil { if err != nil {
return fmt.Errorf("[%s] error presenting token: %v", domain, err) return fmt.Errorf("[%s] error presenting token: %v", domain, err)

View file

@ -7,16 +7,25 @@ import (
"strings" "strings"
) )
// httpChallengeServer implements ChallengeProvider for `http-01` challenge // HTTPProviderServer implements ChallengeProvider for `http-01` challenge
type httpChallengeServer struct { // It may be instantiated without using the NewHTTPProviderServer function if
// you want only to use the default values.
type HTTPProviderServer struct {
iface string iface string
port string port string
done chan bool done chan bool
listener net.Listener listener net.Listener
} }
// Present makes the token available at `HTTP01ChallengePath(token)` // NewHTTPProviderServer creates a new HTTPProviderServer on the selected interface and port.
func (s *httpChallengeServer) Present(domain, token, keyAuth string) error { // 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 == "" { if s.port == "" {
s.port = "80" s.port = "80"
} }
@ -32,7 +41,8 @@ func (s *httpChallengeServer) Present(domain, token, keyAuth string) error {
return nil 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 { if s.listener == nil {
return nil return nil
} }
@ -41,7 +51,7 @@ func (s *httpChallengeServer) CleanUp(domain, token, keyAuth string) error {
return nil return nil
} }
func (s *httpChallengeServer) serve(domain, token, keyAuth string) { func (s *HTTPProviderServer) serve(domain, token, keyAuth string) {
path := HTTP01ChallengePath(token) path := HTTP01ChallengePath(token)
// The handler validates the HOST header and request type. // The handler validates the HOST header and request type.

View file

@ -1,6 +1,7 @@
package acme package acme
import ( import (
"crypto/rand"
"crypto/rsa" "crypto/rsa"
"io/ioutil" "io/ioutil"
"os" "os"
@ -9,8 +10,8 @@ import (
) )
func TestHTTPChallenge(t *testing.T) { func TestHTTPChallenge(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 512) privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey.(*rsa.PrivateKey)} j := &jws{privKey: privKey}
clientChallenge := challenge{Type: HTTP01, Token: "http1"} clientChallenge := challenge{Type: HTTP01, Token: "http1"}
mockValidate := func(_ *jws, _, _ string, chlng challenge) error { mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token
@ -36,7 +37,7 @@ func TestHTTPChallenge(t *testing.T) {
return nil 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 { if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
t.Errorf("Solve error: got %v, want nil", err) t.Errorf("Solve error: got %v, want nil", err)
@ -44,10 +45,10 @@ func TestHTTPChallenge(t *testing.T) {
} }
func TestHTTPChallengeInvalidPort(t *testing.T) { func TestHTTPChallengeInvalidPort(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 128) privKey, _ := rsa.GenerateKey(rand.Reader, 128)
j := &jws{privKey: privKey.(*rsa.PrivateKey)} j := &jws{privKey: privKey}
clientChallenge := challenge{Type: HTTP01, Token: "http2"} 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 { if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil {
t.Errorf("Solve error: got %v, want error", err) t.Errorf("Solve error: got %v, want error", err)

View file

@ -2,7 +2,9 @@ package acme
import ( import (
"bytes" "bytes"
"crypto"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa" "crypto/rsa"
"fmt" "fmt"
"net/http" "net/http"
@ -12,7 +14,7 @@ import (
type jws struct { type jws struct {
directoryURL string directoryURL string
privKey *rsa.PrivateKey privKey crypto.PrivateKey
nonces []string 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) { 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -28,17 +28,13 @@ type registrationMessage struct {
// Registration is returned by the ACME server after the registration // Registration is returned by the ACME server after the registration
// The client implementation should save this registration somewhere. // The client implementation should save this registration somewhere.
type Registration struct { type Registration struct {
Resource string `json:"resource,omitempty"` Resource string `json:"resource,omitempty"`
ID int `json:"id"` ID int `json:"id"`
Key struct { Key jose.JsonWebKey `json:"key"`
Kty string `json:"kty"` Contact []string `json:"contact"`
N string `json:"n"` Agreement string `json:"agreement,omitempty"`
E string `json:"e"` Authorizations string `json:"authorizations,omitempty"`
} `json:"key"` Certificates string `json:"certificates,omitempty"`
Contact []string `json:"contact"`
Agreement string `json:"agreement,omitempty"`
Authorizations string `json:"authorizations,omitempty"`
Certificates string `json:"certificates,omitempty"`
// RecoveryKey recoveryKeyMessage `json:"recoveryKey,omitempty"` // RecoveryKey recoveryKeyMessage `json:"recoveryKey,omitempty"`
} }
@ -113,6 +109,7 @@ type CertificateResource struct {
Domain string `json:"domain"` Domain string `json:"domain"`
CertURL string `json:"certUrl"` CertURL string `json:"certUrl"`
CertStableURL string `json:"certStableUrl"` CertStableURL string `json:"certStableUrl"`
AccountRef string `json:"accountRef,omitempty"`
PrivateKey []byte `json:"-"` PrivateKey []byte `json:"-"`
Certificate []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) logf("[INFO][%s] acme: Trying to solve TLS-SNI-01", domain)
// Generate the Key Authorization for the challenge // 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 { if err != nil {
return err return err
} }
if t.provider == nil {
t.provider = &tlsSNIChallengeServer{}
}
err = t.provider.Present(domain, chlng.Token, keyAuth) err = t.provider.Present(domain, chlng.Token, keyAuth)
if err != nil { if err != nil {
return fmt.Errorf("[%s] error presenting token: %v", domain, err) 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 // TLSSNI01ChallengeCert returns a certificate for the `tls-sni-01` challenge
func TLSSNI01ChallengeCert(keyAuth string) (tls.Certificate, error) { func TLSSNI01ChallengeCert(keyAuth string) (tls.Certificate, error) {
// generate a new RSA key for the certificates // generate a new RSA key for the certificates
tempPrivKey, err := generatePrivateKey(rsakey, 2048) tempPrivKey, err := generatePrivateKey(RSA2048)
if err != nil { if err != nil {
return tls.Certificate{}, err return tls.Certificate{}, err
} }

View file

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

View file

@ -1,6 +1,7 @@
package acme package acme
import ( import (
"crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
@ -11,8 +12,8 @@ import (
) )
func TestTLSSNIChallenge(t *testing.T) { func TestTLSSNIChallenge(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 512) privKey, _ := rsa.GenerateKey(rand.Reader, 512)
j := &jws{privKey: privKey.(*rsa.PrivateKey)} j := &jws{privKey: privKey}
clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni1"} clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni1"}
mockValidate := func(_ *jws, _, _ string, chlng challenge) error { mockValidate := func(_ *jws, _, _ string, chlng challenge) error {
conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{ conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{
@ -43,7 +44,7 @@ func TestTLSSNIChallenge(t *testing.T) {
return nil 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 { if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil {
t.Errorf("Solve error: got %v, want nil", err) t.Errorf("Solve error: got %v, want nil", err)
@ -51,10 +52,10 @@ func TestTLSSNIChallenge(t *testing.T) {
} }
func TestTLSSNIChallengeInvalidPort(t *testing.T) { func TestTLSSNIChallengeInvalidPort(t *testing.T) {
privKey, _ := generatePrivateKey(rsakey, 128) privKey, _ := rsa.GenerateKey(rand.Reader, 128)
j := &jws{privKey: privKey.(*rsa.PrivateKey)} j := &jws{privKey: privKey}
clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni2"} 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 { if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil {
t.Errorf("Solve error: got %v, want error", err) 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", Name: "run",
Usage: "Register an account, then create and install a certificate", Usage: "Register an account, then create and install a certificate",
Action: run, 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", Name: "revoke",
@ -70,6 +76,10 @@ func main() {
Name: "reuse-key", Name: "reuse-key",
Usage: "Used to indicate you want to reuse your current private key for the new certificate.", 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", Name: "email, m",
Usage: "Email used for registration and recovery contact.", Usage: "Email used for registration and recovery contact.",
}, },
cli.IntFlag{ cli.BoolFlag{
Name: "rsa-key-size, B", Name: "accept-tos, a",
Value: 2048, Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.",
Usage: "Size of the RSA key.", },
cli.StringFlag{
Name: "key-type, k",
Value: "rsa2048",
Usage: "Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384",
}, },
cli.StringFlag{ cli.StringFlag{
Name: "path", Name: "path",
@ -116,7 +130,7 @@ func main() {
}, },
cli.StringFlag{ cli.StringFlag{
Name: "dns", 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\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\tFor a more detailed explanation of the parameters, please see the online docs." +
"\n\tValid providers:" + "\n\tValid providers:" +
@ -124,7 +138,7 @@ func main() {
"\n\tdigitalocean: DO_AUTH_TOKEN" + "\n\tdigitalocean: DO_AUTH_TOKEN" +
"\n\tdnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY" + "\n\tdnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY" +
"\n\troute53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION" + "\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", "\n\tmanual: none",
}, },
} }

View file

@ -11,6 +11,11 @@ import (
"github.com/codegangsta/cli" "github.com/codegangsta/cli"
"github.com/xenolf/lego/acme" "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 { func checkFolder(path string) error {
@ -23,7 +28,7 @@ func checkFolder(path string) error {
func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
err := checkFolder(c.GlobalString("path")) err := checkFolder(c.GlobalString("path"))
if err != nil { 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) 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. //TODO: move to account struct? Currently MUST pass email.
acc := NewAccount(c.GlobalString("email"), conf) 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 { if err != nil {
logger().Fatalf("Could not create client: %s", err.Error()) 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) client.SetChallengeProvider(acme.HTTP01, provider)
} }
if c.GlobalIsSet("http") { 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")) client.SetHTTPAddress(c.GlobalString("http"))
} }
if c.GlobalIsSet("tls") { 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")) client.SetTLSAddress(c.GlobalString("tls"))
} }
@ -64,23 +80,23 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
var provider acme.ChallengeProvider var provider acme.ChallengeProvider
switch c.GlobalString("dns") { switch c.GlobalString("dns") {
case "cloudflare": case "cloudflare":
provider, err = acme.NewDNSProviderCloudFlare("", "") provider, err = cloudflare.NewDNSProvider("", "")
case "digitalocean": case "digitalocean":
authToken := os.Getenv("DO_AUTH_TOKEN") authToken := os.Getenv("DO_AUTH_TOKEN")
provider, err = acme.NewDNSProviderDigitalOcean(authToken) provider, err = digitalocean.NewDNSProvider(authToken)
case "dnsimple": case "dnsimple":
provider, err = acme.NewDNSProviderDNSimple("", "") provider, err = dnsimple.NewDNSProvider("", "")
case "route53": case "route53":
awsRegion := os.Getenv("AWS_REGION") awsRegion := os.Getenv("AWS_REGION")
provider, err = acme.NewDNSProviderRoute53("", "", awsRegion) provider, err = route53.NewDNSProvider("", "", awsRegion)
case "rfc2136": case "rfc2136":
nameserver := os.Getenv("RFC2136_NAMESERVER") nameserver := os.Getenv("RFC2136_NAMESERVER")
zone := os.Getenv("RFC2136_ZONE") tsigAlgorithm := os.Getenv("RFC2136_TSIG_ALGORITHM")
tsigKey := os.Getenv("RFC2136_TSIG_KEY") tsigKey := os.Getenv("RFC2136_TSIG_KEY")
tsigSecret := os.Getenv("RFC2136_TSIG_SECRET") tsigSecret := os.Getenv("RFC2136_TSIG_SECRET")
provider, err = acme.NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret) provider, err = rfc2136.NewDNSProvider(nameserver, tsigAlgorithm, tsigKey, tsigSecret)
case "manual": case "manual":
provider, err = acme.NewDNSProviderManual() provider, err = acme.NewDNSProviderManual()
} }
@ -90,6 +106,10 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) {
} }
client.SetChallengeProvider(acme.DNS01, provider) 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 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) { func run(c *cli.Context) {
conf, acc, client := setup(c) conf, acc, client := setup(c)
if acc.Registration == nil { 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 == "" { if acc.Registration.Body.Agreement == "" {
reader := bufio.NewReader(os.Stdin) handleTOS(c, client, acc)
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.")
}
} }
if len(c.GlobalStringSlice("domains")) == 0 { if len(c.GlobalStringSlice("domains")) == 0 {
logger().Fatal("Please specify --domains or -d") 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 { if len(failures) > 0 {
for k, v := range failures { for k, v := range failures {
logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error()) 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()) err := checkFolder(conf.CertPath())
if err != nil { 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) saveCertRes(cert, conf)
@ -205,7 +241,7 @@ func revoke(c *cli.Context) {
err := checkFolder(conf.CertPath()) err := checkFolder(conf.CertPath())
if err != nil { 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") { for _, domain := range c.GlobalStringSlice("domains") {
@ -276,7 +312,7 @@ func renew(c *cli.Context) {
certRes.Certificate = certBytes certRes.Certificate = certBytes
newCert, err := client.RenewCertificate(certRes, true) newCert, err := client.RenewCertificate(certRes, !c.Bool("no-bundle"))
if err != nil { if err != nil {
logger().Fatalf("%s", err.Error()) logger().Fatalf("%s", err.Error())
} }

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"net/url" "net/url"
"os" "os"
"path" "path"
@ -20,11 +21,25 @@ func NewConfiguration(c *cli.Context) *Configuration {
return &Configuration{context: c} return &Configuration{context: c}
} }
// RsaBits returns the current set RSA bit length for private keys // KeyType the type from which private keys should be generated
func (c *Configuration) RsaBits() int { func (c *Configuration) KeyType() (acme.KeyType, error) {
return c.context.GlobalInt("rsa-key-size") 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) { func (c *Configuration) ExcludedSolvers() (cc []acme.Challenge) {
for _, s := range c.context.GlobalStringSlice("exclude") { for _, s := range c.context.GlobalStringSlice("exclude") {
cc = append(cc, acme.Challenge(s)) cc = append(cc, acme.Challenge(s))
@ -39,6 +54,7 @@ func (c *Configuration) ServerPath() string {
return strings.Replace(srvStr, "/", string(os.PathSeparator), -1) return strings.Replace(srvStr, "/", string(os.PathSeparator), -1)
} }
// CertPath gets the path for certificates.
func (c *Configuration) CertPath() string { func (c *Configuration) CertPath() string {
return path.Join(c.context.GlobalString("path"), "certificates") 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) 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 { func (c *Configuration) AccountKeysPath(acc string) string {
return path.Join(c.AccountPath(acc), "keys") return path.Join(c.AccountPath(acc), "keys")
} }

View file

@ -1,21 +1,30 @@
package main package main
import ( import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors"
"io/ioutil" "io/ioutil"
"os" "os"
) )
func generateRsaKey(length int, file string) (*rsa.PrivateKey, error) { func generatePrivateKey(file string) (crypto.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, length)
privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil { if err != nil {
return nil, err 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) certOut, err := os.Create(file)
if err != nil { if err != nil {
@ -28,12 +37,20 @@ func generateRsaKey(length int, file string) (*rsa.PrivateKey, error) {
return privateKey, nil return privateKey, nil
} }
func loadRsaKey(file string) (*rsa.PrivateKey, error) { func loadPrivateKey(file string) (crypto.PrivateKey, error) {
keyBytes, err := ioutil.ReadFile(file) keyBytes, err := ioutil.ReadFile(file)
if err != nil { if err != nil {
return nil, err return nil, err
} }
keyBlock, _ := pem.Decode(keyBytes) 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 ( import (
"os" "os"
@ -29,26 +29,26 @@ func restoreCloudFlareEnv() {
os.Setenv("CLOUDFLARE_API_KEY", cflareAPIKey) os.Setenv("CLOUDFLARE_API_KEY", cflareAPIKey)
} }
func TestNewDNSProviderCloudFlareValid(t *testing.T) { func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "") os.Setenv("CLOUDFLARE_EMAIL", "")
os.Setenv("CLOUDFLARE_API_KEY", "") os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProviderCloudFlare("123", "123") _, err := NewDNSProvider("123", "123")
assert.NoError(t, err) assert.NoError(t, err)
restoreCloudFlareEnv() restoreCloudFlareEnv()
} }
func TestNewDNSProviderCloudFlareValidEnv(t *testing.T) { func TestNewDNSProviderValidEnv(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "test@example.com") os.Setenv("CLOUDFLARE_EMAIL", "test@example.com")
os.Setenv("CLOUDFLARE_API_KEY", "123") os.Setenv("CLOUDFLARE_API_KEY", "123")
_, err := NewDNSProviderCloudFlare("", "") _, err := NewDNSProvider("", "")
assert.NoError(t, err) assert.NoError(t, err)
restoreCloudFlareEnv() restoreCloudFlareEnv()
} }
func TestNewDNSProviderCloudFlareMissingCredErr(t *testing.T) { func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "") os.Setenv("CLOUDFLARE_EMAIL", "")
os.Setenv("CLOUDFLARE_API_KEY", "") os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProviderCloudFlare("", "") _, err := NewDNSProvider("", "")
assert.EqualError(t, err, "CloudFlare credentials missing") assert.EqualError(t, err, "CloudFlare credentials missing")
restoreCloudFlareEnv() restoreCloudFlareEnv()
} }
@ -58,7 +58,7 @@ func TestCloudFlarePresent(t *testing.T) {
t.Skip("skipping live test") t.Skip("skipping live test")
} }
provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey) provider, err := NewDNSProvider(cflareEmail, cflareAPIKey)
assert.NoError(t, err) assert.NoError(t, err)
err = provider.Present(cflareDomain, "", "123d==") err = provider.Present(cflareDomain, "", "123d==")
@ -70,9 +70,9 @@ func TestCloudFlareCleanUp(t *testing.T) {
t.Skip("skipping live test") 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) assert.NoError(t, err)
err = provider.CleanUp(cflareDomain, "", "123d==") 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 ( import (
"bytes" "bytes"
@ -6,28 +7,30 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"sync" "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. // that uses DigitalOcean's REST API to manage TXT records for a domain.
type DNSProviderDigitalOcean struct { type DNSProvider struct {
apiAuthToken string apiAuthToken string
recordIDs map[string]int recordIDs map[string]int
recordIDsMu sync.Mutex 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 // apiAuthToken is the personal access token created in the DigitalOcean account
// control panel, and it will be sent in bearer authorization headers. // control panel, and it will be sent in bearer authorization headers.
func NewDNSProviderDigitalOcean(apiAuthToken string) (*DNSProviderDigitalOcean, error) { func NewDNSProvider(apiAuthToken string) (*DNSProvider, error) {
return &DNSProviderDigitalOcean{ return &DNSProvider{
apiAuthToken: apiAuthToken, apiAuthToken: apiAuthToken,
recordIDs: make(map[string]int), recordIDs: make(map[string]int),
}, nil }, nil
} }
// Present creates a TXT record using the specified parameters // 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 // txtRecordRequest represents the request body to DO's API to make a TXT record
type txtRecordRequest struct { type txtRecordRequest struct {
RecordType string `json:"type"` RecordType string `json:"type"`
@ -45,7 +48,7 @@ func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error {
} `json:"domain_record"` } `json:"domain_record"`
} }
fqdn, value, _ := DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
reqURL := fmt.Sprintf("%s/v2/domains/%s/records", digitalOceanBaseURL, domain) reqURL := fmt.Sprintf("%s/v2/domains/%s/records", digitalOceanBaseURL, domain)
reqData := txtRecordRequest{RecordType: "TXT", Name: fqdn, Data: value} 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 // CleanUp removes the TXT record matching the specified parameters
func (d *DNSProviderDigitalOcean) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := DNS01Record(domain, keyAuth) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
// get the record's unique ID from when we created it // get the record's unique ID from when we created it
d.recordIDsMu.Lock() d.recordIDsMu.Lock()

View file

@ -1,4 +1,4 @@
package acme package digitalocean
import ( import (
"fmt" "fmt"
@ -53,7 +53,7 @@ func TestDigitalOceanPresent(t *testing.T) {
defer mock.Close() defer mock.Close()
digitalOceanBaseURL = mock.URL digitalOceanBaseURL = mock.URL
doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth) doprov, err := NewDNSProvider(fakeDigitalOceanAuth)
if doprov == nil { if doprov == nil {
t.Fatal("Expected non-nil DigitalOcean provider, but was nil") t.Fatal("Expected non-nil DigitalOcean provider, but was nil")
} }
@ -95,7 +95,7 @@ func TestDigitalOceanCleanUp(t *testing.T) {
defer mock.Close() defer mock.Close()
digitalOceanBaseURL = mock.URL digitalOceanBaseURL = mock.URL
doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth) doprov, err := NewDNSProvider(fakeDigitalOceanAuth)
if doprov == nil { if doprov == nil {
t.Fatal("Expected non-nil DigitalOcean provider, but was 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 ( import (
"fmt" "fmt"
@ -6,34 +7,35 @@ import (
"strings" "strings"
"github.com/weppos/dnsimple-go/dnsimple" "github.com/weppos/dnsimple-go/dnsimple"
"github.com/xenolf/lego/acme"
) )
// DNSProviderDNSimple is an implementation of the DNSProvider interface. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProviderDNSimple struct { type DNSProvider struct {
client *dnsimple.Client 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 // Authentication is either done using the passed credentials or - when empty - using the environment
// variables DNSIMPLE_EMAIL and DNSIMPLE_API_KEY. // variables DNSIMPLE_EMAIL and DNSIMPLE_API_KEY.
func NewDNSProviderDNSimple(dnsimpleEmail, dnsimpleApiKey string) (*DNSProviderDNSimple, error) { func NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey string) (*DNSProvider, error) {
if dnsimpleEmail == "" || dnsimpleApiKey == "" { if dnsimpleEmail == "" || dnsimpleAPIKey == "" {
dnsimpleEmail, dnsimpleApiKey = dnsimpleEnvAuth() dnsimpleEmail, dnsimpleAPIKey = dnsimpleEnvAuth()
if dnsimpleEmail == "" || dnsimpleApiKey == "" { if dnsimpleEmail == "" || dnsimpleAPIKey == "" {
return nil, fmt.Errorf("DNSimple credentials missing") return nil, fmt.Errorf("DNSimple credentials missing")
} }
} }
c := &DNSProviderDNSimple{ c := &DNSProvider{
client: dnsimple.NewClient(dnsimpleApiKey, dnsimpleEmail), client: dnsimple.NewClient(dnsimpleAPIKey, dnsimpleEmail),
} }
return c, nil return c, nil
} }
// Present creates a TXT record to fulfil the dns-01 challenge. // Present creates a TXT record to fulfil the dns-01 challenge.
func (c *DNSProviderDNSimple) Present(domain, token, keyAuth string) error { func (c *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth) fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
zoneID, zoneName, err := c.getHostedZone(domain) zoneID, zoneName, err := c.getHostedZone(domain)
if err != nil { 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. // CleanUp removes the TXT record matching the specified parameters.
func (c *DNSProviderDNSimple) CleanUp(domain, token, keyAuth string) error { func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := DNS01Record(domain, keyAuth) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
records, err := c.findTxtRecords(domain, fqdn) records, err := c.findTxtRecords(domain, fqdn)
if err != nil { if err != nil {
@ -67,7 +69,7 @@ func (c *DNSProviderDNSimple) CleanUp(domain, token, keyAuth string) error {
return nil 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() domains, _, err := c.client.Domains.List()
if err != nil { if err != nil {
return "", "", fmt.Errorf("DNSimple API call failed: %v", err) 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 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) zoneID, zoneName, err := c.getHostedZone(domain)
if err != nil { if err != nil {
return nil, err return nil, err
@ -110,7 +112,7 @@ func (c *DNSProviderDNSimple) findTxtRecords(domain, fqdn string) ([]dnsimple.Re
return records, nil 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) name := c.extractRecordName(fqdn, zone)
return &dnsimple.Record{ 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 { func (c *DNSProvider) extractRecordName(fqdn, domain string) string {
name := unFqdn(fqdn) name := acme.UnFqdn(fqdn)
if idx := strings.Index(name, "."+domain); idx != -1 { if idx := strings.Index(name, "."+domain); idx != -1 {
return name[:idx] return name[:idx]
} }

View file

@ -1,4 +1,4 @@
package acme package dnsimple
import ( import (
"os" "os"
@ -29,25 +29,25 @@ func restoreDNSimpleEnv() {
os.Setenv("DNSIMPLE_API_KEY", dnsimpleAPIKey) os.Setenv("DNSIMPLE_API_KEY", dnsimpleAPIKey)
} }
func TestNewDNSProviderDNSimpleValid(t *testing.T) { func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("DNSIMPLE_EMAIL", "") os.Setenv("DNSIMPLE_EMAIL", "")
os.Setenv("DNSIMPLE_API_KEY", "") os.Setenv("DNSIMPLE_API_KEY", "")
_, err := NewDNSProviderDNSimple("example@example.com", "123") _, err := NewDNSProvider("example@example.com", "123")
assert.NoError(t, err) assert.NoError(t, err)
restoreDNSimpleEnv() restoreDNSimpleEnv()
} }
func TestNewDNSProviderDNSimpleValidEnv(t *testing.T) { func TestNewDNSProviderValidEnv(t *testing.T) {
os.Setenv("DNSIMPLE_EMAIL", "example@example.com") os.Setenv("DNSIMPLE_EMAIL", "example@example.com")
os.Setenv("DNSIMPLE_API_KEY", "123") os.Setenv("DNSIMPLE_API_KEY", "123")
_, err := NewDNSProviderDNSimple("", "") _, err := NewDNSProvider("", "")
assert.NoError(t, err) assert.NoError(t, err)
restoreDNSimpleEnv() restoreDNSimpleEnv()
} }
func TestNewDNSProviderDNSimpleMissingCredErr(t *testing.T) { func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("DNSIMPLE_EMAIL", "") os.Setenv("DNSIMPLE_EMAIL", "")
os.Setenv("DNSIMPLE_API_KEY", "") os.Setenv("DNSIMPLE_API_KEY", "")
_, err := NewDNSProviderDNSimple("", "") _, err := NewDNSProvider("", "")
assert.EqualError(t, err, "DNSimple credentials missing") assert.EqualError(t, err, "DNSimple credentials missing")
restoreDNSimpleEnv() restoreDNSimpleEnv()
} }
@ -57,7 +57,7 @@ func TestLiveDNSimplePresent(t *testing.T) {
t.Skip("skipping live test") t.Skip("skipping live test")
} }
provider, err := NewDNSProviderDNSimple(dnsimpleEmail, dnsimpleAPIKey) provider, err := NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey)
assert.NoError(t, err) assert.NoError(t, err)
err = provider.Present(dnsimpleDomain, "", "123d==") err = provider.Present(dnsimpleDomain, "", "123d==")
@ -71,7 +71,7 @@ func TestLiveDNSimpleCleanUp(t *testing.T) {
time.Sleep(time.Second * 1) time.Sleep(time.Second * 1)
provider, err := NewDNSProviderDNSimple(cflareEmail, cflareAPIKey) provider, err := NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey)
assert.NoError(t, err) assert.NoError(t, err)
err = provider.CleanUp(dnsimpleDomain, "", "123d==") 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 ( import (
"bytes" "bytes"
"fmt"
"net" "net"
"strings" "strings"
"sync" "sync"
@ -9,6 +10,7 @@ import (
"time" "time"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/xenolf/lego/acme"
) )
var ( var (
@ -25,6 +27,7 @@ var (
var reqChan = make(chan *dns.Msg, 10) var reqChan = make(chan *dns.Msg, 10)
func TestRFC2136CanaryLocalTestServer(t *testing.T) { func TestRFC2136CanaryLocalTestServer(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc("example.com.", serverHandlerHello) dns.HandleFunc("example.com.", serverHandlerHello)
defer dns.HandleRemove("example.com.") defer dns.HandleRemove("example.com.")
@ -48,6 +51,7 @@ func TestRFC2136CanaryLocalTestServer(t *testing.T) {
} }
func TestRFC2136ServerSuccess(t *testing.T) { func TestRFC2136ServerSuccess(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess) dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
defer dns.HandleRemove(rfc2136TestZone) defer dns.HandleRemove(rfc2136TestZone)
@ -57,9 +61,9 @@ func TestRFC2136ServerSuccess(t *testing.T) {
} }
defer server.Shutdown() defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "") provider, err := NewDNSProvider(addrstr, "", "", "")
if err != nil { 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 { if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil {
t.Errorf("Expected Present() to return no error but the error was -> %v", err) 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) { func TestRFC2136ServerError(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnErr) dns.HandleFunc(rfc2136TestZone, serverHandlerReturnErr)
defer dns.HandleRemove(rfc2136TestZone) defer dns.HandleRemove(rfc2136TestZone)
@ -76,9 +81,9 @@ func TestRFC2136ServerError(t *testing.T) {
} }
defer server.Shutdown() defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "") provider, err := NewDNSProvider(addrstr, "", "", "")
if err != nil { 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 { if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err == nil {
t.Errorf("Expected Present() to return an error but it did not.") 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) { func TestRFC2136TsigClient(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess) dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
defer dns.HandleRemove(rfc2136TestZone) defer dns.HandleRemove(rfc2136TestZone)
@ -97,9 +103,9 @@ func TestRFC2136TsigClient(t *testing.T) {
} }
defer server.Shutdown() defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, rfc2136TestTsigKey, rfc2136TestTsigSecret) provider, err := NewDNSProvider(addrstr, "", rfc2136TestTsigKey, rfc2136TestTsigSecret)
if err != nil { 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 { if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil {
t.Errorf("Expected Present() to return no error but the error was -> %v", err) 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) { func TestRFC2136ValidUpdatePacket(t *testing.T) {
acme.ClearFqdnCache()
dns.HandleFunc(rfc2136TestZone, serverHandlerPassBackRequest) dns.HandleFunc(rfc2136TestZone, serverHandlerPassBackRequest)
defer dns.HandleRemove(rfc2136TestZone) defer dns.HandleRemove(rfc2136TestZone)
@ -116,18 +123,11 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) {
} }
defer server.Shutdown() defer server.Shutdown()
rr := new(dns.TXT) txtRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN TXT %s", rfc2136TestFqdn, rfc2136TestTTL, rfc2136TestValue))
rr.Hdr = dns.RR_Header{ rrs := []dns.RR{txtRR}
Name: rfc2136TestFqdn,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: uint32(rfc2136TestTTL),
}
rr.Txt = []string{rfc2136TestValue}
rrs := make([]dns.RR, 1)
rrs[0] = rr
m := new(dns.Msg) m := new(dns.Msg)
m.SetUpdate(dns.Fqdn(rfc2136TestZone)) m.SetUpdate(rfc2136TestZone)
m.RemoveRRset(rrs)
m.Insert(rrs) m.Insert(rrs)
expectstr := m.String() expectstr := m.String()
expect, err := m.Pack() expect, err := m.Pack()
@ -135,9 +135,9 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) {
t.Fatalf("Error packing expect msg: %v", err) t.Fatalf("Error packing expect msg: %v", err)
} }
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "") provider, err := NewDNSProvider(addrstr, "", "", "")
if err != nil { 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 { 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) { func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg) m := new(dns.Msg)
m.SetReply(req) 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 t := req.IsTsig(); t != nil {
if w.TsigStatus() == 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) { func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg) m := new(dns.Msg)
m.SetReply(req) 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 t := req.IsTsig(); t != nil {
if w.TsigStatus() == nil { if w.TsigStatus() == nil {
@ -227,5 +237,8 @@ func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) {
} }
w.WriteMsg(m) 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 ( import (
"fmt" "fmt"
@ -7,20 +8,21 @@ import (
"github.com/mitchellh/goamz/aws" "github.com/mitchellh/goamz/aws"
"github.com/mitchellh/goamz/route53" "github.com/mitchellh/goamz/route53"
"github.com/xenolf/lego/acme"
) )
// DNSProviderRoute53 is an implementation of the DNSProvider interface // DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProviderRoute53 struct { type DNSProvider struct {
client *route53.Route53 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 // 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 // (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 ], // 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. // 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] region, ok := aws.Regions[awsRegionName]
if !ok { if !ok {
return nil, fmt.Errorf("Invalid AWS region name %s", awsRegionName) 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 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 // - uses EC2 instance metadata credentials (http://169.254.169.254/latest/meta-data/…), if available
// ...and otherwise returns an error // ...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 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 // Present creates a TXT record using the specified parameters
func (r *DNSProviderRoute53) Present(domain, token, keyAuth string) error { func (r *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth) fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"` value = `"` + value + `"`
return r.changeRecord("UPSERT", fqdn, value, ttl) return r.changeRecord("UPSERT", fqdn, value, ttl)
} }
// CleanUp removes the TXT record matching the specified parameters // CleanUp removes the TXT record matching the specified parameters
func (r *DNSProviderRoute53) CleanUp(domain, token, keyAuth string) error { func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, ttl := DNS01Record(domain, keyAuth) fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"` value = `"` + value + `"`
return r.changeRecord("DELETE", fqdn, value, ttl) 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) hostedZoneID, err := r.getHostedZoneID(fqdn)
if err != nil { if err != nil {
return err return err
} }
recordSet := newTXTRecordSet(fqdn, value, ttl) recordSet := newTXTRecordSet(fqdn, value, ttl)
update := route53.Change{action, recordSet} update := route53.Change{Action: action, Record: recordSet}
changes := []route53.Change{update} changes := []route53.Change{update}
req := route53.ChangeResourceRecordSetsRequest{Comment: "Created by Lego", Changes: changes} req := route53.ChangeResourceRecordSetsRequest{Comment: "Created by Lego", Changes: changes}
resp, err := r.client.ChangeResourceRecordSets(hostedZoneID, &req) 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 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) status, err := r.client.GetChange(resp.ChangeInfo.ID)
if err != nil { if err != nil {
return false, err 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{} zones := []route53.HostedZone{}
zoneResp, err := r.client.ListHostedZones("", 0) zoneResp, err := r.client.ListHostedZones("", 0)
if err != nil { if err != nil {

View file

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