mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-22 21:08:38 +00:00

We've been maintaining temporary dev forks of golang.org/x/crypto/{acme,ssh} in https://github.com/tailscale/golang-x-crypto instead of using this repo's tempfork directory as we do with other packages. The reason we were doing that was because x/crypto/ssh depended on x/crypto/ssh/internal/poly1305 and I hadn't noticed there are forwarding wrappers already available in x/crypto/poly1305. It also depended internal/bcrypt_pbkdf but we don't use that so it's easy to just delete that calling code in our tempfork/ssh. Now that our SSH changes have been upstreamed, we can soon unfork from SSH. That leaves ACME remaining. This change copies our tailscale/golang-x-crypto/acme code to tempfork/acme but adds a test that our vendored copied still matches our tailscale/golang-x-crypto repo, where we can continue to do development work and rebases with upstream. A comment on the new test describes the expected workflow. While we could continue to just import & use tailscale/golang-x-crypto/acme, it seems a bit nicer to not have that entire-fork-of-x-crypto visible at all in our transitive deps and the questions that invites. Showing just a fork of an ACME client is much less scary. It does add a step to the process of hacking on the ACME client code, but we do that approximately never anyway, and the extra step is very incremental compared to the existing tedious steps. Updates #8593 Updates #10238 Change-Id: I8af4378c04c1f82e63d31bf4d16dba9f510f9199 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
256 lines
7.0 KiB
Go
256 lines
7.0 KiB
Go
// Copyright 2018 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package acme
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDefaultBackoff(t *testing.T) {
|
|
tt := []struct {
|
|
nretry int
|
|
retryAfter string // Retry-After header
|
|
out time.Duration // expected min; max = min + jitter
|
|
}{
|
|
{-1, "", time.Second}, // verify the lower bound is 1
|
|
{0, "", time.Second}, // verify the lower bound is 1
|
|
{100, "", 10 * time.Second}, // verify the ceiling
|
|
{1, "3600", time.Hour}, // verify the header value is used
|
|
{1, "", 1 * time.Second},
|
|
{2, "", 2 * time.Second},
|
|
{3, "", 4 * time.Second},
|
|
{4, "", 8 * time.Second},
|
|
}
|
|
for i, test := range tt {
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
resp := &http.Response{Header: http.Header{}}
|
|
if test.retryAfter != "" {
|
|
resp.Header.Set("Retry-After", test.retryAfter)
|
|
}
|
|
d := defaultBackoff(test.nretry, r, resp)
|
|
max := test.out + time.Second // + max jitter
|
|
if d < test.out || max < d {
|
|
t.Errorf("%d: defaultBackoff(%v) = %v; want between %v and %v", i, test.nretry, d, test.out, max)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestErrorResponse(t *testing.T) {
|
|
s := `{
|
|
"status": 400,
|
|
"type": "urn:acme:error:xxx",
|
|
"detail": "text"
|
|
}`
|
|
res := &http.Response{
|
|
StatusCode: 400,
|
|
Status: "400 Bad Request",
|
|
Body: io.NopCloser(strings.NewReader(s)),
|
|
Header: http.Header{"X-Foo": {"bar"}},
|
|
}
|
|
err := responseError(res)
|
|
v, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("err = %+v (%T); want *Error type", err, err)
|
|
}
|
|
if v.StatusCode != 400 {
|
|
t.Errorf("v.StatusCode = %v; want 400", v.StatusCode)
|
|
}
|
|
if v.ProblemType != "urn:acme:error:xxx" {
|
|
t.Errorf("v.ProblemType = %q; want urn:acme:error:xxx", v.ProblemType)
|
|
}
|
|
if v.Detail != "text" {
|
|
t.Errorf("v.Detail = %q; want text", v.Detail)
|
|
}
|
|
if !reflect.DeepEqual(v.Header, res.Header) {
|
|
t.Errorf("v.Header = %+v; want %+v", v.Header, res.Header)
|
|
}
|
|
}
|
|
|
|
func TestPostWithRetries(t *testing.T) {
|
|
var count int
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count++
|
|
w.Header().Set("Replay-Nonce", fmt.Sprintf("nonce%d", count))
|
|
if r.Method == "HEAD" {
|
|
// We expect the client to do 2 head requests to fetch
|
|
// nonces, one to start and another after getting badNonce
|
|
return
|
|
}
|
|
|
|
head, err := decodeJWSHead(r.Body)
|
|
switch {
|
|
case err != nil:
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
case head.Nonce == "":
|
|
t.Error("head.Nonce is empty")
|
|
case head.Nonce == "nonce1":
|
|
// Return a badNonce error to force the call to retry.
|
|
w.Header().Set("Retry-After", "0")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"type":"urn:ietf:params:acme:error:badNonce"}`))
|
|
return
|
|
}
|
|
// Make client.Authorize happy; we're not testing its result.
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(`{"status":"valid"}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client := &Client{
|
|
Key: testKey,
|
|
DirectoryURL: ts.URL,
|
|
dir: &Directory{AuthzURL: ts.URL},
|
|
}
|
|
// This call will fail with badNonce, causing a retry
|
|
if _, err := client.Authorize(context.Background(), "example.com"); err != nil {
|
|
t.Errorf("client.Authorize 1: %v", err)
|
|
}
|
|
if count != 3 {
|
|
t.Errorf("total requests count: %d; want 3", count)
|
|
}
|
|
}
|
|
|
|
func TestRetryErrorType(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Replay-Nonce", "nonce")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
w.Write([]byte(`{"type":"rateLimited"}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client := &Client{
|
|
Key: testKey,
|
|
RetryBackoff: func(n int, r *http.Request, res *http.Response) time.Duration {
|
|
// Do no retries.
|
|
return 0
|
|
},
|
|
dir: &Directory{AuthzURL: ts.URL},
|
|
}
|
|
|
|
t.Run("post", func(t *testing.T) {
|
|
testRetryErrorType(t, func() error {
|
|
_, err := client.Authorize(context.Background(), "example.com")
|
|
return err
|
|
})
|
|
})
|
|
t.Run("get", func(t *testing.T) {
|
|
testRetryErrorType(t, func() error {
|
|
_, err := client.GetAuthorization(context.Background(), ts.URL)
|
|
return err
|
|
})
|
|
})
|
|
}
|
|
|
|
func testRetryErrorType(t *testing.T, callClient func() error) {
|
|
t.Helper()
|
|
err := callClient()
|
|
if err == nil {
|
|
t.Fatal("client.Authorize returned nil error")
|
|
}
|
|
acmeErr, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("err is %v (%T); want *Error", err, err)
|
|
}
|
|
if acmeErr.StatusCode != http.StatusTooManyRequests {
|
|
t.Errorf("acmeErr.StatusCode = %d; want %d", acmeErr.StatusCode, http.StatusTooManyRequests)
|
|
}
|
|
if acmeErr.ProblemType != "rateLimited" {
|
|
t.Errorf("acmeErr.ProblemType = %q; want 'rateLimited'", acmeErr.ProblemType)
|
|
}
|
|
}
|
|
|
|
func TestRetryBackoffArgs(t *testing.T) {
|
|
const resCode = http.StatusInternalServerError
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Replay-Nonce", "test-nonce")
|
|
w.WriteHeader(resCode)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
// Canceled in backoff.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
var nretry int
|
|
backoff := func(n int, r *http.Request, res *http.Response) time.Duration {
|
|
nretry++
|
|
if n != nretry {
|
|
t.Errorf("n = %d; want %d", n, nretry)
|
|
}
|
|
if nretry == 3 {
|
|
cancel()
|
|
}
|
|
|
|
if r == nil {
|
|
t.Error("r is nil")
|
|
}
|
|
if res.StatusCode != resCode {
|
|
t.Errorf("res.StatusCode = %d; want %d", res.StatusCode, resCode)
|
|
}
|
|
return time.Millisecond
|
|
}
|
|
|
|
client := &Client{
|
|
Key: testKey,
|
|
RetryBackoff: backoff,
|
|
dir: &Directory{AuthzURL: ts.URL},
|
|
}
|
|
if _, err := client.Authorize(ctx, "example.com"); err == nil {
|
|
t.Error("err is nil")
|
|
}
|
|
if nretry != 3 {
|
|
t.Errorf("nretry = %d; want 3", nretry)
|
|
}
|
|
}
|
|
|
|
func TestUserAgent(t *testing.T) {
|
|
for _, custom := range []string{"", "CUSTOM_UA"} {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Log(r.UserAgent())
|
|
if s := "golang.org/x/crypto/acme"; !strings.Contains(r.UserAgent(), s) {
|
|
t.Errorf("expected User-Agent to contain %q, got %q", s, r.UserAgent())
|
|
}
|
|
if !strings.Contains(r.UserAgent(), custom) {
|
|
t.Errorf("expected User-Agent to contain %q, got %q", custom, r.UserAgent())
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"newOrder": "sure"}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client := &Client{
|
|
Key: testKey,
|
|
DirectoryURL: ts.URL,
|
|
UserAgent: custom,
|
|
}
|
|
if _, err := client.Discover(context.Background()); err != nil {
|
|
t.Errorf("client.Discover: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAccountKidLoop(t *testing.T) {
|
|
// if Client.postNoRetry is called with a nil key argument
|
|
// then Client.Key must be set, otherwise we fall into an
|
|
// infinite loop (which also causes a deadlock).
|
|
client := &Client{dir: &Directory{OrderURL: ":)"}}
|
|
_, _, err := client.postNoRetry(context.Background(), nil, "", nil)
|
|
if err == nil {
|
|
t.Fatal("Client.postNoRetry didn't fail with a nil key")
|
|
}
|
|
expected := "acme: Client.Key must be populated to make POST requests"
|
|
if err.Error() != expected {
|
|
t.Fatalf("Unexpected error returned: wanted %q, got %q", expected, err.Error())
|
|
}
|
|
}
|