diff --git a/acme/challenges.go b/acme/challenges.go new file mode 100644 index 00000000..3f679e00 --- /dev/null +++ b/acme/challenges.go @@ -0,0 +1,15 @@ +package acme + +type Challenge string + +const ( + // HTTP01 is the "http-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#http + // Note: HTTP01ChallengePath returns the URL path to fulfill this challenge + HTTP01 = Challenge("http-01") + // TLSSNI01 is the "tls-sni-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#tls-with-server-name-indication-tls-sni + // Note: TLSSNI01ChallengeCert returns a certificate to fulfill this challenge + TLSSNI01 = Challenge("tls-sni-01") + // DNS01 is the "dns-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#dns + // Note: DNS01Record returns a DNS record which will fulfill this challenge + DNS01 = Challenge("dns-01") +) diff --git a/acme/client.go b/acme/client.go index b8682956..9ddff186 100644 --- a/acme/client.go +++ b/acme/client.go @@ -54,7 +54,7 @@ type Client struct { jws *jws keyBits int issuerCert []byte - solvers map[string]solver + solvers map[Challenge]solver } // NewClient creates a new ACME client on behalf of the user. The client will depend on @@ -93,13 +93,28 @@ func NewClient(caDirURL string, user User, keyBits int) (*Client, error) { // REVIEW: best possibility? // Add all available solvers with the right index as per ACME // spec to this map. Otherwise they won`t be found. - solvers := make(map[string]solver) - solvers["http-01"] = &httpChallenge{jws: jws, validate: validate} - solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, validate: validate} + solvers := make(map[Challenge]solver) + solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate} + solvers[TLSSNI01] = &tlsSNIChallenge{jws: jws, validate: validate} return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil } +// SetChallengeProvider specifies a custom provider that will make the solution available +func (c *Client) SetChallengeProvider(challenge Challenge, p ChallengeProvider) error { + switch challenge { + case HTTP01: + c.solvers[challenge] = &httpChallenge{jws: c.jws, validate: validate, provider: p} + case TLSSNI01: + c.solvers[challenge] = &tlsSNIChallenge{jws: c.jws, validate: validate, provider: p} + case DNS01: + c.solvers[challenge] = &dnsChallenge{jws: c.jws, provider: p} + default: + return fmt.Errorf("Unknown challenge %v", challenge) + } + return nil +} + // SetHTTPAddress specifies a custom interface:port to be used for HTTP based challenges. // If this option is not used, the default port 80 and all interfaces will be used. // To only specify a port and no interface use the ":port" notation. @@ -109,9 +124,8 @@ func (c *Client) SetHTTPAddress(iface string) error { return err } - if chlng, ok := c.solvers["http-01"]; ok { - chlng.(*httpChallenge).iface = host - chlng.(*httpChallenge).port = port + if chlng, ok := c.solvers[HTTP01]; ok { + chlng.(*httpChallenge).provider = &httpChallengeServer{iface: host, port: port} } return nil @@ -126,21 +140,17 @@ func (c *Client) SetTLSAddress(iface string) error { return err } - if chlng, ok := c.solvers["tls-sni-01"]; ok { - chlng.(*tlsSNIChallenge).iface = host - chlng.(*tlsSNIChallenge).port = port + if chlng, ok := c.solvers[TLSSNI01]; ok { + chlng.(*tlsSNIChallenge).provider = &tlsSNIChallengeServer{iface: host, port: port} } - return nil } // ExcludeChallenges explicitly removes challenges from the pool for solving. -func (c *Client) ExcludeChallenges(challenges []string) { +func (c *Client) ExcludeChallenges(challenges []Challenge) { // Loop through all challenges and delete the requested one if found. for _, challenge := range challenges { - if _, ok := c.solvers[challenge]; ok { - delete(c.solvers, challenge) - } + delete(c.solvers, challenge) } } diff --git a/acme/client_test.go b/acme/client_test.go index 3daaed5f..c94d8f3e 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -75,32 +75,32 @@ func TestClientOptPort(t *testing.T) { client.SetHTTPAddress(net.JoinHostPort(optHost, optPort)) client.SetTLSAddress(net.JoinHostPort(optHost, optPort)) - httpSolver, ok := client.solvers["http-01"].(*httpChallenge) + httpSolver, ok := client.solvers[HTTP01].(*httpChallenge) if !ok { t.Fatal("Expected http-01 solver to be httpChallenge type") } if httpSolver.jws != client.jws { t.Error("Expected http-01 to have same jws as client") } - if httpSolver.port != optPort { - t.Errorf("Expected http-01 to have port %s but was %s", optPort, httpSolver.port) + if got := httpSolver.provider.(*httpChallengeServer).port; got != optPort { + t.Errorf("Expected http-01 to have port %s but was %s", optPort, got) } - if httpSolver.iface != optHost { - t.Errorf("Expected http-01 to have iface %s but was %s", optHost, httpSolver.iface) + if got := httpSolver.provider.(*httpChallengeServer).iface; got != optHost { + t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got) } - httpsSolver, ok := client.solvers["tls-sni-01"].(*tlsSNIChallenge) + httpsSolver, ok := client.solvers[TLSSNI01].(*tlsSNIChallenge) if !ok { t.Fatal("Expected tls-sni-01 solver to be httpChallenge type") } if httpsSolver.jws != client.jws { t.Error("Expected tls-sni-01 to have same jws as client") } - if httpsSolver.port != optPort { - t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, httpSolver.port) + if got := httpsSolver.provider.(*tlsSNIChallengeServer).port; got != optPort { + t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got) } - if httpsSolver.port != optPort { - t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, httpSolver.iface) + if got := httpsSolver.provider.(*tlsSNIChallengeServer).iface; got != optHost { + t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, got) } // test setting different host @@ -108,11 +108,11 @@ func TestClientOptPort(t *testing.T) { client.SetHTTPAddress(net.JoinHostPort(optHost, optPort)) client.SetTLSAddress(net.JoinHostPort(optHost, optPort)) - if httpSolver.iface != optHost { - t.Errorf("Expected http-01 to have iface %s but was %s", optHost, httpSolver.iface) + if got := httpSolver.provider.(*httpChallengeServer).iface; got != optHost { + t.Errorf("Expected http-01 to have iface %s but was %s", optHost, got) } - if httpsSolver.port != optPort { - t.Errorf("Expected tls-sni-01 to have port %s but was %s", optHost, httpSolver.iface) + if got := httpsSolver.provider.(*tlsSNIChallengeServer).port; got != optPort { + t.Errorf("Expected tls-sni-01 to have port %s but was %s", optPort, got) } } diff --git a/acme/dns_challenge.go b/acme/dns_challenge.go index 5ee70556..1e000880 100644 --- a/acme/dns_challenge.go +++ b/acme/dns_challenge.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "strings" "time" @@ -19,37 +20,49 @@ var preCheckDNS preCheckDNSFunc = checkDNS var preCheckDNSFallbackCount = 5 -// DNSProvider represents a service for managing DNS records. -type DNSProvider interface { - CreateTXTRecord(fqdn, value string, ttl int) error - RemoveTXTRecord(fqdn, value string, ttl int) error +// DNS01Record returns a DNS record which will fulfill the `dns-01` challenge +func DNS01Record(domain, keyAuth string) (fqdn string, value string, ttl int) { + keyAuthShaBytes := sha256.Sum256([]byte(keyAuth)) + // base64URL encoding without padding + keyAuthSha := base64.URLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size]) + value = strings.TrimRight(keyAuthSha, "=") + ttl = 120 + fqdn = fmt.Sprintf("_acme-challenge.%s.", domain) + return } // dnsChallenge implements the dns-01 challenge according to ACME 7.5 type dnsChallenge struct { jws *jws - provider DNSProvider + provider ChallengeProvider } func (s *dnsChallenge) Solve(chlng challenge, domain string) error { logf("[INFO] acme: Trying to solve DNS-01") + if s.provider == nil { + return errors.New("No DNS Provider configured") + } + // Generate the Key Authorization for the challenge keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey) if err != nil { return err } - keyAuthShaBytes := sha256.Sum256([]byte(keyAuth)) - // base64URL encoding without padding - keyAuthSha := base64.URLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size]) - keyAuthSha = strings.TrimRight(keyAuthSha, "=") - - fqdn := fmt.Sprintf("_acme-challenge.%s.", domain) - if err = s.provider.CreateTXTRecord(fqdn, keyAuthSha, 120); err != nil { - return err + err = s.provider.Present(domain, chlng.Token, keyAuth) + if err != nil { + return fmt.Errorf("Error presenting token %s", err) } + defer func() { + err := s.provider.CleanUp(domain, chlng.Token, keyAuth) + if err != nil { + log.Printf("Error cleaning up %s %v ", domain, err) + } + }() + + fqdn, _, _ := DNS01Record(domain, keyAuth) preCheckDNS(domain, fqdn) @@ -94,10 +107,6 @@ Loop: resp, err = http.Get(chlng.URI) } - if err = s.provider.RemoveTXTRecord(fqdn, keyAuthSha, 120); err != nil { - logf("[WARN] acme: Failed to cleanup DNS record. -> %v ", err) - } - return nil } diff --git a/acme/dns_challenge_cloudflare.go b/acme/dns_challenge_cloudflare.go index da7c8bed..9418dfc2 100644 --- a/acme/dns_challenge_cloudflare.go +++ b/acme/dns_challenge_cloudflare.go @@ -34,8 +34,9 @@ func NewDNSProviderCloudFlare(cloudflareEmail, cloudflareKey string) (*DNSProvid return c, nil } -// CreateTXTRecord creates a TXT record using the specified parameters -func (c *DNSProviderCloudFlare) CreateTXTRecord(fqdn, value string, ttl int) error { +// 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 @@ -50,8 +51,9 @@ func (c *DNSProviderCloudFlare) CreateTXTRecord(fqdn, value string, ttl int) err return nil } -// RemoveTXTRecord removes the TXT record matching the specified parameters -func (c *DNSProviderCloudFlare) RemoveTXTRecord(fqdn, value string, ttl int) error { +// 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 @@ -60,10 +62,9 @@ func (c *DNSProviderCloudFlare) RemoveTXTRecord(fqdn, value string, ttl int) err for _, rec := range records { err := c.client.Records.Delete(c.ctx, rec.ZoneID, rec.ID) if err != nil { - return fmt.Errorf("CloudFlare API call has failed: %v", err) + return err } } - return nil } @@ -140,12 +141,14 @@ func unFqdn(name string) string { // TTL must be between 120 and 86400 seconds func sanitizeTTL(ttl int) int { - if ttl < 120 { - ttl = 120 - } else if ttl > 86400 { - ttl = 86400 + switch { + case ttl < 120: + return 120 + case ttl > 86400: + return 86400 + default: + return ttl } - return ttl } func envAuth() (email, apiKey string) { diff --git a/acme/dns_challenge_cloudflare_test.go b/acme/dns_challenge_cloudflare_test.go index bb17f028..8b3cd461 100644 --- a/acme/dns_challenge_cloudflare_test.go +++ b/acme/dns_challenge_cloudflare_test.go @@ -1,7 +1,6 @@ package acme import ( - "fmt" "os" "testing" "time" @@ -54,7 +53,7 @@ func TestNewDNSProviderCloudFlareMissingCredErr(t *testing.T) { restoreCloudFlareEnv() } -func TestCloudFlareCreateTXTRecord(t *testing.T) { +func TestCloudFlarePresent(t *testing.T) { if !cflareLiveTest { t.Skip("skipping live test") } @@ -62,12 +61,11 @@ func TestCloudFlareCreateTXTRecord(t *testing.T) { provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey) assert.NoError(t, err) - fqdn := fmt.Sprintf("_acme-challenge.123.%s.", cflareDomain) - err = provider.CreateTXTRecord(fqdn, "123d==", 120) + err = provider.Present(cflareDomain, "", "123d==") assert.NoError(t, err) } -func TestCloudFlareRemoveTXTRecord(t *testing.T) { +func TestCloudFlareCleanUp(t *testing.T) { if !cflareLiveTest { t.Skip("skipping live test") } @@ -77,7 +75,6 @@ func TestCloudFlareRemoveTXTRecord(t *testing.T) { provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey) assert.NoError(t, err) - fqdn := fmt.Sprintf("_acme-challenge.123.%s.", cflareDomain) - err = provider.RemoveTXTRecord(fqdn, "123d==", 120) + err = provider.CleanUp(cflareDomain, "", "123d==") assert.NoError(t, err) } diff --git a/acme/dns_challenge_manual.go b/acme/dns_challenge_manual.go index 3f3805de..4f1e3094 100644 --- a/acme/dns_challenge_manual.go +++ b/acme/dns_challenge_manual.go @@ -10,7 +10,7 @@ const ( dnsTemplate = "%s %d IN TXT \"%s\"" ) -// DNSProviderManual is an implementation of the DNSProvider interface +// DNSProviderManual is an implementation of the ChallengeProvider interface type DNSProviderManual struct{} // NewDNSProviderManual returns a DNSProviderManual instance. @@ -18,8 +18,9 @@ func NewDNSProviderManual() (*DNSProviderManual, error) { return &DNSProviderManual{}, nil } -// CreateTXTRecord prints instructions for manually creating the TXT record -func (*DNSProviderManual) CreateTXTRecord(fqdn, value string, ttl int) error { +// Present prints instructions for manually creating the TXT record +func (*DNSProviderManual) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := DNS01Record(domain, keyAuth) dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value) logf("[INFO] acme: Please create the following TXT record in your DNS zone:") logf("[INFO] acme: %s", dnsRecord) @@ -29,9 +30,10 @@ func (*DNSProviderManual) CreateTXTRecord(fqdn, value string, ttl int) error { return nil } -// RemoveTXTRecord prints instructions for manually removing the TXT record -func (*DNSProviderManual) RemoveTXTRecord(fqdn, value string, ttl int) error { - dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value) +// CleanUp prints instructions for manually removing the TXT record +func (*DNSProviderManual) CleanUp(domain, token, keyAuth string) error { + fqdn, _, ttl := DNS01Record(domain, keyAuth) + dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, "...") logf("[INFO] acme: You can now remove this TXT record from your DNS zone:") logf("[INFO] acme: %s", dnsRecord) return nil diff --git a/acme/dns_challenge_rfc2136.go b/acme/dns_challenge_rfc2136.go index cf8412f8..e6f93fa4 100644 --- a/acme/dns_challenge_rfc2136.go +++ b/acme/dns_challenge_rfc2136.go @@ -6,13 +6,14 @@ import ( "time" ) -// DNSProviderRFC2136 is an implementation of the DNSProvider interface that +// 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. @@ -23,6 +24,7 @@ func NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret string) (*DNSPr d := &DNSProviderRFC2136{ nameserver: nameserver, zone: zone, + records: make(map[string]string), } if len(tsigKey) > 0 && len(tsigSecret) > 0 { d.tsigKey = tsigKey @@ -32,13 +34,17 @@ func NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret string) (*DNSPr return d, nil } -// CreateTXTRecord creates a TXT record using the specified parameters -func (r *DNSProviderRFC2136) CreateTXTRecord(fqdn, value string, ttl int) error { +// 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) } -// RemoveTXTRecord removes the TXT record matching the specified parameters -func (r *DNSProviderRFC2136) RemoveTXTRecord(fqdn, value string, ttl int) error { +// 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) } diff --git a/acme/dns_challenge_rfc2136_test.go b/acme/dns_challenge_rfc2136_test.go index 0b832fd3..7e071a7f 100644 --- a/acme/dns_challenge_rfc2136_test.go +++ b/acme/dns_challenge_rfc2136_test.go @@ -11,7 +11,9 @@ import ( ) var ( - rfc2136TestValue = "so6ZGir4GaZqI11h9UccBB==" + rfc2136TestDomain = "123456789.www.example.com" + rfc2136TestKeyAuth = "123d==" + rfc2136TestValue = "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" rfc2136TestFqdn = "_acme-challenge.123456789.www.example.com." rfc2136TestZone = "example.com." rfc2136TestTTL = 120 @@ -58,8 +60,8 @@ func TestRFC2136ServerSuccess(t *testing.T) { if err != nil { t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) } - if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil { - t.Errorf("Expected CreateTXTRecord() 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) } } @@ -77,10 +79,10 @@ func TestRFC2136ServerError(t *testing.T) { if err != nil { t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) } - if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err == nil { - t.Errorf("Expected CreateTXTRecord() to return an error but it did not.") + if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err == nil { + t.Errorf("Expected Present() to return an error but it did not.") } else if !strings.Contains(err.Error(), "NOTZONE") { - t.Errorf("Expected CreateTXTRecord() to return an error with the 'NOTZONE' rcode string but it did not.") + t.Errorf("Expected Present() to return an error with the 'NOTZONE' rcode string but it did not.") } } @@ -98,8 +100,8 @@ func TestRFC2136TsigClient(t *testing.T) { if err != nil { t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) } - if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil { - t.Errorf("Expected CreateTXTRecord() 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) } } @@ -136,8 +138,9 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) { if err != nil { t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err) } - if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil { - t.Errorf("Expected CreateTXTRecord() to return no error but the error was -> %v", err) + + if err := provider.Present(rfc2136TestDomain, "", "1234d=="); err != nil { + t.Errorf("Expected Present() to return no error but the error was -> %v", err) } rcvMsg := <-reqChan diff --git a/acme/dns_challenge_route53.go b/acme/dns_challenge_route53.go index b97a619b..ba6f3e99 100644 --- a/acme/dns_challenge_route53.go +++ b/acme/dns_challenge_route53.go @@ -40,13 +40,15 @@ func NewDNSProviderRoute53(awsAccessKey, awsSecretKey, awsRegionName string) (*D return &DNSProviderRoute53{client: client}, nil } -// CreateTXTRecord creates a TXT record using the specified parameters -func (r *DNSProviderRoute53) CreateTXTRecord(fqdn, value string, ttl int) error { +// Present creates a TXT record using the specified parameters +func (r *DNSProviderRoute53) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := DNS01Record(domain, keyAuth) return r.changeRecord("UPSERT", fqdn, value, ttl) } -// RemoveTXTRecord removes the TXT record matching the specified parameters -func (r *DNSProviderRoute53) RemoveTXTRecord(fqdn, value string, ttl int) error { +// CleanUp removes the TXT record matching the specified parameters +func (r *DNSProviderRoute53) CleanUp(domain, token, keyAuth string) error { + fqdn, value, ttl := DNS01Record(domain, keyAuth) return r.changeRecord("DELETE", fqdn, value, ttl) } @@ -110,7 +112,7 @@ func newTXTRecordSet(fqdn, value string, ttl int) route53.ResourceRecordSet { // Route53 API has pretty strict rate limits (5req/s globally per account) // Hence we check if we are being throttled to maybe retry the request -func rateExceeded (err error) bool { +func rateExceeded(err error) bool { if strings.Contains(err.Error(), "Throttling") { return true } diff --git a/acme/dns_challenge_route53_test.go b/acme/dns_challenge_route53_test.go index c377b84e..3ab573af 100644 --- a/acme/dns_challenge_route53_test.go +++ b/acme/dns_challenge_route53_test.go @@ -108,19 +108,22 @@ func TestNewDNSProviderRoute53InvalidRegionErr(t *testing.T) { assert.EqualError(t, err, "Invalid AWS region name us-east-3") } -func TestRoute53CreateTXTRecord(t *testing.T) { +func TestRoute53Present(t *testing.T) { assert := assert.New(t) testServer := makeRoute53TestServer() provider := makeRoute53Provider(testServer) testServer.ResponseMap(2, serverResponseMap) - err := provider.CreateTXTRecord("_acme-challenge.123.example.com.", "123456d==", 120) - assert.NoError(err, "Expected CreateTXTRecord to return no error") + domain := "example.com" + keyAuth := "123456d==" + + err := provider.Present(domain, "", keyAuth) + assert.NoError(err, "Expected Present to return no error") httpReqs := testServer.WaitRequests(2) httpReq := httpReqs[1] assert.Equal("/2013-04-01/hostedzone/Z2K123214213123/rrset", httpReq.URL.Path, - "Expected CreateTXTRecord to select the correct hostedzone") + "Expected Present to select the correct hostedzone") } diff --git a/acme/http_challenge.go b/acme/http_challenge.go index c3481fe1..b1e96269 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -2,73 +2,44 @@ package acme import ( "fmt" - "net" - "net/http" - "strings" + "log" ) type httpChallenge struct { jws *jws validate validateFunc - iface string - port string - done chan bool + provider ChallengeProvider +} + +// HTTP01ChallengePath returns the URL path for the `http-01` challenge +func HTTP01ChallengePath(token string) string { + return "/.well-known/acme-challenge/" + token } func (s *httpChallenge) Solve(chlng challenge, domain string) error { logf("[INFO][%s] acme: Trying to solve HTTP-01", domain) - s.done = make(chan bool) - // Generate the Key Authorization for the challenge keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey) if err != nil { return err } - // Allow for CLI port override - port := "80" - if s.port != "" { - port = s.port + if s.provider == nil { + s.provider = &httpChallengeServer{} } - iface := "" - if s.iface != "" { - iface = s.iface - } - - listener, err := net.Listen("tcp", net.JoinHostPort(iface, port)) + err = s.provider.Present(domain, chlng.Token, keyAuth) if err != nil { - return fmt.Errorf("Could not start HTTP server for challenge -> %v", err) + return fmt.Errorf("Error presenting token %s", err) } - - path := "/.well-known/acme-challenge/" + chlng.Token - - go s.serve(listener, path, keyAuth, domain) - - err = s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) - listener.Close() - <-s.done - - return err -} - -func (s *httpChallenge) serve(listener net.Listener, path, keyAuth, domain string) { - // The handler validates the HOST header and request type. - // For validation it then writes the token the server returned with the challenge - mux := http.NewServeMux() - mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.Host, domain) && r.Method == "GET" { - w.Header().Add("Content-Type", "text/plain") - w.Write([]byte(keyAuth)) - logf("[INFO][%s] Served key authentication", domain) - } else { - logf("[INFO] Received request for domain %s with method %s", r.Host, r.Method) - w.Write([]byte("TEST")) + defer func() { + err := s.provider.CleanUp(domain, chlng.Token, keyAuth) + if err != nil { + log.Printf("Error cleaning up %s %v ", domain, err) } - }) + }() - http.Serve(listener, mux) - s.done <- true + return s.validate(s.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } diff --git a/acme/http_challenge_server.go b/acme/http_challenge_server.go new file mode 100644 index 00000000..7597e437 --- /dev/null +++ b/acme/http_challenge_server.go @@ -0,0 +1,63 @@ +package acme + +import ( + "fmt" + "net" + "net/http" + "strings" +) + +// httpChallengeServer implements ChallengeProvider for `http-01` challenge +type httpChallengeServer 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 { + if s.port == "" { + s.port = "80" + } + + var err error + s.listener, err = net.Listen("tcp", net.JoinHostPort(s.iface, s.port)) + if err != nil { + return fmt.Errorf("Could not start HTTP server for challenge -> %v", err) + } + + s.done = make(chan bool) + go s.serve(domain, token, keyAuth) + return nil +} + +func (s *httpChallengeServer) CleanUp(domain, token, keyAuth string) error { + if s.listener == nil { + return nil + } + s.listener.Close() + <-s.done + return nil +} + +func (s *httpChallengeServer) serve(domain, token, keyAuth string) { + path := HTTP01ChallengePath(token) + + // The handler validates the HOST header and request type. + // For validation it then writes the token the server returned with the challenge + mux := http.NewServeMux() + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.Host, domain) && r.Method == "GET" { + w.Header().Add("Content-Type", "text/plain") + w.Write([]byte(keyAuth)) + logf("[INFO][%s] Served key authentication", domain) + } else { + logf("[INFO] Received request for domain %s with method %s", r.Host, r.Method) + w.Write([]byte("TEST")) + } + }) + + http.Serve(s.listener, mux) + s.done <- true +} diff --git a/acme/http_challenge_test.go b/acme/http_challenge_test.go index 9ffb27f8..9c33d7d0 100644 --- a/acme/http_challenge_test.go +++ b/acme/http_challenge_test.go @@ -10,7 +10,7 @@ import ( func TestHTTPChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) j := &jws{privKey: privKey.(*rsa.PrivateKey)} - clientChallenge := challenge{Type: "http-01", Token: "http1"} + clientChallenge := challenge{Type: HTTP01, Token: "http1"} mockValidate := func(_ *jws, _, _ string, chlng challenge) error { uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token resp, err := httpGet(uri) @@ -35,7 +35,7 @@ func TestHTTPChallenge(t *testing.T) { return nil } - solver := &httpChallenge{jws: j, validate: mockValidate, port: "23457"} + solver := &httpChallenge{jws: j, validate: mockValidate, provider: &httpChallengeServer{port: "23457"}} if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { t.Errorf("Solve error: got %v, want nil", err) @@ -45,8 +45,8 @@ func TestHTTPChallenge(t *testing.T) { func TestHTTPChallengeInvalidPort(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 128) j := &jws{privKey: privKey.(*rsa.PrivateKey)} - clientChallenge := challenge{Type: "http-01", Token: "http2"} - solver := &httpChallenge{jws: j, validate: stubValidate, port: "123456"} + clientChallenge := challenge{Type: HTTP01, Token: "http2"} + solver := &httpChallenge{jws: j, validate: stubValidate, provider: &httpChallengeServer{port: "123456"}} if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { t.Errorf("Solve error: got %v, want error", err) diff --git a/acme/messages.go b/acme/messages.go index 910f6c06..55e54321 100644 --- a/acme/messages.go +++ b/acme/messages.go @@ -82,7 +82,7 @@ type validationRecord struct { type challenge struct { Resource string `json:"resource,omitempty"` - Type string `json:"type,omitempty"` + Type Challenge `json:"type,omitempty"` Status string `json:"status,omitempty"` URI string `json:"uri,omitempty"` Token string `json:"token,omitempty"` diff --git a/acme/provider.go b/acme/provider.go new file mode 100644 index 00000000..b8998d2c --- /dev/null +++ b/acme/provider.go @@ -0,0 +1,8 @@ +package acme + +// ChallengeProvider presents the solution to a challenge available to be solved +// CleanUp will be called by the challenge if Present ends in a non-error state. +type ChallengeProvider interface { + Present(domain, token, keyAuth string) error + CleanUp(domain, token, keyAuth string) error +} diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index e2511ad3..3c96ea67 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -6,15 +6,13 @@ import ( "crypto/tls" "encoding/hex" "fmt" - "net" - "net/http" + "log" ) type tlsSNIChallenge struct { jws *jws validate validateFunc - iface string - port string + provider ChallengeProvider } func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { @@ -29,41 +27,25 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { return err } - cert, err := t.generateCertificate(keyAuth) + if t.provider == nil { + t.provider = &tlsSNIChallengeServer{} + } + + err = t.provider.Present(domain, chlng.Token, keyAuth) if err != nil { - return err + return fmt.Errorf("Error presenting token %s", err) } - - // Allow for CLI port override - port := "443" - if t.port != "" { - port = t.port - } - - iface := "" - if t.iface != "" { - iface = t.iface - } - - tlsConf := new(tls.Config) - tlsConf.Certificates = []tls.Certificate{cert} - - listener, err := tls.Listen("tcp", net.JoinHostPort(iface, port), tlsConf) - if err != nil { - return fmt.Errorf("Could not start HTTPS server for challenge -> %v", err) - } - defer listener.Close() - - go http.Serve(listener, nil) - + defer func() { + err := t.provider.CleanUp(domain, chlng.Token, keyAuth) + if err != nil { + log.Printf("Error cleaning up %s %v ", domain, err) + } + }() return t.validate(t.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } -func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) { - - zBytes := sha256.Sum256([]byte(keyAuth)) - z := hex.EncodeToString(zBytes[:sha256.Size]) - +// 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) if err != nil { @@ -72,6 +54,8 @@ func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, rsaPrivKey := tempPrivKey.(*rsa.PrivateKey) rsaPrivPEM := pemEncode(rsaPrivKey) + zBytes := sha256.Sum256([]byte(keyAuth)) + z := hex.EncodeToString(zBytes[:sha256.Size]) domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) tempCertPEM, err := generatePemCert(rsaPrivKey, domain) if err != nil { diff --git a/acme/tls_sni_challenge_server.go b/acme/tls_sni_challenge_server.go new file mode 100644 index 00000000..13749632 --- /dev/null +++ b/acme/tls_sni_challenge_server.go @@ -0,0 +1,52 @@ +package acme + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" +) + +// tlsSNIChallengeServer implements ChallengeProvider for `TLS-SNI-01` challenge +type tlsSNIChallengeServer struct { + iface string + port string + done chan bool + listener net.Listener +} + +// Present makes the keyAuth available as a cert +func (s *tlsSNIChallengeServer) Present(domain, token, keyAuth string) error { + if s.port == "" { + s.port = "443" + } + + cert, err := TLSSNI01ChallengeCert(keyAuth) + if err != nil { + return err + } + + tlsConf := new(tls.Config) + tlsConf.Certificates = []tls.Certificate{cert} + + s.listener, err = tls.Listen("tcp", net.JoinHostPort(s.iface, s.port), tlsConf) + if err != nil { + return fmt.Errorf("Could not start HTTPS server for challenge -> %v", err) + } + + s.done = make(chan bool) + go func() { + http.Serve(s.listener, nil) + s.done <- true + }() + return nil +} + +func (s *tlsSNIChallengeServer) CleanUp(domain, token, keyAuth string) error { + if s.listener == nil { + return nil + } + s.listener.Close() + <-s.done + return nil +} diff --git a/acme/tls_sni_challenge_test.go b/acme/tls_sni_challenge_test.go index f2350303..3372912f 100644 --- a/acme/tls_sni_challenge_test.go +++ b/acme/tls_sni_challenge_test.go @@ -13,7 +13,7 @@ import ( func TestTLSSNIChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) j := &jws{privKey: privKey.(*rsa.PrivateKey)} - clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni1"} + clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni1"} mockValidate := func(_ *jws, _, _ string, chlng challenge) error { conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{ InsecureSkipVerify: true, @@ -43,7 +43,7 @@ func TestTLSSNIChallenge(t *testing.T) { return nil } - solver := &tlsSNIChallenge{jws: j, validate: mockValidate, port: "23457"} + solver := &tlsSNIChallenge{jws: j, validate: mockValidate, provider: &tlsSNIChallengeServer{port: "23457"}} if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { t.Errorf("Solve error: got %v, want nil", err) @@ -53,8 +53,8 @@ func TestTLSSNIChallenge(t *testing.T) { func TestTLSSNIChallengeInvalidPort(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 128) j := &jws{privKey: privKey.(*rsa.PrivateKey)} - clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni2"} - solver := &tlsSNIChallenge{jws: j, validate: stubValidate, port: "123456"} + clientChallenge := challenge{Type: TLSSNI01, Token: "tlssni2"} + solver := &tlsSNIChallenge{jws: j, validate: stubValidate, provider: &tlsSNIChallengeServer{port: "123456"}} if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { t.Errorf("Solve error: got %v, want error", err) diff --git a/configuration.go b/configuration.go index a47f1011..503510f8 100644 --- a/configuration.go +++ b/configuration.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/codegangsta/cli" + "github.com/xenolf/lego/acme" ) // Configuration type from CLI and config files. @@ -24,8 +25,11 @@ func (c *Configuration) RsaBits() int { return c.context.GlobalInt("rsa-key-size") } -func (c *Configuration) ExcludedSolvers() []string { - return c.context.GlobalStringSlice("exclude") +func (c *Configuration) ExcludedSolvers() (cc []acme.Challenge) { + for _, s := range c.context.GlobalStringSlice("exclude") { + cc = append(cc, acme.Challenge(s)) + } + return } // ServerPath returns the OS dependent path to the data for a specific CA