Change-Id: I0395c5db6edd7d90f9ec1dadbe881a77c906c732 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/713120 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Daniel McCarney <daniel@binaryparadox.net> Reviewed-by: David Chase <drchase@google.com> Auto-Submit: Sean Liao <sean@liao.dev> Reviewed-by: Sean Liao <sean@liao.dev> Reviewed-by: Michael Knyszek <mknyszek@google.com>
810 lines
23 KiB
Go
810 lines
23 KiB
Go
// 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",
|
|
},
|
|
{
|
|
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 {
|
|
var orderErr *acme.OrderError
|
|
if errors.Is(err, orderErr) {
|
|
t.Fatalf("failed to wait for order %s: %s: %s", orderURL, err, orderErr.Problem)
|
|
} else {
|
|
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 < 20; 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 20 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()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
cmdInstance := exec.Command("./"+cmd, args...)
|
|
cmdInstance.Dir = dir
|
|
cmdInstance.Stdout = &stdout
|
|
cmdInstance.Stderr = &stderr
|
|
|
|
if err := cmdInstance.Start(); err != nil {
|
|
t.Fatalf("failed to start %s: %v", cmd, err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
cmdInstance.Process.Kill()
|
|
cmdInstance.Wait()
|
|
|
|
if t.Failed() || testing.Verbose() {
|
|
t.Logf("=== %s output ===", cmd)
|
|
if stdout.Len() > 0 {
|
|
t.Logf("stdout:\n%s", strings.TrimSpace(stdout.String()))
|
|
}
|
|
if stderr.Len() > 0 {
|
|
t.Logf("stderr:\n%s", strings.TrimSpace(stderr.String()))
|
|
}
|
|
}
|
|
})
|
|
}
|