diff --git a/.travis.yml b/.travis.yml index 4f2ee4d9..ba0113bf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1 +1,14 @@ language: go + +go: + - 1.4.3 + - 1.5.3 + - tip + +install: + - go get -t ./... + - go get golang.org/x/tools/cmd/vet + +script: + - go vet ./... + - go test -v ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 850f6dfb..d6bc07e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added: - CLI: The `--dns` switch. To include the DNS challenge for consideration. Supported are the following solvers: cloudflare, digitalocean, dnsimple, route53, rfc2136 and manual. +- CLI: The `--accept-tos` switch. Indicates your acceptance of the Let's Encrypt terms of service without prompting you. - lib: A new type for challenge identifiers `Challenge` - lib: A new interface for custom challenge providers `ChallengeProvider` - lib: SetChallengeProvider function. Pass a challenge identifier and a Provider to replace the default behaviour of a challenge. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..9939a5ab --- /dev/null +++ b/CONTRIBUTING.md @@ -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). diff --git a/README.md b/README.md index a85a422c..7208a1c2 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ GLOBAL OPTIONS: --webroot Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge --http Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port --tls Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port - --dns Enable the DNS challenge for solving using a provider. + --dns Solve a DNS challenge using the specified provider. Disables all other solvers. Credentials for providers have to be passed through environment variables. For a more detailed explanation of the parameters, please see the online docs. Valid providers: @@ -99,7 +99,7 @@ GLOBAL OPTIONS: digitalocean: DO_AUTH_TOKEN dnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY route53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION - rfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_NAMESERVER, RFC2136_ZONE + rfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER manual: none --help, -h show help --version, -v print the version @@ -127,9 +127,11 @@ $ lego --email="foo@bar.com" --domains="example.com" renew Obtain a certificate using the DNS challenge and AWS Route 53: ```bash -$ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego --email="foo@bar.com" --domains="example.com" --dns="route53" --exclude="http-01" --exclude="tls-sni-01" run +$ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego --email="foo@bar.com" --domains="example.com" --dns="route53" run ``` +Note that `--dns=foo` implies `--exclude=http-01` and `--exclude=tls-sni-01`. lego will not attempt other challenges if you've told it to use DNS instead. + lego defaults to communicating with the production Let's Encrypt ACME server. If you'd like to test something without issuing real certificates, consider using the staging endpoint instead: ```bash diff --git a/account.go b/account.go index 13325470..85ac09f1 100644 --- a/account.go +++ b/account.go @@ -1,7 +1,7 @@ package main import ( - "crypto/rsa" + "crypto" "encoding/json" "io/ioutil" "os" @@ -13,7 +13,7 @@ import ( // Account represents a users local saved credentials type Account struct { Email string `json:"email"` - key *rsa.PrivateKey + key crypto.PrivateKey Registration *acme.RegistrationResource `json:"registration"` conf *Configuration @@ -28,16 +28,18 @@ func NewAccount(email string, conf *Configuration) *Account { logger().Fatalf("Could not check/create directory for account %s: %v", email, err) } - var privKey *rsa.PrivateKey + var privKey crypto.PrivateKey if _, err := os.Stat(accKeyPath); os.IsNotExist(err) { - logger().Printf("No key found for account %s. Generating a %v bit key.", email, conf.RsaBits()) - privKey, err = generateRsaKey(conf.RsaBits(), accKeyPath) + + logger().Printf("No key found for account %s. Generating a curve P384 EC key.", email) + privKey, err = generatePrivateKey(accKeyPath) if err != nil { logger().Fatalf("Could not generate RSA private account key for account %s: %v", email, err) } + logger().Printf("Saved key to %s", accKeyPath) } else { - privKey, err = loadRsaKey(accKeyPath) + privKey, err = loadPrivateKey(accKeyPath) if err != nil { logger().Fatalf("Could not load RSA private key from file %s: %v", accKeyPath, err) } @@ -73,7 +75,7 @@ func (a *Account) GetEmail() string { } // GetPrivateKey returns the private RSA account key. -func (a *Account) GetPrivateKey() *rsa.PrivateKey { +func (a *Account) GetPrivateKey() crypto.PrivateKey { return a.key } diff --git a/acme/challenges.go b/acme/challenges.go index 3f679e00..85790050 100644 --- a/acme/challenges.go +++ b/acme/challenges.go @@ -1,5 +1,6 @@ package acme +// Challenge is a string that identifies a particular type and version of ACME challenge. type Challenge string const ( diff --git a/acme/client.go b/acme/client.go index 700aeab5..5bda6bc2 100644 --- a/acme/client.go +++ b/acme/client.go @@ -3,7 +3,6 @@ package acme import ( "crypto" - "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/json" @@ -38,7 +37,7 @@ func logf(format string, args ...interface{}) { type User interface { GetEmail() string GetRegistration() *RegistrationResource - GetPrivateKey() *rsa.PrivateKey + GetPrivateKey() crypto.PrivateKey } // Interface for all challenge solvers to implement. @@ -53,7 +52,7 @@ type Client struct { directory directory user User jws *jws - keyBits int + keyType KeyType issuerCert []byte solvers map[Challenge]solver } @@ -61,16 +60,12 @@ type Client struct { // NewClient creates a new ACME client on behalf of the user. The client will depend on // the ACME directory located at caDirURL for the rest of its actions. It will // generate private keys for certificates of size keyBits. -func NewClient(caDirURL string, user User, keyBits int) (*Client, error) { +func NewClient(caDirURL string, user User, keyType KeyType) (*Client, error) { privKey := user.GetPrivateKey() if privKey == nil { return nil, errors.New("private key was nil") } - if err := privKey.Validate(); err != nil { - return nil, fmt.Errorf("invalid private key: %v", err) - } - var dir directory if _, err := getJSON(caDirURL, &dir); err != nil { return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err) @@ -95,10 +90,10 @@ func NewClient(caDirURL string, user User, keyBits int) (*Client, error) { // Add all available solvers with the right index as per ACME // spec to this map. Otherwise they won`t be found. solvers := make(map[Challenge]solver) - solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate} - solvers[TLSSNI01] = &tlsSNIChallenge{jws: jws, validate: validate} + solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}} + solvers[TLSSNI01] = &tlsSNIChallenge{jws: jws, validate: validate, provider: &TLSProviderServer{}} - return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil + return &Client{directory: dir, user: user, jws: jws, keyType: keyType, solvers: solvers}, nil } // SetChallengeProvider specifies a custom provider that will make the solution available @@ -126,7 +121,7 @@ func (c *Client) SetHTTPAddress(iface string) error { } if chlng, ok := c.solvers[HTTP01]; ok { - chlng.(*httpChallenge).provider = &httpChallengeServer{iface: host, port: port} + chlng.(*httpChallenge).provider = NewHTTPProviderServer(host, port) } return nil @@ -142,7 +137,7 @@ func (c *Client) SetTLSAddress(iface string) error { } if chlng, ok := c.solvers[TLSSNI01]; ok { - chlng.(*tlsSNIChallenge).provider = &tlsSNIChallengeServer{iface: host, port: port} + chlng.(*tlsSNIChallenge).provider = NewTLSProviderServer(host, port) } return nil } @@ -197,8 +192,10 @@ func (c *Client) Register() (*RegistrationResource, error) { // AgreeToTOS updates the Client registration and sends the agreement to // the server. func (c *Client) AgreeToTOS() error { - c.user.GetRegistration().Body.Agreement = c.user.GetRegistration().TosURL - c.user.GetRegistration().Body.Resource = "reg" + reg := c.user.GetRegistration() + + reg.Body.Agreement = c.user.GetRegistration().TosURL + reg.Body.Resource = "reg" _, err := postJSON(c.jws, c.user.GetRegistration().URI, c.user.GetRegistration().Body, nil) return err } @@ -316,13 +313,12 @@ func (c *Client) RenewCertificate(cert CertificateResource, bundle bool) (Certif links := parseLinks(resp.Header["Link"]) issuerCert, err := c.getIssuerCertificate(links["up"]) if err != nil { - // If we fail to aquire the issuer cert, return the issued certificate - do not fail. + // If we fail to acquire the issuer cert, return the issued certificate - do not fail. logf("[ERROR][%s] acme: Could not bundle issuer certificate: %v", cert.Domain, err) } else { // Success - append the issuer cert to the issued cert. issuerCert = pemEncode(derCertificateBytes(issuerCert)) issuedCert = append(issuedCert, issuerCert...) - cert.Certificate = issuedCert } } @@ -457,7 +453,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, commonName := authz[0] var err error if privKey == nil { - privKey, err = generatePrivateKey(rsakey, c.keyBits) + privKey, err = generatePrivateKey(c.keyType) if err != nil { return CertificateResource{}, err } @@ -471,7 +467,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, } // TODO: should the CSR be customizable? - csr, err := generateCsr(privKey.(*rsa.PrivateKey), commonName.Domain, san) + csr, err := generateCsr(privKey, commonName.Domain, san) if err != nil { return CertificateResource{}, err } @@ -508,6 +504,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, if len(cert) > 0 { cerRes.CertStableURL = resp.Header.Get("Content-Location") + cerRes.AccountRef = c.user.GetRegistration().URI issuedCert := pemEncode(derCertificateBytes(cert)) // If bundle is true, we want to return a certificate bundle. @@ -518,7 +515,7 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, links := parseLinks(resp.Header["Link"]) issuerCert, err := c.getIssuerCertificate(links["up"]) if err != nil { - // If we fail to aquire the issuer cert, return the issued certificate - do not fail. + // If we fail to acquire the issuer cert, return the issued certificate - do not fail. logf("[WARNING][%s] acme: Could not bundle issuer certificate: %v", commonName.Domain, err) } else { // Success - append the issuer cert to the issued cert. diff --git a/acme/client_test.go b/acme/client_test.go index c94d8f3e..e309554f 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -1,6 +1,7 @@ package acme import ( + "crypto" "crypto/rand" "crypto/rsa" "encoding/json" @@ -13,6 +14,7 @@ import ( func TestNewClient(t *testing.T) { keyBits := 32 // small value keeps test fast + keyType := RSA2048 key, err := rsa.GenerateKey(rand.Reader, keyBits) if err != nil { t.Fatal("Could not generate test key:", err) @@ -28,7 +30,7 @@ func TestNewClient(t *testing.T) { w.Write(data) })) - client, err := NewClient(ts.URL, user, keyBits) + client, err := NewClient(ts.URL, user, keyType) if err != nil { t.Fatalf("Could not create client: %v", err) } @@ -40,8 +42,8 @@ func TestNewClient(t *testing.T) { t.Errorf("Expected jws.privKey to be %p but was %p", expected, actual) } - if client.keyBits != keyBits { - t.Errorf("Expected keyBits to be %d but was %d", keyBits, client.keyBits) + if client.keyType != keyType { + t.Errorf("Expected keyType to be %s but was %s", keyType, client.keyType) } if expected, actual := 2, len(client.solvers); actual != expected { @@ -68,7 +70,7 @@ func TestClientOptPort(t *testing.T) { optPort := "1234" optHost := "" - client, err := NewClient(ts.URL, user, keyBits) + client, err := NewClient(ts.URL, user, RSA2048) if err != nil { t.Fatalf("Could not create client: %v", err) } @@ -82,10 +84,10 @@ func TestClientOptPort(t *testing.T) { if httpSolver.jws != client.jws { t.Error("Expected http-01 to have same jws as client") } - if got := httpSolver.provider.(*httpChallengeServer).port; got != optPort { + if got := httpSolver.provider.(*HTTPProviderServer).port; got != optPort { t.Errorf("Expected http-01 to have port %s but was %s", optPort, got) } - if got := httpSolver.provider.(*httpChallengeServer).iface; got != optHost { + if got := httpSolver.provider.(*HTTPProviderServer).iface; got != optHost { t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got) } @@ -96,10 +98,10 @@ func TestClientOptPort(t *testing.T) { if httpsSolver.jws != client.jws { t.Error("Expected tls-sni-01 to have same jws as client") } - if got := httpsSolver.provider.(*tlsSNIChallengeServer).port; got != optPort { + if got := httpsSolver.provider.(*TLSProviderServer).port; got != optPort { t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got) } - if got := httpsSolver.provider.(*tlsSNIChallengeServer).iface; got != optHost { + if got := httpsSolver.provider.(*TLSProviderServer).iface; got != optHost { t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, got) } @@ -108,10 +110,10 @@ func TestClientOptPort(t *testing.T) { client.SetHTTPAddress(net.JoinHostPort(optHost, optPort)) client.SetTLSAddress(net.JoinHostPort(optHost, optPort)) - if got := httpSolver.provider.(*httpChallengeServer).iface; got != optHost { + if got := httpSolver.provider.(*HTTPProviderServer).iface; got != optHost { t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got) } - if got := httpsSolver.provider.(*tlsSNIChallengeServer).port; got != optPort { + if got := httpsSolver.provider.(*TLSProviderServer).port; got != optPort { t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got) } } @@ -140,8 +142,8 @@ func TestValidate(t *testing.T) { })) defer ts.Close() - privKey, _ := generatePrivateKey(rsakey, 512) - j := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} + privKey, _ := rsa.GenerateKey(rand.Reader, 512) + j := &jws{privKey: privKey, directoryURL: ts.URL} tsts := []struct { name string @@ -193,4 +195,4 @@ type mockUser struct { func (u mockUser) GetEmail() string { return u.email } func (u mockUser) GetRegistration() *RegistrationResource { return u.regres } -func (u mockUser) GetPrivateKey() *rsa.PrivateKey { return u.privatekey } +func (u mockUser) GetPrivateKey() crypto.PrivateKey { return u.privatekey } diff --git a/acme/crypto.go b/acme/crypto.go index 347c9bc1..fc20442f 100644 --- a/acme/crypto.go +++ b/acme/crypto.go @@ -10,7 +10,6 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/base64" - "encoding/binary" "encoding/pem" "errors" "fmt" @@ -22,15 +21,19 @@ import ( "time" "golang.org/x/crypto/ocsp" - "golang.org/x/crypto/sha3" ) -type keyType int +// KeyType represents the key algo as well as the key size or curve to use. +type KeyType string type derCertificateBytes []byte +// Constants for all key types we support. const ( - eckey keyType = iota - rsakey + EC256 = KeyType("P256") + EC384 = KeyType("P384") + RSA2048 = KeyType("2048") + RSA4096 = KeyType("4096") + RSA8192 = KeyType("8192") ) const ( @@ -56,14 +59,22 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) { return nil, nil, err } - // We only got one certificate, means we have no issuer certificate - get it. + // We expect the certificate slice to be ordered downwards the chain. + // SRV CRT -> CA. We need to pull the leaf and issuer certs out of it, + // which should always be the first two certificates. If there's no + // OCSP server listed in the leaf cert, there's nothing to do. And if + // we have only one certificate so far, we need to get the issuer cert. + issuedCert := certificates[0] + if len(issuedCert.OCSPServer) == 0 { + return nil, nil, errors.New("no OCSP server specified in cert") + } if len(certificates) == 1 { // TODO: build fallback. If this fails, check the remaining array entries. - if len(certificates[0].IssuingCertificateURL) == 0 { + if len(issuedCert.IssuingCertificateURL) == 0 { return nil, nil, errors.New("no issuing certificate URL") } - resp, err := httpGet(certificates[0].IssuingCertificateURL[0]) + resp, err := httpGet(issuedCert.IssuingCertificateURL[0]) if err != nil { return nil, nil, err } @@ -83,17 +94,8 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) { // We want it ordered right SRV CRT -> CA certificates = append(certificates, issuerCert) } - - // We expect the certificate slice to be ordered downwards the chain. - // SRV CRT -> CA. We need to pull the cert and issuer cert out of it, - // which should always be the last two certificates. - issuedCert := certificates[0] issuerCert := certificates[1] - if len(issuedCert.OCSPServer) == 0 { - return nil, nil, errors.New("no OCSP server specified in cert") - } - // Finally kick off the OCSP request. ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil) if err != nil { @@ -124,8 +126,16 @@ func GetOCSPForCert(bundle []byte) ([]byte, *ocsp.Response, error) { } func getKeyAuthorization(token string, key interface{}) (string, error) { + var publicKey crypto.PublicKey + switch k := key.(type) { + case *ecdsa.PrivateKey: + publicKey = k.Public() + case *rsa.PrivateKey: + publicKey = k.Public() + } + // Generate the Key Authorization for the challenge - jwk := keyAsJWK(key) + jwk := keyAsJWK(publicKey) if jwk == nil { return "", errors.New("Could not generate JWK from key.") } @@ -144,39 +154,6 @@ func getKeyAuthorization(token string, key interface{}) (string, error) { return token + "." + keyThumb, nil } -// Derive the shared secret according to acme spec 5.6 -func performECDH(priv *ecdsa.PrivateKey, pub *ecdsa.PublicKey, outLen int, label string) []byte { - // Derive Z from the private and public keys according to SEC 1 Ver. 2.0 - 3.3.1 - Z, _ := priv.PublicKey.ScalarMult(pub.X, pub.Y, priv.D.Bytes()) - - if len(Z.Bytes())+len(label)+4 > 384 { - return nil - } - - if outLen < 384*(2^32-1) { - return nil - } - - // Derive the shared secret key using the ANS X9.63 KDF - SEC 1 Ver. 2.0 - 3.6.1 - hasher := sha3.New384() - buffer := make([]byte, outLen) - bufferLen := 0 - for i := 0; i < outLen/384; i++ { - hasher.Reset() - - // Ki = Hash(Z || Counter || [SharedInfo]) - hasher.Write(Z.Bytes()) - binary.Write(hasher, binary.BigEndian, i) - hasher.Write([]byte(label)) - - hash := hasher.Sum(nil) - copied := copy(buffer[bufferLen:], hash) - bufferLen += copied - } - - return buffer -} - // parsePEMBundle parses a certificate bundle from top to bottom and returns // a slice of x509 certificates. This function will error if no certificates are found. func parsePEMBundle(bundle []byte) ([]*x509.Certificate, error) { @@ -218,18 +195,25 @@ func parsePEMPrivateKey(key []byte) (crypto.PrivateKey, error) { } } -func generatePrivateKey(t keyType, keyLength int) (crypto.PrivateKey, error) { - switch t { - case eckey: +func generatePrivateKey(keyType KeyType) (crypto.PrivateKey, error) { + + switch keyType { + case EC256: + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case EC384: return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) - case rsakey: - return rsa.GenerateKey(rand.Reader, keyLength) + case RSA2048: + return rsa.GenerateKey(rand.Reader, 2048) + case RSA4096: + return rsa.GenerateKey(rand.Reader, 4096) + case RSA8192: + return rsa.GenerateKey(rand.Reader, 8192) } - return nil, fmt.Errorf("Invalid keytype: %d", t) + return nil, fmt.Errorf("Invalid KeyType: %s", keyType) } -func generateCsr(privateKey *rsa.PrivateKey, domain string, san []string) ([]byte, error) { +func generateCsr(privateKey crypto.PrivateKey, domain string, san []string) ([]byte, error) { template := x509.CertificateRequest{ Subject: pkix.Name{ CommonName: domain, @@ -246,6 +230,9 @@ func generateCsr(privateKey *rsa.PrivateKey, domain string, san []string) ([]byt func pemEncode(data interface{}) []byte { var pemBlock *pem.Block switch key := data.(type) { + case *ecdsa.PrivateKey: + keyBytes, _ := x509.MarshalECPrivateKey(key) + pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes} case *rsa.PrivateKey: pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} break diff --git a/acme/crypto_test.go b/acme/crypto_test.go index 81ab287e..d2fc5088 100644 --- a/acme/crypto_test.go +++ b/acme/crypto_test.go @@ -2,13 +2,14 @@ package acme import ( "bytes" + "crypto/rand" "crypto/rsa" "testing" "time" ) func TestGeneratePrivateKey(t *testing.T) { - key, err := generatePrivateKey(rsakey, 32) + key, err := generatePrivateKey(RSA2048) if err != nil { t.Error("Error generating private key:", err) } @@ -18,12 +19,12 @@ func TestGeneratePrivateKey(t *testing.T) { } func TestGenerateCSR(t *testing.T) { - key, err := generatePrivateKey(rsakey, 512) + key, err := rsa.GenerateKey(rand.Reader, 512) if err != nil { t.Fatal("Error generating private key:", err) } - csr, err := generateCsr(key.(*rsa.PrivateKey), "fizz.buzz", nil) + csr, err := generateCsr(key, "fizz.buzz", nil) if err != nil { t.Error("Error generating CSR:", err) } @@ -52,7 +53,7 @@ func TestPEMEncode(t *testing.T) { } func TestPEMCertExpiration(t *testing.T) { - privKey, err := generatePrivateKey(rsakey, 2048) + privKey, err := generatePrivateKey(RSA2048) if err != nil { t.Fatal("Error generating private key:", err) } diff --git a/acme/dns_challenge.go b/acme/dns_challenge.go index f34fcccc..e5be0105 100644 --- a/acme/dns_challenge.go +++ b/acme/dns_challenge.go @@ -6,17 +6,22 @@ import ( "errors" "fmt" "log" + "net" "strings" "time" "github.com/miekg/dns" + "golang.org/x/net/publicsuffix" ) -type preCheckDNSFunc func(domain, fqdn string) bool +type preCheckDNSFunc func(fqdn, value string) (bool, error) -var preCheckDNS preCheckDNSFunc = checkDNS +var ( + preCheckDNS preCheckDNSFunc = checkDNSPropagation + fqdnToZone = map[string]string{} +) -var preCheckDNSFallbackCount = 5 +var recursiveNameserver = "google-public-dns-a.google.com:53" // DNS01Record returns a DNS record which will fulfill the `dns-01` challenge func DNS01Record(domain, keyAuth string) (fqdn string, value string, ttl int) { @@ -44,7 +49,7 @@ func (s *dnsChallenge) Solve(chlng challenge, domain string) error { } // Generate the Key Authorization for the challenge - keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey) + keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey) if err != nil { return err } @@ -60,54 +65,182 @@ func (s *dnsChallenge) Solve(chlng challenge, domain string) error { } }() - fqdn, _, _ := DNS01Record(domain, keyAuth) + fqdn, value, _ := DNS01Record(domain, keyAuth) - preCheckDNS(domain, fqdn) + logf("[INFO][%s] Checking DNS record propagation...", domain) + + err = WaitFor(60*time.Second, 2*time.Second, func() (bool, error) { + return preCheckDNS(fqdn, value) + }) + if err != nil { + return err + } return s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } -func checkDNS(domain, fqdn string) bool { - // check if the expected DNS entry was created. If not wait for some time and try again. - m := new(dns.Msg) - m.SetQuestion(domain+".", dns.TypeSOA) - c := new(dns.Client) - in, _, err := c.Exchange(m, "google-public-dns-a.google.com:53") +// checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers. +func checkDNSPropagation(fqdn, value string) (bool, error) { + // Initial attempt to resolve at the recursive NS + r, err := dnsQuery(fqdn, dns.TypeTXT, recursiveNameserver, true) if err != nil { - return false + return false, err + } + if r.Rcode == dns.RcodeSuccess { + // If we see a CNAME here then use the alias + for _, rr := range r.Answer { + if cn, ok := rr.(*dns.CNAME); ok { + if cn.Hdr.Name == fqdn { + fqdn = cn.Target + break + } + } + } } - var authorativeNS string - for _, answ := range in.Answer { - soa := answ.(*dns.SOA) - authorativeNS = soa.Ns + authoritativeNss, err := lookupNameservers(fqdn) + if err != nil { + return false, err } - fallbackCnt := 0 - for fallbackCnt < preCheckDNSFallbackCount { - m.SetQuestion(fqdn, dns.TypeTXT) - in, _, err = c.Exchange(m, authorativeNS+":53") - if err != nil { - return false - } - - if len(in.Answer) > 0 { - return true - } - - fallbackCnt++ - if fallbackCnt >= preCheckDNSFallbackCount { - return false - } - - time.Sleep(time.Second * time.Duration(fallbackCnt)) - } - - return false + return checkAuthoritativeNss(fqdn, value, authoritativeNss) } -// toFqdn converts the name into a fqdn appending a trailing dot. -func toFqdn(name string) string { +// checkAuthoritativeNss queries each of the given nameservers for the expected TXT record. +func checkAuthoritativeNss(fqdn, value string, nameservers []string) (bool, error) { + for _, ns := range nameservers { + r, err := dnsQuery(fqdn, dns.TypeTXT, net.JoinHostPort(ns, "53"), false) + if err != nil { + return false, err + } + + if r.Rcode != dns.RcodeSuccess { + return false, fmt.Errorf("NS %s returned %s for %s", ns, dns.RcodeToString[r.Rcode], fqdn) + } + + var found bool + for _, rr := range r.Answer { + if txt, ok := rr.(*dns.TXT); ok { + if strings.Join(txt.Txt, "") == value { + found = true + break + } + } + } + + if !found { + return false, fmt.Errorf("NS %s did not return the expected TXT record", ns) + } + } + + return true, nil +} + +// dnsQuery sends a DNS query to the given nameserver. +// The nameserver should include a port, to facilitate testing where we talk to a mock dns server. +func dnsQuery(fqdn string, rtype uint16, nameserver string, recursive bool) (in *dns.Msg, err error) { + m := new(dns.Msg) + m.SetQuestion(fqdn, rtype) + m.SetEdns0(4096, false) + + if !recursive { + m.RecursionDesired = false + } + + in, err = dns.Exchange(m, nameserver) + if err == dns.ErrTruncated { + tcp := &dns.Client{Net: "tcp"} + in, _, err = tcp.Exchange(m, nameserver) + } + + return +} + +// lookupNameservers returns the authoritative nameservers for the given fqdn. +func lookupNameservers(fqdn string) ([]string, error) { + var authoritativeNss []string + + zone, err := FindZoneByFqdn(fqdn, recursiveNameserver) + if err != nil { + return nil, err + } + + r, err := dnsQuery(zone, dns.TypeNS, recursiveNameserver, true) + if err != nil { + return nil, err + } + + for _, rr := range r.Answer { + if ns, ok := rr.(*dns.NS); ok { + authoritativeNss = append(authoritativeNss, strings.ToLower(ns.Ns)) + } + } + + if len(authoritativeNss) > 0 { + return authoritativeNss, nil + } + return nil, fmt.Errorf("Could not determine authoritative nameservers") +} + +// FindZoneByFqdn determines the zone of the given fqdn +func FindZoneByFqdn(fqdn, nameserver string) (string, error) { + // Do we have it cached? + if zone, ok := fqdnToZone[fqdn]; ok { + return zone, nil + } + + // Query the authorative nameserver for a hopefully non-existing SOA record, + // in the authority section of the reply it will have the SOA of the + // containing zone. rfc2308 has this to say on the subject: + // Name servers authoritative for a zone MUST include the SOA record of + // the zone in the authority section of the response when reporting an + // NXDOMAIN or indicating that no data (NODATA) of the requested type exists + in, err := dnsQuery(fqdn, dns.TypeSOA, nameserver, true) + if err != nil { + return "", err + } + if in.Rcode != dns.RcodeNameError { + if in.Rcode != dns.RcodeSuccess { + return "", fmt.Errorf("NS %s returned %s for %s", nameserver, dns.RcodeToString[in.Rcode], fqdn) + } + // We have a success, so one of the answers has to be a SOA RR + for _, ans := range in.Answer { + if soa, ok := ans.(*dns.SOA); ok { + zone := soa.Hdr.Name + // If we ended up on one of the TLDs, it means the domain did not exist. + publicsuffix, _ := publicsuffix.PublicSuffix(UnFqdn(zone)) + if publicsuffix == UnFqdn(zone) { + return "", fmt.Errorf("Could not determine zone authoritatively") + } + fqdnToZone[fqdn] = zone + return zone, nil + } + } + // Or it is NODATA, fall through to NXDOMAIN + } + // Search the authority section for our precious SOA RR + for _, ns := range in.Ns { + if soa, ok := ns.(*dns.SOA); ok { + zone := soa.Hdr.Name + // If we ended up on one of the TLDs, it means the domain did not exist. + publicsuffix, _ := publicsuffix.PublicSuffix(UnFqdn(zone)) + if publicsuffix == UnFqdn(zone) { + return "", fmt.Errorf("Could not determine zone authoritatively") + } + fqdnToZone[fqdn] = zone + return zone, nil + } + } + return "", fmt.Errorf("NS %s did not return the expected SOA record in the authority section", nameserver) +} + +// ClearFqdnCache clears the cache of fqdn to zone mappings. Primarily used in testing. +func ClearFqdnCache() { + fqdnToZone = map[string]string{} +} + +// ToFqdn converts the name into a fqdn appending a trailing dot. +func ToFqdn(name string) string { n := len(name) if n == 0 || name[n-1] == '.' { return name @@ -115,31 +248,11 @@ func toFqdn(name string) string { return name + "." } -// unFqdn converts the fqdn into a name removing the trailing dot. -func unFqdn(name string) string { +// UnFqdn converts the fqdn into a name removing the trailing dot. +func UnFqdn(name string) string { n := len(name) if n != 0 && name[n-1] == '.' { return name[:n-1] } return name } - -// waitFor polls the given function 'f', once per second, up to 'timeout' seconds. -func waitFor(timeout int, f func() (bool, error)) error { - start := time.Now().Second() - for { - time.Sleep(1 * time.Second) - - if delta := time.Now().Second() - start; delta >= timeout { - return fmt.Errorf("Time limit exceeded (%d seconds)", delta) - } - - stop, err := f() - if err != nil { - return err - } - if stop { - return nil - } - } -} diff --git a/acme/dns_challenge_cloudflare.go b/acme/dns_challenge_cloudflare.go deleted file mode 100644 index 4781ec5b..00000000 --- a/acme/dns_challenge_cloudflare.go +++ /dev/null @@ -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 -} diff --git a/acme/dns_challenge_rfc2136.go b/acme/dns_challenge_rfc2136.go deleted file mode 100644 index e6f93fa4..00000000 --- a/acme/dns_challenge_rfc2136.go +++ /dev/null @@ -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 -} diff --git a/acme/dns_challenge_test.go b/acme/dns_challenge_test.go index 0af40f71..bfc66561 100644 --- a/acme/dns_challenge_test.go +++ b/acme/dns_challenge_test.go @@ -2,19 +2,86 @@ package acme import ( "bufio" + "crypto/rand" "crypto/rsa" "net/http" "net/http/httptest" "os" + "reflect" + "sort" + "strings" "testing" "time" ) +var lookupNameserversTestsOK = []struct { + fqdn string + nss []string +}{ + {"books.google.com.ng.", + []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."}, + }, + {"www.google.com.", + []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."}, + }, + {"physics.georgetown.edu.", + []string{"ns1.georgetown.edu.", "ns2.georgetown.edu.", "ns3.georgetown.edu."}, + }, +} + +var lookupNameserversTestsErr = []struct { + fqdn string + error string +}{ + // invalid tld + {"_null.n0n0.", + "Could not determine zone authoritatively", + }, + // invalid domain + {"_null.com.", + "Could not determine zone authoritatively", + }, + // invalid domain + {"in-valid.co.uk.", + "Could not determine zone authoritatively", + }, +} + +var checkAuthoritativeNssTests = []struct { + fqdn, value string + ns []string + ok bool +}{ + // TXT RR w/ expected value + {"8.8.8.8.asn.routeviews.org.", "151698.8.8.024", []string{"asnums.routeviews.org."}, + true, + }, + // No TXT RR + {"ns1.google.com.", "", []string{"ns2.google.com."}, + false, + }, +} + +var checkAuthoritativeNssTestsErr = []struct { + fqdn, value string + ns []string + error string +}{ + // TXT RR /w unexpected value + {"8.8.8.8.asn.routeviews.org.", "fe01=", []string{"asnums.routeviews.org."}, + "did not return the expected TXT record", + }, + // No TXT RR + {"ns1.google.com.", "fe01=", []string{"ns2.google.com."}, + "did not return the expected TXT record", + }, +} + func TestDNSValidServerResponse(t *testing.T) { - preCheckDNS = func(domain, fqdn string) bool { - return true + preCheckDNS = func(fqdn, value string) (bool, error) { + return true, nil } - privKey, _ := generatePrivateKey(rsakey, 512) + privKey, _ := rsa.GenerateKey(rand.Reader, 512) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Replay-Nonce", "12345") @@ -22,7 +89,7 @@ func TestDNSValidServerResponse(t *testing.T) { })) manualProvider, _ := NewDNSProviderManual() - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} + jws := &jws{privKey: privKey, directoryURL: ts.URL} solver := &dnsChallenge{jws: jws, validate: validate, provider: manualProvider} clientChallenge := challenge{Type: "dns01", Status: "pending", URI: ts.URL, Token: "http8"} @@ -39,7 +106,60 @@ func TestDNSValidServerResponse(t *testing.T) { } func TestPreCheckDNS(t *testing.T) { - if !preCheckDNS("api.letsencrypt.org", "acme-staging.api.letsencrypt.org") { + ok, err := preCheckDNS("acme-staging.api.letsencrypt.org", "fe01=") + if err != nil || !ok { t.Errorf("preCheckDNS failed for acme-staging.api.letsencrypt.org") } } + +func TestLookupNameserversOK(t *testing.T) { + for _, tt := range lookupNameserversTestsOK { + nss, err := lookupNameservers(tt.fqdn) + if err != nil { + t.Fatalf("#%s: got %q; want nil", tt.fqdn, err) + } + + sort.Strings(nss) + sort.Strings(tt.nss) + + if !reflect.DeepEqual(nss, tt.nss) { + t.Errorf("#%s: got %v; want %v", tt.fqdn, nss, tt.nss) + } + } +} + +func TestLookupNameserversErr(t *testing.T) { + for _, tt := range lookupNameserversTestsErr { + _, err := lookupNameservers(tt.fqdn) + if err == nil { + t.Fatalf("#%s: expected %q (error); got ", 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 ", 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 + } + } +} diff --git a/acme/http.go b/acme/http.go index 6933899b..410aead6 100644 --- a/acme/http.go +++ b/acme/http.go @@ -8,11 +8,15 @@ import ( "net/http" "runtime" "strings" + "time" ) // UserAgent (if non-empty) will be tacked onto the User-Agent string in requests. var UserAgent string +// defaultClient is an HTTP client with a reasonable timeout value. +var defaultClient = http.Client{Timeout: 10 * time.Second} + const ( // defaultGoUserAgent is the Go HTTP package user agent string. Too // bad it isn't exported. If it changes, we should update it here, too. @@ -32,7 +36,7 @@ func httpHead(url string) (resp *http.Response, err error) { req.Header.Set("User-Agent", userAgent()) - resp, err = http.DefaultClient.Do(req) + resp, err = defaultClient.Do(req) if err != nil { return resp, err } @@ -50,7 +54,7 @@ func httpPost(url string, bodyType string, body io.Reader) (resp *http.Response, req.Header.Set("Content-Type", bodyType) req.Header.Set("User-Agent", userAgent()) - return http.DefaultClient.Do(req) + return defaultClient.Do(req) } // httpGet performs a GET request with a proper User-Agent string. @@ -62,7 +66,7 @@ func httpGet(url string) (resp *http.Response, err error) { } req.Header.Set("User-Agent", userAgent()) - return http.DefaultClient.Do(req) + return defaultClient.Do(req) } // getJSON performs an HTTP GET request and parses the response body diff --git a/acme/http_challenge.go b/acme/http_challenge.go index a9f8e5cf..95cb1fd8 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -21,15 +21,11 @@ func (s *httpChallenge) Solve(chlng challenge, domain string) error { logf("[INFO][%s] acme: Trying to solve HTTP-01", domain) // Generate the Key Authorization for the challenge - keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey) + keyAuth, err := getKeyAuthorization(chlng.Token, s.jws.privKey) if err != nil { return err } - if s.provider == nil { - s.provider = &httpChallengeServer{} - } - err = s.provider.Present(domain, chlng.Token, keyAuth) if err != nil { return fmt.Errorf("[%s] error presenting token: %v", domain, err) diff --git a/acme/http_challenge_server.go b/acme/http_challenge_server.go index 33882236..42541380 100644 --- a/acme/http_challenge_server.go +++ b/acme/http_challenge_server.go @@ -7,16 +7,25 @@ import ( "strings" ) -// httpChallengeServer implements ChallengeProvider for `http-01` challenge -type httpChallengeServer struct { +// HTTPProviderServer implements ChallengeProvider for `http-01` challenge +// It may be instantiated without using the NewHTTPProviderServer function if +// you want only to use the default values. +type HTTPProviderServer struct { iface string port string done chan bool listener net.Listener } -// Present makes the token available at `HTTP01ChallengePath(token)` -func (s *httpChallengeServer) Present(domain, token, keyAuth string) error { +// NewHTTPProviderServer creates a new HTTPProviderServer on the selected interface and port. +// Setting iface and / or port to an empty string will make the server fall back to +// the "any" interface and port 80 respectively. +func NewHTTPProviderServer(iface, port string) *HTTPProviderServer { + return &HTTPProviderServer{iface: iface, port: port} +} + +// Present starts a web server and makes the token available at `HTTP01ChallengePath(token)` for web requests. +func (s *HTTPProviderServer) Present(domain, token, keyAuth string) error { if s.port == "" { s.port = "80" } @@ -32,7 +41,8 @@ func (s *httpChallengeServer) Present(domain, token, keyAuth string) error { return nil } -func (s *httpChallengeServer) CleanUp(domain, token, keyAuth string) error { +// CleanUp closes the HTTP server and removes the token from `HTTP01ChallengePath(token)` +func (s *HTTPProviderServer) CleanUp(domain, token, keyAuth string) error { if s.listener == nil { return nil } @@ -41,7 +51,7 @@ func (s *httpChallengeServer) CleanUp(domain, token, keyAuth string) error { return nil } -func (s *httpChallengeServer) serve(domain, token, keyAuth string) { +func (s *HTTPProviderServer) serve(domain, token, keyAuth string) { path := HTTP01ChallengePath(token) // The handler validates the HOST header and request type. diff --git a/acme/http_challenge_test.go b/acme/http_challenge_test.go index 56f07345..b1e77543 100644 --- a/acme/http_challenge_test.go +++ b/acme/http_challenge_test.go @@ -1,6 +1,7 @@ package acme import ( + "crypto/rand" "crypto/rsa" "io/ioutil" "os" @@ -9,8 +10,8 @@ import ( ) func TestHTTPChallenge(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - j := &jws{privKey: privKey.(*rsa.PrivateKey)} + privKey, _ := rsa.GenerateKey(rand.Reader, 512) + j := &jws{privKey: privKey} clientChallenge := challenge{Type: HTTP01, Token: "http1"} mockValidate := func(_ *jws, _, _ string, chlng challenge) error { uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token @@ -36,7 +37,7 @@ func TestHTTPChallenge(t *testing.T) { return nil } - solver := &httpChallenge{jws: j, validate: mockValidate, provider: &httpChallengeServer{port: "23457"}} + solver := &httpChallenge{jws: j, validate: mockValidate, provider: &HTTPProviderServer{port: "23457"}} if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { t.Errorf("Solve error: got %v, want nil", err) @@ -44,10 +45,10 @@ func TestHTTPChallenge(t *testing.T) { } func TestHTTPChallengeInvalidPort(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - j := &jws{privKey: privKey.(*rsa.PrivateKey)} + privKey, _ := rsa.GenerateKey(rand.Reader, 128) + j := &jws{privKey: privKey} clientChallenge := challenge{Type: HTTP01, Token: "http2"} - solver := &httpChallenge{jws: j, validate: stubValidate, provider: &httpChallengeServer{port: "123456"}} + solver := &httpChallenge{jws: j, validate: stubValidate, provider: &HTTPProviderServer{port: "123456"}} if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { t.Errorf("Solve error: got %v, want error", err) diff --git a/acme/jws.go b/acme/jws.go index b676fe39..78d82724 100644 --- a/acme/jws.go +++ b/acme/jws.go @@ -2,7 +2,9 @@ package acme import ( "bytes" + "crypto" "crypto/ecdsa" + "crypto/elliptic" "crypto/rsa" "fmt" "net/http" @@ -12,7 +14,7 @@ import ( type jws struct { directoryURL string - privKey *rsa.PrivateKey + privKey crypto.PrivateKey nonces []string } @@ -46,8 +48,20 @@ func (j *jws) post(url string, content []byte) (*http.Response, error) { } func (j *jws) signContent(content []byte) (*jose.JsonWebSignature, error) { - // TODO: support other algorithms - RS512 - signer, err := jose.NewSigner(jose.RS256, j.privKey) + + var alg jose.SignatureAlgorithm + switch k := j.privKey.(type) { + case *rsa.PrivateKey: + alg = jose.RS256 + case *ecdsa.PrivateKey: + if k.Curve == elliptic.P256() { + alg = jose.ES256 + } else if k.Curve == elliptic.P384() { + alg = jose.ES384 + } + } + + signer, err := jose.NewSigner(alg, j.privKey) if err != nil { return nil, err } diff --git a/acme/messages.go b/acme/messages.go index 55e54321..fe3f5748 100644 --- a/acme/messages.go +++ b/acme/messages.go @@ -28,17 +28,13 @@ type registrationMessage struct { // Registration is returned by the ACME server after the registration // The client implementation should save this registration somewhere. type Registration struct { - Resource string `json:"resource,omitempty"` - ID int `json:"id"` - Key struct { - Kty string `json:"kty"` - N string `json:"n"` - E string `json:"e"` - } `json:"key"` - Contact []string `json:"contact"` - Agreement string `json:"agreement,omitempty"` - Authorizations string `json:"authorizations,omitempty"` - Certificates string `json:"certificates,omitempty"` + Resource string `json:"resource,omitempty"` + ID int `json:"id"` + Key jose.JsonWebKey `json:"key"` + Contact []string `json:"contact"` + Agreement string `json:"agreement,omitempty"` + Authorizations string `json:"authorizations,omitempty"` + Certificates string `json:"certificates,omitempty"` // RecoveryKey recoveryKeyMessage `json:"recoveryKey,omitempty"` } @@ -113,6 +109,7 @@ type CertificateResource struct { Domain string `json:"domain"` CertURL string `json:"certUrl"` CertStableURL string `json:"certStableUrl"` + AccountRef string `json:"accountRef,omitempty"` PrivateKey []byte `json:"-"` Certificate []byte `json:"-"` } diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index 2ab3abd0..c36f6acc 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -22,15 +22,11 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { logf("[INFO][%s] acme: Trying to solve TLS-SNI-01", domain) // Generate the Key Authorization for the challenge - keyAuth, err := getKeyAuthorization(chlng.Token, &t.jws.privKey.PublicKey) + keyAuth, err := getKeyAuthorization(chlng.Token, t.jws.privKey) if err != nil { return err } - if t.provider == nil { - t.provider = &tlsSNIChallengeServer{} - } - err = t.provider.Present(domain, chlng.Token, keyAuth) if err != nil { return fmt.Errorf("[%s] error presenting token: %v", domain, err) @@ -47,7 +43,7 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { // TLSSNI01ChallengeCert returns a certificate for the `tls-sni-01` challenge func TLSSNI01ChallengeCert(keyAuth string) (tls.Certificate, error) { // generate a new RSA key for the certificates - tempPrivKey, err := generatePrivateKey(rsakey, 2048) + tempPrivKey, err := generatePrivateKey(RSA2048) if err != nil { return tls.Certificate{}, err } diff --git a/acme/tls_sni_challenge_server.go b/acme/tls_sni_challenge_server.go index 13749632..faaf16f6 100644 --- a/acme/tls_sni_challenge_server.go +++ b/acme/tls_sni_challenge_server.go @@ -7,16 +7,25 @@ import ( "net/http" ) -// tlsSNIChallengeServer implements ChallengeProvider for `TLS-SNI-01` challenge -type tlsSNIChallengeServer struct { +// TLSProviderServer implements ChallengeProvider for `TLS-SNI-01` challenge +// It may be instantiated without using the NewTLSProviderServer function if +// you want only to use the default values. +type TLSProviderServer struct { iface string port string done chan bool listener net.Listener } +// NewTLSProviderServer creates a new TLSProviderServer on the selected interface and port. +// Setting iface and / or port to an empty string will make the server fall back to +// the "any" interface and port 443 respectively. +func NewTLSProviderServer(iface, port string) *TLSProviderServer { + return &TLSProviderServer{iface: iface, port: port} +} + // Present makes the keyAuth available as a cert -func (s *tlsSNIChallengeServer) Present(domain, token, keyAuth string) error { +func (s *TLSProviderServer) Present(domain, token, keyAuth string) error { if s.port == "" { s.port = "443" } @@ -42,7 +51,8 @@ func (s *tlsSNIChallengeServer) Present(domain, token, keyAuth string) error { return nil } -func (s *tlsSNIChallengeServer) CleanUp(domain, token, keyAuth string) error { +// CleanUp closes the HTTP server. +func (s *TLSProviderServer) CleanUp(domain, token, keyAuth string) error { if s.listener == nil { return nil } diff --git a/acme/tls_sni_challenge_test.go b/acme/tls_sni_challenge_test.go index 3372912f..3aec7456 100644 --- a/acme/tls_sni_challenge_test.go +++ b/acme/tls_sni_challenge_test.go @@ -1,6 +1,7 @@ package acme import ( + "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" @@ -11,8 +12,8 @@ import ( ) func TestTLSSNIChallenge(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - j := &jws{privKey: privKey.(*rsa.PrivateKey)} + privKey, _ := rsa.GenerateKey(rand.Reader, 512) + j := &jws{privKey: privKey} clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni1"} mockValidate := func(_ *jws, _, _ string, chlng challenge) error { conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{ @@ -43,7 +44,7 @@ func TestTLSSNIChallenge(t *testing.T) { return nil } - solver := &tlsSNIChallenge{jws: j, validate: mockValidate, provider: &tlsSNIChallengeServer{port: "23457"}} + solver := &tlsSNIChallenge{jws: j, validate: mockValidate, provider: &TLSProviderServer{port: "23457"}} if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { t.Errorf("Solve error: got %v, want nil", err) @@ -51,10 +52,10 @@ func TestTLSSNIChallenge(t *testing.T) { } func TestTLSSNIChallengeInvalidPort(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - j := &jws{privKey: privKey.(*rsa.PrivateKey)} + privKey, _ := rsa.GenerateKey(rand.Reader, 128) + j := &jws{privKey: privKey} clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni2"} - solver := &tlsSNIChallenge{jws: j, validate: stubValidate, provider: &tlsSNIChallengeServer{port: "123456"}} + solver := &tlsSNIChallenge{jws: j, validate: stubValidate, provider: &TLSProviderServer{port: "123456"}} if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { t.Errorf("Solve error: got %v, want error", err) diff --git a/acme/utils.go b/acme/utils.go new file mode 100644 index 00000000..2fa0db30 --- /dev/null +++ b/acme/utils.go @@ -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) + } +} diff --git a/acme/utils_test.go b/acme/utils_test.go new file mode 100644 index 00000000..158af411 --- /dev/null +++ b/acme/utils_test.go @@ -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) + } + } +} diff --git a/cli.go b/cli.go index f0edccf5..83360a45 100644 --- a/cli.go +++ b/cli.go @@ -50,6 +50,12 @@ func main() { Name: "run", Usage: "Register an account, then create and install a certificate", Action: run, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "no-bundle", + Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", + }, + }, }, { Name: "revoke", @@ -70,6 +76,10 @@ func main() { Name: "reuse-key", Usage: "Used to indicate you want to reuse your current private key for the new certificate.", }, + cli.BoolFlag{ + Name: "no-bundle", + Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", + }, }, }, } @@ -88,10 +98,14 @@ func main() { Name: "email, m", Usage: "Email used for registration and recovery contact.", }, - cli.IntFlag{ - Name: "rsa-key-size, B", - Value: 2048, - Usage: "Size of the RSA key.", + cli.BoolFlag{ + Name: "accept-tos, a", + Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.", + }, + cli.StringFlag{ + Name: "key-type, k", + Value: "rsa2048", + Usage: "Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384", }, cli.StringFlag{ Name: "path", @@ -116,7 +130,7 @@ func main() { }, cli.StringFlag{ Name: "dns", - Usage: "Enable the DNS challenge for solving using a provider." + + Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges." + "\n\tCredentials for providers have to be passed through environment variables." + "\n\tFor a more detailed explanation of the parameters, please see the online docs." + "\n\tValid providers:" + @@ -124,7 +138,7 @@ func main() { "\n\tdigitalocean: DO_AUTH_TOKEN" + "\n\tdnsimple: DNSIMPLE_EMAIL, DNSIMPLE_API_KEY" + "\n\troute53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION" + - "\n\trfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_NAMESERVER, RFC2136_ZONE" + + "\n\trfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER" + "\n\tmanual: none", }, } diff --git a/cli_handlers.go b/cli_handlers.go index efd9e368..ab11d11d 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -11,6 +11,11 @@ import ( "github.com/codegangsta/cli" "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/providers/dns/cloudflare" + "github.com/xenolf/lego/providers/dns/digitalocean" + "github.com/xenolf/lego/providers/dns/dnsimple" + "github.com/xenolf/lego/providers/dns/rfc2136" + "github.com/xenolf/lego/providers/dns/route53" ) func checkFolder(path string) error { @@ -23,7 +28,7 @@ func checkFolder(path string) error { func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { err := checkFolder(c.GlobalString("path")) if err != nil { - logger().Fatalf("Cound not check/create path: %s", err.Error()) + logger().Fatalf("Could not check/create path: %s", err.Error()) } conf := NewConfiguration(c) @@ -34,7 +39,12 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { //TODO: move to account struct? Currently MUST pass email. acc := NewAccount(c.GlobalString("email"), conf) - client, err := acme.NewClient(c.GlobalString("server"), acc, conf.RsaBits()) + keyType, err := conf.KeyType() + if err != nil { + logger().Fatal(err.Error()) + } + + client, err := acme.NewClient(c.GlobalString("server"), acc, keyType) if err != nil { logger().Fatalf("Could not create client: %s", err.Error()) } @@ -52,10 +62,16 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { client.SetChallengeProvider(acme.HTTP01, provider) } if c.GlobalIsSet("http") { + if strings.Index(c.GlobalString("http"), ":") == -1 { + logger().Fatalf("The --http switch only accepts interface:port or :port for its argument.") + } client.SetHTTPAddress(c.GlobalString("http")) } if c.GlobalIsSet("tls") { + if strings.Index(c.GlobalString("tls"), ":") == -1 { + logger().Fatalf("The --tls switch only accepts interface:port or :port for its argument.") + } client.SetTLSAddress(c.GlobalString("tls")) } @@ -64,23 +80,23 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { var provider acme.ChallengeProvider switch c.GlobalString("dns") { case "cloudflare": - provider, err = acme.NewDNSProviderCloudFlare("", "") + provider, err = cloudflare.NewDNSProvider("", "") case "digitalocean": authToken := os.Getenv("DO_AUTH_TOKEN") - provider, err = acme.NewDNSProviderDigitalOcean(authToken) + provider, err = digitalocean.NewDNSProvider(authToken) case "dnsimple": - provider, err = acme.NewDNSProviderDNSimple("", "") + provider, err = dnsimple.NewDNSProvider("", "") case "route53": awsRegion := os.Getenv("AWS_REGION") - provider, err = acme.NewDNSProviderRoute53("", "", awsRegion) + provider, err = route53.NewDNSProvider("", "", awsRegion) case "rfc2136": nameserver := os.Getenv("RFC2136_NAMESERVER") - zone := os.Getenv("RFC2136_ZONE") + tsigAlgorithm := os.Getenv("RFC2136_TSIG_ALGORITHM") tsigKey := os.Getenv("RFC2136_TSIG_KEY") tsigSecret := os.Getenv("RFC2136_TSIG_SECRET") - provider, err = acme.NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret) + provider, err = rfc2136.NewDNSProvider(nameserver, tsigAlgorithm, tsigKey, tsigSecret) case "manual": provider, err = acme.NewDNSProviderManual() } @@ -90,6 +106,10 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { } client.SetChallengeProvider(acme.DNS01, provider) + + // --dns=foo indicates that the user specifically want to do a DNS challenge + // infer that the user also wants to exclude all other challenges + client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01}) } return conf, acc, client @@ -123,6 +143,47 @@ func saveCertRes(certRes acme.CertificateResource, conf *Configuration) { } } +func handleTOS(c *cli.Context, client *acme.Client, acc *Account) { + // Check for a global accept override + if c.GlobalBool("accept-tos") { + err := client.AgreeToTOS() + if err != nil { + logger().Fatalf("Could not agree to TOS: %s", err.Error()) + } + + acc.Save() + return + } + + reader := bufio.NewReader(os.Stdin) + logger().Printf("Please review the TOS at %s", acc.Registration.TosURL) + + for { + logger().Println("Do you accept the TOS? Y/n") + text, err := reader.ReadString('\n') + if err != nil { + logger().Fatalf("Could not read from console: %s", err.Error()) + } + + text = strings.Trim(text, "\r\n") + + if text == "n" { + logger().Fatal("You did not accept the TOS. Unable to proceed.") + } + + if text == "Y" || text == "y" || text == "" { + err = client.AgreeToTOS() + if err != nil { + logger().Fatalf("Could not agree to TOS: %s", err.Error()) + } + acc.Save() + break + } + + logger().Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.") + } +} + func run(c *cli.Context) { conf, acc, client := setup(c) if acc.Registration == nil { @@ -145,41 +206,16 @@ func run(c *cli.Context) { } + // If the agreement URL is empty, the account still needs to accept the LE TOS. if acc.Registration.Body.Agreement == "" { - reader := bufio.NewReader(os.Stdin) - logger().Printf("Please review the TOS at %s", acc.Registration.TosURL) - - for { - logger().Println("Do you accept the TOS? Y/n") - text, err := reader.ReadString('\n') - if err != nil { - logger().Fatalf("Could not read from console -> %s", err.Error()) - } - - text = strings.Trim(text, "\r\n") - - if text == "n" { - logger().Fatal("You did not accept the TOS. Unable to proceed.") - } - - if text == "Y" || text == "y" || text == "" { - err = client.AgreeToTOS() - if err != nil { - logger().Fatalf("Could not agree to tos -> %s", err) - } - acc.Save() - break - } - - logger().Println("Your input was invalid. Please answer with one of Y/y, n or by pressing enter.") - } + handleTOS(c, client, acc) } if len(c.GlobalStringSlice("domains")) == 0 { logger().Fatal("Please specify --domains or -d") } - cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), true, nil) + cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil) if len(failures) > 0 { for k, v := range failures { logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error()) @@ -193,7 +229,7 @@ func run(c *cli.Context) { err := checkFolder(conf.CertPath()) if err != nil { - logger().Fatalf("Cound not check/create path: %s", err.Error()) + logger().Fatalf("Could not check/create path: %s", err.Error()) } saveCertRes(cert, conf) @@ -205,7 +241,7 @@ func revoke(c *cli.Context) { err := checkFolder(conf.CertPath()) if err != nil { - logger().Fatalf("Cound not check/create path: %s", err.Error()) + logger().Fatalf("Could not check/create path: %s", err.Error()) } for _, domain := range c.GlobalStringSlice("domains") { @@ -276,7 +312,7 @@ func renew(c *cli.Context) { certRes.Certificate = certBytes - newCert, err := client.RenewCertificate(certRes, true) + newCert, err := client.RenewCertificate(certRes, !c.Bool("no-bundle")) if err != nil { logger().Fatalf("%s", err.Error()) } diff --git a/configuration.go b/configuration.go index 503510f8..a437fd56 100644 --- a/configuration.go +++ b/configuration.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/url" "os" "path" @@ -20,11 +21,25 @@ func NewConfiguration(c *cli.Context) *Configuration { return &Configuration{context: c} } -// RsaBits returns the current set RSA bit length for private keys -func (c *Configuration) RsaBits() int { - return c.context.GlobalInt("rsa-key-size") +// KeyType the type from which private keys should be generated +func (c *Configuration) KeyType() (acme.KeyType, error) { + switch strings.ToUpper(c.context.GlobalString("key-type")) { + case "RSA2048": + return acme.RSA2048, nil + case "RSA4096": + return acme.RSA4096, nil + case "RSA8192": + return acme.RSA8192, nil + case "EC256": + return acme.EC256, nil + case "EC384": + return acme.EC384, nil + } + + return "", fmt.Errorf("Unsupported KeyType: %s", c.context.GlobalString("key-type")) } +// ExcludedSolvers is a list of solvers that are to be excluded. func (c *Configuration) ExcludedSolvers() (cc []acme.Challenge) { for _, s := range c.context.GlobalStringSlice("exclude") { cc = append(cc, acme.Challenge(s)) @@ -39,6 +54,7 @@ func (c *Configuration) ServerPath() string { return strings.Replace(srvStr, "/", string(os.PathSeparator), -1) } +// CertPath gets the path for certificates. func (c *Configuration) CertPath() string { return path.Join(c.context.GlobalString("path"), "certificates") } @@ -54,7 +70,7 @@ func (c *Configuration) AccountPath(acc string) string { return path.Join(c.AccountsPath(), acc) } -// AccountPath returns the OS dependent path to the keys of a particular account +// AccountKeysPath returns the OS dependent path to the keys of a particular account func (c *Configuration) AccountKeysPath(acc string) string { return path.Join(c.AccountPath(acc), "keys") } diff --git a/crypto.go b/crypto.go index 3644ed99..8b23e2fc 100644 --- a/crypto.go +++ b/crypto.go @@ -1,21 +1,30 @@ package main import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "io/ioutil" "os" ) -func generateRsaKey(length int, file string) (*rsa.PrivateKey, error) { - privateKey, err := rsa.GenerateKey(rand.Reader, length) +func generatePrivateKey(file string) (crypto.PrivateKey, error) { + + privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { return nil, err } - pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + keyBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return nil, err + } + + pemKey := pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes} certOut, err := os.Create(file) if err != nil { @@ -28,12 +37,20 @@ func generateRsaKey(length int, file string) (*rsa.PrivateKey, error) { return privateKey, nil } -func loadRsaKey(file string) (*rsa.PrivateKey, error) { +func loadPrivateKey(file string) (crypto.PrivateKey, error) { keyBytes, err := ioutil.ReadFile(file) if err != nil { return nil, err } keyBlock, _ := pem.Decode(keyBytes) - return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + + switch keyBlock.Type { + case "RSA PRIVATE KEY": + return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + case "EC PRIVATE KEY": + return x509.ParseECPrivateKey(keyBlock.Bytes) + } + + return nil, errors.New("Unknown private key type.") } diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go new file mode 100644 index 00000000..307cc4ef --- /dev/null +++ b/providers/dns/cloudflare/cloudflare.go @@ -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"` +} diff --git a/acme/dns_challenge_cloudflare_test.go b/providers/dns/cloudflare/cloudflare_test.go similarity index 73% rename from acme/dns_challenge_cloudflare_test.go rename to providers/dns/cloudflare/cloudflare_test.go index 8b3cd461..63936ce6 100644 --- a/acme/dns_challenge_cloudflare_test.go +++ b/providers/dns/cloudflare/cloudflare_test.go @@ -1,4 +1,4 @@ -package acme +package cloudflare import ( "os" @@ -29,26 +29,26 @@ func restoreCloudFlareEnv() { os.Setenv("CLOUDFLARE_API_KEY", cflareAPIKey) } -func TestNewDNSProviderCloudFlareValid(t *testing.T) { +func TestNewDNSProviderValid(t *testing.T) { os.Setenv("CLOUDFLARE_EMAIL", "") os.Setenv("CLOUDFLARE_API_KEY", "") - _, err := NewDNSProviderCloudFlare("123", "123") + _, err := NewDNSProvider("123", "123") assert.NoError(t, err) restoreCloudFlareEnv() } -func TestNewDNSProviderCloudFlareValidEnv(t *testing.T) { +func TestNewDNSProviderValidEnv(t *testing.T) { os.Setenv("CLOUDFLARE_EMAIL", "test@example.com") os.Setenv("CLOUDFLARE_API_KEY", "123") - _, err := NewDNSProviderCloudFlare("", "") + _, err := NewDNSProvider("", "") assert.NoError(t, err) restoreCloudFlareEnv() } -func TestNewDNSProviderCloudFlareMissingCredErr(t *testing.T) { +func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("CLOUDFLARE_EMAIL", "") os.Setenv("CLOUDFLARE_API_KEY", "") - _, err := NewDNSProviderCloudFlare("", "") + _, err := NewDNSProvider("", "") assert.EqualError(t, err, "CloudFlare credentials missing") restoreCloudFlareEnv() } @@ -58,7 +58,7 @@ func TestCloudFlarePresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey) + provider, err := NewDNSProvider(cflareEmail, cflareAPIKey) assert.NoError(t, err) err = provider.Present(cflareDomain, "", "123d==") @@ -70,9 +70,9 @@ func TestCloudFlareCleanUp(t *testing.T) { t.Skip("skipping live test") } - time.Sleep(time.Second * 1) + time.Sleep(time.Second * 2) - provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey) + provider, err := NewDNSProvider(cflareEmail, cflareAPIKey) assert.NoError(t, err) err = provider.CleanUp(cflareDomain, "", "123d==") diff --git a/acme/dns_challenge_digitalocean.go b/providers/dns/digitalocean/digitalocean.go similarity index 83% rename from acme/dns_challenge_digitalocean.go rename to providers/dns/digitalocean/digitalocean.go index 176439e6..fb3e00de 100644 --- a/acme/dns_challenge_digitalocean.go +++ b/providers/dns/digitalocean/digitalocean.go @@ -1,4 +1,5 @@ -package acme +// Package digitalocean implements a DNS provider for solving the DNS-01 challenge using digitalocean DNS. +package digitalocean import ( "bytes" @@ -6,28 +7,30 @@ import ( "fmt" "net/http" "sync" + + "github.com/xenolf/lego/acme" ) -// DNSProviderDigitalOcean is an implementation of the DNSProvider interface +// DNSProvider is an implementation of the acme.ChallengeProvider interface // that uses DigitalOcean's REST API to manage TXT records for a domain. -type DNSProviderDigitalOcean struct { +type DNSProvider struct { apiAuthToken string recordIDs map[string]int recordIDsMu sync.Mutex } -// NewDNSProviderDigitalOcean returns a new DNSProviderDigitalOcean instance. +// NewDNSProvider returns a new DNSProvider instance. // apiAuthToken is the personal access token created in the DigitalOcean account // control panel, and it will be sent in bearer authorization headers. -func NewDNSProviderDigitalOcean(apiAuthToken string) (*DNSProviderDigitalOcean, error) { - return &DNSProviderDigitalOcean{ +func NewDNSProvider(apiAuthToken string) (*DNSProvider, error) { + return &DNSProvider{ apiAuthToken: apiAuthToken, recordIDs: make(map[string]int), }, nil } // Present creates a TXT record using the specified parameters -func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error { +func (d *DNSProvider) Present(domain, token, keyAuth string) error { // txtRecordRequest represents the request body to DO's API to make a TXT record type txtRecordRequest struct { RecordType string `json:"type"` @@ -45,7 +48,7 @@ func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error { } `json:"domain_record"` } - fqdn, value, _ := DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) reqURL := fmt.Sprintf("%s/v2/domains/%s/records", digitalOceanBaseURL, domain) reqData := txtRecordRequest{RecordType: "TXT", Name: fqdn, Data: value} @@ -87,8 +90,8 @@ func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters -func (d *DNSProviderDigitalOcean) CleanUp(domain, token, keyAuth string) error { - fqdn, _, _ := DNS01Record(domain, keyAuth) +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) // get the record's unique ID from when we created it d.recordIDsMu.Lock() diff --git a/acme/dns_challenge_digitalocean_test.go b/providers/dns/digitalocean/digitalocean_test.go similarity index 96% rename from acme/dns_challenge_digitalocean_test.go rename to providers/dns/digitalocean/digitalocean_test.go index cf62090e..8fc383a0 100644 --- a/acme/dns_challenge_digitalocean_test.go +++ b/providers/dns/digitalocean/digitalocean_test.go @@ -1,4 +1,4 @@ -package acme +package digitalocean import ( "fmt" @@ -53,7 +53,7 @@ func TestDigitalOceanPresent(t *testing.T) { defer mock.Close() digitalOceanBaseURL = mock.URL - doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth) + doprov, err := NewDNSProvider(fakeDigitalOceanAuth) if doprov == nil { t.Fatal("Expected non-nil DigitalOcean provider, but was nil") } @@ -95,7 +95,7 @@ func TestDigitalOceanCleanUp(t *testing.T) { defer mock.Close() digitalOceanBaseURL = mock.URL - doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth) + doprov, err := NewDNSProvider(fakeDigitalOceanAuth) if doprov == nil { t.Fatal("Expected non-nil DigitalOcean provider, but was nil") } diff --git a/acme/dns_challenge_dnsimple.go b/providers/dns/dnsimple/dnsimple.go similarity index 66% rename from acme/dns_challenge_dnsimple.go rename to providers/dns/dnsimple/dnsimple.go index f22590c7..cde77298 100644 --- a/acme/dns_challenge_dnsimple.go +++ b/providers/dns/dnsimple/dnsimple.go @@ -1,4 +1,5 @@ -package acme +// Package dnsimple implements a DNS provider for solving the DNS-01 challenge using dnsimple DNS. +package dnsimple import ( "fmt" @@ -6,34 +7,35 @@ import ( "strings" "github.com/weppos/dnsimple-go/dnsimple" + "github.com/xenolf/lego/acme" ) -// DNSProviderDNSimple is an implementation of the DNSProvider interface. -type DNSProviderDNSimple struct { +// DNSProvider is an implementation of the acme.ChallengeProvider interface. +type DNSProvider struct { client *dnsimple.Client } -// NewDNSProviderDNSimple returns a DNSProviderDNSimple instance with a configured dnsimple client. +// NewDNSProvider returns a DNSProvider instance with a configured dnsimple client. // Authentication is either done using the passed credentials or - when empty - using the environment // variables DNSIMPLE_EMAIL and DNSIMPLE_API_KEY. -func NewDNSProviderDNSimple(dnsimpleEmail, dnsimpleApiKey string) (*DNSProviderDNSimple, error) { - if dnsimpleEmail == "" || dnsimpleApiKey == "" { - dnsimpleEmail, dnsimpleApiKey = dnsimpleEnvAuth() - if dnsimpleEmail == "" || dnsimpleApiKey == "" { +func NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey string) (*DNSProvider, error) { + if dnsimpleEmail == "" || dnsimpleAPIKey == "" { + dnsimpleEmail, dnsimpleAPIKey = dnsimpleEnvAuth() + if dnsimpleEmail == "" || dnsimpleAPIKey == "" { return nil, fmt.Errorf("DNSimple credentials missing") } } - c := &DNSProviderDNSimple{ - client: dnsimple.NewClient(dnsimpleApiKey, dnsimpleEmail), + c := &DNSProvider{ + client: dnsimple.NewClient(dnsimpleAPIKey, dnsimpleEmail), } return c, nil } // Present creates a TXT record to fulfil the dns-01 challenge. -func (c *DNSProviderDNSimple) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := DNS01Record(domain, keyAuth) +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) zoneID, zoneName, err := c.getHostedZone(domain) if err != nil { @@ -50,8 +52,8 @@ func (c *DNSProviderDNSimple) Present(domain, token, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (c *DNSProviderDNSimple) CleanUp(domain, token, keyAuth string) error { - fqdn, _, _ := DNS01Record(domain, keyAuth) +func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) records, err := c.findTxtRecords(domain, fqdn) if err != nil { @@ -67,7 +69,7 @@ func (c *DNSProviderDNSimple) CleanUp(domain, token, keyAuth string) error { return nil } -func (c *DNSProviderDNSimple) getHostedZone(domain string) (string, string, error) { +func (c *DNSProvider) getHostedZone(domain string) (string, string, error) { domains, _, err := c.client.Domains.List() if err != nil { return "", "", fmt.Errorf("DNSimple API call failed: %v", err) @@ -88,7 +90,7 @@ func (c *DNSProviderDNSimple) getHostedZone(domain string) (string, string, erro return fmt.Sprintf("%v", hostedDomain.Id), hostedDomain.Name, nil } -func (c *DNSProviderDNSimple) findTxtRecords(domain, fqdn string) ([]dnsimple.Record, error) { +func (c *DNSProvider) findTxtRecords(domain, fqdn string) ([]dnsimple.Record, error) { zoneID, zoneName, err := c.getHostedZone(domain) if err != nil { return nil, err @@ -110,7 +112,7 @@ func (c *DNSProviderDNSimple) findTxtRecords(domain, fqdn string) ([]dnsimple.Re return records, nil } -func (c *DNSProviderDNSimple) newTxtRecord(zone, fqdn, value string, ttl int) *dnsimple.Record { +func (c *DNSProvider) newTxtRecord(zone, fqdn, value string, ttl int) *dnsimple.Record { name := c.extractRecordName(fqdn, zone) return &dnsimple.Record{ @@ -121,8 +123,8 @@ func (c *DNSProviderDNSimple) newTxtRecord(zone, fqdn, value string, ttl int) *d } } -func (c *DNSProviderDNSimple) extractRecordName(fqdn, domain string) string { - name := unFqdn(fqdn) +func (c *DNSProvider) extractRecordName(fqdn, domain string) string { + name := acme.UnFqdn(fqdn) if idx := strings.Index(name, "."+domain); idx != -1 { return name[:idx] } diff --git a/acme/dns_challenge_dnsimple_test.go b/providers/dns/dnsimple/dnsimple_test.go similarity index 75% rename from acme/dns_challenge_dnsimple_test.go rename to providers/dns/dnsimple/dnsimple_test.go index 0f51afdd..7cb19fea 100644 --- a/acme/dns_challenge_dnsimple_test.go +++ b/providers/dns/dnsimple/dnsimple_test.go @@ -1,4 +1,4 @@ -package acme +package dnsimple import ( "os" @@ -29,25 +29,25 @@ func restoreDNSimpleEnv() { os.Setenv("DNSIMPLE_API_KEY", dnsimpleAPIKey) } -func TestNewDNSProviderDNSimpleValid(t *testing.T) { +func TestNewDNSProviderValid(t *testing.T) { os.Setenv("DNSIMPLE_EMAIL", "") os.Setenv("DNSIMPLE_API_KEY", "") - _, err := NewDNSProviderDNSimple("example@example.com", "123") + _, err := NewDNSProvider("example@example.com", "123") assert.NoError(t, err) restoreDNSimpleEnv() } -func TestNewDNSProviderDNSimpleValidEnv(t *testing.T) { +func TestNewDNSProviderValidEnv(t *testing.T) { os.Setenv("DNSIMPLE_EMAIL", "example@example.com") os.Setenv("DNSIMPLE_API_KEY", "123") - _, err := NewDNSProviderDNSimple("", "") + _, err := NewDNSProvider("", "") assert.NoError(t, err) restoreDNSimpleEnv() } -func TestNewDNSProviderDNSimpleMissingCredErr(t *testing.T) { +func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("DNSIMPLE_EMAIL", "") os.Setenv("DNSIMPLE_API_KEY", "") - _, err := NewDNSProviderDNSimple("", "") + _, err := NewDNSProvider("", "") assert.EqualError(t, err, "DNSimple credentials missing") restoreDNSimpleEnv() } @@ -57,7 +57,7 @@ func TestLiveDNSimplePresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderDNSimple(dnsimpleEmail, dnsimpleAPIKey) + provider, err := NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey) assert.NoError(t, err) err = provider.Present(dnsimpleDomain, "", "123d==") @@ -71,7 +71,7 @@ func TestLiveDNSimpleCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderDNSimple(cflareEmail, cflareAPIKey) + provider, err := NewDNSProvider(dnsimpleEmail, dnsimpleAPIKey) assert.NoError(t, err) err = provider.CleanUp(dnsimpleDomain, "", "123d==") diff --git a/providers/dns/rfc2136/rfc2136.go b/providers/dns/rfc2136/rfc2136.go new file mode 100644 index 00000000..3d32409b --- /dev/null +++ b/providers/dns/rfc2136/rfc2136.go @@ -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 +} diff --git a/acme/dns_challenge_rfc2136_test.go b/providers/dns/rfc2136/rfc2136_test.go similarity index 74% rename from acme/dns_challenge_rfc2136_test.go rename to providers/dns/rfc2136/rfc2136_test.go index f9fc5dea..2aa8aa22 100644 --- a/acme/dns_challenge_rfc2136_test.go +++ b/providers/dns/rfc2136/rfc2136_test.go @@ -1,7 +1,8 @@ -package acme +package rfc2136 import ( "bytes" + "fmt" "net" "strings" "sync" @@ -9,6 +10,7 @@ import ( "time" "github.com/miekg/dns" + "github.com/xenolf/lego/acme" ) var ( @@ -25,6 +27,7 @@ var ( var reqChan = make(chan *dns.Msg, 10) func TestRFC2136CanaryLocalTestServer(t *testing.T) { + acme.ClearFqdnCache() dns.HandleFunc("example.com.", serverHandlerHello) defer dns.HandleRemove("example.com.") @@ -48,6 +51,7 @@ func TestRFC2136CanaryLocalTestServer(t *testing.T) { } func TestRFC2136ServerSuccess(t *testing.T) { + acme.ClearFqdnCache() dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess) defer dns.HandleRemove(rfc2136TestZone) @@ -57,9 +61,9 @@ func TestRFC2136ServerSuccess(t *testing.T) { } defer server.Shutdown() - provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "") + provider, err := NewDNSProvider(addrstr, "", "", "") if err != nil { - t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) + t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err) } if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil { t.Errorf("Expected Present() to return no error but the error was -> %v", err) @@ -67,6 +71,7 @@ func TestRFC2136ServerSuccess(t *testing.T) { } func TestRFC2136ServerError(t *testing.T) { + acme.ClearFqdnCache() dns.HandleFunc(rfc2136TestZone, serverHandlerReturnErr) defer dns.HandleRemove(rfc2136TestZone) @@ -76,9 +81,9 @@ func TestRFC2136ServerError(t *testing.T) { } defer server.Shutdown() - provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "") + provider, err := NewDNSProvider(addrstr, "", "", "") if err != nil { - t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) + t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err) } if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err == nil { t.Errorf("Expected Present() to return an error but it did not.") @@ -88,6 +93,7 @@ func TestRFC2136ServerError(t *testing.T) { } func TestRFC2136TsigClient(t *testing.T) { + acme.ClearFqdnCache() dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess) defer dns.HandleRemove(rfc2136TestZone) @@ -97,9 +103,9 @@ func TestRFC2136TsigClient(t *testing.T) { } defer server.Shutdown() - provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, rfc2136TestTsigKey, rfc2136TestTsigSecret) + provider, err := NewDNSProvider(addrstr, "", rfc2136TestTsigKey, rfc2136TestTsigSecret) if err != nil { - t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) + t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err) } if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil { t.Errorf("Expected Present() to return no error but the error was -> %v", err) @@ -107,6 +113,7 @@ func TestRFC2136TsigClient(t *testing.T) { } func TestRFC2136ValidUpdatePacket(t *testing.T) { + acme.ClearFqdnCache() dns.HandleFunc(rfc2136TestZone, serverHandlerPassBackRequest) defer dns.HandleRemove(rfc2136TestZone) @@ -116,18 +123,11 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) { } defer server.Shutdown() - rr := new(dns.TXT) - rr.Hdr = dns.RR_Header{ - Name: rfc2136TestFqdn, - Rrtype: dns.TypeTXT, - Class: dns.ClassINET, - Ttl: uint32(rfc2136TestTTL), - } - rr.Txt = []string{rfc2136TestValue} - rrs := make([]dns.RR, 1) - rrs[0] = rr + txtRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN TXT %s", rfc2136TestFqdn, rfc2136TestTTL, rfc2136TestValue)) + rrs := []dns.RR{txtRR} m := new(dns.Msg) - m.SetUpdate(dns.Fqdn(rfc2136TestZone)) + m.SetUpdate(rfc2136TestZone) + m.RemoveRRset(rrs) m.Insert(rrs) expectstr := m.String() expect, err := m.Pack() @@ -135,9 +135,9 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) { t.Fatalf("Error packing expect msg: %v", err) } - provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "") + provider, err := NewDNSProvider(addrstr, "", "", "") if err != nil { - t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) + t.Fatalf("Expected NewDNSProvider() to return no error but the error was -> %v", err) } if err := provider.Present(rfc2136TestDomain, "", "1234d=="); err != nil { @@ -198,6 +198,11 @@ func serverHandlerHello(w dns.ResponseWriter, req *dns.Msg) { func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) { m := new(dns.Msg) m.SetReply(req) + if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET { + // Return SOA to appease findZoneByFqdn() + soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", rfc2136TestZone, rfc2136TestTTL, rfc2136TestZone, rfc2136TestZone)) + m.Answer = []dns.RR{soaRR} + } if t := req.IsTsig(); t != nil { if w.TsigStatus() == nil { @@ -218,6 +223,11 @@ func serverHandlerReturnErr(w dns.ResponseWriter, req *dns.Msg) { func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) { m := new(dns.Msg) m.SetReply(req) + if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET { + // Return SOA to appease findZoneByFqdn() + soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", rfc2136TestZone, rfc2136TestTTL, rfc2136TestZone, rfc2136TestZone)) + m.Answer = []dns.RR{soaRR} + } if t := req.IsTsig(); t != nil { if w.TsigStatus() == nil { @@ -227,5 +237,8 @@ func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) { } w.WriteMsg(m) - reqChan <- req + if req.Opcode != dns.OpcodeQuery || req.Question[0].Qtype != dns.TypeSOA || req.Question[0].Qclass != dns.ClassINET { + // Only talk back when it is not the SOA RR. + reqChan <- req + } } diff --git a/acme/dns_challenge_route53.go b/providers/dns/route53/route53.go similarity index 73% rename from acme/dns_challenge_route53.go rename to providers/dns/route53/route53.go index 9ee77e35..eb1ffdf3 100644 --- a/acme/dns_challenge_route53.go +++ b/providers/dns/route53/route53.go @@ -1,4 +1,5 @@ -package acme +// Package route53 implements a DNS provider for solving the DNS-01 challenge using route53 DNS. +package route53 import ( "fmt" @@ -7,20 +8,21 @@ import ( "github.com/mitchellh/goamz/aws" "github.com/mitchellh/goamz/route53" + "github.com/xenolf/lego/acme" ) -// DNSProviderRoute53 is an implementation of the DNSProvider interface -type DNSProviderRoute53 struct { +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { client *route53.Route53 } -// NewDNSProviderRoute53 returns a DNSProviderRoute53 instance with a configured route53 client. +// NewDNSProvider returns a DNSProvider instance with a configured route53 client. // Authentication is either done using the passed credentials or - when empty - falling back to -// the customary AWS credential mechanisms, including the file refernced by $AWS_CREDENTIAL_FILE +// the customary AWS credential mechanisms, including the file referenced by $AWS_CREDENTIAL_FILE // (defaulting to $HOME/.aws/credentials) optionally scoped to $AWS_PROFILE, credentials // supplied by the environment variables AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY [ + AWS_SECURITY_TOKEN ], // and finally credentials available via the EC2 instance metadata service. -func NewDNSProviderRoute53(awsAccessKey, awsSecretKey, awsRegionName string) (*DNSProviderRoute53, error) { +func NewDNSProvider(awsAccessKey, awsSecretKey, awsRegionName string) (*DNSProvider, error) { region, ok := aws.Regions[awsRegionName] if !ok { return nil, fmt.Errorf("Invalid AWS region name %s", awsRegionName) @@ -32,35 +34,36 @@ func NewDNSProviderRoute53(awsAccessKey, awsSecretKey, awsRegionName string) (*D // - uses AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY and optionally AWS_SECURITY_TOKEN, if provided // - uses EC2 instance metadata credentials (http://169.254.169.254/latest/meta-data/…), if available // ...and otherwise returns an error - if auth, err := aws.GetAuth(awsAccessKey, awsSecretKey); err != nil { + auth, err := aws.GetAuth(awsAccessKey, awsSecretKey) + if err != nil { return nil, err - } else { - client := route53.New(auth, region) - return &DNSProviderRoute53{client: client}, nil } + + client := route53.New(auth, region) + return &DNSProvider{client: client}, nil } // Present creates a TXT record using the specified parameters -func (r *DNSProviderRoute53) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := DNS01Record(domain, keyAuth) +func (r *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) value = `"` + value + `"` return r.changeRecord("UPSERT", fqdn, value, ttl) } // CleanUp removes the TXT record matching the specified parameters -func (r *DNSProviderRoute53) CleanUp(domain, token, keyAuth string) error { - fqdn, value, ttl := DNS01Record(domain, keyAuth) +func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) value = `"` + value + `"` return r.changeRecord("DELETE", fqdn, value, ttl) } -func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) error { +func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { hostedZoneID, err := r.getHostedZoneID(fqdn) if err != nil { return err } recordSet := newTXTRecordSet(fqdn, value, ttl) - update := route53.Change{action, recordSet} + update := route53.Change{Action: action, Record: recordSet} changes := []route53.Change{update} req := route53.ChangeResourceRecordSetsRequest{Comment: "Created by Lego", Changes: changes} resp, err := r.client.ChangeResourceRecordSets(hostedZoneID, &req) @@ -68,7 +71,7 @@ func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) e return err } - return waitFor(90, func() (bool, error) { + return acme.WaitFor(90*time.Second, 5*time.Second, func() (bool, error) { status, err := r.client.GetChange(resp.ChangeInfo.ID) if err != nil { return false, err @@ -80,7 +83,7 @@ func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) e }) } -func (r *DNSProviderRoute53) getHostedZoneID(fqdn string) (string, error) { +func (r *DNSProvider) getHostedZoneID(fqdn string) (string, error) { zones := []route53.HostedZone{} zoneResp, err := r.client.ListHostedZones("", 0) if err != nil { diff --git a/acme/dns_challenge_route53_test.go b/providers/dns/route53/route53_test.go similarity index 79% rename from acme/dns_challenge_route53_test.go rename to providers/dns/route53/route53_test.go index 20eb008b..8bb675c3 100644 --- a/acme/dns_challenge_route53_test.go +++ b/providers/dns/route53/route53_test.go @@ -1,8 +1,10 @@ -package acme +package route53 import ( + "net/http" "os" "testing" + "time" "github.com/mitchellh/goamz/aws" "github.com/mitchellh/goamz/route53" @@ -63,9 +65,9 @@ var GetChangeAnswer = ` ` var serverResponseMap = testutil.ResponseMap{ - "/2013-04-01/hostedzone/": testutil.Response{200, nil, ListHostedZonesAnswer}, - "/2013-04-01/hostedzone/Z2K123214213123/rrset": testutil.Response{200, nil, ChangeResourceRecordSetsAnswer}, - "/2013-04-01/change/asdf": testutil.Response{200, nil, GetChangeAnswer}, + "/2013-04-01/hostedzone/": testutil.Response{Status: 200, Headers: nil, Body: ListHostedZonesAnswer}, + "/2013-04-01/hostedzone/Z2K123214213123/rrset": testutil.Response{Status: 200, Headers: nil, Body: ChangeResourceRecordSetsAnswer}, + "/2013-04-01/change/asdf": testutil.Response{Status: 200, Headers: nil, Body: GetChangeAnswer}, } func init() { @@ -89,40 +91,49 @@ func makeRoute53TestServer() *testutil.HTTPServer { return testServer } -func makeRoute53Provider(server *testutil.HTTPServer) *DNSProviderRoute53 { - auth := aws.Auth{"abc", "123", ""} +func makeRoute53Provider(server *testutil.HTTPServer) *DNSProvider { + auth := aws.Auth{AccessKey: "abc", SecretKey: "123", Token: ""} client := route53.NewWithClient(auth, aws.Region{Route53Endpoint: server.URL}, testutil.DefaultClient) - return &DNSProviderRoute53{client: client} + return &DNSProvider{client: client} } -func TestNewDNSProviderRoute53Valid(t *testing.T) { +func TestNewDNSProviderValid(t *testing.T) { os.Setenv("AWS_ACCESS_KEY_ID", "") os.Setenv("AWS_SECRET_ACCESS_KEY", "") - _, err := NewDNSProviderRoute53("123", "123", "us-east-1") + _, err := NewDNSProvider("123", "123", "us-east-1") assert.NoError(t, err) restoreRoute53Env() } -func TestNewDNSProviderRoute53ValidEnv(t *testing.T) { +func TestNewDNSProviderValidEnv(t *testing.T) { os.Setenv("AWS_ACCESS_KEY_ID", "123") os.Setenv("AWS_SECRET_ACCESS_KEY", "123") - _, err := NewDNSProviderRoute53("", "", "us-east-1") + _, err := NewDNSProvider("", "", "us-east-1") assert.NoError(t, err) restoreRoute53Env() } -func TestNewDNSProviderRoute53MissingAuthErr(t *testing.T) { +func TestNewDNSProviderMissingAuthErr(t *testing.T) { os.Setenv("AWS_ACCESS_KEY_ID", "") os.Setenv("AWS_SECRET_ACCESS_KEY", "") os.Setenv("AWS_CREDENTIAL_FILE", "") // in case test machine has this variable set os.Setenv("HOME", "/") // in case test machine has ~/.aws/credentials - _, err := NewDNSProviderRoute53("", "", "us-east-1") + + // The default AWS HTTP client retries three times with a deadline of 10 seconds. + // Replace the default HTTP client with one that does not retry and has a low timeout. + awsClient := aws.RetryingClient + aws.RetryingClient = &http.Client{Timeout: time.Millisecond} + + _, err := NewDNSProvider("", "", "us-east-1") assert.EqualError(t, err, "No valid AWS authentication found") restoreRoute53Env() + + // restore default AWS HTTP client + aws.RetryingClient = awsClient } -func TestNewDNSProviderRoute53InvalidRegionErr(t *testing.T) { - _, err := NewDNSProviderRoute53("123", "123", "us-east-3") +func TestNewDNSProviderInvalidRegionErr(t *testing.T) { + _, err := NewDNSProvider("123", "123", "us-east-3") assert.EqualError(t, err, "Invalid AWS region name us-east-3") }