Платформа ЦРНП "Мирокод" для разработки проектов
https://git.mirocod.ru
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
394 lines
13 KiB
394 lines
13 KiB
// Copyright 2020 Matthew Holt |
|
// |
|
// Licensed under the Apache License, Version 2.0 (the "License"); |
|
// you may not use this file except in compliance with the License. |
|
// You may obtain a copy of the License at |
|
// |
|
// http://www.apache.org/licenses/LICENSE-2.0 |
|
// |
|
// Unless required by applicable law or agreed to in writing, software |
|
// distributed under the License is distributed on an "AS IS" BASIS, |
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
// See the License for the specific language governing permissions and |
|
// limitations under the License. |
|
|
|
package acme |
|
|
|
import ( |
|
"bytes" |
|
"context" |
|
"crypto" |
|
"encoding/json" |
|
"errors" |
|
"fmt" |
|
"io" |
|
"net/http" |
|
"regexp" |
|
"runtime" |
|
"strconv" |
|
"strings" |
|
"sync" |
|
"time" |
|
|
|
"go.uber.org/zap" |
|
) |
|
|
|
// httpPostJWS performs robust HTTP requests by JWS-encoding the JSON of input. |
|
// If output is specified, the response body is written into it: if the response |
|
// Content-Type is JSON, it will be JSON-decoded into output (which must be a |
|
// pointer); otherwise, if output is an io.Writer, the response body will be |
|
// written to it uninterpreted. In all cases, the returned response value's |
|
// body will have been drained and closed, so there is no need to close it again. |
|
// It automatically retries in the case of network, I/O, or badNonce errors. |
|
func (c *Client) httpPostJWS(ctx context.Context, privateKey crypto.Signer, |
|
kid, endpoint string, input, output interface{}) (*http.Response, error) { |
|
|
|
if err := c.provision(ctx); err != nil { |
|
return nil, err |
|
} |
|
|
|
var resp *http.Response |
|
var err error |
|
|
|
// we can retry on internal server errors just in case it was a hiccup, |
|
// but we probably don't need to retry so many times in that case |
|
internalServerErrors, maxInternalServerErrors := 0, 3 |
|
|
|
// set a hard cap on the number of retries for any other reason |
|
const maxAttempts = 10 |
|
var attempts int |
|
for attempts = 1; attempts <= maxAttempts; attempts++ { |
|
if attempts > 1 { |
|
select { |
|
case <-time.After(250 * time.Millisecond): |
|
case <-ctx.Done(): |
|
return nil, ctx.Err() |
|
} |
|
} |
|
|
|
var nonce string // avoid shadowing err |
|
nonce, err = c.nonce(ctx) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
var encodedPayload []byte // avoid shadowing err |
|
encodedPayload, err = jwsEncodeJSON(input, privateKey, keyID(kid), nonce, endpoint) |
|
if err != nil { |
|
return nil, fmt.Errorf("encoding payload: %v", err) |
|
} |
|
|
|
resp, err = c.httpReq(ctx, http.MethodPost, endpoint, encodedPayload, output) |
|
if err == nil { |
|
return resp, nil |
|
} |
|
|
|
// "When a server rejects a request because its nonce value was |
|
// unacceptable (or not present), it MUST provide HTTP status code 400 |
|
// (Bad Request), and indicate the ACME error type |
|
// 'urn:ietf:params:acme:error:badNonce'. An error response with the |
|
// 'badNonce' error type MUST include a Replay-Nonce header field with a |
|
// fresh nonce that the server will accept in a retry of the original |
|
// query (and possibly in other requests, according to the server's |
|
// nonce scoping policy). On receiving such a response, a client SHOULD |
|
// retry the request using the new nonce." §6.5 |
|
var problem Problem |
|
if errors.As(err, &problem) { |
|
if problem.Type == ProblemTypeBadNonce { |
|
if c.Logger != nil { |
|
c.Logger.Debug("server rejected our nonce; retrying", |
|
zap.String("detail", problem.Detail), |
|
zap.Error(err)) |
|
} |
|
continue |
|
} |
|
} |
|
|
|
// internal server errors *could* just be a hiccup and it may be worth |
|
// trying again, but not nearly so many times as for other reasons |
|
if resp != nil && resp.StatusCode >= 500 { |
|
internalServerErrors++ |
|
if internalServerErrors < maxInternalServerErrors { |
|
continue |
|
} |
|
} |
|
|
|
// for any other error, there's not much we can do automatically |
|
break |
|
} |
|
|
|
return resp, fmt.Errorf("request to %s failed after %d attempts: %w", |
|
endpoint, attempts, err) |
|
} |
|
|
|
// httpReq robustly performs an HTTP request using the given method to the given endpoint, honoring |
|
// the given context's cancellation. The joseJSONPayload is optional; if not nil, it is expected to |
|
// be a JOSE+JSON encoding. The output is also optional; if not nil, the response body will be read |
|
// into output. If the response Content-Type is JSON, it will be JSON-decoded into output, which |
|
// must be a pointer type. If the response is any other Content-Type and if output is a io.Writer, |
|
// it will be written (without interpretation or decoding) to output. In all cases, the returned |
|
// response value will have the body drained and closed, so there is no need to close it again. |
|
// |
|
// If there are any network or I/O errors, the request will be retried as safely and resiliently as |
|
// possible. |
|
func (c *Client) httpReq(ctx context.Context, method, endpoint string, joseJSONPayload []byte, output interface{}) (*http.Response, error) { |
|
// even if the caller doesn't specify an output, we still use a |
|
// buffer to store possible error response (we reset it later) |
|
buf := bufPool.Get().(*bytes.Buffer) |
|
defer bufPool.Put(buf) |
|
|
|
var resp *http.Response |
|
var err error |
|
|
|
// potentially retry the request if there's network, I/O, or server internal errors |
|
const maxAttempts = 3 |
|
for attempt := 0; attempt < maxAttempts; attempt++ { |
|
if attempt > 0 { |
|
// traffic calming ahead |
|
select { |
|
case <-time.After(250 * time.Millisecond): |
|
case <-ctx.Done(): |
|
return nil, ctx.Err() |
|
} |
|
} |
|
|
|
var body io.Reader |
|
if joseJSONPayload != nil { |
|
body = bytes.NewReader(joseJSONPayload) |
|
} |
|
|
|
var req *http.Request |
|
req, err = http.NewRequestWithContext(ctx, method, endpoint, body) |
|
if err != nil { |
|
return nil, fmt.Errorf("creating request: %w", err) |
|
} |
|
if len(joseJSONPayload) > 0 { |
|
req.Header.Set("Content-Type", "application/jose+json") |
|
} |
|
|
|
// on first attempt, we need to reset buf since it |
|
// came from the pool; after first attempt, we should |
|
// still reset it because we might be retrying after |
|
// a partial download |
|
buf.Reset() |
|
|
|
var retry bool |
|
resp, retry, err = c.doHTTPRequest(req, buf) |
|
if err != nil { |
|
if retry { |
|
if c.Logger != nil { |
|
c.Logger.Warn("HTTP request failed; retrying", |
|
zap.String("url", req.URL.String()), |
|
zap.Error(err)) |
|
} |
|
continue |
|
} |
|
break |
|
} |
|
|
|
// check for HTTP errors |
|
switch { |
|
case resp.StatusCode >= 200 && resp.StatusCode < 300: // OK |
|
case resp.StatusCode >= 400 && resp.StatusCode < 600: // error |
|
if parseMediaType(resp) == "application/problem+json" { |
|
// "When the server responds with an error status, it SHOULD provide |
|
// additional information using a problem document [RFC7807]." (§6.7) |
|
var problem Problem |
|
err = json.Unmarshal(buf.Bytes(), &problem) |
|
if err != nil { |
|
return resp, fmt.Errorf("HTTP %d: JSON-decoding problem details: %w (raw='%s')", |
|
resp.StatusCode, err, buf.String()) |
|
} |
|
if resp.StatusCode >= 500 && joseJSONPayload == nil { |
|
// a 5xx status is probably safe to retry on even after a |
|
// request that had no I/O errors; it could be that the |
|
// server just had a hiccup... so try again, but only if |
|
// there is no request body, because we can't replay a |
|
// request that has an anti-replay nonce, obviously |
|
err = problem |
|
continue |
|
} |
|
return resp, problem |
|
} |
|
return resp, fmt.Errorf("HTTP %d: %s", resp.StatusCode, buf.String()) |
|
default: // what even is this |
|
return resp, fmt.Errorf("unexpected status code: HTTP %d", resp.StatusCode) |
|
} |
|
|
|
// do not retry if we got this far (success) |
|
break |
|
} |
|
if err != nil { |
|
return resp, err |
|
} |
|
|
|
// if expecting a body, finally decode it |
|
if output != nil { |
|
contentType := parseMediaType(resp) |
|
switch contentType { |
|
case "application/json": |
|
// unmarshal JSON |
|
err = json.Unmarshal(buf.Bytes(), output) |
|
if err != nil { |
|
return resp, fmt.Errorf("JSON-decoding response body: %w", err) |
|
} |
|
|
|
default: |
|
// don't interpret anything else here; just hope |
|
// it's a Writer and copy the bytes |
|
w, ok := output.(io.Writer) |
|
if !ok { |
|
return resp, fmt.Errorf("response Content-Type is %s but target container is not io.Writer: %T", contentType, output) |
|
} |
|
_, err = io.Copy(w, buf) |
|
if err != nil { |
|
return resp, err |
|
} |
|
} |
|
} |
|
|
|
return resp, nil |
|
} |
|
|
|
// doHTTPRequest performs an HTTP request at most one time. It returns the response |
|
// (with drained and closed body), having drained any request body into buf. If |
|
// retry == true is returned, then the request should be safe to retry in the case |
|
// of an error. However, in some cases a retry may be recommended even if part of |
|
// the response body has been read and written into buf. Thus, the buffer may have |
|
// been partially written to and should be reset before being reused. |
|
// |
|
// This method remembers any nonce returned by the server. |
|
func (c *Client) doHTTPRequest(req *http.Request, buf *bytes.Buffer) (resp *http.Response, retry bool, err error) { |
|
req.Header.Set("User-Agent", c.userAgent()) |
|
|
|
resp, err = c.httpClient().Do(req) |
|
if err != nil { |
|
return resp, true, fmt.Errorf("performing request: %w", err) |
|
} |
|
defer resp.Body.Close() |
|
|
|
if c.Logger != nil { |
|
c.Logger.Debug("http request", |
|
zap.String("method", req.Method), |
|
zap.String("url", req.URL.String()), |
|
zap.Reflect("headers", req.Header), |
|
zap.Int("status_code", resp.StatusCode), |
|
zap.Reflect("response_headers", resp.Header)) |
|
} |
|
|
|
// "The server MUST include a Replay-Nonce header field |
|
// in every successful response to a POST request and |
|
// SHOULD provide it in error responses as well." §6.5 |
|
// |
|
// "Before sending a POST request to the server, an ACME |
|
// client needs to have a fresh anti-replay nonce to put |
|
// in the 'nonce' header of the JWS. In most cases, the |
|
// client will have gotten a nonce from a previous |
|
// request." §7.2 |
|
// |
|
// So basically, we need to remember the nonces we get |
|
// and use them at the next opportunity. |
|
c.nonces.push(resp.Header.Get(replayNonce)) |
|
|
|
// drain the response body, even if we aren't keeping it |
|
// (this allows us to reuse the connection and also read |
|
// any error information) |
|
_, err = io.Copy(buf, resp.Body) |
|
if err != nil { |
|
// this is likely a network or I/O error, but is it worth retrying? |
|
// technically the request has already completed, it was just our |
|
// download of the response that failed; so we probably should not |
|
// retry if the request succeeded... however, if there was an HTTP |
|
// error, it likely didn't count against any server-enforced rate |
|
// limits, and we DO want to know the error information, so it should |
|
// be safe to retry the request in those cases AS LONG AS there is |
|
// no request body, which in the context of ACME likely includes an |
|
// anti-replay nonce, which obviously we can't reuse |
|
retry = resp.StatusCode >= 400 && req.Body == nil |
|
return resp, retry, fmt.Errorf("reading response body: %w", err) |
|
} |
|
|
|
return resp, false, nil |
|
} |
|
|
|
func (c *Client) httpClient() *http.Client { |
|
if c.HTTPClient == nil { |
|
return http.DefaultClient |
|
} |
|
return c.HTTPClient |
|
} |
|
|
|
func (c *Client) userAgent() string { |
|
ua := fmt.Sprintf("acmez (%s; %s)", runtime.GOOS, runtime.GOARCH) |
|
if c.UserAgent != "" { |
|
ua = c.UserAgent + " " + ua |
|
} |
|
return ua |
|
} |
|
|
|
// extractLinks extracts the URL from the Link header with the |
|
// designated relation rel. It may return more than value |
|
// if there are multiple matching Link values. |
|
// |
|
// Originally by Isaac: https://github.com/eggsampler/acme |
|
// and has been modified to support multiple matching Links. |
|
func extractLinks(resp *http.Response, rel string) []string { |
|
if resp == nil { |
|
return nil |
|
} |
|
var links []string |
|
for _, l := range resp.Header["Link"] { |
|
matches := linkRegex.FindAllStringSubmatch(l, -1) |
|
for _, m := range matches { |
|
if len(m) != 3 { |
|
continue |
|
} |
|
if m[2] == rel { |
|
links = append(links, m[1]) |
|
} |
|
} |
|
} |
|
return links |
|
} |
|
|
|
// parseMediaType returns only the media type from the |
|
// Content-Type header of resp. |
|
func parseMediaType(resp *http.Response) string { |
|
if resp == nil { |
|
return "" |
|
} |
|
ct := resp.Header.Get("Content-Type") |
|
sep := strings.Index(ct, ";") |
|
if sep < 0 { |
|
return ct |
|
} |
|
return strings.TrimSpace(ct[:sep]) |
|
} |
|
|
|
// retryAfter returns a duration from the response's Retry-After |
|
// header field, if it exists. It can return an error if the |
|
// header contains an invalid value. If there is no error but |
|
// there is no Retry-After header provided, then the fallback |
|
// duration is returned instead. |
|
func retryAfter(resp *http.Response, fallback time.Duration) (time.Duration, error) { |
|
if resp == nil { |
|
return fallback, nil |
|
} |
|
raSeconds := resp.Header.Get("Retry-After") |
|
if raSeconds == "" { |
|
return fallback, nil |
|
} |
|
ra, err := strconv.Atoi(raSeconds) |
|
if err != nil || ra < 0 { |
|
return 0, fmt.Errorf("response had invalid Retry-After header: %s", raSeconds) |
|
} |
|
return time.Duration(ra) * time.Second, nil |
|
} |
|
|
|
var bufPool = sync.Pool{ |
|
New: func() interface{} { |
|
return new(bytes.Buffer) |
|
}, |
|
} |
|
|
|
var linkRegex = regexp.MustCompile(`<(.+?)>;\s*rel="(.+?)"`)
|
|
|