aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--acme/pebble_test.go793
1 files changed, 793 insertions, 0 deletions
diff --git a/acme/pebble_test.go b/acme/pebble_test.go
new file mode 100644
index 0000000..625e20b
--- /dev/null
+++ b/acme/pebble_test.go
@@ -0,0 +1,793 @@
+// Copyright 2025 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_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme"
+)
+
+const (
+ // pebbleModVersion is the module version used for Pebble and Pebble's
+ // challenge test server. It is ignored if `-pebble-local-dir` is provided.
+ pebbleModVersion = "v2.7.0"
+ // startingPort is the first port number used for binding interface
+ // addresses. Each call to takeNextPort() will increment a port number
+ // starting at this value.
+ startingPort = 5555
+)
+
+var (
+ pebbleLocalDir = flag.String(
+ "pebble-local-dir",
+ "",
+ "Local Pebble to use, instead of fetching from source",
+ )
+ nextPort atomic.Uint32
+)
+
+func init() {
+ nextPort.Store(startingPort)
+}
+
+func TestWithPebble(t *testing.T) {
+ // We want to use process groups w/ syscall.Kill, and the acme package
+ // is very platform-agnostic, so skip on non-Linux.
+ if runtime.GOOS != "linux" {
+ t.Skip("skipping pebble tests on non-linux OS")
+ }
+
+ if testing.Short() {
+ t.Skip("skipping pebble tests in short mode")
+ }
+
+ tests := []struct {
+ name string
+ challSrv func(*environment) (challengeServer, string)
+ }{
+ {
+ name: "TLSALPN01-Issuance",
+ challSrv: func(env *environment) (challengeServer, string) {
+ bindAddr := fmt.Sprintf(":%d", env.config.TLSPort)
+ return newChallTLSServer(bindAddr), bindAddr
+ },
+ },
+
+ {
+ name: "HTTP01-Issuance",
+ challSrv: func(env *environment) (challengeServer, string) {
+ bindAddr := fmt.Sprintf(":%d", env.config.HTTPPort)
+ return newChallHTTPServer(bindAddr), bindAddr
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ env := startPebbleEnvironment(t, nil)
+ challSrv, challSrvAddr := tt.challSrv(&env)
+ challSrv.Run()
+
+ t.Cleanup(func() {
+ challSrv.Shutdown()
+ })
+
+ waitForServer(t, challSrvAddr)
+ testIssuance(t, &env, challSrv)
+ })
+ }
+}
+
+// challengeServer abstracts over the details of running a challenge response
+// server for some supported acme.Challenge type. Responses are provisioned
+// during the test issuance process to be presented to the ACME server's
+// validation authority.
+type challengeServer interface {
+ Run()
+ Shutdown() error
+ Supported(chal *acme.Challenge) bool
+ Provision(client *acme.Client, ident acme.AuthzID, chal *acme.Challenge) error
+}
+
+// challTLSServer is a simple challenge response server that listens for TLS
+// connections on a specific port and if they are TLS-ALPN-01 challenge
+// requests, completes the handshake using the configured challenge response
+// certificate for the SNI value provided.
+type challTLSServer struct {
+ *http.Server
+ // mu protects challCerts.
+ mu sync.RWMutex
+ // challCerts is a map from SNI domain name to challenge response certificate.
+ challCerts map[string]*tls.Certificate
+}
+
+// https://datatracker.ietf.org/doc/html/rfc8737#section-4
+const acmeTLSAlpnProtocol = "acme-tls/1"
+
+func newChallTLSServer(address string) *challTLSServer {
+ challServer := &challTLSServer{Server: &http.Server{
+ Addr: address,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
+ }, challCerts: make(map[string]*tls.Certificate)}
+
+ // Configure the server to support the TLS-ALPN-01 challenge protocol
+ // and to use a callback for selecting the handshake certificate.
+ challServer.Server.TLSConfig = &tls.Config{
+ NextProtos: []string{acmeTLSAlpnProtocol},
+ GetCertificate: challServer.getCertificate,
+ }
+
+ return challServer
+}
+
+func (c *challTLSServer) Shutdown() error {
+ log.Printf("challTLSServer: shutting down")
+ ctx, cancel := context.WithTimeout(context.Background(), 10)
+ defer cancel()
+ return c.Server.Shutdown(ctx)
+}
+
+func (c *challTLSServer) Run() {
+ go func() {
+ // Note: certFile and keyFile are empty because our config uses a
+ // GetCertificate callback.
+ if err := c.Server.ListenAndServeTLS("", ""); err != nil {
+ if !errors.Is(err, http.ErrServerClosed) {
+ log.Printf("challTLSServer error: %v", err)
+ }
+ }
+ }()
+}
+
+func (c *challTLSServer) Supported(chal *acme.Challenge) bool {
+ return chal.Type == "tls-alpn-01"
+}
+
+func (c *challTLSServer) Provision(client *acme.Client, ident acme.AuthzID, chal *acme.Challenge) error {
+ respCert, err := client.TLSALPN01ChallengeCert(chal.Token, ident.Value)
+ if err != nil {
+ return fmt.Errorf("challTLSServer: failed to generate challlenge response cert for %s: %w",
+ ident.Value, err)
+ }
+
+ log.Printf("challTLSServer: setting challenge response certificate for %s", ident.Value)
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.challCerts[ident.Value] = &respCert
+
+ return nil
+}
+
+func (c *challTLSServer) getCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ // Verify the request looks like a TLS-ALPN-01 challenge request.
+ if len(clientHello.SupportedProtos) != 1 || clientHello.SupportedProtos[0] != acmeTLSAlpnProtocol {
+ return nil, fmt.Errorf(
+ "challTLSServer: non-TLS-ALPN-01 challenge request received with SupportedProtos: %s",
+ clientHello.SupportedProtos)
+ }
+
+ serverName := clientHello.ServerName
+
+ // TLS-ALPN-01 challenge requests for IP addresses are encoded in the SNI
+ // using the reverse-DNS notation. See RFC 8738 Section 6:
+ // https://www.rfc-editor.org/rfc/rfc8738.html#section-6
+ if strings.HasSuffix(serverName, ".in-addr.arpa") {
+ serverName = strings.TrimSuffix(serverName, ".in-addr.arpa")
+ parts := strings.Split(serverName, ".")
+ for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
+ parts[i], parts[j] = parts[j], parts[i]
+ }
+ serverName = strings.Join(parts, ".")
+ }
+
+ log.Printf("challTLSServer: selecting certificate for request from %s for %s",
+ clientHello.Conn.RemoteAddr(), serverName)
+
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ cert := c.challCerts[serverName]
+ if cert == nil {
+ return nil, fmt.Errorf("challTLSServer: no challenge response certificate configured for %s", serverName)
+ }
+
+ return cert, nil
+}
+
+// challHTTPServer is a simple challenge response server that listens for HTTP
+// connections on a specific port and if they are HTTP-01 challenge requests,
+// serves the challenge response key authorization.
+type challHTTPServer struct {
+ *http.Server
+ // mu protects challMap
+ mu sync.RWMutex
+ // challMap is a mapping from request path to response body.
+ challMap map[string]string
+}
+
+func newChallHTTPServer(address string) *challHTTPServer {
+ challServer := &challHTTPServer{
+ Server: &http.Server{
+ Addr: address,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
+ },
+ challMap: make(map[string]string),
+ }
+
+ challServer.Server.Handler = challServer
+
+ return challServer
+}
+
+func (c *challHTTPServer) Supported(chal *acme.Challenge) bool {
+ return chal.Type == "http-01"
+}
+
+func (c *challHTTPServer) Provision(client *acme.Client, ident acme.AuthzID, chall *acme.Challenge) error {
+ path := client.HTTP01ChallengePath(chall.Token)
+ body, err := client.HTTP01ChallengeResponse(chall.Token)
+ if err != nil {
+ return fmt.Errorf("failed to generate HTTP-01 challenge response for %v challenge %s token %s: %w",
+ ident, chall.URI, chall.Token, err)
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ log.Printf("challHTTPServer: setting challenge response for %s", path)
+ c.challMap[path] = body
+
+ return nil
+}
+
+func (c *challHTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ log.Printf("challHTTPServer: handling %s to %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ response, exists := c.challMap[r.URL.Path]
+
+ if !exists {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write([]byte(response))
+}
+
+func (c *challHTTPServer) Shutdown() error {
+ log.Printf("challHTTPServer: shutting down")
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return c.Server.Shutdown(ctx)
+}
+
+func (c *challHTTPServer) Run() {
+ go func() {
+ if err := c.Server.ListenAndServe(); err != nil {
+ if !errors.Is(err, http.ErrServerClosed) {
+ log.Printf("challHTTPServer error: %v", err)
+ }
+ }
+ }()
+}
+
+func testIssuance(t *testing.T, env *environment, challSrv challengeServer) {
+ t.Helper()
+
+ // Bound the total issuance process by a timeout of 60 seconds.
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ // Create a new ACME account.
+ client := env.client
+ acct, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS)
+ if err != nil {
+ t.Fatalf("failed to register account: %v", err)
+ }
+ if acct.Status != acme.StatusValid {
+ t.Fatalf("expected new account status to be valid, got %v", acct.Status)
+ }
+ log.Printf("registered account: %s", acct.URI)
+
+ // Create a new order for some example identifiers
+ identifiers := []acme.AuthzID{
+ {
+ Type: "dns",
+ Value: "example.com",
+ },
+ {
+ Type: "dns",
+ Value: "www.example.com",
+ },
+ // TODO(@cpu): enable this identifier once IP addresses are handled correctly
+ // by acme.TLSALPN01ChallengeCert
+ /*
+ {
+ Type: "ip",
+ Value: "127.0.0.1",
+ },
+ */
+ }
+ order, err := client.AuthorizeOrder(ctx, identifiers)
+ if err != nil {
+ t.Fatalf("failed to create order for %v: %v", identifiers, err)
+ }
+ if order.Status != acme.StatusPending {
+ t.Fatalf("expected new order status to be pending, got %v", order.Status)
+ }
+ orderURL := order.URI
+ log.Printf("created order: %v", orderURL)
+
+ // For each pending authz provision a supported challenge type's response
+ // with the test challenge server, and tell the ACME server to verify it.
+ for _, authzURL := range order.AuthzURLs {
+ authz, err := client.GetAuthorization(ctx, authzURL)
+ if err != nil {
+ t.Fatalf("failed to get order %s authorization %s: %v",
+ orderURL, authzURL, err)
+ }
+
+ if authz.Status != acme.StatusPending {
+ continue
+ }
+
+ for _, challenge := range authz.Challenges {
+ if challenge.Status != acme.StatusPending || !challSrv.Supported(challenge) {
+ continue
+ }
+
+ if err := challSrv.Provision(client, authz.Identifier, challenge); err != nil {
+ t.Fatalf("failed to provision challenge %s: %v", challenge.URI, err)
+ }
+
+ _, err = client.Accept(ctx, challenge)
+ if err != nil {
+ t.Fatalf("failed to accept order %s challenge %s: %v",
+ orderURL, challenge.URI, err)
+ }
+ }
+ }
+
+ // Wait for the order to become ready for finalization.
+ order, err = client.WaitOrder(ctx, order.URI)
+ if err != nil {
+ t.Fatalf("failed to wait for order %s: %s", orderURL, err)
+ }
+ if order.Status != acme.StatusReady {
+ t.Fatalf("expected order %s status to be ready, got %v",
+ orderURL, order.Status)
+ }
+
+ // Generate a certificate keypair and a CSR for the order identifiers.
+ certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("failed to generate certificate key: %v", err)
+ }
+ var dnsNames []string
+ var ipAddresses []net.IP
+ for _, ident := range identifiers {
+ switch ident.Type {
+ case "dns":
+ dnsNames = append(dnsNames, ident.Value)
+ case "ip":
+ ipAddresses = append(ipAddresses, net.ParseIP(ident.Value))
+ default:
+ t.Fatalf("unsupported identifier type: %s", ident.Type)
+ }
+ }
+ csrDer, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
+ DNSNames: dnsNames,
+ IPAddresses: ipAddresses,
+ }, certKey)
+ if err != nil {
+ t.Fatalf("failed to create CSR: %v", err)
+ }
+
+ // Finalize the order by creating a certificate with our CSR.
+ chain, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csrDer, true)
+ if err != nil {
+ t.Fatalf("failed to finalize order %s with finalize URL %s: %v",
+ orderURL, order.FinalizeURL, err)
+ }
+
+ // Split the chain into the leaf and any intermediates.
+ leaf := chain[0]
+ intermediatesDER := chain[1:]
+ leafCert, err := x509.ParseCertificate(leaf)
+ if err != nil {
+ t.Fatalf("failed to parse order %s leaf certificate: %v", orderURL, err)
+ }
+ intermediates := x509.NewCertPool()
+ for i, intermediateDER := range intermediatesDER {
+ intermediate, err := x509.ParseCertificate(intermediateDER)
+ if err != nil {
+ t.Fatalf("failed to parse intermediate %d: %v", i, err)
+ }
+ intermediates.AddCert(intermediate)
+ }
+
+ // Verify there is a valid path from the leaf certificate to Pebble's
+ // issuing root using the provided intermediate certificates.
+ roots, err := env.RootCert()
+ if err != nil {
+ t.Fatalf("failed to get Pebble issuer root certs: %v", err)
+ }
+ paths, err := leafCert.Verify(x509.VerifyOptions{
+ Intermediates: intermediates,
+ Roots: roots,
+ })
+ if err != nil {
+ t.Fatalf("failed to verify order %s leaf certificate: %v", orderURL, err)
+ }
+ log.Printf("verified %d path(s) from issued leaf certificate to Pebble root CA", len(paths))
+
+ // Also verify that the leaf cert is valid for each of the DNS names
+ // and IP addresses from our order's identifiers.
+ for _, name := range dnsNames {
+ if err := leafCert.VerifyHostname(name); err != nil {
+ t.Fatalf("failed to verify order %s leaf certificate for order DNS name %s: %v",
+ orderURL, name, err)
+ }
+ }
+ for _, ip := range ipAddresses {
+ if err := leafCert.VerifyHostname(ip.String()); err != nil {
+ t.Fatalf("failed to verify order %s leaf certificate for order IP address %s: %v",
+ orderURL, ip, err)
+ }
+ }
+}
+
+type environment struct {
+ config *environmentConfig
+ client *acme.Client
+}
+
+// RootCert returns the Pebble CA's primary issuing hierarchy root certificate.
+// This is generated randomly at each startup and can be used to verify
+// certificate chains issued by Pebble's ACME interface. Note that this
+// is separate from the static root certificate used by the Pebble ACME
+// HTTPS interface.
+func (e *environment) RootCert() (*x509.CertPool, error) {
+ // NOTE: in the future we may want to consider the alternative chains
+ // returned as Link alternative headers.
+ rootURL := fmt.Sprintf("https://%s/roots/0", e.config.pebbleConfig.ManagementListenAddress)
+ resp, err := e.client.HTTPClient.Get(rootURL)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to GET Pebble root CA from %s: %v", rootURL, err)
+ }
+
+ roots := x509.NewCertPool()
+ rootPEM, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse Pebble root CA PEM: %v", err)
+ }
+ rootDERBlock, _ := pem.Decode(rootPEM)
+ rootCA, err := x509.ParseCertificate(rootDERBlock.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse Pebble root CA DER: %v", err)
+ }
+ roots.AddCert(rootCA)
+
+ return roots, nil
+}
+
+// environmentConfig describes the Pebble configuration, and configuration
+// shared between pebble and pebble-challtestsrv.
+type environmentConfig struct {
+ pebbleConfig
+ dnsPort uint32
+}
+
+// defaultConfig returns an environmentConfig populated with default values.
+// The provided pebbleDir is used to specify certificate/private key paths
+// for the HTTPS ACME interface.
+func defaultConfig(pebbleDir string) environmentConfig {
+ return environmentConfig{
+ pebbleConfig: pebbleConfig{
+ ListenAddress: fmt.Sprintf("127.0.0.1:%d", takeNextPort()),
+ ManagementListenAddress: fmt.Sprintf("127.0.0.1:%d", takeNextPort()),
+ HTTPPort: takeNextPort(),
+ TLSPort: takeNextPort(),
+ Certificate: fmt.Sprintf("%s/test/certs/localhost/cert.pem", pebbleDir),
+ PrivateKey: fmt.Sprintf("%s/test/certs/localhost/key.pem", pebbleDir),
+ OCSPResponderURL: "",
+ ExternalAccountBindingRequired: false,
+ ExternalAccountMACKeys: make(map[string]string),
+ DomainBlocklist: []string{"blocked-domain.example"},
+ Profiles: map[string]struct {
+ Description string
+ ValidityPeriod uint64
+ }{
+ "default": {
+ Description: "default profile",
+ ValidityPeriod: 3600,
+ },
+ },
+ RetryAfter: struct {
+ Authz int
+ Order int
+ }{
+ 3,
+ 5,
+ },
+ },
+ dnsPort: takeNextPort(),
+ }
+}
+
+// pebbleConfig matches the JSON structure of the Pebble configuration file.
+type pebbleConfig struct {
+ ListenAddress string
+ ManagementListenAddress string
+ HTTPPort uint32
+ TLSPort uint32
+ Certificate string
+ PrivateKey string
+ OCSPResponderURL string
+ ExternalAccountBindingRequired bool
+ ExternalAccountMACKeys map[string]string
+ DomainBlocklist []string
+ Profiles map[string]struct {
+ Description string
+ ValidityPeriod uint64
+ }
+ RetryAfter struct {
+ Authz int
+ Order int
+ }
+}
+
+func takeNextPort() uint32 {
+ return nextPort.Add(1) - 1
+}
+
+// startPebbleEnvironment is a test helper that spawns Pebble and Pebble
+// challenge test server processes based on the provided environmentConfig. The
+// processes will be torn down when the test ends.
+func startPebbleEnvironment(t *testing.T, config *environmentConfig) environment {
+ t.Helper()
+
+ var pebbleDir string
+ if *pebbleLocalDir != "" {
+ pebbleDir = *pebbleLocalDir
+ } else {
+ pebbleDir = fetchModule(t, "github.com/letsencrypt/pebble/v2", pebbleModVersion)
+ }
+
+ binDir := prepareBinaries(t, pebbleDir)
+
+ if config == nil {
+ cfg := defaultConfig(pebbleDir)
+ config = &cfg
+ }
+
+ marshalConfig := struct {
+ Pebble pebbleConfig
+ }{
+ Pebble: config.pebbleConfig,
+ }
+
+ configData, err := json.Marshal(marshalConfig)
+ if err != nil {
+ t.Fatalf("failed to marshal config: %v", err)
+ }
+
+ configFile, err := os.CreateTemp("", "pebble-config-*.json")
+ if err != nil {
+ t.Fatalf("failed to create temp config file: %v", err)
+ }
+ t.Cleanup(func() { os.Remove(configFile.Name()) })
+
+ if _, err := configFile.Write(configData); err != nil {
+ t.Fatalf("failed to write config file: %v", err)
+ }
+ configFile.Close()
+
+ log.Printf("pebble dir: %s", pebbleDir)
+ log.Printf("config file: %s", configFile.Name())
+
+ // Spawn the Pebble CA server. It answers ACME requests and performs
+ // outbound validations. We configure it to use a mock DNS server that
+ // always answers 127.0.0.1 for all A queries so that validation
+ // requests for any domain name will resolve to our local challenge
+ // server instances.
+ spawnServerProcess(t, binDir, "pebble", "-config", configFile.Name(),
+ "-dnsserver", fmt.Sprintf("127.0.0.1:%d", config.dnsPort),
+ "-strict")
+
+ // Spawn the Pebble challenge test server. We'll use it to mock DNS
+ // responses but disable all the other interfaces. We want to stand
+ // up our own challenge response servers for TLS-ALPN-01,
+ // etc.
+ // Note: we specify -defaultIPv6 "" so that no AAAA records are served.
+ // The LUCI CI runners have issues with IPv6 connectivity on localhost.
+ spawnServerProcess(t, binDir, "pebble-challtestsrv",
+ "-dns01", fmt.Sprintf(":%d", config.dnsPort),
+ "-defaultIPv6", "",
+ "-management", fmt.Sprintf(":%d", takeNextPort()),
+ "-doh", "",
+ "-http01", "",
+ "-tlsalpn01", "",
+ "-https01", "")
+
+ waitForServer(t, config.pebbleConfig.ListenAddress)
+ waitForServer(t, fmt.Sprintf("127.0.0.1:%d", config.dnsPort))
+
+ log.Printf("pebble environment ready")
+
+ // Construct a cert pool that contains the CA certificate used by the ACME
+ // interface's certificate chain. This is separate from the issuing
+ // hierarchy and is used for the ACME client to interact with the ACME
+ // interface without cert verification error.
+ caCertPath := filepath.Join(pebbleDir, "test/certs/pebble.minica.pem")
+ caCert, err := os.ReadFile(caCertPath)
+ if err != nil {
+ t.Fatalf("failed to read CA certificate %s: %v", caCertPath, err)
+ }
+ caCertPool := x509.NewCertPool()
+ if !caCertPool.AppendCertsFromPEM(caCert) {
+ t.Fatalf("failed to parse CA certificate %s", caCertPath)
+ }
+ httpClient := &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ RootCAs: caCertPool,
+ },
+ },
+ }
+
+ // Create an ACME account keypair/client and verify it can discover
+ // the Pebble server's ACME directory without error.
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("failed to generate account key: %v", err)
+ }
+ client := &acme.Client{
+ Key: key,
+ HTTPClient: httpClient,
+ DirectoryURL: fmt.Sprintf("https://%s/dir", config.ListenAddress),
+ }
+ _, err = client.Discover(context.TODO())
+ if err != nil {
+ t.Fatalf("failed to discover ACME directory: %v", err)
+ }
+
+ return environment{
+ config: config,
+ client: client,
+ }
+}
+
+func waitForServer(t *testing.T, addr string) {
+ t.Helper()
+
+ for i := 0; i < 10; i++ {
+ if conn, err := net.Dial("tcp", addr); err == nil {
+ conn.Close()
+ return
+ }
+ time.Sleep(time.Duration(i*100) * time.Millisecond)
+ }
+ t.Fatalf("failed to connect to %q after 10 tries", addr)
+}
+
+// fetchModule fetches the module at the given version and returns the directory
+// containing its source tree. It skips the test if fetching modules is not
+// possible in this environment.
+//
+// Copied from the stdlib cryptotest.FetchModule and adapted to not rely on the
+// stdlib internal testenv package.
+func fetchModule(t *testing.T, module, version string) string {
+ // If the default GOMODCACHE doesn't exist, use a temporary directory
+ // instead. (For example, run.bash sets GOPATH=/nonexist-gopath.)
+ out, err := exec.Command("go", "env", "GOMODCACHE").Output()
+ if err != nil {
+ t.Errorf("go env GOMODCACHE: %v\n%s", err, out)
+ if ee, ok := err.(*exec.ExitError); ok {
+ t.Logf("%s", ee.Stderr)
+ }
+ t.FailNow()
+ }
+ modcacheOk := false
+ if gomodcache := string(bytes.TrimSpace(out)); gomodcache != "" {
+ if _, err := os.Stat(gomodcache); err == nil {
+ modcacheOk = true
+ }
+ }
+ if !modcacheOk {
+ t.Setenv("GOMODCACHE", t.TempDir())
+ // Allow t.TempDir() to clean up subdirectories.
+ t.Setenv("GOFLAGS", os.Getenv("GOFLAGS")+" -modcacherw")
+ }
+
+ t.Logf("fetching %s@%s\n", module, version)
+
+ output, err := exec.Command("go", "mod", "download", "-json", module+"@"+version).CombinedOutput()
+ if err != nil {
+ t.Fatalf("failed to download %s@%s: %s\n%s\n", module, version, err, output)
+ }
+ var j struct {
+ Dir string
+ }
+ if err := json.Unmarshal(output, &j); err != nil {
+ t.Fatalf("failed to parse 'go mod download': %s\n%s\n", err, output)
+ }
+
+ return j.Dir
+}
+
+func prepareBinaries(t *testing.T, pebbleDir string) string {
+ t.Helper()
+
+ // We don't want to build in the module cache dir, which might not be
+ // writable or to pollute the user's clone with binaries if pebbleLocalDir
+ //is used.
+ binDir := t.TempDir()
+
+ build := func(cmd string) {
+ log.Printf("building %s", cmd)
+ buildCmd := exec.Command(
+ "go",
+ "build", "-o", filepath.Join(binDir, cmd), "-mod", "mod", "./cmd/"+cmd)
+ buildCmd.Dir = pebbleDir
+ output, err := buildCmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("failed to build %s: %s\n%s\n", cmd, err, output)
+ }
+ }
+
+ build("pebble")
+ build("pebble-challtestsrv")
+
+ return binDir
+}
+
+func spawnServerProcess(t *testing.T, dir string, cmd string, args ...string) {
+ t.Helper()
+
+ cmdInstance := exec.Command("./"+cmd, args...)
+ cmdInstance.Dir = dir
+ cmdInstance.Stdout = os.Stdout
+ cmdInstance.Stderr = os.Stderr
+ if err := cmdInstance.Start(); err != nil {
+ t.Fatalf("failed to start %s: %v", cmd, err)
+ }
+ t.Cleanup(func() {
+ cmdInstance.Process.Kill()
+ })
+}