github.com/letsencrypt/boulder@v0.20251208.0/test/load-generator/state.go (about)

     1  package main
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/ecdsa"
     7  	"crypto/elliptic"
     8  	"crypto/rand"
     9  	"crypto/tls"
    10  	"crypto/x509"
    11  	"encoding/json"
    12  	"errors"
    13  	"fmt"
    14  	"io"
    15  	"log"
    16  	"net"
    17  	"net/http"
    18  	"os"
    19  	"reflect"
    20  	"runtime"
    21  	"sort"
    22  	"strings"
    23  	"sync"
    24  	"sync/atomic"
    25  	"time"
    26  
    27  	"github.com/go-jose/go-jose/v4"
    28  
    29  	"github.com/letsencrypt/boulder/test/load-generator/acme"
    30  	"github.com/letsencrypt/challtestsrv"
    31  )
    32  
    33  // account is an ACME v2 account resource. It does not have a `jose.Signer`
    34  // because we need to set the Signer options per-request with the URL being
    35  // POSTed and must construct it on the fly from the `key`. Accounts are
    36  // protected by a `sync.Mutex` that must be held for updates (see
    37  // `account.Update`).
    38  type account struct {
    39  	key             *ecdsa.PrivateKey
    40  	id              string
    41  	finalizedOrders []string
    42  	certs           []string
    43  	mu              sync.Mutex
    44  }
    45  
    46  // update locks an account resource's mutex and sets the `finalizedOrders` and
    47  // `certs` fields to the provided values.
    48  func (acct *account) update(finalizedOrders, certs []string) {
    49  	acct.mu.Lock()
    50  	defer acct.mu.Unlock()
    51  
    52  	acct.finalizedOrders = append(acct.finalizedOrders, finalizedOrders...)
    53  	acct.certs = append(acct.certs, certs...)
    54  }
    55  
    56  type acmeCache struct {
    57  	// The current V2 account (may be nil for legacy load generation)
    58  	acct *account
    59  	// Pending orders waiting for authorization challenge validation
    60  	pendingOrders []*OrderJSON
    61  	// Fulfilled orders in a valid status waiting for finalization
    62  	fulfilledOrders []string
    63  	// Finalized orders that have certificates
    64  	finalizedOrders []string
    65  
    66  	// A list of URLs for issued certificates
    67  	certs []string
    68  	// The nonce source for JWS signature nonce headers
    69  	ns *nonceSource
    70  }
    71  
    72  // signEmbeddedV2Request signs the provided request data using the acmeCache's
    73  // account's private key. The provided URL is set as a protected header per ACME
    74  // v2 JWS standards. The resulting JWS contains an **embedded** JWK - this makes
    75  // this function primarily applicable to new account requests where no key ID is
    76  // known.
    77  func (c *acmeCache) signEmbeddedV2Request(data []byte, url string) (*jose.JSONWebSignature, error) {
    78  	// Create a signing key for the account's private key
    79  	signingKey := jose.SigningKey{
    80  		Key:       c.acct.key,
    81  		Algorithm: jose.ES256,
    82  	}
    83  	// Create a signer, setting the URL protected header
    84  	signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{
    85  		NonceSource: c.ns,
    86  		EmbedJWK:    true,
    87  		ExtraHeaders: map[jose.HeaderKey]any{
    88  			"url": url,
    89  		},
    90  	})
    91  	if err != nil {
    92  		return nil, err
    93  	}
    94  
    95  	// Sign the data with the signer
    96  	signed, err := signer.Sign(data)
    97  	if err != nil {
    98  		return nil, err
    99  	}
   100  	return signed, nil
   101  }
   102  
   103  // signKeyIDV2Request signs the provided request data using the acmeCache's
   104  // account's private key. The provided URL is set as a protected header per ACME
   105  // v2 JWS standards. The resulting JWS contains a Key ID header that is
   106  // populated using the acmeCache's account's ID. This is the default JWS signing
   107  // style for ACME v2 requests and should be used everywhere but where the key ID
   108  // is unknown (e.g. new-account requests where an account doesn't exist yet).
   109  func (c *acmeCache) signKeyIDV2Request(data []byte, url string) (*jose.JSONWebSignature, error) {
   110  	// Create a JWK with the account's private key and key ID
   111  	jwk := &jose.JSONWebKey{
   112  		Key:       c.acct.key,
   113  		Algorithm: "ECDSA",
   114  		KeyID:     c.acct.id,
   115  	}
   116  
   117  	// Create a signing key with the JWK
   118  	signerKey := jose.SigningKey{
   119  		Key:       jwk,
   120  		Algorithm: jose.ES256,
   121  	}
   122  
   123  	// Ensure the signer's nonce source and URL header will be set
   124  	opts := &jose.SignerOptions{
   125  		NonceSource: c.ns,
   126  		ExtraHeaders: map[jose.HeaderKey]any{
   127  			"url": url,
   128  		},
   129  	}
   130  
   131  	// Construct the signer with the configured options
   132  	signer, err := jose.NewSigner(signerKey, opts)
   133  	if err != nil {
   134  		return nil, err
   135  	}
   136  
   137  	// Sign the data with the signer
   138  	signed, err := signer.Sign(data)
   139  	if err != nil {
   140  		return nil, err
   141  	}
   142  	return signed, nil
   143  }
   144  
   145  type RateDelta struct {
   146  	Inc    int64
   147  	Period time.Duration
   148  }
   149  
   150  type Plan struct {
   151  	Runtime time.Duration
   152  	Rate    int64
   153  	Delta   *RateDelta
   154  }
   155  
   156  type respCode struct {
   157  	code int
   158  	num  int
   159  }
   160  
   161  // State holds *all* the stuff
   162  type State struct {
   163  	domainBase      string
   164  	email           string
   165  	maxRegs         int
   166  	maxNamesPerCert int
   167  	realIP          string
   168  	certKey         *ecdsa.PrivateKey
   169  
   170  	operations []func(*State, *acmeCache) error
   171  
   172  	rMu sync.RWMutex
   173  
   174  	// accts holds V2 account objects
   175  	accts []*account
   176  
   177  	challSrv    *challtestsrv.ChallSrv
   178  	callLatency latencyWriter
   179  
   180  	directory  *acme.Directory
   181  	challStrat acme.ChallengeStrategy
   182  	httpClient *http.Client
   183  
   184  	revokeChance float32
   185  
   186  	reqTotal  int64
   187  	respCodes map[int]*respCode
   188  	cMu       sync.Mutex
   189  
   190  	wg *sync.WaitGroup
   191  }
   192  
   193  type rawAccount struct {
   194  	FinalizedOrders []string `json:"finalizedOrders"`
   195  	Certs           []string `json:"certs"`
   196  	ID              string   `json:"id"`
   197  	RawKey          []byte   `json:"rawKey"`
   198  }
   199  
   200  type snapshot struct {
   201  	Accounts []rawAccount
   202  }
   203  
   204  func (s *State) numAccts() int {
   205  	s.rMu.RLock()
   206  	defer s.rMu.RUnlock()
   207  	return len(s.accts)
   208  }
   209  
   210  // Snapshot will save out generated accounts
   211  func (s *State) Snapshot(filename string) error {
   212  	fmt.Printf("[+] Saving accounts to %s\n", filename)
   213  	snap := snapshot{}
   214  	for _, acct := range s.accts {
   215  		k, err := x509.MarshalECPrivateKey(acct.key)
   216  		if err != nil {
   217  			return err
   218  		}
   219  		snap.Accounts = append(snap.Accounts, rawAccount{
   220  			Certs:           acct.certs,
   221  			FinalizedOrders: acct.finalizedOrders,
   222  			ID:              acct.id,
   223  			RawKey:          k,
   224  		})
   225  	}
   226  	cont, err := json.Marshal(snap)
   227  	if err != nil {
   228  		return err
   229  	}
   230  	return os.WriteFile(filename, cont, os.ModePerm)
   231  }
   232  
   233  // Restore previously generated accounts
   234  func (s *State) Restore(filename string) error {
   235  	fmt.Printf("[+] Loading accounts from %q\n", filename)
   236  	// NOTE(@cpu): Using os.O_CREATE here explicitly to create the file if it does
   237  	// not exist.
   238  	f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0600)
   239  	if err != nil {
   240  		return err
   241  	}
   242  
   243  	content, err := io.ReadAll(f)
   244  	if err != nil {
   245  		return err
   246  	}
   247  	// If the file's content is the empty string it was probably just created.
   248  	// Avoid an unmarshaling error by assuming an empty file is an empty snapshot.
   249  	if string(content) == "" {
   250  		content = []byte("{}")
   251  	}
   252  
   253  	snap := snapshot{}
   254  	err = json.Unmarshal(content, &snap)
   255  	if err != nil {
   256  		return err
   257  	}
   258  	for _, a := range snap.Accounts {
   259  		key, err := x509.ParseECPrivateKey(a.RawKey)
   260  		if err != nil {
   261  			continue
   262  		}
   263  		s.accts = append(s.accts, &account{
   264  			key:             key,
   265  			id:              a.ID,
   266  			finalizedOrders: a.FinalizedOrders,
   267  			certs:           a.Certs,
   268  		})
   269  	}
   270  	return nil
   271  }
   272  
   273  // New returns a pointer to a new State struct or an error
   274  func New(
   275  	directoryURL string,
   276  	domainBase string,
   277  	realIP string,
   278  	maxRegs, maxNamesPerCert int,
   279  	latencyPath string,
   280  	userEmail string,
   281  	operations []string,
   282  	challStrat string,
   283  	revokeChance float32) (*State, error) {
   284  	certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
   285  	if err != nil {
   286  		return nil, err
   287  	}
   288  	directory, err := acme.NewDirectory(directoryURL)
   289  	if err != nil {
   290  		return nil, err
   291  	}
   292  	strategy, err := acme.NewChallengeStrategy(challStrat)
   293  	if err != nil {
   294  		return nil, err
   295  	}
   296  	if revokeChance > 1 {
   297  		return nil, errors.New("revokeChance must be between 0.0 and 1.0")
   298  	}
   299  	httpClient := &http.Client{
   300  		Transport: &http.Transport{
   301  			DialContext: (&net.Dialer{
   302  				Timeout:   10 * time.Second,
   303  				KeepAlive: 30 * time.Second,
   304  			}).DialContext,
   305  			TLSHandshakeTimeout: 5 * time.Second,
   306  			TLSClientConfig: &tls.Config{
   307  				InsecureSkipVerify: true, // CDN bypass can cause validation failures
   308  			},
   309  			MaxIdleConns:    500,
   310  			IdleConnTimeout: 90 * time.Second,
   311  		},
   312  		Timeout: 10 * time.Second,
   313  	}
   314  	latencyFile, err := newLatencyFile(latencyPath)
   315  	if err != nil {
   316  		return nil, err
   317  	}
   318  	s := &State{
   319  		httpClient:      httpClient,
   320  		directory:       directory,
   321  		challStrat:      strategy,
   322  		certKey:         certKey,
   323  		domainBase:      domainBase,
   324  		callLatency:     latencyFile,
   325  		wg:              new(sync.WaitGroup),
   326  		realIP:          realIP,
   327  		maxRegs:         maxRegs,
   328  		maxNamesPerCert: maxNamesPerCert,
   329  		email:           userEmail,
   330  		respCodes:       make(map[int]*respCode),
   331  		revokeChance:    revokeChance,
   332  	}
   333  
   334  	// convert operations strings to methods
   335  	for _, opName := range operations {
   336  		op, present := stringToOperation[opName]
   337  		if !present {
   338  			return nil, fmt.Errorf("unknown operation %q", opName)
   339  		}
   340  		s.operations = append(s.operations, op)
   341  	}
   342  
   343  	return s, nil
   344  }
   345  
   346  // Run runs the WFE load-generator
   347  func (s *State) Run(
   348  	ctx context.Context,
   349  	httpOneAddrs []string,
   350  	tlsALPNOneAddrs []string,
   351  	dnsAddrs []string,
   352  	fakeDNS string,
   353  	p Plan) error {
   354  	// Create a new challenge server binding the requested addrs.
   355  	challSrv, err := challtestsrv.New(challtestsrv.Config{
   356  		HTTPOneAddrs:    httpOneAddrs,
   357  		TLSALPNOneAddrs: tlsALPNOneAddrs,
   358  		DNSOneAddrs:     dnsAddrs,
   359  		// Use a logger that has a load-generator prefix
   360  		Log: log.New(os.Stdout, "load-generator challsrv - ", log.LstdFlags),
   361  	})
   362  	// Setup the challenge server to return the mock "fake DNS" IP address
   363  	challSrv.SetDefaultDNSIPv4(fakeDNS)
   364  	// Disable returning any AAAA records.
   365  	challSrv.SetDefaultDNSIPv6("")
   366  
   367  	if err != nil {
   368  		return err
   369  	}
   370  	// Save the challenge server in the state
   371  	s.challSrv = challSrv
   372  
   373  	// Start the Challenge server in its own Go routine
   374  	go s.challSrv.Run()
   375  
   376  	if p.Delta != nil {
   377  		go func() {
   378  			for {
   379  				time.Sleep(p.Delta.Period)
   380  				atomic.AddInt64(&p.Rate, p.Delta.Inc)
   381  			}
   382  		}()
   383  	}
   384  
   385  	// Run sending loop
   386  	stop := make(chan bool, 1)
   387  	fmt.Println("[+] Beginning execution plan")
   388  	i := int64(0)
   389  	go func() {
   390  		for {
   391  			start := time.Now()
   392  			select {
   393  			case <-stop:
   394  				return
   395  			default:
   396  				s.wg.Add(1)
   397  				go s.sendCall()
   398  				atomic.AddInt64(&i, 1)
   399  			}
   400  			sf := time.Duration(time.Second.Nanoseconds()/atomic.LoadInt64(&p.Rate)) - time.Since(start)
   401  			time.Sleep(sf)
   402  		}
   403  	}()
   404  	go func() {
   405  		lastTotal := int64(0)
   406  		lastReqTotal := int64(0)
   407  		for {
   408  			time.Sleep(time.Second)
   409  			curTotal := atomic.LoadInt64(&i)
   410  			curReqTotal := atomic.LoadInt64(&s.reqTotal)
   411  			fmt.Printf(
   412  				"%s Action rate: %d/s [expected: %d/s], Request rate: %d/s, Responses: [%s]\n",
   413  				time.Now().Format(time.DateTime),
   414  				curTotal-lastTotal,
   415  				atomic.LoadInt64(&p.Rate),
   416  				curReqTotal-lastReqTotal,
   417  				s.respCodeString(),
   418  			)
   419  			lastTotal = curTotal
   420  			lastReqTotal = curReqTotal
   421  		}
   422  	}()
   423  
   424  	select {
   425  	case <-time.After(p.Runtime):
   426  		fmt.Println("[+] Execution plan finished")
   427  	case <-ctx.Done():
   428  		fmt.Println("[!] Execution plan cancelled")
   429  	}
   430  	stop <- true
   431  	fmt.Println("[+] Waiting for pending flows to finish before killing challenge server")
   432  	s.wg.Wait()
   433  	fmt.Println("[+] Shutting down challenge server")
   434  	s.challSrv.Shutdown()
   435  	return nil
   436  }
   437  
   438  // HTTP utils
   439  
   440  func (s *State) addRespCode(code int) {
   441  	s.cMu.Lock()
   442  	defer s.cMu.Unlock()
   443  	code = code / 100
   444  	if e, ok := s.respCodes[code]; ok {
   445  		e.num++
   446  	} else if !ok {
   447  		s.respCodes[code] = &respCode{code, 1}
   448  	}
   449  }
   450  
   451  // codes is a convenience type for holding copies of the state object's
   452  // `respCodes` field of `map[int]*respCode`. Unlike the state object the
   453  // respCodes are copied by value and not held as pointers. The codes type allows
   454  // sorting the response codes for output.
   455  type codes []respCode
   456  
   457  func (c codes) Len() int {
   458  	return len(c)
   459  }
   460  
   461  func (c codes) Less(i, j int) bool {
   462  	return c[i].code < c[j].code
   463  }
   464  
   465  func (c codes) Swap(i, j int) {
   466  	c[i], c[j] = c[j], c[i]
   467  }
   468  
   469  func (s *State) respCodeString() string {
   470  	s.cMu.Lock()
   471  	list := codes{}
   472  	for _, v := range s.respCodes {
   473  		list = append(list, *v)
   474  	}
   475  	s.cMu.Unlock()
   476  	sort.Sort(list)
   477  	counts := []string{}
   478  	for _, v := range list {
   479  		counts = append(counts, fmt.Sprintf("%dxx: %d", v.code, v.num))
   480  	}
   481  	return strings.Join(counts, ", ")
   482  }
   483  
   484  var userAgent = "boulder load-generator -- heyo ^_^"
   485  
   486  func (s *State) post(
   487  	url string,
   488  	payload []byte,
   489  	ns *nonceSource,
   490  	latencyTag string,
   491  	expectedCode int) (*http.Response, error) {
   492  	req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
   493  	if err != nil {
   494  		return nil, err
   495  	}
   496  	req.Header.Add("X-Real-IP", s.realIP)
   497  	req.Header.Add("User-Agent", userAgent)
   498  	req.Header.Add("Content-Type", "application/jose+json")
   499  	atomic.AddInt64(&s.reqTotal, 1)
   500  	started := time.Now()
   501  	resp, err := s.httpClient.Do(req)
   502  	finished := time.Now()
   503  	state := "error"
   504  	// Defer logging the latency and result
   505  	defer func() {
   506  		s.callLatency.Add(latencyTag, started, finished, state)
   507  	}()
   508  	if err != nil {
   509  		return nil, err
   510  	}
   511  	go s.addRespCode(resp.StatusCode)
   512  	if newNonce := resp.Header.Get("Replay-Nonce"); newNonce != "" {
   513  		ns.addNonce(newNonce)
   514  	}
   515  	if resp.StatusCode != expectedCode {
   516  		return nil, fmt.Errorf("POST %q returned HTTP status %d, expected %d",
   517  			url, resp.StatusCode, expectedCode)
   518  	}
   519  	state = "good"
   520  	return resp, nil
   521  }
   522  
   523  type nonceSource struct {
   524  	mu        sync.Mutex
   525  	noncePool []string
   526  	s         *State
   527  }
   528  
   529  func (ns *nonceSource) getNonce() (string, error) {
   530  	nonceURL := ns.s.directory.EndpointURL(acme.NewNonceEndpoint)
   531  	latencyTag := string(acme.NewNonceEndpoint)
   532  	started := time.Now()
   533  	resp, err := ns.s.httpClient.Head(nonceURL)
   534  	finished := time.Now()
   535  	state := "error"
   536  	defer func() {
   537  		ns.s.callLatency.Add(fmt.Sprintf("HEAD %s", latencyTag),
   538  			started, finished, state)
   539  	}()
   540  	if err != nil {
   541  		return "", err
   542  	}
   543  	defer resp.Body.Close()
   544  	if nonce := resp.Header.Get("Replay-Nonce"); nonce != "" {
   545  		state = "good"
   546  		return nonce, nil
   547  	}
   548  	return "", errors.New("'Replay-Nonce' header not supplied")
   549  }
   550  
   551  // Nonce satisfies the interface jose.NonceSource, should probably actually be per context but ¯\_(ツ)_/¯ for now
   552  func (ns *nonceSource) Nonce() (string, error) {
   553  	ns.mu.Lock()
   554  	if len(ns.noncePool) == 0 {
   555  		ns.mu.Unlock()
   556  		return ns.getNonce()
   557  	}
   558  	defer ns.mu.Unlock()
   559  	nonce := ns.noncePool[0]
   560  	if len(ns.noncePool) > 1 {
   561  		ns.noncePool = ns.noncePool[1:]
   562  	} else {
   563  		ns.noncePool = []string{}
   564  	}
   565  	return nonce, nil
   566  }
   567  
   568  func (ns *nonceSource) addNonce(nonce string) {
   569  	ns.mu.Lock()
   570  	defer ns.mu.Unlock()
   571  	ns.noncePool = append(ns.noncePool, nonce)
   572  }
   573  
   574  // addAccount adds the provided account to the state's list of accts
   575  func (s *State) addAccount(acct *account) {
   576  	s.rMu.Lock()
   577  	defer s.rMu.Unlock()
   578  
   579  	s.accts = append(s.accts, acct)
   580  }
   581  
   582  func (s *State) sendCall() {
   583  	defer s.wg.Done()
   584  	c := &acmeCache{}
   585  
   586  	for _, op := range s.operations {
   587  		err := op(s, c)
   588  		if err != nil {
   589  			method := runtime.FuncForPC(reflect.ValueOf(op).Pointer()).Name()
   590  			fmt.Printf("[FAILED] %s: %s\n", method, err)
   591  			break
   592  		}
   593  	}
   594  	// If the acmeCache's V2 account isn't nil, update it based on the cache's
   595  	// finalizedOrders and certs.
   596  	if c.acct != nil {
   597  		c.acct.update(c.finalizedOrders, c.certs)
   598  	}
   599  }