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:
parent
13f98daabf
commit
0fe66b6e8e
|
@ -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.
|
||||
|
|
2
Makefile
2
Makefile
|
@ -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
2
go.mod
|
@ -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
4
go.sum
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
[](https://goreportcard.com/report/github.com/letsencrypt/challtestsrv)
|
||||
[](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.**
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue