ACMEv2 support for load-generator. (#3479)

This commit adds new ACMEv2 actions for the load-generator and an
example configuration for load testing ACME v2 against a local boulder
instance.

The load-generator's existing V1 code remains but the two ACME versions
can not be intermixed in the same load generator plan. The load
generator should be making 100% ACME v1 action calls or 100% ACME v2
action calls.

Follow-up work:

* Adding a short load generator run for V1 and V2 to the integration tests/CI
* Randomizing the # of names in pending orders - right now they are always 1 identifier orders.
* Making it easier to save state on an initial run without needing to create an empty JSON file
* DNS-01 support & Wildcard names

I'm going to start chipping at the "Follow-up" items but wanted to get the meat of this PR up for review now.

Resolves #3137
This commit is contained in:
Daniel McCarney 2018-02-27 21:09:09 -05:00 committed by Roland Bracewell Shoemaker
parent 2956b0c938
commit b99907d4a9
4 changed files with 853 additions and 58 deletions

View File

@ -20,12 +20,17 @@ import (
"time"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/probs"
"gopkg.in/square/go-jose.v2"
)
var (
// stringToOperation maps a configured plan action to a function that can
// operate on a state/context. V2 and V1 operations can **not** be intermixed
// in the same plan.
stringToOperation = map[string]func(*State, *context) error{
/* ACME v1 Operations */
"newRegistration": newRegistration,
"getRegistration": getRegistration,
"newAuthorization": newAuthorization,
@ -33,16 +38,167 @@ var (
"solveTLSOne": solveTLSOne,
"newCertificate": newCertificate,
"revokeCertificate": revokeCertificate,
/* ACME v2 Operations */
"newAccount": newAccount,
"getAccount": getAccount,
"newOrder": newOrder,
"fulfillOrder": fulfillOrder,
"finalizeOrder": finalizeOrder,
}
)
var plainReg = []byte(`{"resource":"new-reg"}`)
// 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"
)
var newRegPath = "/acme/new-reg"
var challengePath = "/acme/challenge"
var newCertPath = "/acme/new-cert"
var 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
// API results that contain an order. We duplicate it here instead of moving it
// somewhere exported for this one utility.
type OrderJSON struct {
// The URL field isn't returned by the API, we populate it manually with the
// `Location` header.
URL string
Status core.AcmeStatus `json:"status"`
Expires time.Time `json:"expires"`
Identifiers []core.AcmeIdentifier `json:"identifiers"`
Authorizations []string `json:"authorizations"`
Finalize string `json:"finalize"`
Certificate string `json:"certificate,omitempty"`
Error *probs.ProblemDetails `json:"error,omitempty"`
}
// getAccount takes a randomly selected v2 account from `state.accts` and puts it
// into `ctx.acct`. The context `nonceSource` is also populated as convenience.
func getAccount(s *State, ctx *context) error {
s.rMu.RLock()
defer s.rMu.RUnlock()
// There must be an existing v2 account in the state
if len(s.accts) == 0 {
return errors.New("no accounts to return")
}
// Select a random account from the state and put it into the context
ctx.acct = s.accts[mrand.Intn(len(s.accts))]
ctx.ns = &nonceSource{s: s}
return nil
}
// getRegistration takes an existing v1 account from `state.regs` and puts it
// into `ctx.reg`. The context `nonceSource` is also populated as convenience.
func getRegistration(s *State, ctx *context) error {
s.rMu.RLock()
defer s.rMu.RUnlock()
// There must be an existing v1 registration in the state
if len(s.regs) == 0 {
return errors.New("no registrations to return")
}
// Select a random registration from the state and put it into the context
ctx.reg = s.regs[mrand.Intn(len(s.regs))]
ctx.ns = &nonceSource{s: s}
return nil
}
// newAccount puts a V2 account into the provided context. If the state provided
// has too many accounts already (based on `state.NumAccts` and `state.maxRegs`)
// then `newAccount` puts an existing account from the state into the context,
// otherwise it creates a new account and puts it into both the state and the
// context.
func newAccount(s *State, ctx *context) error {
// Check the max regs and if exceeded, just return an existing account instead
// of creating a new one.
if s.maxRegs != 0 && s.numAccts() >= s.maxRegs {
return getAccount(s, ctx)
}
// Create a random signing key
signKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
ctx.acct = &account{
key: signKey,
}
ctx.ns = &nonceSource{s: s}
// Prepare an account registration message body
reqBody := struct {
ToSAgreed bool `json:"termsOfServiceAgreed"`
Contact []string
}{
ToSAgreed: true,
}
// Set the account contact email if configured
if s.email != "" {
reqBody.Contact = []string{fmt.Sprintf("mailto:%s", s.email)}
}
reqBodyStr, err := json.Marshal(&reqBody)
if err != nil {
return err
}
// 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))
if err != nil {
return err
}
bodyBuf := []byte(jws.FullSerialize())
// 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)
nFinished := time.Now()
nState := "error"
defer func() {
s.callLatency.Add(
fmt.Sprintf("POST %s", newAcctPath), nStarted, nFinished, nState)
}()
if err != nil {
return fmt.Errorf("%s, post failed: %s", newAcctPath, err)
}
defer resp.Body.Close()
// We expect that the result is a created account
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 status %d: %s", newAcctPath, 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)
}
ctx.acct.id = locHeader
// Add the account to the state
nState = "good"
s.addAccount(ctx.acct)
return nil
}
// newRegistration puts a V1 registration into the provided context. If the
// state provided has too many registrations already (based on `state.NumAccts`
// and `state.maxRegs`) then `newRegistration` puts an existing registration
// from the state into the context otherwise it creates a new registration and
// puts it into both the state and the context.
func newRegistration(s *State, ctx *context) error {
// if we have generated the max number of registrations just become getRegistration
if s.maxRegs != 0 && s.numRegs() >= s.maxRegs {
@ -72,10 +228,10 @@ func newRegistration(s *State, ctx *context) error {
if s.email != "" {
regStr = []byte(fmt.Sprintf(`{"resource":"new-reg","contact":["mailto:%s"]}`, s.email))
} else {
regStr = plainReg
regStr = []byte(`{"resource":"new-reg"}`)
}
// build the JWS object
requestPayload, err := s.signWithNonce(newRegPath, true, regStr, signer)
requestPayload, err := s.signWithNonce(regStr, signer)
if err != nil {
return fmt.Errorf("/acme/new-reg, sign failed: %s", err)
}
@ -113,7 +269,7 @@ func newRegistration(s *State, ctx *context) error {
regStr = []byte(fmt.Sprintf(`{"resource":"reg","agreement":"%s"}`, terms))
// build the JWS object
requestPayload, err = s.signWithNonce("/acme/reg", false, regStr, signer)
requestPayload, err = s.signWithNonce(regStr, signer)
if err != nil {
return fmt.Errorf("/acme/reg, sign failed: %s", err)
}
@ -143,23 +299,102 @@ func newRegistration(s *State, ctx *context) error {
return nil
}
func newAuthorization(s *State, ctx *context) error {
// generate a random(-ish) domain name, will cause some multiples but not enough to make rate limits annoying!
// randDomain generates a random(-ish) domain name as a subdomain of the
// provided base domain.
func randDomain(base string) string {
// This approach will cause some repeat domains but not enough to make rate
// limits annoying!
n := time.Now().UnixNano()
b := new(bytes.Buffer)
binary.Write(b, binary.LittleEndian, n)
randomDomain := fmt.Sprintf("%x.%s", sha1.Sum(b.Bytes()), s.domainBase)
return fmt.Sprintf("%x.%s", sha1.Sum(b.Bytes()), base)
}
// create the registration object
// newOrder creates a new pending order object for a random set of domains using
// the context's account.
func newOrder(s *State, ctx *context) error {
// generate a random(-ish) domain name, will cause some multiples but not enough to make rate limits annoying!
randomDomain := randDomain(s.domainBase)
// create the new order request object
initOrder := struct {
Identifiers []core.AcmeIdentifier
}{
Identifiers: []core.AcmeIdentifier{
{
Type: core.IdentifierDNS,
Value: randomDomain,
},
},
}
initOrderStr, err := json.Marshal(&initOrder)
if err != nil {
return err
}
// 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)
if err != nil {
return err
}
bodyBuf := []byte(jws.FullSerialize())
// POST the new-order endpoint
nStarted := time.Now()
resp, err := s.post(url, bodyBuf, ctx.ns)
nFinished := time.Now()
nState := "error"
defer func() {
s.callLatency.Add(
fmt.Sprintf("POST %s", newOrderPath), nStarted, nFinished, nState)
}()
if err != nil {
return fmt.Errorf("%s, post failed: %s", newOrderPath, err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%s, bad response: %s", newOrderPath, 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)
}
// Unmarshal the Order object
var orderJSON OrderJSON
err = json.Unmarshal(body, &orderJSON)
if err != nil {
return err
}
// 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)
}
orderJSON.URL = orderURL
// Store the pending order in the context
ctx.pendingOrders = append(ctx.pendingOrders, &orderJSON)
nState = "good"
return nil
}
// newAuthorization creates a new authz for a random domain name using the
// context's registration. The resulting pending authorization is stored in the
// context's list of pending authorizations.
func newAuthorization(s *State, ctx *context) error {
// generate a random(-ish) domain name, will cause some multiples but not enough to make rate limits annoying!
randomDomain := randDomain(s.domainBase)
// create the new-authz object
initAuth := fmt.Sprintf(`{"resource":"new-authz","identifier":{"type":"dns","value":"%s"}}`, randomDomain)
// build the JWS object
getNew := false
// do a coin flip to decide whether to get a new nonce via HEAD
if mrand.Intn(1) == 0 {
getNew = true
}
requestPayload, err := s.signWithNonce("/acme/new-authz", getNew, []byte(initAuth), ctx.reg.signer)
requestPayload, err := s.signWithNonce([]byte(initAuth), ctx.reg.signer)
if err != nil {
return err
}
@ -203,6 +438,194 @@ func newAuthorization(s *State, ctx *context) error {
return nil
}
// popPendingOrder *removes* a random pendingOrder from the context, returning
// it.
func popPendingOrder(ctx *context) *OrderJSON {
orderIndex := mrand.Intn(len(ctx.pendingOrders))
order := ctx.pendingOrders[orderIndex]
ctx.pendingOrders = append(ctx.pendingOrders[:orderIndex], ctx.pendingOrders[orderIndex+1:]...)
return order
}
// getAuthorization fetches an authorization by GETing the provided URL. It
// records the latency and result of the GET operation in the state.
func getAuthorization(s *State, url string) (*core.Authorization, error) {
// GET the provided URL, tracking elapsed time
aStarted := time.Now()
resp, err := s.get(url)
aFinished := time.Now()
aState := "error"
// Defer logging the latency and result
defer func() {
s.callLatency.Add("GET /acme/authz/{ID}", aStarted, aFinished, aState)
}()
// If there was an error, note the state and return
if err != nil {
return nil, fmt.Errorf("%s bad response: %s", url, err)
}
// Read the response body
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Unmarshal an authorization from the HTTP response body
var authz core.Authorization
err = json.Unmarshal(body, &authz)
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]
aState = "good"
return &authz, nil
}
// completeAuthorization processes a provided authorization by solving its
// HTTP-01 challenge using the context's account and the state's challenge
// server. Aftering POSTing the authorization's HTTP-01 challenge the
// authorization will be polled waiting for a state change.
func completeAuthorization(authz *core.Authorization, s *State, ctx *context) error {
// Skip if the authz isn't pending
if authz.Status != core.StatusPending {
return nil
}
// Find a challenge to solve from the pending authorization. For now, we only
// process HTTP-01 challenges and must error if there isn't a HTTP-01
// challenge to solve.
var chalToSolve *core.Challenge
for _, challenge := range authz.Challenges {
if challenge.Type == core.ChallengeTypeHTTP01 {
chalToSolve = &challenge
break
}
}
if chalToSolve == nil {
return errors.New("no http-01 challenges to complete")
}
// Compute the key authorization from the context account's key
jwk := &jose.JSONWebKey{Key: &ctx.acct.key.PublicKey}
thumbprint, err := jwk.Thumbprint(crypto.SHA256)
if err != nil {
return err
}
authStr := fmt.Sprintf("%s.%s", chalToSolve.Token, base64.RawURLEncoding.EncodeToString(thumbprint))
// Add the challenge response to the state's test server
s.challSrv.addHTTPOneChallenge(chalToSolve.Token, authStr)
// Clean up after we're done
defer s.challSrv.deleteHTTPOneChallenge(chalToSolve.Token)
// Prepare the Challenge POST body
update := fmt.Sprintf(`{"keyAuthorization":"%s"}`, authStr)
jws, err := ctx.signKeyIDV2Request([]byte(update), chalToSolve.URL)
if err != nil {
return err
}
requestPayload := []byte(jws.FullSerialize())
// POST the challenge update to begin the challenge process
cStarted := time.Now()
resp, err := s.post(chalToSolve.URL, requestPayload, ctx.ns)
cFinished := time.Now()
cState := "error"
// Record the final latency and state when finished
defer func() {
s.callLatency.Add("POST /acme/challenge/{ID}", cStarted, cFinished, cState)
}()
if err != nil {
return err
}
// Read the response body and cleanup when finished
defer resp.Body.Close()
_, err = ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
// The response code is expected to be Status OK
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response code: %d", resp.StatusCode)
}
// Poll the authorization waiting for the challenge response to be recorded in
// a change of state. The polling may sleep and retry a few times if required
pollAuthorization(authz, s, ctx)
if err != nil {
return err
}
// The challenge is completed, the authz is valid
cState = "good"
return nil
}
// pollAuthorization GETs a provided authorization up to three times, sleeping
// in between attempts, waiting for the status of the returned authorization to
// be valid. If the status is invalid, or if three GETs do not produce the
// 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)
for i := 0; i < 3; i++ {
// Fetch the authz by its URL
authz, err := getAuthorization(s, authzURL)
if err != nil {
return nil
}
// If the authz is invalid, abort with an error
if authz.Status == "invalid" {
return fmt.Errorf("Authorization %q failed challenge and is status invalid", authzURL)
}
// If the authz is valid, return with no error - the authz is ready to go!
if authz.Status == "valid" {
return nil
}
// Otherwise sleep and try again
time.Sleep(3 * time.Second)
}
return fmt.Errorf("Timed out polling authorization %q", authzURL)
}
// fulfillOrder processes a pending order from the context, completing each
// authorization's HTTP-01 challenge using the context's account, and finally
// placing the now-ready-to-be-finalized order into the context's list of
// fulfilled orders.
func fulfillOrder(s *State, ctx *context) error {
// There must be at least one pending order in the context to fulfill
if len(ctx.pendingOrders) == 0 {
return errors.New("no pending orders to fulfill")
}
// Get an order to fulfill from the context
order := popPendingOrder(ctx)
// Each of its authorizations need to be processed
for _, url := range order.Authorizations {
// Fetch the authz by its URL
authz, err := getAuthorization(s, url)
if err != nil {
return nil
}
// Complete the authorization by solving a challenge
completeAuthorization(authz, s, ctx)
}
// Once all of the authorizations have been fulfilled the order is fulfilled
// and ready for future finalization.
ctx.fulfilledOrders = append(ctx.fulfilledOrders, order.URL)
return nil
}
// popPending **removes** a random pending authorization from the context,
// returning it.
func popPending(ctx *context) *core.Authorization {
authzIndex := mrand.Intn(len(ctx.pendingAuthz))
authz := ctx.pendingAuthz[authzIndex]
@ -210,6 +633,8 @@ func popPending(ctx *context) *core.Authorization {
return authz
}
// solveHTTPOne solves a pending authorization's HTTP-01 challenge. It polls the
// authorization waiting for the status to change to valid.
func solveHTTPOne(s *State, ctx *context) error {
if len(ctx.pendingAuthz) == 0 {
return errors.New("no pending authorizations to complete")
@ -236,7 +661,7 @@ func solveHTTPOne(s *State, ctx *context) error {
defer s.challSrv.deleteHTTPOneChallenge(chall.Token)
update := fmt.Sprintf(`{"resource":"challenge","keyAuthorization":"%s"}`, authStr)
requestPayload, err := s.signWithNonce(challengePath, false, []byte(update), ctx.reg.signer)
requestPayload, err := s.signWithNonce([]byte(update), ctx.reg.signer)
if err != nil {
return err
}
@ -296,6 +721,9 @@ func solveHTTPOne(s *State, ctx *context) error {
return nil
}
// solveTLSOne removes a pending authorization from the context and solves the
// TLS-SNI-01 challenge associated with it, polling until the authorization
// changes state.
func solveTLSOne(s *State, ctx *context) error {
if len(ctx.pendingAuthz) == 0 {
return errors.New("no pending authorizations to complete")
@ -320,7 +748,7 @@ func solveTLSOne(s *State, ctx *context) error {
authStr := fmt.Sprintf("%s.%s", chall.Token, base64.RawURLEncoding.EncodeToString(thumbprint))
update := fmt.Sprintf(`{"resource":"challenge","keyAuthorization":"%s"}`, authStr)
requestPayload, err := s.signWithNonce(challengePath, false, []byte(update), ctx.reg.signer)
requestPayload, err := s.signWithNonce([]byte(update), ctx.reg.signer)
if err != nil {
return err
}
@ -380,6 +808,173 @@ func solveTLSOne(s *State, ctx *context) error {
return nil
}
// getOrder GETs an order by URL, returning an OrderJSON object. It tracks the
// latency of the GET operation in the provided state.
func getOrder(s *State, url string) (*OrderJSON, error) {
// GET the order URL
aStarted := time.Now()
resp, err := s.get(url)
aFinished := time.Now()
aState := "error"
// Track the latency and result
defer func() {
s.callLatency.Add("GET /acme/order/{ID}", aStarted, aFinished, aState)
}()
// If there was an error, track that result
if err != nil {
return nil, fmt.Errorf("%s bad response: %s", url, err)
}
// Read the response body
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%s, bad response: %s", newOrderPath, 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)
}
// Unmarshal the Order object from the response body
var orderJSON OrderJSON
err = json.Unmarshal(body, &orderJSON)
if err != nil {
return nil, err
}
// Populate the order's URL based on the URL we fetched it from
orderJSON.URL = url
aState = "good"
return &orderJSON, nil
}
// pollOrderForCert polls a provided order, waiting for the status to change to
// valid such that a certificate URL for the order is known. Three attempts are
// made to check the order status, sleeping 3s between each. If these attempts
// expire without the status becoming valid an error is returned.
func pollOrderForCert(order *OrderJSON, s *State, ctx *context) (*OrderJSON, error) {
for i := 0; i < 3; i++ {
// Fetch the order by its URL
order, err := getOrder(s, order.URL)
if err != nil {
return nil, err
}
// If the order is invalid, fail
if order.Status == "invalid" {
return nil, fmt.Errorf("Order %q failed and is status invalid", order.URL)
}
// If the order is valid, return with no error - the authz is ready to go!
if order.Status == "valid" {
return order, nil
}
// Otherwise sleep and try again
time.Sleep(3 * time.Second)
}
return nil, fmt.Errorf("Timed out polling order %q", order.URL)
}
// popFulfilledOrder **removes** a fulfilled order from the context, returning
// it. Fulfilled orders have all of their authorizations satisfied.
func popFulfilledOrder(ctx *context) string {
orderIndex := mrand.Intn(len(ctx.fulfilledOrders))
order := ctx.fulfilledOrders[orderIndex]
ctx.fulfilledOrders = append(ctx.fulfilledOrders[:orderIndex], ctx.fulfilledOrders[orderIndex+1:]...)
return order
}
// finalizeOrder removes a fulfilled order from the context and POSTs a CSR to
// the order's finalization URL. The CSR's key is set from the state's
// `certKey`. The order is then polled for the status to change to valid so that
// the certificate URL can be added to the context. The context's `certs` list
// is updated with the URL for the order's certificate.
func finalizeOrder(s *State, ctx *context) error {
// There must be at least one fulfilled order in the context
if len(ctx.fulfilledOrders) < 1 {
return fmt.Errorf("No fulfilled orders in the context ready to be finalized")
}
// Pop a fulfilled order to process, and then GET its contents
orderID := popFulfilledOrder(ctx)
order, err := getOrder(s, orderID)
if err != nil {
return err
}
// Mark down the finalization URL for the order
finalizeURL := order.Finalize
// Pull the values from the order identifiers for use in the CSR
dnsNames := make([]string, len(order.Identifiers))
for i, ident := range order.Identifiers {
dnsNames[i] = ident.Value
}
// Create a CSR using the state's certKey
csr, err := x509.CreateCertificateRequest(
rand.Reader,
&x509.CertificateRequest{DNSNames: dnsNames},
s.certKey,
)
if err != nil {
return err
}
// Create the finalization request body with the encoded CSR
request := fmt.Sprintf(
`{"csr":"%s"}`,
base64.URLEncoding.EncodeToString(csr),
)
// Sign the request body with the context's account key/keyID
jws, err := ctx.signKeyIDV2Request([]byte(request), finalizeURL)
if err != nil {
return err
}
requestPayload := []byte(jws.FullSerialize())
// POST the finalization URL for the order
started := time.Now()
resp, err := s.post(finalizeURL, requestPayload, ctx.ns)
finished := time.Now()
state := "error"
// Track the latency and the result state
defer func() {
s.callLatency.Add("POST /acme/order/finalize", started, finished, state)
}()
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad response, status %d", resp.StatusCode)
}
// Read the body to ensure there isn't an error. We don't need the actual
// contents.
_, err = ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
// Poll the order waiting for the certificate to be ready
completedOrder, err := pollOrderForCert(order, s, ctx)
if err != nil {
return err
}
// The valid order should have a certificate URL
certURL := completedOrder.Certificate
if certURL == "" {
return fmt.Errorf("Order %q was finalized but has no cert URL", order.URL)
}
// Append the certificate URL into the context's list of certificates
ctx.certs = append(ctx.certs, certURL)
ctx.finalizedOrders = append(ctx.finalizedOrders, order.URL)
state = "good"
return nil
}
// min returns the smaller of the two inputs
func min(a, b int) int {
if a > b {
return b
@ -387,6 +982,11 @@ func min(a, b int) int {
return a
}
// newCertificate POST's the v1 new-cert endpoint with a CSR for a random subset
// of domains that have finalized authz's in the context (Up to
// `state.maxNamesPerCert` domains). The CSR's private key is the
// `state.certKey`. The context's `certs` list is updated with the URL of the
// certificate produced.
func newCertificate(s *State, ctx *context) error {
authsLen := len(ctx.finalizedAuthz)
num := min(mrand.Intn(authsLen), s.maxNamesPerCert)
@ -409,7 +1009,7 @@ func newCertificate(s *State, ctx *context) error {
)
// build the JWS object
requestPayload, err := s.signWithNonce(newCertPath, false, []byte(request), ctx.reg.signer)
requestPayload, err := s.signWithNonce([]byte(request), ctx.reg.signer)
if err != nil {
return err
}
@ -440,6 +1040,13 @@ func newCertificate(s *State, ctx *context) error {
return nil
}
// revokeCertificate revokes a random certificate from the context's list of
// certificates. Presently it always uses the context's registration and the V1
// style of revocation. The certificate is removed from the context's `certs`
// list.
//
// TODO(@cpu): Write a V2 version of `revokeCertificate` that uses the context's
// account and a key ID JWS.
func revokeCertificate(s *State, ctx *context) error {
// randomly select a cert to revoke
if len(ctx.certs) == 0 {
@ -458,7 +1065,7 @@ func revokeCertificate(s *State, ctx *context) error {
}
request := fmt.Sprintf(`{"resource":"revoke-cert","certificate":"%s"}`, base64.URLEncoding.EncodeToString(body))
requestPayload, err := s.signWithNonce(revokeCertPath, false, []byte(request), ctx.reg.signer)
requestPayload, err := s.signWithNonce([]byte(request), ctx.reg.signer)
if err != nil {
return err
}

View File

@ -21,18 +21,17 @@ type Config struct {
RateDelta string // requests / s^2
Runtime string // how long to run for
}
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
DomainBase string // base domain name to create authorizations for
ChallTypes []string // which challenges to complete, empty means use all
HTTPOneAddr string // address to listen for http-01 validation requests on
TLSOneAddr string // address to listen for tls-sni-01 validation requests on
RealIP string // value of the Real-IP header to use when bypassing CDN
CertKeySize int // size of the key to use when creating CSRs
RegEmail string // email to use in registrations
Results string // path to save metrics to
MaxRegs int // maximum number of registrations to create
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
DomainBase string // base domain name to create authorizations for
HTTPOneAddr string // address to listen for http-01 validation requests on
TLSOneAddr string // address to listen for tls-sni-01 validation requests on
RealIP string // value of the Real-IP header to use when bypassing CDN
CertKeySize int // size of the key to use when creating CSRs
RegEmail string // email to use in registrations
Results string // path to save metrics to
MaxRegs int // maximum number of registrations to create
}
func main() {
@ -45,7 +44,7 @@ func main() {
configBytes, err := ioutil.ReadFile(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to read wfe config file %q: %s\n", &configPath, err)
fmt.Fprintf(os.Stderr, "Failed to read wfe config file %q: %s\n", *configPath, err)
os.Exit(1)
}
var config Config

View File

@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io/ioutil"
mrand "math/rand"
"net"
"net/http"
"os"
@ -36,6 +35,7 @@ type RatePeriod struct {
Rate int64
}
// registration is an ACME v1 registration resource
type registration struct {
key *ecdsa.PrivateKey
signer jose.Signer
@ -44,6 +44,31 @@ type registration struct {
mu sync.Mutex
}
// account is an ACME v2 account resource. It does not have a `jose.Signer`
// because we need to set the Signer options per-request with the URL being
// POSTed and must construct it on the fly from the `key`. Accounts are
// protected by a `sync.Mutex` that must be held for updates (see
// `account.Update`).
type account struct {
key *ecdsa.PrivateKey
id string
finalizedOrders []string
certs []string
mu sync.Mutex
}
// update locks an account resource's mutex and sets the `finalizedOrders` and
// `certs` fields to the provided values.
func (acct *account) update(finalizedOrders, certs []string) {
acct.mu.Lock()
defer acct.mu.Unlock()
acct.finalizedOrders = append(acct.finalizedOrders, finalizedOrders...)
acct.certs = append(acct.certs, certs...)
}
// update locks a registration resource's mutx and sets the `finalizedAuthz` and
// `certs` fields to the provided values.
func (r *registration) update(finalizedAuthz, certs []string) {
r.mu.Lock()
defer r.mu.Unlock()
@ -53,11 +78,102 @@ func (r *registration) update(finalizedAuthz, certs []string) {
}
type context struct {
reg *registration
pendingAuthz []*core.Authorization
/* ACME V1 Context */
// The current V1 registration (may be nil for V2 load generation)
reg *registration
// Pending authorizations waiting for challenge validation
pendingAuthz []*core.Authorization
// IDs of finalized authorizations in valid status
finalizedAuthz []string
certs []string
ns *nonceSource
/* ACME V2 Context */
// The current V2 account (may be nil for legacy load generation)
acct *account
// Pending orders waiting for authorization challenge validation
pendingOrders []*OrderJSON
// Fulfilled orders in a valid status waiting for finalization
fulfilledOrders []string
// Finalized orders that have certificates
finalizedOrders []string
/* Shared Context */
// A list of URLs for issued certificates
certs []string
// The nonce source for JWS signature nonce headers
ns *nonceSource
}
// signEmbeddedV2Request signs the provided request data using the context's
// account's private key. The provided URL is set as a protected header per ACME
// v2 JWS standards. The resulting JWS contains an **embedded** JWK - this makes
// this function primarily applicable to new account requests where no key ID is
// known.
func (c *context) signEmbeddedV2Request(data []byte, url string) (*jose.JSONWebSignature, error) {
// Create a signing key for the account's private key
signingKey := jose.SigningKey{
Key: c.acct.key,
Algorithm: jose.ES256,
}
// Create a signer, setting the URL protected header
signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{
NonceSource: c.ns,
EmbedJWK: true,
ExtraHeaders: map[jose.HeaderKey]interface{}{
"url": url,
},
})
if err != nil {
return nil, err
}
// Sign the data with the signer
signed, err := signer.Sign(data)
if err != nil {
return nil, err
}
return signed, nil
}
// signKeyIDV2Request signs the provided request data using the context's
// account's private key. The provided URL is set as a protected header per ACME
// v2 JWS standards. The resulting JWS contains a Key ID header that is
// populated using the context's account's ID. This is the default JWS signing
// style for ACME v2 requests and should be used everywhere but where the key ID
// is unknown (e.g. new-account requests where an account doesn't exist yet).
func (c *context) signKeyIDV2Request(data []byte, url string) (*jose.JSONWebSignature, error) {
// Create a JWK with the account's private key and key ID
jwk := &jose.JSONWebKey{
Key: c.acct.key,
Algorithm: "ECDSA",
KeyID: c.acct.id,
}
// Create a signing key with the JWK
signerKey := jose.SigningKey{
Key: jwk,
Algorithm: jose.ES256,
}
// Ensure the signer's nonce source and URL header will be set
opts := &jose.SignerOptions{
NonceSource: c.ns,
ExtraHeaders: map[jose.HeaderKey]interface{}{
"url": url,
},
}
// Construct the signer with the configured options
signer, err := jose.NewSigner(signerKey, opts)
if err != nil {
return nil, err
}
// Sign the data with the signer
signed, err := signer.Sign(data)
if err != nil {
return nil, err
}
return signed, nil
}
type RateDelta struct {
@ -88,8 +204,12 @@ type State struct {
operations []func(*State, *context) error
rMu sync.RWMutex
rMu sync.RWMutex
// regs holds V1 registration objects
regs []*registration
// accts holds V2 account objects
accts []*account
challSrv *challSrv
callLatency latencyWriter
@ -109,8 +229,22 @@ type rawRegistration struct {
RawKey []byte `json:"rawKey"`
}
type rawAccount struct {
FinalizedOrders []string `json:"finalizedOrders"`
Certs []string `json:"certs"`
ID string `json:"id"`
RawKey []byte `json:"rawKey"`
}
type snapshot struct {
Registrations []rawRegistration
Accounts []rawAccount
}
func (s *State) numAccts() int {
s.rMu.RLock()
defer s.rMu.RUnlock()
return len(s.accts)
}
func (s *State) numRegs() int {
@ -119,9 +253,9 @@ func (s *State) numRegs() int {
return len(s.regs)
}
// Snapshot will save out generated registrations and certs (ignoring authorizations)
// Snapshot will save out generated registrations and accounts
func (s *State) Snapshot(filename string) error {
fmt.Printf("[+] Saving registrations to %s\n", filename)
fmt.Printf("[+] Saving registrations/accounts to %s\n", filename)
snap := snapshot{}
// assume rMu lock operations aren't happening right now
for _, reg := range s.regs {
@ -135,6 +269,18 @@ func (s *State) Snapshot(filename string) error {
RawKey: k,
})
}
for _, acct := range s.accts {
k, err := x509.MarshalECPrivateKey(acct.key)
if err != nil {
return err
}
snap.Accounts = append(snap.Accounts, rawAccount{
Certs: acct.certs,
FinalizedOrders: acct.finalizedOrders,
ID: acct.id,
RawKey: k,
})
}
cont, err := json.Marshal(snap)
if err != nil {
return err
@ -142,9 +288,9 @@ func (s *State) Snapshot(filename string) error {
return ioutil.WriteFile(filename, cont, os.ModePerm)
}
// Restore previously generated registrations and certs
// Restore previously generated registrations and accounts
func (s *State) Restore(filename string) error {
fmt.Printf("[+] Loading registrations from %s\n", filename)
fmt.Printf("[+] Loading registrations/accounts from %s\n", filename)
content, err := ioutil.ReadFile(filename)
if err != nil {
return err
@ -175,6 +321,21 @@ func (s *State) Restore(filename string) error {
certs: r.Certs,
})
}
for _, a := range snap.Accounts {
key, err := x509.ParseECPrivateKey(a.RawKey)
if err != nil {
continue
}
if err != nil {
continue
}
s.accts = append(s.accts, &account{
key: key,
id: a.ID,
finalizedOrders: a.FinalizedOrders,
certs: a.Certs,
})
}
return nil
}
@ -381,9 +542,11 @@ func (s *State) get(path string) (*http.Response, error) {
}
// Nonce utils, these methods are used to generate/store/retrieve the nonces
// required for JWS
// required for JWS in V1 ACME requests
func (s *State) signWithNonce(endpoint string, alwaysNew bool, payload []byte, signer jose.Signer) ([]byte, error) {
// signWithNonce signs the provided message with the provided signer, returning
// the raw JWS bytes or an error. signWithNonce is not compatible with ACME v2
func (s *State) signWithNonce(payload []byte, signer jose.Signer) ([]byte, error) {
jws, err := signer.Sign(payload)
if err != nil {
return nil, err
@ -438,6 +601,15 @@ func (ns *nonceSource) addNonce(nonce string) {
ns.noncePool = append(ns.noncePool, nonce)
}
// addAccount adds the provided account to the state's list of accts
func (s *State) addAccount(acct *account) {
s.rMu.Lock()
defer s.rMu.Unlock()
s.accts = append(s.accts, acct)
}
// addRegistration adds the provided registration to the state's list of regs
func (s *State) addRegistration(reg *registration) {
s.rMu.Lock()
defer s.rMu.Unlock()
@ -445,17 +617,6 @@ func (s *State) addRegistration(reg *registration) {
s.regs = append(s.regs, reg)
}
func getRegistration(s *State, ctx *context) error {
s.rMu.RLock()
defer s.rMu.RUnlock()
if len(s.regs) == 0 {
return errors.New("no registrations to return")
}
ctx.reg = s.regs[mrand.Intn(len(s.regs))]
return nil
}
func (s *State) sendCall() {
defer s.wg.Done()
ctx := &context{}
@ -468,7 +629,13 @@ func (s *State) sendCall() {
break
}
}
// If the context's V1 registration
if ctx.reg != nil {
ctx.reg.update(ctx.finalizedAuthz, ctx.certs)
}
// If the context's V2 account isn't nil, update it based on the context's
// finalizedOrders and certs.
if ctx.acct != nil {
ctx.acct.update(ctx.finalizedOrders, ctx.certs)
}
}

View File

@ -0,0 +1,22 @@
{
"plan": {
"actions": [
"newAccount",
"newOrder",
"fulfillOrder",
"finalizeOrder"
],
"rate": 5,
"runtime": "5m",
"rateDelta": "5/1m"
},
"apiBase": "http://localhost:4001",
"domainBase": "com",
"httpOneAddr": "localhost:5002",
"regKeySize": 2048,
"certKeySize": 2048,
"regEmail": "loadtesting@letsencrypt.org",
"maxRegs": 20,
"dontSaveState": true,
"results": "v2-example-latency.json"
}