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 }