From 7efa727289daa0cab488e85cd0291481c480e56e Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Tue, 2 Apr 2019 13:23:38 -0400 Subject: [PATCH] load-generator: bootstrap URLs from ACME server directory. (#4137) Removing hard-coded paths and using the server directory to bootstrap endpoint URLs improves RFC 8555 compatibility. This branch also updates the `github.com/letsencrypt/challtestsrv` vendored dep to the latest release. There are no upstream unit tests to run. Updates https://github.com/letsencrypt/boulder/issues/4086 - there are still a few Pebble compatibility issues to work out. I started on what became a near total rewrite of the load-generator and decided it was best to pull out some smaller PRs and re-evaluate. I'm optimistic that stashing little bits of a Go testing/boulder focused ACME client in `test/load-generator` will one day help https://github.com/letsencrypt/boulder/issues/4127 --- test/load-generator/acme/directory.go | 248 ++++++++++++++++++ test/load-generator/acme/directory_test.go | 207 +++++++++++++++ test/load-generator/boulder-calls.go | 57 ++-- .../config/integration-test-config.json | 2 +- .../config/pebble-test-config.json | 22 ++ test/load-generator/main.go | 9 +- test/load-generator/state.go | 25 +- 7 files changed, 524 insertions(+), 46 deletions(-) create mode 100644 test/load-generator/acme/directory.go create mode 100644 test/load-generator/acme/directory_test.go create mode 100644 test/load-generator/config/pebble-test-config.json diff --git a/test/load-generator/acme/directory.go b/test/load-generator/acme/directory.go new file mode 100644 index 000000000..397a4c0ca --- /dev/null +++ b/test/load-generator/acme/directory.go @@ -0,0 +1,248 @@ +// Package acme provides ACME client functionality tailored to the needs of the +// load-generator. It is not a general purpose ACME client library. +package acme + +import ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "time" +) + +const ( + // NewNonceEndpoint is the directory key for the newNonce endpoint. + NewNonceEndpoint Endpoint = "newNonce" + // NewAccountEndpoint is the directory key for the newAccount endpoint. + NewAccountEndpoint Endpoint = "newAccount" + // NewOrderEndpoint is the directory key for the newOrder endpoint. + NewOrderEndpoint Endpoint = "newOrder" + // RevokeCertEndpoint is the directory key for the revokeCert endpoint. + RevokeCertEndpoint Endpoint = "revokeCert" + // KeyChangeEndpoint is the directory key for the keyChange endpoint. + KeyChangeEndpoint Endpoint = "keyChange" +) + +var ( + // ErrEmptyDirectory is returned if NewDirectory is provided and empty directory URL. + ErrEmptyDirectory = errors.New("directoryURL must not be empty") + // ErrInvalidDirectoryURL is returned if NewDirectory is provided an invalid directory URL. + ErrInvalidDirectoryURL = errors.New("directoryURL is not a valid URL") + // ErrInvalidDirectoryHTTPCode is returned if NewDirectory is provided a directory URL + // that returns something other than HTTP Status OK to a GET request. + ErrInvalidDirectoryHTTPCode = errors.New("GET request to directoryURL did not result in HTTP Status 200") + // ErrInvalidDirectoryJSON is returned if NewDirectory is provided a directory URL + // that returns invalid JSON. + ErrInvalidDirectoryJSON = errors.New("GET request to directoryURL returned invalid JSON") + // ErrInvalidDirectoryMeta is returned if NewDirectory is provided a directory + // URL that returns a directory resource with an invalid or missing "meta" key. + ErrInvalidDirectoryMeta = errors.New(`server's directory resource had invalid or missing "meta" key`) + // ErrInvalidTermsOfSerivce is returned if NewDirectory is provided + // a directory URL that returns a directory resource with an invalid or + // missing "termsOfService" key in the "meta" map. + ErrInvalidTermsOfService = errors.New(`server's directory resource had invalid or missing "meta.termsOfService" key`) + + // RequiredEndpoints is a slice of Endpoint keys that must be present in the + // ACME server's directory. The load-generator uses each of these endpoints + // and expects to be able to find a URL for each in the server's directory + // resource. + RequiredEndpoints = []Endpoint{ + NewNonceEndpoint, NewAccountEndpoint, + NewOrderEndpoint, RevokeCertEndpoint, + } +) + +// Endpoint represents a string key used for looking up an endpoint URL in an ACME +// server directory resource. +// +// E.g. NewOrderEndpoint -> "newOrder" -> "https://acme.example.com/acme/v1/new-order-plz" +// +// See "ACME Resource Types" registry - RFC 8555 Section 9.7.5. +type Endpoint string + +// ErrMissingEndpoint is an error returned if NewDirectory is provided an ACME +// server directory URL that is missing a key for a required endpoint in the +// response JSON. See also RequiredEndpoints. +type ErrMissingEndpoint struct { + endpoint Endpoint +} + +// Error returns the error message for an ErrMissingEndpoint error. +func (e ErrMissingEndpoint) Error() string { + return fmt.Sprintf( + "directoryURL JSON was missing required key for %q endpoint", + e.endpoint, + ) +} + +// ErrInvalidEndpointURL is an error returned if NewDirectory is provided an +// ACME server directory URL that has an invalid URL for a required endpoint. +// See also RequiredEndpoints. +type ErrInvalidEndpointURL struct { + endpoint Endpoint + value string +} + +// Error returns the error message for an ErrInvalidEndpointURL error. +func (e ErrInvalidEndpointURL) Error() string { + return fmt.Sprintf( + "directoryURL JSON had invalid URL value (%q) for %q endpoint", + e.value, e.endpoint) +} + +// Directory is a type for holding URLs extracted from the ACME server's +// Directory resource. +// +// See RFC 8555 Section 7.1.1 "Directory". +// +// Its public API is read-only and therefore it is safe for concurrent access. +type Directory struct { + // TermsOfService is the URL identifying the current terms of service found in + // the ACME server's directory resource's "meta" field. + TermsOfService string + // endpointURLs is a map from endpoint name to URL. + endpointURLs map[Endpoint]string +} + +// getRawDirectory validates the provided directoryURL and makes a GET request +// to fetch the raw bytes of the server's directory resource. If the URL is +// invalid, if there is an error getting the directory bytes, or if the HTTP +// response code is not 200 an error is returned. +func getRawDirectory(directoryURL string) ([]byte, error) { + if directoryURL == "" { + return nil, ErrEmptyDirectory + } + + if _, err := url.Parse(directoryURL); err != nil { + return nil, ErrInvalidDirectoryURL + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + TLSClientConfig: &tls.Config{ + // Bypassing CDN or testing against Pebble instances can cause + // validation failures. For a **test-only** tool its acceptable to skip + // cert verification of the ACME server's HTTPs certificate. + InsecureSkipVerify: true, + }, + MaxIdleConns: 1, + IdleConnTimeout: 15 * time.Second, + }, + Timeout: 10 * time.Second, + } + + resp, err := httpClient.Get(directoryURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, ErrInvalidDirectoryHTTPCode + } + + rawDirectory, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return rawDirectory, nil +} + +// termsOfService reads the termsOfService key from the meta key of the raw +// directory resource. +func termsOfService(rawDirectory map[string]interface{}) (string, error) { + var directoryMeta map[string]interface{} + + if rawDirectoryMeta, ok := rawDirectory["meta"]; !ok { + return "", ErrInvalidDirectoryMeta + } else if directoryMetaMap, ok := rawDirectoryMeta.(map[string]interface{}); !ok { + return "", ErrInvalidDirectoryMeta + } else { + directoryMeta = directoryMetaMap + } + + rawToSURL, ok := directoryMeta["termsOfService"] + if !ok { + return "", ErrInvalidTermsOfService + } + + tosURL, ok := rawToSURL.(string) + if !ok { + return "", ErrInvalidTermsOfService + } + return tosURL, nil +} + +// NewDirectory creates a Directory populated from the ACME directory resource +// returned by a GET request to the provided directoryURL. It also checks that +// the fetched directory contains each of the RequiredEndpoints. +func NewDirectory(directoryURL string) (*Directory, error) { + // Fetch the raw directory JSON + dirContents, err := getRawDirectory(directoryURL) + if err != nil { + return nil, err + } + + // Unmarshal the directory + var dirResource map[string]interface{} + if err := json.Unmarshal(dirContents, &dirResource); err != nil { + return nil, ErrInvalidDirectoryJSON + } + + // serverURL tries to find a valid url.URL for the provided endpoint in + // the unmarshaled directory resource. + serverURL := func(name Endpoint) (*url.URL, error) { + if rawURL, ok := dirResource[string(name)]; !ok { + return nil, ErrMissingEndpoint{endpoint: name} + } else if urlString, ok := rawURL.(string); !ok { + return nil, ErrInvalidEndpointURL{endpoint: name, value: urlString} + } else if url, err := url.Parse(urlString); err != nil { + return nil, ErrInvalidEndpointURL{endpoint: name, value: urlString} + } else { + return url, nil + } + } + + // Create an empty directory to populate + directory := &Directory{ + endpointURLs: make(map[Endpoint]string), + } + + // Every required endpoint must have a valid URL populated from the directory + for _, endpointName := range RequiredEndpoints { + url, err := serverURL(endpointName) + if err != nil { + return nil, err + } + directory.endpointURLs[endpointName] = url.String() + } + + // Populate the terms-of-service + tos, err := termsOfService(dirResource) + if err != nil { + return nil, err + } + directory.TermsOfService = tos + return directory, nil +} + +// EndpointURL returns the string representation of the ACME server's URL for +// the provided endpoint. If the Endpoint is not known an empty string is +// returned. +func (d *Directory) EndpointURL(ep Endpoint) string { + if url, ok := d.endpointURLs[ep]; ok { + return url + } + + return "" +} diff --git a/test/load-generator/acme/directory_test.go b/test/load-generator/acme/directory_test.go new file mode 100644 index 000000000..63e7fe7ff --- /dev/null +++ b/test/load-generator/acme/directory_test.go @@ -0,0 +1,207 @@ +package acme + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/letsencrypt/boulder/test" +) + +// Path constants for test cases and mockDirectoryServer handlers. +const ( + wrongStatusCodePath = "/dir-wrong-status" + invalidJSONPath = "/dir-bad-json" + missingEndpointPath = "/dir-missing-endpoint" + invalidEndpointURLPath = "/dir-invalid-endpoint" + validDirectoryPath = "/dir-valid" + invalidMetaDirectoryPath = "/dir-valid-meta-invalid" + invalidMetaDirectoryToSPath = "/dir-valid-meta-valid-tos-invalid" +) + +// mockDirectoryServer is an httptest.Server that returns mock data for ACME +// directory GET requests based on the requested path. +type mockDirectoryServer struct { + *httptest.Server +} + +// newMockDirectoryServer creates a mockDirectoryServer that returns mock data +// based on the requested path. The returned server will not be started +// automatically. +func newMockDirectoryServer() *mockDirectoryServer { + m := http.NewServeMux() + + m.HandleFunc(wrongStatusCodePath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnavailableForLegalReasons) + }) + + m.HandleFunc(invalidJSONPath, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{`) + }) + + m.HandleFunc(missingEndpointPath, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{}`) + }) + + m.HandleFunc(invalidEndpointURLPath, func(w http.ResponseWriter, r *http.Request) { + invalidURLDir := fmt.Sprintf(`{ + "newAccount": "", + "newNonce": %q, + "newOrder": "", + "revokeCert": "" + }`, "http://"+string([]byte{0x7F})) + fmt.Fprint(w, invalidURLDir) + }) + + m.HandleFunc(invalidMetaDirectoryPath, func(w http.ResponseWriter, r *http.Request) { + noMetaDir := `{ + "keyChange": "https://localhost:14000/rollover-account-key", + "newAccount": "https://localhost:14000/sign-me-up", + "newNonce": "https://localhost:14000/nonce-plz", + "newOrder": "https://localhost:14000/order-plz", + "revokeCert": "https://localhost:14000/revoke-cert" + }` + fmt.Fprint(w, noMetaDir) + }) + + m.HandleFunc(invalidMetaDirectoryToSPath, func(w http.ResponseWriter, r *http.Request) { + noToSDir := `{ + "keyChange": "https://localhost:14000/rollover-account-key", + "meta": { + "chaos": "reigns" + }, + "newAccount": "https://localhost:14000/sign-me-up", + "newNonce": "https://localhost:14000/nonce-plz", + "newOrder": "https://localhost:14000/order-plz", + "revokeCert": "https://localhost:14000/revoke-cert" + }` + fmt.Fprint(w, noToSDir) + }) + + m.HandleFunc(validDirectoryPath, func(w http.ResponseWriter, r *http.Request) { + validDir := `{ + "keyChange": "https://localhost:14000/rollover-account-key", + "meta": { + "termsOfService": "data:text/plain,Do%20what%20thou%20wilt" + }, + "newAccount": "https://localhost:14000/sign-me-up", + "newNonce": "https://localhost:14000/nonce-plz", + "newOrder": "https://localhost:14000/order-plz", + "revokeCert": "https://localhost:14000/revoke-cert" + }` + fmt.Fprint(w, validDir) + }) + + srv := &mockDirectoryServer{ + Server: httptest.NewUnstartedServer(m), + } + + return srv +} + +// TestNew tests that creating a new Client and populating the endpoint map +// works correctly. +func TestNew(t *testing.T) { + unreachableDirectoryURL := "http://localhost:1987" + + srv := newMockDirectoryServer() + srv.Start() + defer srv.Close() + + srvUrl, _ := url.Parse(srv.URL) + _, port, _ := net.SplitHostPort(srvUrl.Host) + + testURL := func(path string) string { + return fmt.Sprintf("http://localhost:%s%s", port, path) + } + + wrongStatusCodeURL := testURL(wrongStatusCodePath) + + invalidJSONURL := testURL(invalidJSONPath) + + missingEndpointURL := testURL(missingEndpointPath) + missingEndpointErr := ErrMissingEndpoint{ + endpoint: NewNonceEndpoint, + } + + invalidEndpointURL := testURL(invalidEndpointURLPath) + invalidEndpointErr := ErrInvalidEndpointURL{ + endpoint: NewNonceEndpoint, + value: "http://" + string([]byte{0x7F}), + } + + invalidDirectoryMetaURL := testURL(invalidMetaDirectoryPath) + + invalidDirectoryToSURL := testURL(invalidMetaDirectoryToSPath) + + validDirectoryURL := testURL(validDirectoryPath) + + testCases := []struct { + Name string + DirectoryURL string + ExpectedError string + }{ + { + Name: "empty directory URL", + ExpectedError: ErrEmptyDirectory.Error(), + }, + { + Name: "invalid directory URL", + DirectoryURL: "http://" + string([]byte{0x1, 0x7F}), + ExpectedError: ErrInvalidDirectoryURL.Error(), + }, + { + Name: "unreachable directory URL", + DirectoryURL: unreachableDirectoryURL, + ExpectedError: "Get http://localhost:1987: dial tcp 127.0.0.1:1987: connect: connection refused", + }, + { + Name: "wrong directory HTTP status code", + DirectoryURL: wrongStatusCodeURL, + ExpectedError: ErrInvalidDirectoryHTTPCode.Error(), + }, + { + Name: "invalid directory JSON", + DirectoryURL: invalidJSONURL, + ExpectedError: ErrInvalidDirectoryJSON.Error(), + }, + { + Name: "directory JSON missing required endpoint", + DirectoryURL: missingEndpointURL, + ExpectedError: missingEndpointErr.Error(), + }, + { + Name: "directory JSON with invalid endpoint URL", + DirectoryURL: invalidEndpointURL, + ExpectedError: invalidEndpointErr.Error(), + }, + { + Name: "directory JSON missing meta key", + DirectoryURL: invalidDirectoryMetaURL, + ExpectedError: ErrInvalidDirectoryMeta.Error(), + }, + { + Name: "directory JSON missing meta TermsOfService key", + DirectoryURL: invalidDirectoryToSURL, + ExpectedError: ErrInvalidTermsOfService.Error(), + }, + { + Name: "valid directory", + DirectoryURL: validDirectoryURL, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + _, err := NewDirectory(tc.DirectoryURL) + if err == nil && tc.ExpectedError != "" { + t.Errorf("expected error %q got nil", tc.ExpectedError) + } else if err != nil { + test.AssertEquals(t, err.Error(), tc.ExpectedError) + } + }) + } +} diff --git a/test/load-generator/boulder-calls.go b/test/load-generator/boulder-calls.go index b601aa570..a2ee54471 100644 --- a/test/load-generator/boulder-calls.go +++ b/test/load-generator/boulder-calls.go @@ -16,11 +16,11 @@ import ( "io/ioutil" mrand "math/rand" "net/http" - "strings" "time" "github.com/letsencrypt/boulder/core" "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test/load-generator/acme" "gopkg.in/square/go-jose.v2" ) @@ -37,17 +37,6 @@ var ( } ) -// API path constants -const ( - newAcctPath = "/acme/new-acct" - newOrderPath = "/acme/new-order" - newRegPath = "/acme/new-reg" - newAuthzPath = "/acme/new-authz" - challengePath = "/acme/challenge" - newCertPath = "/acme/new-cert" - revokeCertPath = "/acme/revoke-cert" -) - // It's awkward to work with core.Order or corepb.Order when the API returns // a different object than either of these types can represent without // converting field values. The WFE uses an unexported `orderJSON` type for the @@ -123,7 +112,8 @@ func newAccount(s *State, ctx *context) error { // Sign the new account registration body using a JWS with an embedded JWK // because we do not have a key ID from the server yet. - jws, err := ctx.signEmbeddedV2Request(reqBodyStr, fmt.Sprintf("%s%s", s.apiBase, newAcctPath)) + newAccountURL := s.directory.EndpointURL(acme.NewAccountEndpoint) + jws, err := ctx.signEmbeddedV2Request(reqBodyStr, newAccountURL) if err != nil { return err } @@ -131,15 +121,15 @@ func newAccount(s *State, ctx *context) error { // POST the account creation request to the server nStarted := time.Now() - resp, err := s.post(fmt.Sprintf("%s%s", s.apiBase, newAcctPath), bodyBuf, ctx.ns) + resp, err := s.post(newAccountURL, bodyBuf, ctx.ns) nFinished := time.Now() nState := "error" defer func() { s.callLatency.Add( - fmt.Sprintf("POST %s", newAcctPath), nStarted, nFinished, nState) + fmt.Sprintf("POST %s", acme.NewAccountEndpoint), nStarted, nFinished, nState) }() if err != nil { - return fmt.Errorf("%s, post failed: %s", newAcctPath, err) + return fmt.Errorf("%s, post failed: %s", newAccountURL, err) } defer resp.Body.Close() @@ -147,16 +137,16 @@ func newAccount(s *State, ctx *context) error { if resp.StatusCode != http.StatusCreated { body, err := ioutil.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("%s, bad response: %s", newAcctPath, body) + return fmt.Errorf("%s, bad response: %s", newAccountURL, body) } - return fmt.Errorf("%s, bad response status %d: %s", newAcctPath, resp.StatusCode, body) + return fmt.Errorf("%s, bad response status %d: %s", newAccountURL, resp.StatusCode, body) } // Populate the context account's key ID with the Location header returned by // the server locHeader := resp.Header.Get("Location") if locHeader == "" { - return fmt.Errorf("%s, bad response - no Location header with account ID", newAcctPath) + return fmt.Errorf("%s, bad response - no Location header with account ID", newAccountURL) } ctx.acct.id = locHeader @@ -205,8 +195,8 @@ func newOrder(s *State, ctx *context) error { } // Sign the new order request with the context account's key/key ID - url := fmt.Sprintf("%s%s", s.apiBase, newOrderPath) - jws, err := ctx.signKeyIDV2Request(initOrderStr, url) + newOrderURL := s.directory.EndpointURL(acme.NewOrderEndpoint) + jws, err := ctx.signKeyIDV2Request(initOrderStr, newOrderURL) if err != nil { return err } @@ -214,25 +204,25 @@ func newOrder(s *State, ctx *context) error { // POST the new-order endpoint nStarted := time.Now() - resp, err := s.post(url, bodyBuf, ctx.ns) + resp, err := s.post(newOrderURL, bodyBuf, ctx.ns) nFinished := time.Now() nState := "error" defer func() { s.callLatency.Add( - fmt.Sprintf("POST %s", newOrderPath), nStarted, nFinished, nState) + fmt.Sprintf("POST %s", acme.NewOrderEndpoint), nStarted, nFinished, nState) }() if err != nil { - return fmt.Errorf("%s, post failed: %s", newOrderPath, err) + return fmt.Errorf("%s, post failed: %s", newOrderURL, err) } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("%s, bad response: %s", newOrderPath, body) + return fmt.Errorf("%s, bad response: %s", newOrderURL, body) } // We expect that the result is a created order if resp.StatusCode != http.StatusCreated { - return fmt.Errorf("%s, bad response status %d: %s", newOrderPath, resp.StatusCode, body) + return fmt.Errorf("%s, bad response status %d: %s", newOrderURL, resp.StatusCode, body) } // Unmarshal the Order object @@ -245,7 +235,7 @@ func newOrder(s *State, ctx *context) error { // Populate the URL of the order from the Location header orderURL := resp.Header.Get("Location") if orderURL == "" { - return fmt.Errorf("%s, bad response - no Location header with order ID", newOrderPath) + return fmt.Errorf("%s, bad response - no Location header with order ID", newOrderURL) } orderJSON.URL = orderURL @@ -294,10 +284,9 @@ func getAuthorization(s *State, url string) (*core.Authorization, error) { if err != nil { return nil, fmt.Errorf("%s response: %s", url, body) } - // The Authorization ID is not set in the response so we populate it based on - // the URL - paths := strings.Split(url, "/") - authz.ID = paths[len(paths)-1] + // The Authorization ID is not set in the response so we populate it using the + // URL + authz.ID = url aState = "good" return &authz, nil } @@ -389,7 +378,7 @@ func completeAuthorization(authz *core.Authorization, s *State, ctx *context) er // correct authorization state an error is returned. If no error is returned // then the authorization is valid and ready. func pollAuthorization(authz *core.Authorization, s *State, ctx *context) error { - authzURL := fmt.Sprintf("%s/acme/authz/%s", s.apiBase, authz.ID) + authzURL := authz.ID for i := 0; i < 3; i++ { // Fetch the authz by its URL authz, err := getAuthorization(s, authzURL) @@ -461,12 +450,12 @@ func getOrder(s *State, url string) (*OrderJSON, error) { defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("%s, bad response: %s", newOrderPath, body) + return nil, fmt.Errorf("%s, bad response: %s", url, body) } // We expect a HTTP status OK response if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s, bad response status %d: %s", newOrderPath, resp.StatusCode, body) + return nil, fmt.Errorf("%s, bad response status %d: %s", url, resp.StatusCode, body) } // Unmarshal the Order object from the response body diff --git a/test/load-generator/config/integration-test-config.json b/test/load-generator/config/integration-test-config.json index 1e97f4c17..870558d92 100644 --- a/test/load-generator/config/integration-test-config.json +++ b/test/load-generator/config/integration-test-config.json @@ -10,7 +10,7 @@ "runtime": "10s", "rateDelta": "5/1m" }, - "apiBase": "http://boulder:4001", + "directoryURL": "http://boulder:4001/directory", "domainBase": "com", "httpOneAddr": "localhost:5002", "regKeySize": 2048, diff --git a/test/load-generator/config/pebble-test-config.json b/test/load-generator/config/pebble-test-config.json new file mode 100644 index 000000000..5feaf656d --- /dev/null +++ b/test/load-generator/config/pebble-test-config.json @@ -0,0 +1,22 @@ +{ + "plan": { + "actions": [ + "newAccount", + "newOrder", + "fulfillOrder", + "finalizeOrder" + ], + "rate": 1, + "runtime": "1s", + "rateDelta": "1/1m" + }, + "directoryURL": "https://localhost:14000/dir", + "domainBase": "com", + "httpOneAddr": "localhost:5002", + "regKeySize": 2048, + "certKeySize": 2048, + "regEmail": "loadtesting@letsencrypt.org", + "maxRegs": 20, + "maxNamesPerCert": 20, + "dontSaveState": true +} diff --git a/test/load-generator/main.go b/test/load-generator/main.go index a58bc6dae..ea0b66986 100644 --- a/test/load-generator/main.go +++ b/test/load-generator/main.go @@ -23,7 +23,7 @@ type Config struct { } ExternalState string // path to file to load/save registrations etc to/from DontSaveState bool // don't save changes to external state - APIBase string // ACME API address to send requests to + DirectoryURL string // ACME server directory URL DomainBase string // base domain name to create authorizations for HTTPOneAddr string // address to listen for http-01 validation requests on RealIP string // value of the Real-IP header to use when bypassing CDN @@ -42,6 +42,11 @@ func main() { deltaArg := flag.String("delta", "", "") flag.Parse() + if *configPath == "" { + fmt.Fprintf(os.Stderr, "-config argument must not be empty\n") + os.Exit(1) + } + configBytes, err := ioutil.ReadFile(*configPath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to read load-generator config file %q: %s\n", *configPath, err) @@ -68,7 +73,7 @@ func main() { } s, err := New( - config.APIBase, + config.DirectoryURL, config.CertKeySize, config.DomainBase, config.RealIP, diff --git a/test/load-generator/state.go b/test/load-generator/state.go index ee0d5cefb..60565b164 100644 --- a/test/load-generator/state.go +++ b/test/load-generator/state.go @@ -27,6 +27,7 @@ import ( "gopkg.in/square/go-jose.v2" + "github.com/letsencrypt/boulder/test/load-generator/acme" "github.com/letsencrypt/challtestsrv" ) @@ -166,7 +167,6 @@ type respCode struct { // State holds *all* the stuff type State struct { - apiBase string domainBase string email string maxRegs int @@ -183,7 +183,9 @@ type State struct { challSrv *challtestsrv.ChallSrv callLatency latencyWriter - client *http.Client + + directory *acme.Directory + httpClient *http.Client getTotal int64 postTotal int64 @@ -265,7 +267,7 @@ func (s *State) Restore(filename string) error { // New returns a pointer to a new State struct or an error func New( - apiBase string, + directoryURL string, keySize int, domainBase string, realIP string, @@ -277,7 +279,11 @@ func New( if err != nil { return nil, err } - client := &http.Client{ + directory, err := acme.NewDirectory(directoryURL) + if err != nil { + return nil, err + } + httpClient := &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 10 * time.Second, @@ -297,8 +303,8 @@ func New( return nil, err } s := &State{ - client: client, - apiBase: apiBase, + httpClient: httpClient, + directory: directory, certKey: certKey, domainBase: domainBase, callLatency: latencyFile, @@ -466,7 +472,7 @@ func (s *State) post(endpoint string, payload []byte, ns *nonceSource) (*http.Re req.Header.Add("User-Agent", userAgent) req.Header.Add("Content-Type", "application/jose+json") atomic.AddInt64(&s.postTotal, 1) - resp, err := s.client.Do(req) + resp, err := s.httpClient.Do(req) if err != nil { return nil, err } @@ -485,7 +491,7 @@ func (s *State) get(path string) (*http.Response, error) { req.Header.Add("X-Real-IP", s.realIP) req.Header.Add("User-Agent", userAgent) atomic.AddInt64(&s.getTotal, 1) - resp, err := s.client.Get(path) + resp, err := s.httpClient.Get(path) if err != nil { return nil, err } @@ -510,8 +516,9 @@ type nonceSource struct { } func (ns *nonceSource) getNonce() (string, error) { + directoryURL := ns.s.directory.EndpointURL(acme.NewNonceEndpoint) started := time.Now() - resp, err := ns.s.client.Head(fmt.Sprintf("%s/directory", ns.s.apiBase)) + resp, err := ns.s.httpClient.Head(directoryURL) finished := time.Now() state := "good" defer func() { ns.s.callLatency.Add("HEAD /directory", started, finished, state) }()