caddy/caddytest/caddytest.go
2024-06-18 23:48:21 -05:00

328 lines
8.5 KiB
Go

package caddytest
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/cookiejar"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
caddycmd "github.com/caddyserver/caddy/v2/cmd"
// plug in Caddy modules here
_ "github.com/caddyserver/caddy/v2/modules/standard"
)
// Defaults store any configuration required to make the tests run
type Defaults struct {
// Certificates we expect to be loaded before attempting to run the tests
Certificates []string
// TestRequestTimeout is the time to wait for a http request to
TestRequestTimeout time.Duration
// LoadRequestTimeout is the time to wait for the config to be loaded against the caddy server
LoadRequestTimeout time.Duration
}
// Default testing values
var Default = Defaults{
Certificates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"},
TestRequestTimeout: 5 * time.Second,
LoadRequestTimeout: 5 * time.Second,
}
// Tester represents an instance of a test client.
type Tester struct {
Client *http.Client
adminPort int
portOne int
portTwo int
started atomic.Bool
configLoaded bool
configFileName string
envFileName string
}
// NewTester will create a new testing client with an attached cookie jar
func NewTester() (*Tester, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("failed to create cookiejar: %w", err)
}
return &Tester{
Client: &http.Client{
Transport: CreateTestingTransport(),
Jar: jar,
Timeout: Default.TestRequestTimeout,
},
configLoaded: false,
}, nil
}
type configLoadError struct {
Response string
}
func (e configLoadError) Error() string { return e.Response }
func timeElapsed(start time.Time, name string) {
elapsed := time.Since(start)
log.Printf("%s took %s", name, elapsed)
}
// launch caddy will start the server
func (tc *Tester) LaunchCaddy() error {
if !tc.started.CompareAndSwap(false, true) {
return fmt.Errorf("already launched caddy with this tester")
}
if err := tc.startServer(); err != nil {
return fmt.Errorf("failed to start server: %w", err)
}
return nil
}
func (tc *Tester) CleanupCaddy() error {
// now shutdown the server, since the test is done.
defer func() {
// try to remove pthe tmp config file we created
if tc.configFileName != "" {
os.Remove(tc.configFileName)
}
if tc.envFileName != "" {
os.Remove(tc.envFileName)
}
}()
resp, err := http.Post(fmt.Sprintf("http://localhost:%d/stop", tc.adminPort), "", nil)
if err != nil {
return fmt.Errorf("couldn't stop caddytest server: %w", err)
}
resp.Body.Close()
for retries := 0; retries < 10; retries++ {
if tc.isCaddyAdminRunning() != nil {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timed out waiting for caddytest server to stop")
}
func (tc *Tester) AdminPort() int {
return tc.adminPort
}
func (tc *Tester) PortOne() int {
return tc.portOne
}
func (tc *Tester) PortTwo() int {
return tc.portTwo
}
func (tc *Tester) ReplaceTestingPlaceholders(x string) string {
x = strings.ReplaceAll(x, "{$TESTING_CADDY_ADMIN_BIND}", fmt.Sprintf("localhost:%d", tc.adminPort))
x = strings.ReplaceAll(x, "{$TESTING_CADDY_ADMIN_PORT}", fmt.Sprintf("%d", tc.adminPort))
x = strings.ReplaceAll(x, "{$TESTING_CADDY_PORT_ONE}", fmt.Sprintf("%d", tc.portOne))
x = strings.ReplaceAll(x, "{$TESTING_CADDY_PORT_TWO}", fmt.Sprintf("%d", tc.portTwo))
return x
}
// LoadConfig loads the config to the tester server and also ensures that the config was loaded
// it should not be run
func (tc *Tester) LoadConfig(rawConfig string, configType string) error {
if tc.adminPort == 0 {
return fmt.Errorf("load config called where startServer didnt succeed")
}
rawConfig = tc.ReplaceTestingPlaceholders(rawConfig)
// replace special testing placeholders so we can have our admin api be on a random port
// normalize JSON config
if configType == "json" {
var conf any
if err := json.Unmarshal([]byte(rawConfig), &conf); err != nil {
return err
}
c, err := json.Marshal(conf)
if err != nil {
return err
}
rawConfig = string(c)
}
client := &http.Client{
Timeout: Default.LoadRequestTimeout,
}
start := time.Now()
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", tc.adminPort), strings.NewReader(rawConfig))
if err != nil {
return fmt.Errorf("failed to create request. %w", err)
}
if configType == "json" {
req.Header.Add("Content-Type", "application/json")
} else {
req.Header.Add("Content-Type", "text/"+configType)
}
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("unable to contact caddy server. %w", err)
}
timeElapsed(start, "caddytest: config load time")
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("unable to read response. %w", err)
}
if res.StatusCode != 200 {
return configLoadError{Response: string(body)}
}
tc.configLoaded = true
// if the config is not loaded at this point, it is a bug in caddy's config.Load
// the contract for config.Load states that the config must be loaded before it returns, and that it will
// error if the config fails to apply
return nil
}
func (tc *Tester) GetCurrentConfig(receiver any) error {
client := &http.Client{
Timeout: Default.LoadRequestTimeout,
}
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.adminPort))
if err != nil {
return err
}
defer resp.Body.Close()
actualBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
err = json.Unmarshal(actualBytes, receiver)
if err != nil {
return err
}
return nil
}
func getFreePort() (int, error) {
lr, err := net.Listen("tcp", "localhost:0")
if err != nil {
return 0, err
}
port := strings.Split(lr.Addr().String(), ":")
if len(port) < 2 {
return 0, fmt.Errorf("no port available")
}
i, err := strconv.Atoi(port[1])
if err != nil {
return 0, err
}
err = lr.Close()
if err != nil {
return 0, fmt.Errorf("failed to close listener: %w", err)
}
return i, nil
}
// launches caddy, and then ensures the Caddy sub-process is running.
func (tc *Tester) startServer() error {
if tc.isCaddyAdminRunning() == nil {
return fmt.Errorf("caddy test admin port still in use")
}
a, err := getFreePort()
if err != nil {
return fmt.Errorf("could not find a open port to listen on: %w", err)
}
tc.adminPort = a
tc.portOne, err = getFreePort()
if err != nil {
return fmt.Errorf("could not find a open portOne: %w", err)
}
tc.portTwo, err = getFreePort()
if err != nil {
return fmt.Errorf("could not find a open portOne: %w", err)
}
// setup the init config file, and set the cleanup afterwards
{
f, err := os.CreateTemp("", "")
if err != nil {
return err
}
tc.configFileName = f.Name()
initConfig := fmt.Sprintf(`{
admin localhost:%d
}`, a)
if _, err := f.WriteString(initConfig); err != nil {
return err
}
}
// start inprocess caddy server
go func() {
_ = caddycmd.MainForTesting("run", "--config", tc.configFileName, "--adapter", "caddyfile")
}()
// wait for caddy admin api to start. it should happen quickly.
for retries := 10; retries > 0 && tc.isCaddyAdminRunning() != nil; retries-- {
time.Sleep(100 * time.Millisecond)
}
// one more time to return the error
return tc.isCaddyAdminRunning()
}
func (tc *Tester) isCaddyAdminRunning() error {
// assert that caddy is running
client := &http.Client{
Timeout: Default.LoadRequestTimeout,
}
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.adminPort))
if err != nil {
return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", tc.adminPort)
}
resp.Body.Close()
return nil
}
// CreateTestingTransport creates a testing transport that forces call dialing connections to happen locally
func CreateTestingTransport() *http.Transport {
dialer := net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 5 * time.Second,
DualStack: true,
}
dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
parts := strings.Split(addr, ":")
destAddr := fmt.Sprintf("127.0.0.1:%s", parts[1])
log.Printf("caddytest: redirecting the dialer from %s to %s", addr, destAddr)
return dialer.DialContext(ctx, network, destAddr)
}
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
}
}