From 237689b0cfb53a90984467cba0d9aecafddfdf54 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 11:50:57 +0000 Subject: [PATCH 01/10] Run gofmt on acme/tls_sni_challenge. --- acme/tls_sni_challenge.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index 57ddd7d6..d5f249bd 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -146,8 +146,8 @@ func (t *tlsSNIChallenge) startSNITLSServer(cert tls.Certificate) { } // Signal successfull start t.start <- tlsListener - + http.Serve(tlsListener, nil) - + t.end <- nil } From 58a2fd2267f95513126479c8ae95bb23f769ead5 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 11:51:30 +0000 Subject: [PATCH 02/10] Split off validation function. This is a loop that interacts with the ACME server, not the individual challenges. Also switch to exponential back-off polling for good measure. --- acme/client.go | 79 +++++++++++++++++++++++++++++++++++++++ acme/http_challenge.go | 47 +---------------------- acme/tls_sni_challenge.go | 47 +---------------------- 3 files changed, 81 insertions(+), 92 deletions(-) diff --git a/acme/client.go b/acme/client.go index 6897ed4b..598f5692 100644 --- a/acme/client.go +++ b/acme/client.go @@ -670,3 +670,82 @@ func parseLinks(links []string) map[string]string { return linkMap } + +// validate makes the ACME server start validating a +// challenge response, only returning once it is done. +func validate(j *jws, uri string, chlng challenge) error { + var challengeResponse challenge + + if err := postJSON(j, uri, chlng, &challengeResponse); err != nil { + return err + } + + interval := 1 * time.Second + maxInterval := 15 * time.Minute + + // After the path is sent, the ACME server will access our server. + // Repeatedly check the server for an updated status on our request. + for { + switch challengeResponse.Status { + case "valid": + logf("The server validated our request") + return nil + case "pending": + break + case "invalid": + return errors.New("The server could not validate our request.") + default: + return errors.New("The server returned an unexpected state.") + } + + // Poll with exponential back-off. + time.Sleep(interval) + interval *= 2 + if interval > maxInterval { + interval = maxInterval + } + + if err := getJSON(uri, &challengeResponse); err != nil { + return err + } + } + + return nil +} + +// getJSON performs an HTTP GET request and parses the response body +// as JSON, into the provided respBody object. +func getJSON(uri string, respBody interface{}) error { + resp, err := http.Get(uri) + if err != nil { + return fmt.Errorf("failed to get %q: %v", uri, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return handleHTTPError(resp) + } + + return json.NewDecoder(resp.Body).Decode(respBody) +} + +// postJSON performs an HTTP POST request and parses the response body +// as JSON, into the provided respBody object. +func postJSON(j *jws, uri string, reqBody, respBody interface{}) error { + jsonBytes, err := json.Marshal(reqBody) + if err != nil { + return errors.New("Failed to marshal network message...") + } + + resp, err := j.post(uri, jsonBytes) + if err != nil { + return fmt.Errorf("Failed to post JWS message. -> %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return handleHTTPError(resp) + } + + return json.NewDecoder(resp.Body).Decode(respBody) +} diff --git a/acme/http_challenge.go b/acme/http_challenge.go index 8ace6795..979ec4b3 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -1,13 +1,10 @@ package acme import ( - "encoding/json" - "errors" "fmt" "net" "net/http" "strings" - "time" ) type httpChallenge struct { @@ -47,49 +44,7 @@ func (s *httpChallenge) Solve(chlng challenge, domain string) error { close(s.end) }() - jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) - if err != nil { - return errors.New("Failed to marshal network message...") - } - - // Tell the server we handle HTTP-01 - resp, err := s.jws.post(chlng.URI, jsonBytes) - if err != nil { - return fmt.Errorf("Failed to post JWS message. -> %v", err) - } - - // After the path is sent, the ACME server will access our server. - // Repeatedly check the server for an updated status on our request. - var challengeResponse challenge -Loop: - for { - if resp.StatusCode >= http.StatusBadRequest { - return handleHTTPError(resp) - } - - err = json.NewDecoder(resp.Body).Decode(&challengeResponse) - resp.Body.Close() - if err != nil { - return err - } - - switch challengeResponse.Status { - case "valid": - logf("The server validated our request") - break Loop - case "pending": - break - case "invalid": - return errors.New("The server could not validate our request.") - default: - return errors.New("The server returned an unexpected state.") - } - - time.Sleep(1 * time.Second) - resp, err = http.Get(chlng.URI) - } - - return nil + return validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } func (s *httpChallenge) startHTTPServer(domain string, token string, keyAuth string) { diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index d5f249bd..634bf899 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -5,12 +5,9 @@ import ( "crypto/sha256" "crypto/tls" "encoding/hex" - "encoding/json" - "errors" "fmt" "net" "net/http" - "time" ) type tlsSNIChallenge struct { @@ -57,49 +54,7 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { close(t.end) }() - jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) - if err != nil { - return errors.New("Failed to marshal network message...") - } - - // Tell the server we handle TLS-SNI-01 - resp, err := t.jws.post(chlng.URI, jsonBytes) - if err != nil { - return fmt.Errorf("Failed to post JWS message. -> %v", err) - } - - // After the path is sent, the ACME server will access our server. - // Repeatedly check the server for an updated status on our request. - var challengeResponse challenge -Loop: - for { - if resp.StatusCode >= http.StatusBadRequest { - return handleHTTPError(resp) - } - - err = json.NewDecoder(resp.Body).Decode(&challengeResponse) - resp.Body.Close() - if err != nil { - return err - } - - switch challengeResponse.Status { - case "valid": - logf("The server validated our request") - break Loop - case "pending": - break - case "invalid": - return errors.New("The server could not validate our request.") - default: - return errors.New("The server returned an unexpected state.") - } - - time.Sleep(1 * time.Second) - resp, err = http.Get(chlng.URI) - } - - return nil + return validate(t.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) { From 5dc33c8c084672f43917681fa648a9a92a504725 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 11:58:08 +0000 Subject: [PATCH 03/10] Simplify httpChallenge code. Solve is blocking, so no need to run initialization code in a separate goroutine. Removes the need for s.start. Once the listener is closed, all I/O resources have been returned. No need to wait for http.Serve to return. Removes the need for s.end. --- acme/http_challenge.go | 39 +++++---------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/acme/http_challenge.go b/acme/http_challenge.go index 979ec4b3..87359049 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -10,45 +10,18 @@ import ( type httpChallenge struct { jws *jws optPort string - start chan net.Listener - end chan error } func (s *httpChallenge) Solve(chlng challenge, domain string) error { logf("[INFO] acme: Trying to solve HTTP-01") - s.start = make(chan net.Listener) - s.end = make(chan error) - // Generate the Key Authorization for the challenge keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey) if err != nil { return err } - go s.startHTTPServer(domain, chlng.Token, keyAuth) - var listener net.Listener - select { - case listener = <-s.start: - break - case err := <-s.end: - return fmt.Errorf("Could not start HTTP server for challenge -> %v", err) - } - - // Make sure we properly close the HTTP server before we return - defer func() { - listener.Close() - err = <-s.end - close(s.start) - close(s.end) - }() - - return validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) -} - -func (s *httpChallenge) startHTTPServer(domain string, token string, keyAuth string) { - // Allow for CLI port override port := ":80" if s.optPort != "" { @@ -60,13 +33,12 @@ func (s *httpChallenge) startHTTPServer(domain string, token string, keyAuth str // if the domain:port bind failed, fall back to :port bind and try that instead. listener, err = net.Listen("tcp", port) if err != nil { - s.end <- err + return fmt.Errorf("Could not start HTTP server for challenge -> %v", err) } } - // Signal successfull start - s.start <- listener + defer listener.Close() - path := "/.well-known/acme-challenge/" + token + path := "/.well-known/acme-challenge/" + chlng.Token // The handler validates the HOST header and request type. // For validation it then writes the token the server returned with the challenge @@ -81,8 +53,7 @@ func (s *httpChallenge) startHTTPServer(domain string, token string, keyAuth str } }) - http.Serve(listener, nil) + go http.Serve(listener, nil) - // Signal that the server was shut down - s.end <- nil + return validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } From 38cb60624fff4f86800e29bee40217700d86e4a7 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 12:02:26 +0000 Subject: [PATCH 04/10] Simplify tlsSNIChallenge code. Solve is blocking, so no need to run initialization code in a separate goroutine. Removes the need for s.start. Once the listener is closed, all I/O resources have been returned. No need to wait for http.Serve to return. Removes the need for s.end. --- acme/tls_sni_challenge.go | 59 ++++++++++----------------------------- 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index 634bf899..5d963954 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -6,15 +6,12 @@ import ( "crypto/tls" "encoding/hex" "fmt" - "net" "net/http" ) type tlsSNIChallenge struct { jws *jws optPort string - start chan net.Listener - end chan error } func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { @@ -23,36 +20,33 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { logf("[INFO] acme: Trying to solve TLS-SNI-01") - t.start = make(chan net.Listener) - t.end = make(chan error) - // Generate the Key Authorization for the challenge keyAuth, err := getKeyAuthorization(chlng.Token, &t.jws.privKey.PublicKey) if err != nil { return err } - certificate, err := t.generateCertificate(keyAuth) + cert, err := t.generateCertificate(keyAuth) if err != nil { return err } - go t.startSNITLSServer(certificate) - var listener net.Listener - select { - case listener = <-t.start: - break - case err := <-t.end: - return fmt.Errorf("Could not start HTTPS server for challenge -> %v", err) + // Allow for CLI port override + port := ":443" + if t.optPort != "" { + port = ":" + t.optPort } - // Make sure we properly close the HTTP server before we return - defer func() { - listener.Close() - err = <-t.end - close(t.start) - close(t.end) - }() + tlsConf := new(tls.Config) + tlsConf.Certificates = []tls.Certificate{cert} + + listener, err := tls.Listen("tcp", 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) return validate(t.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } @@ -83,26 +77,3 @@ func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, return certificate, nil } - -func (t *tlsSNIChallenge) startSNITLSServer(cert tls.Certificate) { - - // Allow for CLI port override - port := ":443" - if t.optPort != "" { - port = ":" + t.optPort - } - - tlsConf := new(tls.Config) - tlsConf.Certificates = []tls.Certificate{cert} - - tlsListener, err := tls.Listen("tcp", port, tlsConf) - if err != nil { - t.end <- err - } - // Signal successfull start - t.start <- tlsListener - - http.Serve(tlsListener, nil) - - t.end <- nil -} From bee1326835c4aeeb210f85e778ebe0b1f8e745b9 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 12:05:40 +0000 Subject: [PATCH 05/10] Use a local ServeMux in httpChallenge.Solve. Avoids modifying global state. --- acme/http_challenge.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/acme/http_challenge.go b/acme/http_challenge.go index 87359049..d313b1ab 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -42,7 +42,8 @@ func (s *httpChallenge) Solve(chlng challenge, domain string) error { // The handler validates the HOST header and request type. // For validation it then writes the token the server returned with the challenge - http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + 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)) @@ -53,7 +54,7 @@ func (s *httpChallenge) Solve(chlng challenge, domain string) error { } }) - go http.Serve(listener, nil) + go http.Serve(listener, mux) return validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } From 2dc2fdd1af2fa172a9172ef3232f6baa2aff18a5 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 14:53:53 +0000 Subject: [PATCH 06/10] Split off tests for validate, simplifying HTTP-01 and TLS-SNI-01 tests. --- acme/client.go | 20 ++- acme/client_test.go | 72 +++++++++ acme/http_challenge.go | 7 +- acme/http_challenge_test.go | 277 +++++---------------------------- acme/tls_sni_challenge.go | 7 +- acme/tls_sni_challenge_test.go | 86 +++------- 6 files changed, 152 insertions(+), 317 deletions(-) diff --git a/acme/client.go b/acme/client.go index 598f5692..221642d3 100644 --- a/acme/client.go +++ b/acme/client.go @@ -99,8 +99,8 @@ func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client // 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, optPort: optPort} - solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, optPort: optPort} + solvers["http-01"] = &httpChallenge{jws: jws, validate: validate, optPort: optPort} + solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, validate: validate, optPort: optPort} return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil } @@ -671,6 +671,11 @@ func parseLinks(links []string) map[string]string { return linkMap } +var ( + pollInterval = 1 * time.Second + maxPollInterval = 15 * time.Minute +) + // validate makes the ACME server start validating a // challenge response, only returning once it is done. func validate(j *jws, uri string, chlng challenge) error { @@ -680,8 +685,7 @@ func validate(j *jws, uri string, chlng challenge) error { return err } - interval := 1 * time.Second - maxInterval := 15 * time.Minute + delay := pollInterval // After the path is sent, the ACME server will access our server. // Repeatedly check the server for an updated status on our request. @@ -699,10 +703,10 @@ func validate(j *jws, uri string, chlng challenge) error { } // Poll with exponential back-off. - time.Sleep(interval) - interval *= 2 - if interval > maxInterval { - interval = maxInterval + time.Sleep(delay) + delay *= 2 + if delay > maxPollInterval { + delay = maxPollInterval } if err := getJSON(uri, &challengeResponse); err != nil { diff --git a/acme/client_test.go b/acme/client_test.go index 3e97e187..3718d466 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -59,6 +60,77 @@ func TestNewClient(t *testing.T) { } } +func TestValidate(t *testing.T) { + // Disable polling delay in validate for faster tests. + pollInterval = 0 + + var statuses []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Minimal stub ACME server for validation. + w.Header().Add("Replay-Nonce", "12345") + switch r.Method { + case "HEAD": + case "POST": + st := statuses[0] + statuses = statuses[1:] + writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"}) + + case "GET": + st := statuses[0] + statuses = statuses[1:] + writeJSONResponse(w, &challenge{Type: "http-01", Status: st, URI: "http://example.com/", Token: "token"}) + + default: + http.Error(w, r.Method, http.StatusMethodNotAllowed) + } + })) + defer ts.Close() + + privKey, _ := generatePrivateKey(rsakey, 512) + j := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} + + tsts := []struct { + name string + statuses []string + want string + }{ + {"POST-unexpected", []string{"weird"}, "unexpected"}, + {"POST-valid", []string{"valid"}, ""}, + {"POST-invalid", []string{"invalid"}, "not validate"}, + {"GET-unexpected", []string{"pending", "weird"}, "unexpected"}, + {"GET-valid", []string{"pending", "valid"}, ""}, + {"GET-invalid", []string{"pending", "invalid"}, "not validate"}, + } + + for _, tst := range tsts { + statuses = tst.statuses + if err := validate(j, ts.URL, challenge{Type: "http-01", Token: "token"}); err == nil && tst.want != "" { + t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want) + } else if err != nil && !strings.Contains(err.Error(), tst.want) { + t.Errorf("[%s] validate: got error %v, want something with %q", tst.name, err, tst.want) + } + } +} + +// writeJSONResponse marshals the body as JSON and writes it to the response. +func writeJSONResponse(w http.ResponseWriter, body interface{}) { + bs, err := json.Marshal(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(bs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// stubValidate is like validate, except it does nothing. +func stubValidate(j *jws, uri string, chlng challenge) error { + return nil +} + type mockUser struct { email string regres *RegistrationResource diff --git a/acme/http_challenge.go b/acme/http_challenge.go index d313b1ab..5ebf0dd6 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -8,8 +8,9 @@ import ( ) type httpChallenge struct { - jws *jws - optPort string + jws *jws + validate func(j *jws, uri string, chlng challenge) error + optPort string } func (s *httpChallenge) Solve(chlng challenge, domain string) error { @@ -56,5 +57,5 @@ func (s *httpChallenge) Solve(chlng challenge, domain string) error { go http.Serve(listener, mux) - return validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) + return s.validate(s.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } diff --git a/acme/http_challenge_test.go b/acme/http_challenge_test.go index bf085ffd..759967d0 100644 --- a/acme/http_challenge_test.go +++ b/acme/http_challenge_test.go @@ -2,263 +2,56 @@ package acme import ( "crypto/rsa" - "crypto/tls" - "encoding/json" "io/ioutil" "net/http" - "net/http/httptest" - "regexp" "strings" "testing" - - "github.com/square/go-jose" ) -func TestHTTPNonRootBind(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - solver := &httpChallenge{jws: jws} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "localhost:4000", Token: "http1"} - - // validate error on non-root bind to 80 - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("BIND: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Could not start HTTP server for challenge -> listen tcp :80: bind: permission denied" - if err.Error() != expectedError { - t.Errorf("Expected error \"%s\" but instead got \"%s\"", expectedError, err.Error()) - } - } -} - -func TestHTTPShortRSA(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey), nonces: []string{"test1", "test2"}} - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "http://localhost:4000", Token: "http2"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Failed to post JWS message. -> crypto/rsa: message too long for RSA public key size" - if err.Error() != expectedError { - t.Errorf("Expected error %s but instead got %s", expectedError, err.Error()) - } - } -} - -func TestHTTPConnectionRefusal(t *testing.T) { +func TestHTTPChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) - jws := &jws{privKey: privKey.(*rsa.PrivateKey), nonces: []string{"test1", "test2"}} - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: "http://localhost:4000", Token: "http3"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - reg := "/Failed to post JWS message\\. -> Post http:\\/\\/localhost:4000: dial tcp 127\\.0\\.0\\.1:4000: (getsockopt: )?connection refused/g" - test2 := "Failed to post JWS message. -> Post http://localhost:4000: dial tcp 127.0.0.1:4000: connection refused" - r, _ := regexp.Compile(reg) - if r.MatchString(err.Error()) && r.MatchString(test2) { - t.Errorf("Expected \"%s\" to match %s", err.Error(), reg) - } - } -} - -func TestHTTPUnexpectedServerState(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"what\",\"uri\":\"http://some.url\",\"token\":\"http4\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http4"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "The server returned an unexpected state." - if err.Error() != expectedError { - t.Errorf("Expected error %s but instead got %s", expectedError, err.Error()) - } - } -} - -func TestHTTPChallengeServerUnexpectedDomain(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - client := &http.Client{Transport: tr} - req, _ := client.Get("https://localhost:23456/.well-known/acme-challenge/" + "htto5") - reqBytes, _ := ioutil.ReadAll(req.Body) - if string(reqBytes) != "TEST" { - t.Error("Expected http01 server to return string TEST on unexpected domain.") - } - } - - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"invalid\",\"uri\":\"http://some.url\",\"token\":\"http5\"}")) - })) - - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http5"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } -} - -func TestHTTPServerError(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "HEAD" { - w.Header().Add("Replay-Nonce", "12345") - } else { - w.WriteHeader(http.StatusInternalServerError) - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"urn:acme:error:unauthorized\",\"detail\":\"Error creating new authz :: Syntax error\"}")) - } - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http6"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "acme: Error 500 - urn:acme:error:unauthorized - Error creating new authz :: Syntax error" - if err.Error() != expectedError { - t.Errorf("Expected error |%s| but instead got |%s|", expectedError, err.Error()) - } - } -} - -func TestHTTPInvalidServerState(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"invalid\",\"uri\":\"http://some.url\",\"token\":\"http7\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http7"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("UNEXPECTED: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "The server could not validate our request." - if err.Error() != expectedError { - t.Errorf("Expected error |%s| but instead got |%s|", expectedError, err.Error()) - } - } -} - -func TestHTTPValidServerResponse(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Replay-Nonce", "12345") - w.Write([]byte("{\"type\":\"http01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}")) - })) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23456"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http8"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err) - } -} - -func TestHTTPValidFull(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 512) - - ts := httptest.NewServer(nil) - - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &httpChallenge{jws: jws, optPort: "23457"} - clientChallenge := challenge{Type: "http01", Status: "pending", URI: ts.URL, Token: "http9"} - - // Validate server on port 23456 which responds appropriately - ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request challenge - w.Header().Add("Replay-Nonce", "12345") - - if r.Method == "HEAD" { - return - } - - clientJws, _ := ioutil.ReadAll(r.Body) - j, err := jose.ParseSigned(string(clientJws)) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "http-01", Token: "http1"} + mockValidate := func(_ *jws, _ string, chlng challenge) error { + uri := "http://localhost:23457/.well-known/acme-challenge/" + chlng.Token + resp, err := http.Get(uri) if err != nil { - t.Errorf("Client sent invalid JWS to the server.\n\t%v", err) - return - } - output, err := j.Verify(&privKey.(*rsa.PrivateKey).PublicKey) - if err != nil { - t.Errorf("Unable to verify client data -> %v", err) - } - json.Unmarshal(output, &request) - - transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - client := &http.Client{Transport: transport} - - reqURL := "http://localhost:23457/.well-known/acme-challenge/" + clientChallenge.Token - t.Logf("Request URL is: %s", reqURL) - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - t.Error(err) - } - req.Host = "127.0.0.1" - resp, err := client.Do(req) - if err != nil { - t.Errorf("Expected the solver to listen on port 23457 -> %v", err) + return err } defer resp.Body.Close() - body, _ := ioutil.ReadAll(resp.Body) + if want := "text/plain"; resp.Header.Get("Content-Type") != want { + t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } bodyStr := string(body) - if resp.Header.Get("Content-Type") != "text/plain" { - t.Errorf("Expected server to respond with content type text/plain.") + if bodyStr != chlng.KeyAuthorization { + t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization) } - tokenRegex := regexp.MustCompile("^[\\w-]{43}$") - parts := strings.Split(bodyStr, ".") + return nil + } + solver := &httpChallenge{jws: j, validate: mockValidate, optPort: "23457"} - if len(parts) != 2 { - t.Errorf("Expected server token to be a composite of two strings, seperated by a dot") - } - - if parts[0] != clientChallenge.Token { - t.Errorf("Expected the first part of the server token to be the challenge token.") - } - - if !tokenRegex.MatchString(parts[1]) { - t.Errorf("Expected the second part of the server token to be a properly formatted key authorization") - } - - valid := challenge{Type: "http01", Status: "valid", URI: ts.URL, Token: "1234567812"} - jsonBytes, _ := json.Marshal(&valid) - w.Write(jsonBytes) - }) - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err) + if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } +} + +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, optPort: "123456"} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Error("Solve error: got %v, want error", err) + } else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) } } diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index 5d963954..e9ce68dd 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -10,8 +10,9 @@ import ( ) type tlsSNIChallenge struct { - jws *jws - optPort string + jws *jws + validate func(j *jws, uri string, chlng challenge) error + optPort string } func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { @@ -48,7 +49,7 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { go http.Serve(listener, nil) - return validate(t.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) + return t.validate(t.jws, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) } func (t *tlsSNIChallenge) generateCertificate(keyAuth string) (tls.Certificate, error) { diff --git a/acme/tls_sni_challenge_test.go b/acme/tls_sni_challenge_test.go index 41c3f9db..c90d9ab3 100644 --- a/acme/tls_sni_challenge_test.go +++ b/acme/tls_sni_challenge_test.go @@ -5,61 +5,17 @@ import ( "crypto/sha256" "crypto/tls" "encoding/hex" - "encoding/json" "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" + "strings" "testing" - - "github.com/square/go-jose" ) -func TestTLSSNINonRootBind(t *testing.T) { - privKey, _ := generatePrivateKey(rsakey, 128) - jws := &jws{privKey: privKey.(*rsa.PrivateKey)} - - solver := &tlsSNIChallenge{jws: jws} - clientChallenge := challenge{Type: "tls-sni-01", Status: "pending", URI: "localhost:4000", Token: "tls1"} - - // validate error on non-root bind to 443 - if err := solver.Solve(clientChallenge, "127.0.0.1"); err == nil { - t.Error("BIND: Expected Solve to return an error but the error was nil.") - } else { - expectedError := "Could not start HTTPS server for challenge -> listen tcp :443: bind: permission denied" - if err.Error() != expectedError { - t.Errorf("Expected error \"%s\" but instead got \"%s\"", expectedError, err.Error()) - } - } -} - -func TestTLSSNI(t *testing.T) { +func TestTLSSNIChallenge(t *testing.T) { privKey, _ := generatePrivateKey(rsakey, 512) - optPort := "5001" - - ts := httptest.NewServer(nil) - - ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request challenge - w.Header().Add("Replay-Nonce", "12345") - - if r.Method == "HEAD" { - return - } - - clientJws, _ := ioutil.ReadAll(r.Body) - j, err := jose.ParseSigned(string(clientJws)) - if err != nil { - t.Errorf("Client sent invalid JWS to the server.\n\t%v", err) - return - } - output, err := j.Verify(&privKey.(*rsa.PrivateKey).PublicKey) - if err != nil { - t.Errorf("Unable to verify client data -> %v", err) - } - json.Unmarshal(output, &request) - - conn, err := tls.Dial("tcp", "localhost:"+optPort, &tls.Config{ + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: "tls-sni-01", Token: "tlssni1"} + mockValidate := func(_ *jws, _ string, chlng challenge) error { + conn, err := tls.Dial("tcp", "localhost:23457", &tls.Config{ InsecureSkipVerify: true, }) if err != nil { @@ -77,7 +33,7 @@ func TestTLSSNI(t *testing.T) { t.Errorf("Expected the challenge certificate to have exactly one DNSNames entry but had %d", count) } - zBytes := sha256.Sum256([]byte(request.KeyAuthorization)) + zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization)) z := hex.EncodeToString(zBytes[:sha256.Size]) domain := fmt.Sprintf("%s.%s.acme.invalid", z[:32], z[32:]) @@ -85,16 +41,24 @@ func TestTLSSNI(t *testing.T) { t.Errorf("Expected the challenge certificate DNSName to match %s but was %s", domain, remoteCert.DNSNames[0]) } - valid := challenge{Type: "tls-sni-01", Status: "valid", URI: ts.URL, Token: "tls1"} - jsonBytes, _ := json.Marshal(&valid) - w.Write(jsonBytes) - }) + return nil + } + solver := &tlsSNIChallenge{jws: j, validate: mockValidate, optPort: "23457"} - jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL} - solver := &tlsSNIChallenge{jws: jws, optPort: optPort} - clientChallenge := challenge{Type: "tls-sni-01", Status: "pending", URI: ts.URL, Token: "tls1"} - - if err := solver.Solve(clientChallenge, "127.0.0.1"); err != nil { - t.Error("UNEXPECTED: Expected Solve to return no error but the error was %s.", err.Error()) + if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } +} + +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, optPort: "123456"} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Error("Solve error: got %v, want error", err) + } else if want := "invalid port 123456"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) } } From 039b7c50dcd4ab28868c1b38cf7c3ac8d8f45406 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 15:59:15 +0000 Subject: [PATCH 07/10] Use postJSON and getJSON wherever possible. Encapsulates JSON marshalling. --- acme/client.go | 112 +++++++++++-------------------------------------- 1 file changed, 25 insertions(+), 87 deletions(-) diff --git a/acme/client.go b/acme/client.go index 221642d3..ef8570c5 100644 --- a/acme/client.go +++ b/acme/client.go @@ -68,16 +68,9 @@ func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client return nil, fmt.Errorf("invalid private key: %v", err) } - dirResp, err := http.Get(caDirURL) - if err != nil { - return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err) - } - defer dirResp.Body.Close() - var dir directory - err = json.NewDecoder(dirResp.Body).Decode(&dir) - if err != nil { - return nil, fmt.Errorf("decode directory: %v", err) + if err := getJSON(caDirURL, &dir); err != nil { + return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err) } if dir.NewRegURL == "" { @@ -121,32 +114,16 @@ func (c *Client) Register() (*RegistrationResource, error) { regMsg.Contact = []string{} } - jsonBytes, err := json.Marshal(regMsg) - if err != nil { - return nil, err - } - - resp, err := c.jws.post(c.directory.NewRegURL, jsonBytes) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode >= http.StatusBadRequest { - return nil, handleHTTPError(resp) - } - var serverReg Registration - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&serverReg) + hdr, err := postJSON(c.jws, c.directory.NewRegURL, regMsg, &serverReg) if err != nil { return nil, err } reg := &RegistrationResource{Body: serverReg} - links := parseLinks(resp.Header["Link"]) - reg.URI = resp.Header.Get("Location") + links := parseLinks(hdr["Link"]) + reg.URI = hdr.Get("Location") if links["terms-of-service"] != "" { reg.TosURL = links["terms-of-service"] } @@ -165,22 +142,8 @@ func (c *Client) Register() (*RegistrationResource, error) { func (c *Client) AgreeToTOS() error { c.user.GetRegistration().Body.Agreement = c.user.GetRegistration().TosURL c.user.GetRegistration().Body.Resource = "reg" - jsonBytes, err := json.Marshal(&c.user.GetRegistration().Body) - if err != nil { - return err - } - - resp, err := c.jws.post(c.user.GetRegistration().URI, jsonBytes) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusAccepted { - return handleHTTPError(resp) - } - - return nil + _, err := postJSON(c.jws, c.user.GetRegistration().URI, c.user.GetRegistration().Body, nil) + return err } // ObtainCertificates tries to obtain certificates from the CA server @@ -277,22 +240,8 @@ func (c *Client) RevokeCertificate(certificate []byte) error { encodedCert := base64.URLEncoding.EncodeToString(x509Cert.Raw) - jsonBytes, err := json.Marshal(revokeCertMessage{Resource: "revoke-cert", Certificate: encodedCert}) - if err != nil { - return err - } - - resp, err := c.jws.post(c.directory.RevokeCertURL, jsonBytes) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return handleHTTPError(resp) - } - - return nil + _, err = postJSON(c.jws, c.directory.RevokeCertURL, revokeCertMessage{Resource: "revoke-cert", Certificate: encodedCert}, nil) + return err } // RenewCertificate takes a CertificateResource and tries to renew the certificate. @@ -428,37 +377,21 @@ func (c *Client) getChallenges(domains []string) ([]authorizationResource, map[s for _, domain := range domains { go func(domain string) { - jsonBytes, err := json.Marshal(authorization{Resource: "new-authz", Identifier: identifier{Type: "dns", Value: domain}}) + authMsg := authorization{Resource: "new-authz", Identifier: identifier{Type: "dns", Value: domain}} + var authz authorization + hdr, err := postJSON(c.jws, c.user.GetRegistration().NewAuthzURL, authMsg, &authz) if err != nil { errc <- domainError{Domain: domain, Error: err} return } - resp, err := c.jws.post(c.user.GetRegistration().NewAuthzURL, jsonBytes) - if err != nil { - errc <- domainError{Domain: domain, Error: err} - return - } - - if resp.StatusCode != http.StatusCreated { - errc <- domainError{Domain: domain, Error: handleHTTPError(resp)} - } - - links := parseLinks(resp.Header["Link"]) + links := parseLinks(hdr["Link"]) if links["next"] == "" { logf("[ERROR] acme: Server did not provide next link to proceed") return } - var authz authorization - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&authz) - if err != nil { - errc <- domainError{Domain: domain, Error: err} - } - resp.Body.Close() - - resc <- authorizationResource{Body: authz, NewCertURL: links["next"], AuthURL: resp.Header.Get("Location"), Domain: domain} + resc <- authorizationResource{Body: authz, NewCertURL: links["next"], AuthURL: hdr.Get("Location"), Domain: domain} }(domain) } @@ -681,7 +614,8 @@ var ( func validate(j *jws, uri string, chlng challenge) error { var challengeResponse challenge - if err := postJSON(j, uri, chlng, &challengeResponse); err != nil { + _, err := postJSON(j, uri, chlng, &challengeResponse) + if err != nil { return err } @@ -735,21 +669,25 @@ func getJSON(uri string, respBody interface{}) error { // postJSON performs an HTTP POST request and parses the response body // as JSON, into the provided respBody object. -func postJSON(j *jws, uri string, reqBody, respBody interface{}) error { +func postJSON(j *jws, uri string, reqBody, respBody interface{}) (http.Header, error) { jsonBytes, err := json.Marshal(reqBody) if err != nil { - return errors.New("Failed to marshal network message...") + return nil, errors.New("Failed to marshal network message...") } resp, err := j.post(uri, jsonBytes) if err != nil { - return fmt.Errorf("Failed to post JWS message. -> %v", err) + return nil, fmt.Errorf("Failed to post JWS message. -> %v", err) } defer resp.Body.Close() if resp.StatusCode >= http.StatusBadRequest { - return handleHTTPError(resp) + return resp.Header, handleHTTPError(resp) } - return json.NewDecoder(resp.Body).Decode(respBody) + if respBody == nil { + return resp.Header, nil + } + + return resp.Header, json.NewDecoder(resp.Body).Decode(respBody) } From b2c88d7a5d1a94eb7a296219473206fafebc834d Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 21:01:08 +0000 Subject: [PATCH 08/10] Make solvers configurable. Allows selecting which solvers are available, and specifying options for them. --- acme/client.go | 44 +++++++++++++++++++++++++++++++++++++------- acme/client_test.go | 27 +++++++++++++++++++++++++-- cli.go | 6 +++--- cli_handlers.go | 2 +- configuration.go | 4 ++-- 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/acme/client.go b/acme/client.go index ef8570c5..777cc8e0 100644 --- a/acme/client.go +++ b/acme/client.go @@ -16,8 +16,13 @@ import ( "time" ) -// Logger is an optional custom logger. -var Logger *log.Logger +var ( + // DefaultSolvers is the set of solvers to use if none is given to NewClient. + DefaultSolvers = []string{"http-01", "tls-sni-01"} + + // Logger is an optional custom logger. + Logger *log.Logger +) // logf writes a log entry. It uses Logger if not // nil, otherwise it uses the default log.Logger. @@ -56,9 +61,12 @@ type Client struct { // the ACME directory located at caDirURL for the rest of its actions. It will // generate private keys for certificates of size keyBits. And, if the challenge // type requires it, the client will open a port at optPort to solve the challenge. -// If optPort is blank, the port required by the spec will be used, but you must -// forward the required port to optPort for the challenge to succeed. -func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client, error) { +// +// If optSolvers is nil, the value of DefaultSolvers is used. If given explicitly, +// it is a set of solver names to enable. The "http-01" and "tls-sni-01" solvers +// take an optional TCP port to listen on after a colon, e.g. "http-01:80". If +// the port is not specified, the port required by the spec will be used. +func NewClient(caDirURL string, user User, keyBits int, optSolvers []string) (*Client, error) { privKey := user.GetPrivateKey() if privKey == nil { return nil, errors.New("private key was nil") @@ -92,8 +100,30 @@ func NewClient(caDirURL string, user User, keyBits int, optPort string) (*Client // 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, optPort: optPort} - solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, validate: validate, optPort: optPort} + if optSolvers == nil { + optSolvers = DefaultSolvers + } + for _, s := range optSolvers { + ss := strings.SplitN(s, ":", 2) + switch ss[0] { + case "http-01": + optPort := "" + if len(ss) > 1 { + optPort = ss[1] + } + solvers["http-01"] = &httpChallenge{jws: jws, validate: validate, optPort: optPort} + + case "tls-sni-01": + optPort := "" + if len(ss) > 1 { + optPort = ss[1] + } + solvers["tls-sni-01"] = &tlsSNIChallenge{jws: jws, validate: validate, optPort: optPort} + + default: + return nil, fmt.Errorf("unknown solver: %s", s) + } + } return &Client{directory: dir, user: user, jws: jws, keyBits: keyBits, solvers: solvers}, nil } diff --git a/acme/client_test.go b/acme/client_test.go index 3718d466..e62e3731 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -27,8 +27,7 @@ func TestNewClient(t *testing.T) { w.Write(data) })) - caURL, optPort := ts.URL, "1234" - client, err := NewClient(caURL, user, keyBits, optPort) + client, err := NewClient(ts.URL, user, keyBits, nil) if err != nil { t.Fatalf("Could not create client: %v", err) } @@ -47,6 +46,30 @@ func TestNewClient(t *testing.T) { if expected, actual := 2, len(client.solvers); actual != expected { t.Fatalf("Expected %d solver(s), got %d", expected, actual) } +} + +func TestNewClientOptPort(t *testing.T) { + keyBits := 32 // small value keeps test fast + key, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + t.Fatal("Could not generate test key:", err) + } + user := mockUser{ + email: "test@test.com", + regres: new(RegistrationResource), + privatekey: key, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := json.Marshal(directory{NewAuthzURL: "http://test", NewCertURL: "http://test", NewRegURL: "http://test", RevokeCertURL: "http://test"}) + w.Write(data) + })) + + optPort := "1234" + client, err := NewClient(ts.URL, user, keyBits, []string{"http-01:" + optPort}) + if err != nil { + t.Fatalf("Could not create client: %v", err) + } httpSolver, ok := client.solvers["http-01"].(*httpChallenge) if !ok { diff --git a/cli.go b/cli.go index 1074989f..ce262367 100644 --- a/cli.go +++ b/cli.go @@ -74,9 +74,9 @@ func main() { Usage: "Directory to use for storing the data", Value: defaultPath, }, - cli.StringFlag{ - Name: "port", - Usage: "Challenges will use this port to listen on. Please make sure to forward port 443 to this port on your machine. Otherwise use setcap on the binary", + cli.StringSliceFlag{ + Name: "solvers, S", + Usage: "Add an explicit solver for challenges. Solvers: \"http-01[:port]\", \"tls-sni-01[:port]\".", }, } diff --git a/cli_handlers.go b/cli_handlers.go index dafa25c9..c6ff4f5e 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -33,7 +33,7 @@ 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(), conf.OptPort()) + client, err := acme.NewClient(c.GlobalString("server"), acc, conf.RsaBits(), conf.Solvers()) if err != nil { logger().Fatal("Could not create client:", err) } diff --git a/configuration.go b/configuration.go index e96896aa..3f39f281 100644 --- a/configuration.go +++ b/configuration.go @@ -24,8 +24,8 @@ func (c *Configuration) RsaBits() int { return c.context.GlobalInt("rsa-key-size") } -func (c *Configuration) OptPort() string { - return c.context.GlobalString("port") +func (c *Configuration) Solvers() []string { + return c.context.GlobalStringSlice("solvers") } // ServerPath returns the OS dependent path to the data for a specific CA From 71624f607a62749ecb38b23af430de8749571862 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 21:32:53 +0000 Subject: [PATCH 09/10] Replace exponential back-off in validate with Retry-After header. Last paragraph of ACME spec, section 6.5: To check on the status of an authorization, the client sends a GET request to the authorization URI, and the server responds with the current authorization object. In responding to poll requests while the validation is still in progress, the server MUST return a 202 (Accepted) response with a Retry-After header field. --- acme/client.go | 33 ++++++++++++++------------------- acme/client_test.go | 4 +--- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/acme/client.go b/acme/client.go index 777cc8e0..025ae915 100644 --- a/acme/client.go +++ b/acme/client.go @@ -77,7 +77,7 @@ func NewClient(caDirURL string, user User, keyBits int, optSolvers []string) (*C } var dir directory - if err := getJSON(caDirURL, &dir); err != nil { + if _, err := getJSON(caDirURL, &dir); err != nil { return nil, fmt.Errorf("get directory at '%s': %v", caDirURL, err) } @@ -634,23 +634,16 @@ func parseLinks(links []string) map[string]string { return linkMap } -var ( - pollInterval = 1 * time.Second - maxPollInterval = 15 * time.Minute -) - // validate makes the ACME server start validating a // challenge response, only returning once it is done. func validate(j *jws, uri string, chlng challenge) error { var challengeResponse challenge - _, err := postJSON(j, uri, chlng, &challengeResponse) + hdr, err := postJSON(j, uri, chlng, &challengeResponse) if err != nil { return err } - delay := pollInterval - // After the path is sent, the ACME server will access our server. // Repeatedly check the server for an updated status on our request. for { @@ -666,14 +659,16 @@ func validate(j *jws, uri string, chlng challenge) error { return errors.New("The server returned an unexpected state.") } - // Poll with exponential back-off. - time.Sleep(delay) - delay *= 2 - if delay > maxPollInterval { - delay = maxPollInterval + ra, err := strconv.Atoi(hdr.Get("Retry-After")) + if err != nil { + // The ACME server MUST return a Retry-After. + // If it doesn't, we'll just poll hard. + ra = 1 } + time.Sleep(time.Duration(ra) * time.Second) - if err := getJSON(uri, &challengeResponse); err != nil { + hdr, err = getJSON(uri, &challengeResponse) + if err != nil { return err } } @@ -683,18 +678,18 @@ func validate(j *jws, uri string, chlng challenge) error { // getJSON performs an HTTP GET request and parses the response body // as JSON, into the provided respBody object. -func getJSON(uri string, respBody interface{}) error { +func getJSON(uri string, respBody interface{}) (http.Header, error) { resp, err := http.Get(uri) if err != nil { - return fmt.Errorf("failed to get %q: %v", uri, err) + return nil, fmt.Errorf("failed to get %q: %v", uri, err) } defer resp.Body.Close() if resp.StatusCode >= http.StatusBadRequest { - return handleHTTPError(resp) + return resp.Header, handleHTTPError(resp) } - return json.NewDecoder(resp.Body).Decode(respBody) + return resp.Header, json.NewDecoder(resp.Body).Decode(respBody) } // postJSON performs an HTTP POST request and parses the response body diff --git a/acme/client_test.go b/acme/client_test.go index e62e3731..7a2f586a 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -84,13 +84,11 @@ func TestNewClientOptPort(t *testing.T) { } func TestValidate(t *testing.T) { - // Disable polling delay in validate for faster tests. - pollInterval = 0 - var statuses []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Minimal stub ACME server for validation. w.Header().Add("Replay-Nonce", "12345") + w.Header().Add("Retry-After", "0") switch r.Method { case "HEAD": case "POST": From e32b9abfb2808024f376a7241b6a362363503adf Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Sat, 5 Dec 2015 22:07:12 +0000 Subject: [PATCH 10/10] Remove ObtainCertificates and rename ObtainSANCertificate to ObtainCertificate. Also removes revokation abilities from RenewCertificate. Makes the API more orthogonal. These things are not provided by the ACME protocol, but were convenience helpers. --- acme/client.go | 98 +++---------------------------------------------- cli_handlers.go | 8 +++- 2 files changed, 11 insertions(+), 95 deletions(-) diff --git a/acme/client.go b/acme/client.go index 025ae915..c37b7fe9 100644 --- a/acme/client.go +++ b/acme/client.go @@ -176,56 +176,12 @@ func (c *Client) AgreeToTOS() error { return err } -// ObtainCertificates tries to obtain certificates from the CA server -// using the challenges it has configured. The returned certificates are -// PEM encoded byte slices. -// If bundle is true, the []byte contains both the issuer certificate and -// your issued certificate as a bundle. -func (c *Client) ObtainCertificates(domains []string, bundle bool) ([]CertificateResource, map[string]error) { - if bundle { - logf("[INFO] acme: Obtaining bundled certificates for %v", strings.Join(domains, ", ")) - } else { - logf("[INFO] acme: Obtaining certificates for %v", strings.Join(domains, ", ")) - } - - challenges, failures := c.getChallenges(domains) - if len(challenges) == 0 { - return nil, failures - } - - err := c.solveChallenges(challenges) - for k, v := range err { - failures[k] = v - } - - if len(failures) == len(domains) { - return nil, failures - } - - // remove failed challenges from slice - var succeededChallenges []authorizationResource - for _, chln := range challenges { - if failures[chln.Domain] == nil { - succeededChallenges = append(succeededChallenges, chln) - } - } - - logf("[INFO] acme: Validations succeeded; requesting certificates") - - certs, err := c.requestCertificates(succeededChallenges, bundle) - for k, v := range err { - failures[k] = v - } - - return certs, failures -} - -// ObtainSANCertificate tries to obtain a single certificate using all domains passed into it. +// ObtainCertificate tries to obtain a single certificate using all domains passed into it. // The first domain in domains is used for the CommonName field of the certificate, all other // domains are added using the Subject Alternate Names extension. // If bundle is true, the []byte contains both the issuer certificate and // your issued certificate as a bundle. -func (c *Client) ObtainSANCertificate(domains []string, bundle bool) (CertificateResource, map[string]error) { +func (c *Client) ObtainCertificate(domains []string, bundle bool) (CertificateResource, map[string]error) { if bundle { logf("[INFO] acme: Obtaining bundled SAN certificate for %v", strings.Join(domains, ", ")) } else { @@ -281,7 +237,7 @@ func (c *Client) RevokeCertificate(certificate []byte) error { // this function will start a new-cert flow where a new certificate gets generated. // If bundle is true, the []byte contains both the issuer certificate and // your issued certificate as a bundle. -func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bundle bool) (CertificateResource, error) { +func (c *Client) RenewCertificate(cert CertificateResource, bundle bool) (CertificateResource, error) { // Input certificate is PEM encoded. Decode it here as we may need the decoded // cert later on in the renewal process. The input may be a bundle or a single certificate. certificates, err := parsePEMBundle(cert.Certificate) @@ -319,9 +275,6 @@ func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bund // TODO: Further test if we can actually use the new certificate (Our private key works) if !x509Cert.Equal(serverCert) { logf("[INFO] acme: [%s] Server responded with renewed certificate", cert.Domain) - if revokeOld { - c.RevokeCertificate(cert.Certificate) - } issuedCert := pemEncode(derCertificateBytes(serverCertBytes)) // If bundle is true, we want to return a certificate bundle. // To do this, we need the issuer certificate. @@ -345,16 +298,8 @@ func (c *Client) RenewCertificate(cert CertificateResource, revokeOld bool, bund return cert, nil } - newCerts, failures := c.ObtainCertificates([]string{cert.Domain}, bundle) - if len(failures) > 0 { - return CertificateResource{}, failures[cert.Domain] - } - - if revokeOld { - c.RevokeCertificate(cert.Certificate) - } - - return newCerts[0], nil + newCert, failures := c.ObtainCertificate([]string{cert.Domain}, bundle) + return newCert, failures[cert.Domain] } // Looks through the challenge combinations to find a solvable match. @@ -449,39 +394,6 @@ func (c *Client) getChallenges(domains []string) ([]authorizationResource, map[s return challenges, failures } -// requestCertificates iterates all granted authorizations, creates RSA private keys and CSRs. -// It then uses these to request a certificate from the CA and returns the list of successfully -// granted certificates. -func (c *Client) requestCertificates(challenges []authorizationResource, bundle bool) ([]CertificateResource, map[string]error) { - resc, errc := make(chan CertificateResource), make(chan domainError) - for _, authz := range challenges { - go func(authz authorizationResource, resc chan CertificateResource, errc chan domainError) { - certRes, err := c.requestCertificate([]authorizationResource{authz}, bundle) - if err != nil { - errc <- domainError{Domain: authz.Domain, Error: err} - } else { - resc <- certRes - } - }(authz, resc, errc) - } - - var certs []CertificateResource - failures := make(map[string]error) - for i := 0; i < len(challenges); i++ { - select { - case res := <-resc: - certs = append(certs, res) - case err := <-errc: - failures[err.Domain] = err.Error - } - } - - close(resc) - close(errc) - - return certs, failures -} - func (c *Client) requestCertificate(authz []authorizationResource, bundle bool) (CertificateResource, error) { if len(authz) == 0 { return CertificateResource{}, errors.New("Passed no authorizations to requestCertificate!") diff --git a/cli_handlers.go b/cli_handlers.go index c6ff4f5e..5036ad73 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -125,7 +125,7 @@ func run(c *cli.Context) { logger().Fatal("Please specify --domains") } - cert, failures := client.ObtainSANCertificate(c.GlobalStringSlice("domains"), true) + cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), true) if len(failures) > 0 { for k, v := range failures { logger().Printf("[%s] Could not obtain certificates\n\t%v", k, v) @@ -203,12 +203,16 @@ func renew(c *cli.Context) { certRes.PrivateKey = keyBytes certRes.Certificate = certBytes - newCert, err := client.RenewCertificate(certRes, true, true) + newCert, err := client.RenewCertificate(certRes, true) if err != nil { logger().Printf("%v", err) return } + if err := client.RevokeCertificate(certBytes); err != nil { + logger().Printf("%v (ignored)", err) + } + saveCertRes(newCert, conf) } }