test: Copy challtestsrv management API from pebble (#8094)

- Copy
https://pkg.go.dev/github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv
to `test/chall-test-srv`
- Rename pebble-challtestsrv to chall-test-srv, consistent with other
test server naming in Boulder
- Replace Dockerfile go install with Makefile compilation of
`chall-test-srv`
- Run chall-test-srv from `./bin/chall-test-srv`
- Bump `github.com/letsencrypt/challtestsrv` from `v1.2.1` to `v1.3.2`
in go.mod
- Update boulder-ci GitHub workflow to use `go1.24.1_2025-04-02`

Part of #7963
This commit is contained in:
Samantha Frank 2025-04-03 15:10:18 -04:00 committed by GitHub
parent 13f98daabf
commit 0fe66b6e8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1278 additions and 22 deletions

View File

@ -36,7 +36,7 @@ jobs:
matrix:
# Add additional docker image tags here and all tests will be run with the additional image.
BOULDER_TOOLS_TAG:
- go1.24.1_2025-03-10
- go1.24.1_2025-04-02
# Tests command definitions. Use the entire "docker compose" command you want to run.
tests:
# Run ./test.sh --help for a description of each of the flags.

View File

@ -6,7 +6,7 @@ VERSION ?= 1.0.0
EPOCH ?= 1
MAINTAINER ?= "Community"
CMDS = admin boulder ceremony ct-test-srv pardot-test-srv
CMDS = admin boulder ceremony ct-test-srv pardot-test-srv chall-test-srv
CMD_BINS = $(addprefix bin/, $(CMDS) )
OBJECTS = $(CMD_BINS)

2
go.mod
View File

@ -16,7 +16,7 @@ require (
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1
github.com/jmhodges/clock v1.2.0
github.com/letsencrypt/borp v0.0.0-20240620175310-a78493c6e2bd
github.com/letsencrypt/challtestsrv v1.2.1
github.com/letsencrypt/challtestsrv v1.3.2
github.com/letsencrypt/pkcs11key/v4 v4.0.0
github.com/letsencrypt/validator/v10 v10.0.0-20230215210743-a0c7dfc17158
github.com/miekg/dns v1.1.61

4
go.sum
View File

@ -159,8 +159,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/letsencrypt/borp v0.0.0-20240620175310-a78493c6e2bd h1:3c+LdlAOEcW1qmG8gtkMCyAEoslmj6XCmniB+926kMM=
github.com/letsencrypt/borp v0.0.0-20240620175310-a78493c6e2bd/go.mod h1:gMSMCNKhxox/ccR923EJsIvHeVVYfCABGbirqa0EwuM=
github.com/letsencrypt/challtestsrv v1.2.1 h1:Lzv4jM+wSgVMCeO5a/F/IzSanhClstFMnX6SfrAJXjI=
github.com/letsencrypt/challtestsrv v1.2.1/go.mod h1:Ur4e4FvELUXLGhkMztHOsPIsvGxD/kzSJninOrkM+zc=
github.com/letsencrypt/challtestsrv v1.3.2 h1:pIDLBCLXR3B1DLmOmkkqg29qVa7DDozBnsOpL9PxmAY=
github.com/letsencrypt/challtestsrv v1.3.2/go.mod h1:Ur4e4FvELUXLGhkMztHOsPIsvGxD/kzSJninOrkM+zc=
github.com/letsencrypt/pkcs11key/v4 v4.0.0 h1:qLc/OznH7xMr5ARJgkZCCWk+EomQkiNTOoOF5LAgagc=
github.com/letsencrypt/pkcs11key/v4 v4.0.0/go.mod h1:EFUvBDay26dErnNb70Nd0/VW3tJiIbETBPTl9ATXQag=
github.com/letsencrypt/validator/v10 v10.0.0-20230215210743-a0c7dfc17158 h1:HGFsIltYMUiB5eoFSowFzSoXkocM2k9ctmJ57QMGjys=

View File

@ -13,7 +13,6 @@ RUN curl "https://dl.google.com/go/go${GO_VERSION}.$(echo $TARGETPLATFORM | sed
RUN go install github.com/rubenv/sql-migrate/sql-migrate@v1.1.2
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.1
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@bb9882e6ae58f0a80a6390b50a5ec3bd63e46a3c
RUN go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@17d64a3
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.0
RUN go install honnef.co/go/tools/cmd/staticcheck@2025.1
RUN go install github.com/jsha/minica@v1.1.0

View File

@ -0,0 +1,237 @@
# Boulder Challenge Test Server
**Important note: The `chall-test-srv` command is for TEST USAGE ONLY. It
is trivially insecure, offering no authentication. Only use
`chall-test-srv` in a controlled test environment.**
The standalone `chall-test-srv` binary lets you run HTTP-01, HTTPS HTTP-01,
DNS-01, and TLS-ALPN-01 challenge servers that external programs can add/remove
challenge responses to using a HTTP management API.
For example this is used by the Boulder integration tests to easily add/remove
TXT records for DNS-01 challenges for the `chisel.py` ACME client, and to test
redirect behaviour for HTTP-01 challenge validation.
### Usage
```
Usage of chall-test-srv:
-defaultIPv4 string
Default IPv4 address for mock DNS responses to A queries (default "127.0.0.1")
-defaultIPv6 string
Default IPv6 address for mock DNS responses to AAAA queries (default "::1")
-dns01 string
Comma separated bind addresses/ports for DNS-01 challenges and fake DNS data. Set empty to disable. (default ":8053")
-http01 string
Comma separated bind addresses/ports for HTTP-01 challenges. Set empty to disable. (default ":5002")
-https01 string
Comma separated bind addresses/ports for HTTPS HTTP-01 challenges. Set empty to disable. (default ":5003")
-management string
Bind address/port for management HTTP interface (default ":8055")
-tlsalpn01 string
Comma separated bind addresses/ports for TLS-ALPN-01 and HTTPS HTTP-01 challenges. Set empty to disable. (default ":5001")
```
To disable a challenge type, set the bind address to `""`. E.g.:
* To run HTTP-01 only: `chall-test-srv -https01 "" -dns01 "" -tlsalpn01 ""`
* To run HTTPS-01 only: `chall-test-srv -http01 "" -dns01 "" -tlsalpn01 ""`
* To run DNS-01 only: `chall-test-srv -http01 "" -https01 "" -tlsalpn01 ""`
* To run TLS-ALPN-01 only: `chall-test-srv -http01 "" -https01 "" -dns01 ""`
### Management Interface
_Note: These examples assume the default `-management` interface address, `:8055`._
#### Mock DNS
##### Default A/AAAA Responses
You can set the default IPv4 and IPv6 addresses used for `A` and `AAAA` query
responses using the `-defaultIPv4` and `-defaultIPv6` command line flags.
To change the default IPv4 address used for responses to `A` queries that do not
match explicit mocks at runtime run:
curl -d '{"ip":"10.10.10.2"}' http://localhost:8055/set-default-ipv4
Similarly to change the default IPv6 address used for responses to `AAAA` queries
that do not match explicit mocks run:
curl -d '{"ip":"::1"}' http://localhost:8055/set-default-ipv6
To clear the default IPv4 or IPv6 address POST the same endpoints with an empty
(`""`) IP.
##### Mocked A/AAAA Responses
To add IPv4 addresses to be returned for `A` queries for
`test-host.letsencrypt.org` run:
curl -d '{"host":"test-host.letsencrypt.org", "addresses":["12.12.12.12", "13.13.13.13"]}' http://localhost:8055/add-a
The mocked `A` responses can be removed by running:
curl -d '{"host":"test-host.letsencrypt.org"}' http://localhost:8055/clear-a
To add IPv6 addresses to be returned for `AAAA` queries for
`test-host.letsencrypt.org` run:
curl -d '{"host":"test-host.letsencrypt.org", "addresses":["2001:4860:4860::8888", "2001:4860:4860::8844"]}' http://localhost:8055/add-aaaa
The mocked `AAAA` responses can be removed by running:
curl -d '{"host":"test-host.letsencrypt.org"}' http://localhost:8055/clear-aaaa
##### Mocked CAA Responses
To add a mocked CAA policy for `test-host.letsencrypt.org` that allows issuance
by `letsencrypt.org` run:
curl -d '{"host":"test-host.letsencrypt.org", "policies":[{"tag":"issue","value":"letsencrypt.org"}]}' http://localhost:8055/add-caa
To remove the mocked CAA policy for `test-host.letsencrypt.org` run:
curl -d '{"host":"test-host.letsencrypt.org"}' http://localhost:8055/clear-caa
##### Mocked CNAME Responses
To add a mocked CNAME record for `_acme-challenge.test-host.letsencrypt.org` run:
curl -d '{"host":"_acme-challenge.test-host.letsencrypt.org", "target": "challenges.letsencrypt.org"}' http://localhost:8055/set-cname
To remove a mocked CNAME record for `_acme-challenge.test-host.letsencrypt.org` run:
curl -d '{"host":"_acme-challenge.test-host.letsencrypt.org", "target": "challenges.letsencrypt.org"}' http://localhost:8055/clear-cname
##### Mocked SERVFAIL Responses
To configure the DNS server to return SERVFAIL for all queries for `test-host.letsencrypt.org` run:
curl -d '{"host":"test-host.letsencrypt.org"}' http://localhost:8055/set-servfail
Subsequently any query types (A, AAAA, TXT) for the name will return a SERVFAIL response, overriding any A/AAAA/TXT/CNAME mocks that may also be configured.
To remove the SERVFAIL configuration for `test-host.letsencrypt.org` run:
curl -d '{"host":"test-host.letsencrypt.org"}' http://localhost:8055/clear-servfail
#### HTTP-01
To add an HTTP-01 challenge response for the token `"aaaa"` with the content `"bbbb"` run:
curl -d '{"token":"aaaa", "content":"bbbb"}' http://localhost:8055/add-http01
Afterwards the challenge response will be available over HTTP at
`http://localhost:5002/.well-known/acme-challenge/aaaa`, and HTTPS at
`https://localhost:5002/.well-known/acme-challenge/aaaa`.
The HTTP-01 challenge response for the `"aaaa"` token can be deleted by running:
curl -d '{"token":"aaaa"}' http://localhost:8055/del-http01
##### Redirects
To add a redirect from `/.well-known/acme-challenge/whatever` to
`https://localhost:5003/ok` run:
curl -d '{"path":"/.well-known/whatever", "targetURL": "https://localhost:5003/ok"}' http://localhost:8055/add-redirect
Afterwards HTTP requests to `http://localhost:5002/.well-known/whatever/` will
be redirected to `https://localhost:5003/ok`. HTTPS requests that match the
path will not be served a redirect to prevent loops when redirecting the same
path from HTTP to HTTPS.
To remove the redirect run:
curl -d '{"path":"/.well-known/whatever"}' http://localhost:8055/del-redirect
#### DNS-01
To add a DNS-01 challenge response for `_acme-challenge.test-host.letsencrypt.org` with
the value `"foo"` run:
curl -d '{"host":"_acme-challenge.test-host.letsencrypt.org.", "value": "foo"}' http://localhost:8055/set-txt
To remove the mocked DNS-01 challenge response run:
curl -d '{"host":"_acme-challenge.test-host.letsencrypt.org."}' http://localhost:8055/clear-txt
Note that a period character is required at the end of the host name here.
#### TLS-ALPN-01
To add a TLS-ALPN-01 challenge response certificate for the host
`test-host.letsencrypt.org` with the key authorization `"foo"` run:
curl -d '{"host":"test-host.letsencrypt.org", "content":"foo"}' http://localhost:8055/add-tlsalpn01
To remove the mocked TLS-ALPN-01 challenge response run:
curl -d '{"host":"test-host.letsencrypt.org"}' http://localhost:8055/del-tlsalpn01
#### Request History
`chall-test-srv` keeps track of the requests processed by each of the
challenge servers and exposes this information via JSON.
To get the history of HTTP requests to `example.com` run:
curl -d '{"host":"example.com"}' http://localhost:8055/http-request-history
Each HTTP request event is an object of the form:
```
{
"URL": "/test-whatever/dude?token=blah",
"Host": "example.com",
"HTTPS": true,
"ServerName": "example-sni.com"
}
```
If the HTTP request was over the HTTPS interface then HTTPS will be true and the
ServerName field will be populated with the SNI value sent by the client in the
initial TLS hello.
To get the history of DNS requests for `example.com` run:
curl -d '{"host":"example.com"}' http://localhost:8055/dns-request-history
Each DNS request event is an object of the form:
```
{
"Question": {
"Name": "example.com.",
"Qtype": 257,
"Qclass": 1
}
}
```
To get the history of TLS-ALPN-01 requests for the SNI host `example.com` run:
curl -d '{"host":"example.com"}' http://localhost:8055/tlsalpn01-request-history
Each TLS-ALPN-01 request event is an object of the form:
```
{
"ServerName": "example.com",
"SupportedProtos": [
"dogzrule"
]
}
```
The ServerName field is populated with the SNI value sent by the client in the
initial TLS hello. The SupportedProtos field is set with the advertised
supported next protocols from the initial TLS hello.
To clear HTTP request history for `example.com` run:
curl -d '{"host":"example.com", "type":"http"}' http://localhost:8055/clear-request-history
Similarly, to clear DNS request history for `example.com` run:
curl -d '{"host":"example.com", "type":"dns"}' http://localhost:8055/clear-request-history
And to clear TLS-ALPN-01 request history for `example.com` run:
curl -d '{"host":"example.com", "type":"tlsalpn"}' http://localhost:8055/clear-request-history

View File

@ -0,0 +1,65 @@
package main
import "net/http"
// addDNS01 handles an HTTP POST request to add a new DNS-01 challenge TXT
// record for a given host/value.
//
// The POST body is expected to have two non-empty parameters:
// "host" - the hostname to add the mock TXT response under.
// "value" - the key authorization value to return in the TXT response.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addDNS01(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Host string
Value string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host or value it's a bad request
if request.Host == "" || request.Value == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Add the DNS-01 challenge response TXT to the challenge server
srv.challSrv.AddDNSOneChallenge(request.Host, request.Value)
srv.log.Printf("Added DNS-01 TXT challenge for Host %q - Value %q\n",
request.Host, request.Value)
w.WriteHeader(http.StatusOK)
}
// delDNS01 handles an HTTP POST request to delete an existing DNS-01 challenge
// TXT record for a given host.
//
// The POST body is expected to have one non-empty parameter:
// "host" - the hostname to remove the mock TXT response for.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delDNS01(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host value it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Delete the DNS-01 challenge response TXT for the given host from the
// challenge server
srv.challSrv.DeleteDNSOneChallenge(request.Host)
srv.log.Printf("Removed DNS-01 TXT challenge for Host %q\n", request.Host)
w.WriteHeader(http.StatusOK)
}

View File

@ -0,0 +1,122 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/letsencrypt/challtestsrv"
)
// clearHistory handles an HTTP POST request to clear the challenge server
// request history for a specific hostname and type of event.
//
// The POST body is expected to have two parameters:
// "host" - the hostname to clear history for.
// "type" - the type of event to clear. May be "http", "dns", or "tlsalpn".
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) clearHistory(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
Type string `json:"type"`
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
typeMap := map[string]challtestsrv.RequestEventType{
"http": challtestsrv.HTTPRequestEventType,
"dns": challtestsrv.DNSRequestEventType,
"tlsalpn": challtestsrv.TLSALPNRequestEventType,
}
if request.Host == "" {
http.Error(w, "host parameter must not be empty", http.StatusBadRequest)
return
}
if code, ok := typeMap[request.Type]; ok {
srv.challSrv.ClearRequestHistory(request.Host, code)
srv.log.Printf("Cleared challenge server request history for %q %q events\n",
request.Host, request.Type)
w.WriteHeader(http.StatusOK)
return
}
http.Error(w, fmt.Sprintf("%q event type unknown", request.Type), http.StatusBadRequest)
}
// getHTTPHistory returns only the HTTPRequestEvents for the given hostname
// from the challenge server's request history in JSON form.
func (srv *managementServer) getHTTPHistory(w http.ResponseWriter, r *http.Request) {
host, err := requestHost(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
srv.writeHistory(
srv.challSrv.RequestHistory(host, challtestsrv.HTTPRequestEventType),
w)
}
// getDNSHistory returns only the DNSRequestEvents from the challenge
// server's request history in JSON form.
func (srv *managementServer) getDNSHistory(w http.ResponseWriter, r *http.Request) {
host, err := requestHost(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
srv.writeHistory(
srv.challSrv.RequestHistory(host, challtestsrv.DNSRequestEventType),
w)
}
// getTLSALPNHistory returns only the TLSALPNRequestEvents from the challenge
// server's request history in JSON form.
func (srv *managementServer) getTLSALPNHistory(w http.ResponseWriter, r *http.Request) {
host, err := requestHost(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
srv.writeHistory(
srv.challSrv.RequestHistory(host, challtestsrv.TLSALPNRequestEventType),
w)
}
// requestHost extracts the Host parameter of a JSON POST body in the provided
// request, or returns an error.
func requestHost(r *http.Request) (string, error) {
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
return "", err
}
if request.Host == "" {
return "", errors.New("host parameter of POST body must not be empty")
}
return request.Host, nil
}
// writeHistory writes the provided list of challtestsrv.RequestEvents to the
// provided http.ResponseWriter in JSON form.
func (srv *managementServer) writeHistory(
history []challtestsrv.RequestEvent, w http.ResponseWriter,
) {
// Always write an empty JSON list instead of `null`
if history == nil {
history = []challtestsrv.RequestEvent{}
}
jsonHistory, err := json.MarshalIndent(history, "", " ")
if err != nil {
srv.log.Printf("Error marshaling history: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(jsonHistory)
}

View File

@ -0,0 +1,24 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
)
// mustParsePOST will attempt to read a JSON POST body from the provided request
// and unmarshal it into the provided ob. If an error occurs at any point it
// will be returned.
func mustParsePOST(ob interface{}, request *http.Request) error {
jsonBody, err := io.ReadAll(request.Body)
if err != nil {
return err
}
if string(jsonBody) == "" {
return errors.New("Expected JSON POST body, was empty")
}
return json.Unmarshal(jsonBody, ob)
}

View File

@ -0,0 +1,128 @@
package main
import "net/http"
// addHTTP01 handles an HTTP POST request to add a new HTTP-01 challenge
// response for a given token.
//
// The POST body is expected to have two non-empty parameters:
// "token" - the HTTP-01 challenge token to add the mock HTTP-01 response under
// in the `/.well-known/acme-challenge/` path.
//
// "content" - the key authorization value to return in the HTTP response.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addHTTP01(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Token string
Content string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty token or content it's a bad request
if request.Token == "" || request.Content == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Add the HTTP-01 challenge to the challenge server
srv.challSrv.AddHTTPOneChallenge(request.Token, request.Content)
srv.log.Printf("Added HTTP-01 challenge for token %q - key auth %q\n",
request.Token, request.Content)
w.WriteHeader(http.StatusOK)
}
// delHTTP01 handles an HTTP POST request to delete an existing HTTP-01
// challenge response for a given token.
//
// The POST body is expected to have one non-empty parameter:
// "token" - the HTTP-01 challenge token to remove the mock HTTP-01 response
// from.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delHTTP01(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Token string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty token it's a bad request
if request.Token == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Delete the HTTP-01 challenge for the given token from the challenge server
srv.challSrv.DeleteHTTPOneChallenge(request.Token)
srv.log.Printf("Removed HTTP-01 challenge for token %q\n", request.Token)
w.WriteHeader(http.StatusOK)
}
// addHTTPRedirect handles an HTTP POST request to add a new 301 redirect to be
// served for the given path to the given target URL.
//
// The POST body is expected to have two non-empty parameters:
// "path" - the path that when matched in an HTTP request will return the
// redirect.
//
// "targetURL" - the URL that the client will be redirected to when making HTTP
// requests for the redirected path.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addHTTPRedirect(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Path string
TargetURL string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty path or target URL it's a bad request
if request.Path == "" || request.TargetURL == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Add the HTTP redirect to the challenge server
srv.challSrv.AddHTTPRedirect(request.Path, request.TargetURL)
srv.log.Printf("Added HTTP redirect for path %q to %q\n",
request.Path, request.TargetURL)
w.WriteHeader(http.StatusOK)
}
// delHTTPRedirect handles an HTTP POST request to delete an existing HTTP
// redirect for a given path.
//
// The POST body is expected to have one non-empty parameter:
// "path" - the path to remove a redirect for.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delHTTPRedirect(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Path string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Path == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Delete the HTTP redirect for the given path from the challenge server
srv.challSrv.DeleteHTTPRedirect(request.Path)
srv.log.Printf("Removed HTTP redirect for path %q\n", request.Path)
w.WriteHeader(http.StatusOK)
}

171
test/chall-test-srv/main.go Normal file
View File

@ -0,0 +1,171 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/letsencrypt/challtestsrv"
"github.com/letsencrypt/boulder/cmd"
)
// managementServer is a small HTTP server that can control a challenge server,
// adding and deleting challenge responses as required
type managementServer struct {
// A managementServer is a http.Server
*http.Server
log *log.Logger
// The challenge server that is under control by the management server
challSrv *challtestsrv.ChallSrv
}
func (srv *managementServer) Run() {
srv.log.Printf("Starting management server on %s", srv.Server.Addr)
// Start the HTTP server in its own dedicated Go routine
go func() {
err := srv.ListenAndServe()
if err != nil && !strings.Contains(err.Error(), "Server closed") {
srv.log.Print(err)
}
}()
}
func (srv *managementServer) Shutdown() {
if err := srv.Server.Shutdown(context.Background()); err != nil {
srv.log.Printf("Err shutting down management server")
}
}
func filterEmpty(input []string) []string {
var output []string
for _, val := range input {
trimmed := strings.TrimSpace(val)
if trimmed != "" {
output = append(output, trimmed)
}
}
return output
}
func main() {
httpOneBind := flag.String("http01", ":5002",
"Comma separated bind addresses/ports for HTTP-01 challenges. Set empty to disable.")
httpsOneBind := flag.String("https01", ":5003",
"Comma separated bind addresses/ports for HTTPS HTTP-01 challenges. Set empty to disable.")
dohBind := flag.String("doh", ":8443",
"Comma separated bind addresses/ports for DoH queries. Set empty to disable.")
dohCert := flag.String("doh-cert", "", "Path to certificate file for DoH server.")
dohCertKey := flag.String("doh-cert-key", "", "Path to certificate key file for DoH server.")
dnsOneBind := flag.String("dns01", ":8053",
"Comma separated bind addresses/ports for DNS-01 challenges and fake DNS data. Set empty to disable.")
tlsAlpnOneBind := flag.String("tlsalpn01", ":5001",
"Comma separated bind addresses/ports for TLS-ALPN-01 and HTTPS HTTP-01 challenges. Set empty to disable.")
managementBind := flag.String("management", ":8055",
"Bind address/port for management HTTP interface")
defaultIPv4 := flag.String("defaultIPv4", "127.0.0.1",
"Default IPv4 address for mock DNS responses to A queries")
defaultIPv6 := flag.String("defaultIPv6", "::1",
"Default IPv6 address for mock DNS responses to AAAA queries")
flag.Parse()
if len(flag.Args()) > 0 {
fmt.Printf("invalid command line arguments: %s\n", strings.Join(flag.Args(), " "))
flag.Usage()
os.Exit(1)
}
httpOneAddresses := filterEmpty(strings.Split(*httpOneBind, ","))
httpsOneAddresses := filterEmpty(strings.Split(*httpsOneBind, ","))
dohAddresses := filterEmpty(strings.Split(*dohBind, ","))
dnsOneAddresses := filterEmpty(strings.Split(*dnsOneBind, ","))
tlsAlpnOneAddresses := filterEmpty(strings.Split(*tlsAlpnOneBind, ","))
logger := log.New(os.Stdout, "chall-test-srv - ", log.Ldate|log.Ltime)
// Create a new challenge server with the provided config
srv, err := challtestsrv.New(challtestsrv.Config{
HTTPOneAddrs: httpOneAddresses,
HTTPSOneAddrs: httpsOneAddresses,
DOHAddrs: dohAddresses,
DOHCert: *dohCert,
DOHCertKey: *dohCertKey,
DNSOneAddrs: dnsOneAddresses,
TLSALPNOneAddrs: tlsAlpnOneAddresses,
Log: logger,
})
cmd.FailOnError(err, "Unable to construct challenge server")
// Create a new management server with the provided config
oobSrv := managementServer{
Server: &http.Server{
Addr: *managementBind,
ReadTimeout: 30 * time.Second,
},
challSrv: srv,
log: logger,
}
// Register handlers on the management server for adding challenge responses
// for the configured challenges.
if *httpOneBind != "" || *httpsOneBind != "" {
http.HandleFunc("/add-http01", oobSrv.addHTTP01)
http.HandleFunc("/del-http01", oobSrv.delHTTP01)
http.HandleFunc("/add-redirect", oobSrv.addHTTPRedirect)
http.HandleFunc("/del-redirect", oobSrv.delHTTPRedirect)
}
if *dnsOneBind != "" {
http.HandleFunc("/set-default-ipv4", oobSrv.setDefaultDNSIPv4)
http.HandleFunc("/set-default-ipv6", oobSrv.setDefaultDNSIPv6)
// TODO(@cpu): It might make sense to revisit this API in the future to have
// one endpoint that accepts the mock type required (A, AAAA, CNAME, etc)
// instead of having separate endpoints per type.
http.HandleFunc("/set-txt", oobSrv.addDNS01)
http.HandleFunc("/clear-txt", oobSrv.delDNS01)
http.HandleFunc("/add-a", oobSrv.addDNSARecord)
http.HandleFunc("/clear-a", oobSrv.delDNSARecord)
http.HandleFunc("/add-aaaa", oobSrv.addDNSAAAARecord)
http.HandleFunc("/clear-aaaa", oobSrv.delDNSAAAARecord)
http.HandleFunc("/add-caa", oobSrv.addDNSCAARecord)
http.HandleFunc("/clear-caa", oobSrv.delDNSCAARecord)
http.HandleFunc("/set-cname", oobSrv.addDNSCNAMERecord)
http.HandleFunc("/clear-cname", oobSrv.delDNSCNAMERecord)
http.HandleFunc("/set-servfail", oobSrv.addDNSServFailRecord)
http.HandleFunc("/clear-servfail", oobSrv.delDNSServFailRecord)
srv.SetDefaultDNSIPv4(*defaultIPv4)
srv.SetDefaultDNSIPv6(*defaultIPv6)
if *defaultIPv4 != "" {
logger.Printf("Answering A queries with %s by default",
*defaultIPv4)
}
if *defaultIPv6 != "" {
logger.Printf("Answering AAAA queries with %s by default",
*defaultIPv6)
}
}
if *tlsAlpnOneBind != "" {
http.HandleFunc("/add-tlsalpn01", oobSrv.addTLSALPN01)
http.HandleFunc("/del-tlsalpn01", oobSrv.delTLSALPN01)
}
http.HandleFunc("/clear-request-history", oobSrv.clearHistory)
http.HandleFunc("/http-request-history", oobSrv.getHTTPHistory)
http.HandleFunc("/dns-request-history", oobSrv.getDNSHistory)
http.HandleFunc("/tlsalpn01-request-history", oobSrv.getTLSALPNHistory)
// Start all of the sub-servers in their own Go routines so that the main Go
// routine can spin forever looking for signals to catch.
go srv.Run()
go oobSrv.Run()
cmd.CatchSignals(func() {
srv.Shutdown()
oobSrv.Shutdown()
})
}

View File

@ -0,0 +1,351 @@
// addDNS01 handles an HTTP POST request to add a new DNS-01 challenge TXT
package main
import (
"net/http"
"strings"
"github.com/letsencrypt/challtestsrv"
)
// setDefaultDNSIPv4 handles an HTTP POST request to set the default IPv4
// address used for all A query responses that do not match more-specific mocked
// responses.
//
// The POST body is expected to have one parameter:
// "ip" - the string representation of an IPv4 address to use for all A queries
// that do not match more specific mocks.
//
// Providing an empty string as the IP value will disable the default
// A responses.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) setDefaultDNSIPv4(w http.ResponseWriter, r *http.Request) {
var request struct {
IP string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Set the challenge server's default IPv4 address - we allow request.IP to be
// the empty string so that the default can be cleared using the same
// method.
srv.challSrv.SetDefaultDNSIPv4(request.IP)
srv.log.Printf("Set default IPv4 address for DNS A queries to %q\n", request.IP)
w.WriteHeader(http.StatusOK)
}
// setDefaultDNSIPv6 handles an HTTP POST request to set the default IPv6
// address used for all AAAA query responses that do not match more-specific
// mocked responses.
//
// The POST body is expected to have one parameter:
// "ip" - the string representation of an IPv6 address to use for all AAAA
// queries that do not match more specific mocks.
//
// Providing an empty string as the IP value will disable the default
// A responses.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) setDefaultDNSIPv6(w http.ResponseWriter, r *http.Request) {
var request struct {
IP string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Set the challenge server's default IPv6 address - we allow request.IP to be
// the empty string so that the default can be cleared using the same
// method.
srv.challSrv.SetDefaultDNSIPv6(request.IP)
srv.log.Printf("Set default IPv6 address for DNS AAAA queries to %q\n", request.IP)
w.WriteHeader(http.StatusOK)
}
// addDNSARecord handles an HTTP POST request to add a mock A query response record
// for a host.
//
// The POST body is expected to have two non-empty parameters:
// "host" - the hostname that when queried should return the mocked A record.
// "addresses" - an array of IPv4 addresses in string representation that should
// be used for the A records returned for the query.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addDNSARecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
Addresses []string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has no addresses or an empty host it's a bad request
if len(request.Addresses) == 0 || request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.AddDNSARecord(request.Host, request.Addresses)
srv.log.Printf("Added response for DNS A queries to %q : %s\n",
request.Host, strings.Join(request.Addresses, ", "))
w.WriteHeader(http.StatusOK)
}
// delDNSARecord handles an HTTP POST request to delete an existing mock A
// policy record for a host.
//
// The POST body is expected to have one non-empty parameter:
// "host" - the hostname to remove the mock A record for.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delDNSARecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.DeleteDNSARecord(request.Host)
srv.log.Printf("Removed response for DNS A queries to %q", request.Host)
w.WriteHeader(http.StatusOK)
}
// addDNSAAAARecord handles an HTTP POST request to add a mock AAAA query
// response record for a host.
//
// The POST body is expected to have two non-empty parameters:
// "host" - the hostname that when queried should return the mocked A record.
// "addresses" - an array of IPv6 addresses in string representation that should
// be used for the AAAA records returned for the query.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addDNSAAAARecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
Addresses []string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has no addresses or an empty host it's a bad request
if len(request.Addresses) == 0 || request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.AddDNSAAAARecord(request.Host, request.Addresses)
srv.log.Printf("Added response for DNS AAAA queries to %q : %s\n",
request.Host, strings.Join(request.Addresses, ", "))
w.WriteHeader(http.StatusOK)
}
// delDNSAAAARecord handles an HTTP POST request to delete an existing mock AAAA
// policy record for a host.
//
// The POST body is expected to have one non-empty parameter:
// "host" - the hostname to remove the mock AAAA record for.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delDNSAAAARecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.DeleteDNSAAAARecord(request.Host)
srv.log.Printf("Removed response for DNS AAAA queries to %q", request.Host)
w.WriteHeader(http.StatusOK)
}
// addDNSCAARecord handles an HTTP POST request to add a mock CAA query
// response record for a host.
//
// The POST body is expected to have two non-empty parameters:
// "host" - the hostname that when queried should return the mocked CAA record.
// "policies" - an array of CAA policy objects. Each policy object is expected
// to have two non-empty keys, "tag" and "value".
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addDNSCAARecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
Policies []challtestsrv.MockCAAPolicy
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has no host or no caa policies it's a bad request
if request.Host == "" || len(request.Policies) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.AddDNSCAARecord(request.Host, request.Policies)
srv.log.Printf("Added response for DNS CAA queries to %q", request.Host)
w.WriteHeader(http.StatusOK)
}
// delDNSCAARecord handles an HTTP POST request to delete an existing mock CAA
// policy record for a host.
//
// The POST body is expected to have one non-empty parameter:
// "host" - the hostname to remove the mock CAA policy for.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delDNSCAARecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.DeleteDNSCAARecord(request.Host)
srv.log.Printf("Removed response for DNS CAA queries to %q", request.Host)
w.WriteHeader(http.StatusOK)
}
// addDNSCNAMERecord handles an HTTP POST request to add a mock CNAME query
// response record and alias for a host.
//
// The POST body is expected to have two non-empty parameters:
// "host" - the hostname that should be treated as an alias to the target
// "target" - the hostname whose mocked DNS records should be returned
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addDNSCNAMERecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
Target string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has no host or no caa policies it's a bad request
if request.Host == "" || request.Target == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.AddDNSCNAMERecord(request.Host, request.Target)
srv.log.Printf("Added response for DNS CNAME queries to %q targeting %q", request.Host, request.Target)
w.WriteHeader(http.StatusOK)
}
// delDNSCNAMERecord handles an HTTP POST request to delete an existing mock
// CNAME record for a host.
//
// The POST body is expected to have one non-empty parameters:
// "host" - the hostname to remove the mock CNAME alias for.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delDNSCNAMERecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.DeleteDNSCNAMERecord(request.Host)
srv.log.Printf("Removed response for DNS CNAME queries to %q", request.Host)
w.WriteHeader(http.StatusOK)
}
// addDNSServFailRecord handles an HTTP POST request to add a mock SERVFAIL
// response record for a host. All queries for that host will subsequently
// result in SERVFAIL responses, overriding any other mocks.
//
// The POST body is expected to have one non-empty parameter:
// "host" - the hostname that should return SERVFAIL responses.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addDNSServFailRecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has no host it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.AddDNSServFailRecord(request.Host)
srv.log.Printf("Added SERVFAIL response for DNS queries to %q", request.Host)
w.WriteHeader(http.StatusOK)
}
// delDNSServFailRecord handles an HTTP POST request to delete an existing mock
// SERVFAIL record for a host.
//
// The POST body is expected to have one non-empty parameters:
// "host" - the hostname to remove the mock SERVFAIL response from.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delDNSServFailRecord(w http.ResponseWriter, r *http.Request) {
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
srv.challSrv.DeleteDNSServFailRecord(request.Host)
srv.log.Printf("Removed SERVFAIL response for DNS queries to %q", request.Host)
w.WriteHeader(http.StatusOK)
}

View File

@ -0,0 +1,65 @@
package main
import "net/http"
// addTLSALPN01 handles an HTTP POST request to add a new TLS-ALPN-01 challenge
// response certificate for a given host.
//
// The POST body is expected to have two non-empty parameters:
// "host" - the hostname to add the challenge response certificate for.
// "content" - the key authorization value to use to construct the TLS-ALPN-01
// challenge response certificate.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) addTLSALPN01(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Host string
Content string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host or content it's a bad request
if request.Host == "" || request.Content == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Add the TLS-ALPN-01 challenge to the challenge server
srv.challSrv.AddTLSALPNChallenge(request.Host, request.Content)
srv.log.Printf("Added TLS-ALPN-01 challenge for host %q - key auth %q\n",
request.Host, request.Content)
w.WriteHeader(http.StatusOK)
}
// delTLSALPN01 handles an HTTP POST request to delete an existing TLS-ALPN-01
// challenge response for a given host.
//
// The POST body is expected to have one non-empty parameter:
// "host" - the hostname to remove the TLS-ALPN-01 challenge response for.
//
// A successful POST will write http.StatusOK to the client.
func (srv *managementServer) delTLSALPN01(w http.ResponseWriter, r *http.Request) {
// Unmarshal the request body JSON as a request object
var request struct {
Host string
}
if err := mustParsePOST(&request, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the request has an empty host it's a bad request
if request.Host == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Delete the TLS-ALPN-01 challenge for the given host from the challenge server
srv.challSrv.DeleteTLSALPNChallenge(request.Host)
srv.log.Printf("Removed TLS-ALPN-01 challenge for host %q\n", request.Host)
w.WriteHeader(http.StatusOK)
}

View File

@ -3,8 +3,8 @@ import requests
class ChallTestServer:
"""
ChallTestServer is a wrapper around pebble-challtestsrv's HTTP management
API. If the pebble-challtestsrv process you want to interact with is using
ChallTestServer is a wrapper around chall-test-srv's HTTP management
API. If the chall-test-srv process you want to interact with is using
a -management argument other than the default ('http://10.77.77.77:8055') you
can instantiate the ChallTestServer using the -management address in use. If
no custom address is provided the default is assumed.

View File

@ -226,7 +226,7 @@ def start(fakeclock):
print("Error querying DNS. Is consul running? `docker compose ps bconsul`. %s" % (e))
return False
# Start the pebble-challtestsrv first so it can be used to resolve DNS for
# Start the chall-test-srv first so it can be used to resolve DNS for
# gRPC.
startChallSrv()
@ -254,7 +254,7 @@ def start(fakeclock):
def check():
"""Return true if all started processes are still alive.
Log about anything that died. The pebble-challtestsrv is not considered when
Log about anything that died. The chall-test-srv is not considered when
checking processes.
"""
global processes
@ -274,7 +274,7 @@ def check():
def startChallSrv():
"""
Start the pebble-challtestsrv and wait for it to become available. See also
Start the chall-test-srv and wait for it to become available. See also
stopChallSrv.
"""
global challSrvProcess
@ -287,7 +287,7 @@ def startChallSrv():
# which is used is controlled by mock DNS data added by the relevant
# integration tests.
challSrvProcess = run([
'pebble-challtestsrv',
'./bin/chall-test-srv',
'--defaultIPv4', os.environ.get("FAKE_DNS"),
'-defaultIPv6', '',
'--dns01', ':8053,:8054',
@ -299,13 +299,13 @@ def startChallSrv():
'-https01', '10.77.77.77:443',
'--tlsalpn01', '10.88.88.88:443'],
None)
# Wait for the pebble-challtestsrv management port.
# Wait for the chall-test-srv management port.
if not waitport(8055, ' '.join(challSrvProcess.args)):
return False
def stopChallSrv():
"""
Stop the running pebble-challtestsrv (if any) and wait for it to terminate.
Stop the running chall-test-srv (if any) and wait for it to terminate.
See also startChallSrv.
"""
global challSrvProcess

View File

@ -447,7 +447,7 @@ def test_http_challenge_timeout():
to a slow HTTP server appropriately.
"""
# Start a simple python HTTP server on port 80 in its own thread.
# NOTE(@cpu): The pebble-challtestsrv binds 10.77.77.77:80 for HTTP-01
# NOTE(@cpu): The chall-test-srv binds 10.77.77.77:80 for HTTP-01
# challenges so we must use the 10.88.88.88 address for the throw away
# server for this test and add a mock DNS entry that directs the VA to it.
httpd = SlowHTTPServer(("10.88.88.88", 80), SlowHTTPRequestHandler)
@ -786,11 +786,11 @@ def multiva_setup(client, guestlist):
# Add an A record for the redirect target that sends it to the real chall
# test srv for a valid HTTP-01 response.
redirHostname = "pebble-challtestsrv.example.com"
redirHostname = "chall-test-srv.example.com"
challSrv.add_a_record(redirHostname, ["10.77.77.77"])
# Start a simple python HTTP server on port 80 in its own thread.
# NOTE(@cpu): The pebble-challtestsrv binds 10.77.77.77:80 for HTTP-01
# NOTE(@cpu): The chall-test-srv binds 10.77.77.77:80 for HTTP-01
# challenges so we must use the 10.88.88.88 address for the throw away
# server for this test and add a mock DNS entry that directs the VA to it.
redirect = "http://{0}/.well-known/acme-challenge/{1}".format(

View File

@ -41,7 +41,7 @@ TARGET="${BUILD}/opt/boulder"
COMMIT_ID="$(git rev-parse --short=8 HEAD)"
mkdir -p "${TARGET}/bin"
for NAME in admin boulder ceremony ct-test-srv pardot-test-srv ; do
for NAME in admin boulder ceremony ct-test-srv pardot-test-srv chall-test-srv ; do
cp -a "bin/${NAME}" "${TARGET}/bin/"
done

View File

@ -5,14 +5,14 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/letsencrypt/challtestsrv)](https://goreportcard.com/report/github.com/letsencrypt/challtestsrv)
[![GolangCI](https://golangci.com/badges/github.com/letsencrypt/challtestsrv.svg)](https://golangci.com/r/github.com/letsencrypt/challtestsrv)
The `challtestsrv` package offers a library/command that can be used by test
The `challtestsrv` package offers a library that can be used by test
code to respond to HTTP-01, DNS-01, and TLS-ALPN-01 ACME challenges. The
`challtestsrv` package can also be used as a mock DNS server letting
developers mock `A`, `AAAA`, `CNAME`, and `CAA` DNS data for specific hostnames.
The mock server will resolve up to one level of `CNAME` aliasing for accepted
DNS request types.
**Important note: The `challtestsrv` command and library are for TEST USAGE
**Important note: The `challtestsrv` library is for TEST USAGE
ONLY. It is trivially insecure, offering no authentication. Only use
`challtestsrv` in a controlled test environment.**

View File

@ -5,6 +5,7 @@ package challtestsrv
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
@ -95,10 +96,17 @@ type Config struct {
HTTPOneAddrs []string
// HTTPSOneAddrs are the HTTPS HTTP-01 challenge server bind addresses/ports
HTTPSOneAddrs []string
// DOHAddrs are the DOH challenge server bind addresses/ports
DOHAddrs []string
// DNSOneAddrs are the DNS-01 challenge server bind addresses/ports
DNSOneAddrs []string
// TLSALPNOneAddrs are the TLS-ALPN-01 challenge server bind addresses/ports
TLSALPNOneAddrs []string
// DOHCert is required if DOHAddrs is nonempty.
DOHCert string
// DOHCertKey is required if DOHAddrs is nonempty.
DOHCertKey string
}
// validate checks that a challenge server Config is valid. To be valid it must
@ -112,7 +120,7 @@ func (c *Config) validate() error {
len(c.TLSALPNOneAddrs) < 1 {
return fmt.Errorf(
"config must specify at least one HTTPOneAddrs entry, one HTTPSOneAddr " +
"entry, one DNSOneAddrs entry, or one TLSALPNOneAddrs entry")
"entry, one DOHAddrs, one DNSOneAddrs entry, or one TLSALPNOneAddrs entry")
}
// If there is no configured log make a default with a prefix
if c.Log == nil {
@ -167,6 +175,15 @@ func New(config Config) (*ChallSrv, error) {
dnsOneServer(address, challSrv.dnsHandler)...)
}
for _, address := range config.DOHAddrs {
challSrv.log.Printf("Creating DoH server on %s\n", address)
s, err := dohServer(address, config.DOHCert, config.DOHCertKey, http.HandlerFunc(challSrv.dohHandler))
if err != nil {
return nil, err
}
challSrv.servers = append(challSrv.servers, s)
}
// If there are TLS-ALPN-01 addresses configured, create TLS-ALPN-01 servers
for _, address := range config.TLSALPNOneAddrs {
challSrv.log.Printf("Creating TLS-ALPN-01 challenge server on %s\n", address)

View File

@ -1,7 +1,10 @@
package challtestsrv
import (
"fmt"
"io"
"net"
"net/http"
"github.com/miekg/dns"
)
@ -154,12 +157,57 @@ func (s *ChallSrv) caaAnswers(q dns.Question) []dns.RR {
return records
}
type writeMsg interface {
WriteMsg(*dns.Msg) error
}
type dnsToHTTPWriter struct {
http.ResponseWriter
}
func (d *dnsToHTTPWriter) WriteMsg(m *dns.Msg) error {
d.Header().Set("Content-Type", "application/dns-message")
d.WriteHeader(http.StatusOK)
b, err := m.Pack()
if err != nil {
return err
}
_, err = d.Write(b)
return err
}
// dohHandler handles a DoH request by POST only.
func (s *ChallSrv) dohHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
msg := new(dns.Msg)
err = msg.Unpack(body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, err)
return
}
s.dnsHandlerInner(&dnsToHTTPWriter{w}, msg)
}
// dnsHandler is a miekg/dns handler that can process a dns.Msg request and
// write a response to the provided dns.ResponseWriter. TXT, A, AAAA, CNAME,
// and CAA queries types are supported and answered using the ChallSrv's mock
// DNS data. A host that is aliased by a CNAME record will follow that alias
// one level and return the requested record types for that alias' target
func (s *ChallSrv) dnsHandler(w dns.ResponseWriter, r *dns.Msg) {
s.dnsHandlerInner(w, r)
}
func (s *ChallSrv) dnsHandlerInner(w writeMsg, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Compress = false

View File

@ -1,6 +1,8 @@
package challtestsrv
import (
"context"
"net/http"
"time"
"github.com/miekg/dns"
@ -55,3 +57,30 @@ func dnsOneServer(address string, handler dnsHandler) []challengeServer {
}
return []challengeServer{udpServer, tcpServer}
}
type doh struct {
*http.Server
tlsCert, tlsCertKey string
}
func (s *doh) Shutdown() error {
return s.Server.Shutdown(context.Background())
}
func (s *doh) ListenAndServe() error {
return s.Server.ListenAndServeTLS(s.tlsCert, s.tlsCertKey)
}
// dohServer creates a DoH server.
func dohServer(address string, tlsCert, tlsCertKey string, handler http.Handler) (challengeServer, error) {
return &doh{
&http.Server{
Handler: handler,
Addr: address,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
},
tlsCert,
tlsCertKey,
}, nil
}

2
vendor/modules.txt vendored
View File

@ -207,7 +207,7 @@ github.com/jmhodges/clock
# github.com/letsencrypt/borp v0.0.0-20240620175310-a78493c6e2bd
## explicit; go 1.20
github.com/letsencrypt/borp
# github.com/letsencrypt/challtestsrv v1.2.1
# github.com/letsencrypt/challtestsrv v1.3.2
## explicit; go 1.13
github.com/letsencrypt/challtestsrv
# github.com/letsencrypt/pkcs11key/v4 v4.0.0