github.com/letsencrypt/boulder@v0.20251208.0/test/load-generator/acme/directory.go (about) 1 // Package acme provides ACME client functionality tailored to the needs of the 2 // load-generator. It is not a general purpose ACME client library. 3 package acme 4 5 import ( 6 "crypto/tls" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "net" 12 "net/http" 13 "net/url" 14 "time" 15 ) 16 17 const ( 18 // NewNonceEndpoint is the directory key for the newNonce endpoint. 19 NewNonceEndpoint Endpoint = "newNonce" 20 // NewAccountEndpoint is the directory key for the newAccount endpoint. 21 NewAccountEndpoint Endpoint = "newAccount" 22 // NewOrderEndpoint is the directory key for the newOrder endpoint. 23 NewOrderEndpoint Endpoint = "newOrder" 24 // RevokeCertEndpoint is the directory key for the revokeCert endpoint. 25 RevokeCertEndpoint Endpoint = "revokeCert" 26 // KeyChangeEndpoint is the directory key for the keyChange endpoint. 27 KeyChangeEndpoint Endpoint = "keyChange" 28 ) 29 30 var ( 31 // ErrEmptyDirectory is returned if NewDirectory is provided and empty directory URL. 32 ErrEmptyDirectory = errors.New("directoryURL must not be empty") 33 // ErrInvalidDirectoryURL is returned if NewDirectory is provided an invalid directory URL. 34 ErrInvalidDirectoryURL = errors.New("directoryURL is not a valid URL") 35 // ErrInvalidDirectoryHTTPCode is returned if NewDirectory is provided a directory URL 36 // that returns something other than HTTP Status OK to a GET request. 37 ErrInvalidDirectoryHTTPCode = errors.New("GET request to directoryURL did not result in HTTP Status 200") 38 // ErrInvalidDirectoryJSON is returned if NewDirectory is provided a directory URL 39 // that returns invalid JSON. 40 ErrInvalidDirectoryJSON = errors.New("GET request to directoryURL returned invalid JSON") 41 // ErrInvalidDirectoryMeta is returned if NewDirectory is provided a directory 42 // URL that returns a directory resource with an invalid or missing "meta" key. 43 ErrInvalidDirectoryMeta = errors.New(`server's directory resource had invalid or missing "meta" key`) 44 // ErrInvalidTermsOfService is returned if NewDirectory is provided 45 // a directory URL that returns a directory resource with an invalid or 46 // missing "termsOfService" key in the "meta" map. 47 ErrInvalidTermsOfService = errors.New(`server's directory resource had invalid or missing "meta.termsOfService" key`) 48 49 // RequiredEndpoints is a slice of Endpoint keys that must be present in the 50 // ACME server's directory. The load-generator uses each of these endpoints 51 // and expects to be able to find a URL for each in the server's directory 52 // resource. 53 RequiredEndpoints = []Endpoint{ 54 NewNonceEndpoint, NewAccountEndpoint, 55 NewOrderEndpoint, RevokeCertEndpoint, 56 } 57 ) 58 59 // Endpoint represents a string key used for looking up an endpoint URL in an ACME 60 // server directory resource. 61 // 62 // E.g. NewOrderEndpoint -> "newOrder" -> "https://acme.example.com/acme/v1/new-order-plz" 63 // 64 // See "ACME Resource Types" registry - RFC 8555 Section 9.7.5. 65 type Endpoint string 66 67 // ErrMissingEndpoint is an error returned if NewDirectory is provided an ACME 68 // server directory URL that is missing a key for a required endpoint in the 69 // response JSON. See also RequiredEndpoints. 70 type ErrMissingEndpoint struct { 71 endpoint Endpoint 72 } 73 74 // Error returns the error message for an ErrMissingEndpoint error. 75 func (e ErrMissingEndpoint) Error() string { 76 return fmt.Sprintf( 77 "directoryURL JSON was missing required key for %q endpoint", 78 e.endpoint, 79 ) 80 } 81 82 // ErrInvalidEndpointURL is an error returned if NewDirectory is provided an 83 // ACME server directory URL that has an invalid URL for a required endpoint. 84 // See also RequiredEndpoints. 85 type ErrInvalidEndpointURL struct { 86 endpoint Endpoint 87 value string 88 } 89 90 // Error returns the error message for an ErrInvalidEndpointURL error. 91 func (e ErrInvalidEndpointURL) Error() string { 92 return fmt.Sprintf( 93 "directoryURL JSON had invalid URL value (%q) for %q endpoint", 94 e.value, e.endpoint) 95 } 96 97 // Directory is a type for holding URLs extracted from the ACME server's 98 // Directory resource. 99 // 100 // See RFC 8555 Section 7.1.1 "Directory". 101 // 102 // Its public API is read-only and therefore it is safe for concurrent access. 103 type Directory struct { 104 // TermsOfService is the URL identifying the current terms of service found in 105 // the ACME server's directory resource's "meta" field. 106 TermsOfService string 107 // endpointURLs is a map from endpoint name to URL. 108 endpointURLs map[Endpoint]string 109 } 110 111 // getRawDirectory validates the provided directoryURL and makes a GET request 112 // to fetch the raw bytes of the server's directory resource. If the URL is 113 // invalid, if there is an error getting the directory bytes, or if the HTTP 114 // response code is not 200 an error is returned. 115 func getRawDirectory(directoryURL string) ([]byte, error) { 116 if directoryURL == "" { 117 return nil, ErrEmptyDirectory 118 } 119 120 if _, err := url.Parse(directoryURL); err != nil { 121 return nil, ErrInvalidDirectoryURL 122 } 123 124 httpClient := &http.Client{ 125 Transport: &http.Transport{ 126 DialContext: (&net.Dialer{ 127 Timeout: 10 * time.Second, 128 KeepAlive: 30 * time.Second, 129 }).DialContext, 130 TLSHandshakeTimeout: 5 * time.Second, 131 TLSClientConfig: &tls.Config{ 132 // Bypassing CDN or testing against Pebble instances can cause 133 // validation failures. For a **test-only** tool its acceptable to skip 134 // cert verification of the ACME server's HTTPs certificate. 135 InsecureSkipVerify: true, 136 }, 137 MaxIdleConns: 1, 138 IdleConnTimeout: 15 * time.Second, 139 }, 140 Timeout: 10 * time.Second, 141 } 142 143 resp, err := httpClient.Get(directoryURL) 144 if err != nil { 145 return nil, err 146 } 147 defer resp.Body.Close() 148 149 if resp.StatusCode != http.StatusOK { 150 return nil, ErrInvalidDirectoryHTTPCode 151 } 152 153 rawDirectory, err := io.ReadAll(resp.Body) 154 if err != nil { 155 return nil, err 156 } 157 158 return rawDirectory, nil 159 } 160 161 // termsOfService reads the termsOfService key from the meta key of the raw 162 // directory resource. 163 func termsOfService(rawDirectory map[string]any) (string, error) { 164 var directoryMeta map[string]any 165 166 if rawDirectoryMeta, ok := rawDirectory["meta"]; !ok { 167 return "", ErrInvalidDirectoryMeta 168 } else if directoryMetaMap, ok := rawDirectoryMeta.(map[string]any); !ok { 169 return "", ErrInvalidDirectoryMeta 170 } else { 171 directoryMeta = directoryMetaMap 172 } 173 174 rawToSURL, ok := directoryMeta["termsOfService"] 175 if !ok { 176 return "", ErrInvalidTermsOfService 177 } 178 179 tosURL, ok := rawToSURL.(string) 180 if !ok { 181 return "", ErrInvalidTermsOfService 182 } 183 return tosURL, nil 184 } 185 186 // NewDirectory creates a Directory populated from the ACME directory resource 187 // returned by a GET request to the provided directoryURL. It also checks that 188 // the fetched directory contains each of the RequiredEndpoints. 189 func NewDirectory(directoryURL string) (*Directory, error) { 190 // Fetch the raw directory JSON 191 dirContents, err := getRawDirectory(directoryURL) 192 if err != nil { 193 return nil, err 194 } 195 196 // Unmarshal the directory 197 var dirResource map[string]any 198 err = json.Unmarshal(dirContents, &dirResource) 199 if err != nil { 200 return nil, ErrInvalidDirectoryJSON 201 } 202 203 // serverURL tries to find a valid url.URL for the provided endpoint in 204 // the unmarshaled directory resource. 205 serverURL := func(name Endpoint) (*url.URL, error) { 206 if rawURL, ok := dirResource[string(name)]; !ok { 207 return nil, ErrMissingEndpoint{endpoint: name} 208 } else if urlString, ok := rawURL.(string); !ok { 209 return nil, ErrInvalidEndpointURL{endpoint: name, value: urlString} 210 } else if url, err := url.Parse(urlString); err != nil { 211 return nil, ErrInvalidEndpointURL{endpoint: name, value: urlString} 212 } else { 213 return url, nil 214 } 215 } 216 217 // Create an empty directory to populate 218 directory := &Directory{ 219 endpointURLs: make(map[Endpoint]string), 220 } 221 222 // Every required endpoint must have a valid URL populated from the directory 223 for _, endpointName := range RequiredEndpoints { 224 url, err := serverURL(endpointName) 225 if err != nil { 226 return nil, err 227 } 228 directory.endpointURLs[endpointName] = url.String() 229 } 230 231 // Populate the terms-of-service 232 tos, err := termsOfService(dirResource) 233 if err != nil { 234 return nil, err 235 } 236 directory.TermsOfService = tos 237 return directory, nil 238 } 239 240 // EndpointURL returns the string representation of the ACME server's URL for 241 // the provided endpoint. If the Endpoint is not known an empty string is 242 // returned. 243 func (d *Directory) EndpointURL(ep Endpoint) string { 244 if url, ok := d.endpointURLs[ep]; ok { 245 return url 246 } 247 248 return "" 249 }