Merge remote-tracking branch 'challtestsrv/main'

This commit is contained in:
Matthew McPherrin 2024-05-24 16:07:25 -04:00
commit e73ff76da4
8 changed files with 1283 additions and 0 deletions

79
challtestsrv/README.md Normal file
View File

@ -0,0 +1,79 @@
# Challenge Test Server
[![Build Status](https://travis-ci.org/letsencrypt/challtestsrv.svg?branch=master)](https://travis-ci.org/letsencrypt/challtestsrv)
[![Coverage Status](https://coveralls.io/repos/github/letsencrypt/challtestsrv/badge.svg)](https://coveralls.io/github/letsencrypt/challtestsrv)
[![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 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` library is for TEST USAGE
ONLY. It is trivially insecure, offering no authentication. Only use
`challtestsrv` in a controlled test environment.**
For example this package is used by the Boulder
[`load-generator`](https://github.com/letsencrypt/boulder/tree/9e39680e3f78c410e2d780a7badfe200a31698eb/test/load-generator)
command to manage its own in-process HTTP-01 challenge server.
### Usage
Create a challenge server responding to HTTP-01 challenges on ":8888" and
DNS-01 challenges on ":9999" and "10.0.0.1:9998":
```
import "github.com/letsencrypt/pebble/challtestsrv"
challSrv, err := challtestsrv.New(challsrv.Config{
HTTPOneAddr: []string{":8888"},
DNSOneAddr: []string{":9999", "10.0.0.1:9998"},
})
if err != nil {
panic(err)
}
```
Run the Challenge server and subservers:
```
// Start the Challenge server in its own Go routine
go challSrv.Run()
```
Add an HTTP-01 response for the token `"aaa"` and the value `"bbb"`, defer
cleaning it up again:
```
challSrv.AddHTTPOneChallenge("aaa", "bbb")
defer challSrv.DeleteHTTPOneChallenge("aaa")
```
Add a DNS-01 TXT response for the host `"_acme-challenge.example.com."` and the
value `"bbb"`, defer cleaning it up again:
```
challSrv.AddDNSOneChallenge("_acme-challenge.example.com.", "bbb")
defer challSrv.DeleteHTTPOneChallenge("_acme-challenge.example.com.")
```
Get the history of HTTP requests processed by the challenge server for the host
"example.com":
```
requestHistory := challSrv.RequestHistory("example.com", challtestsrv.HTTPRequestEventType)
```
Clear the history of HTTP requests processed by the challenge server for the
host "example.com":
```
challSrv.ClearRequestHistory("example.com", challtestsrv.HTTPRequestEventType)
```
Stop the Challenge server and subservers:
```
// Shutdown the Challenge server
challSrv.Shutdown()
```
For more information on the package API see Godocs and the associated package
sourcecode.

View File

@ -0,0 +1,218 @@
// Package challtestsrv provides a trivially insecure acme challenge response
// server for rapidly testing HTTP-01, DNS-01 and TLS-ALPN-01 challenge types.
package challtestsrv
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
)
const (
// Default to using localhost for both A and AAAA queries that don't match
// more specific mock host data.
defaultIPv4 = "127.0.0.1"
defaultIPv6 = "::1"
)
// challengeServers offer common functionality to start up and shutdown.
type challengeServer interface {
ListenAndServe() error
Shutdown() error
}
// ChallSrv is a multi-purpose challenge server. Each ChallSrv may have one or
// more ACME challenges it provides servers for. It is safe to use concurrently.
type ChallSrv struct {
log *log.Logger
// servers are the individual challenge server listeners started in New() and
// closed in Shutdown().
servers []challengeServer
// challMu is a RWMutex used to control concurrent updates to the challenge
// response data maps below.
challMu sync.RWMutex
// requestHistory is a map from hostname to a map of event type to a list of
// sequential request events
requestHistory map[string]map[RequestEventType][]RequestEvent
// httpOne is a map of token values to key authorizations used for HTTP-01
// responses.
httpOne map[string]string
// dnsOne is a map of DNS host values to key authorizations used for DNS-01
// responses.
dnsOne map[string][]string
// dnsMocks holds mock DNS data used to respond to DNS queries other than
// DNS-01 TXT challenge lookups.
dnsMocks mockDNSData
// tlsALPNOne is a map of token values to key authorizations used for TLS-ALPN-01
// responses.
tlsALPNOne map[string]string
// redirects is a map of paths to URLs. HTTP challenge servers respond to
// requests for these paths with a 301 to the corresponding URL.
redirects map[string]string
}
// mockDNSData holds mock responses for DNS A, AAAA, and CAA lookups.
type mockDNSData struct {
// The IPv4 address used for all A record responses that don't match a host in
// aRecords.
defaultIPv4 string
// The IPv6 address used for all AAAA record responses that don't match a host
// in aaaaRecords.
defaultIPv6 string
// A map of host to IPv4 addresses in string form for A record responses.
aRecords map[string][]string
// A map of host to IPv6 addresses in string form for AAAA record responses.
aaaaRecords map[string][]string
// A map of host to CAA policies for CAA responses.
caaRecords map[string][]MockCAAPolicy
// A map of host to CNAME records.
cnameRecords map[string]string
// A map of hostnames that should receive a SERVFAIL response for all queries.
servFailRecords map[string]bool
}
// MockCAAPolicy holds a tag and a value for a CAA record. See
// https://tools.ietf.org/html/rfc6844
type MockCAAPolicy struct {
Tag string
Value string
}
// Config holds challenge server configuration
type Config struct {
Log *log.Logger
// HTTPOneAddrs are the HTTP-01 challenge server bind addresses/ports
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
// specify a bind address for at least one challenge type. If there is no
// configured log in the config a default is provided.
func (c *Config) validate() error {
// There needs to be at least one challenge type with a bind address
if len(c.HTTPOneAddrs) < 1 &&
len(c.HTTPSOneAddrs) < 1 &&
len(c.DNSOneAddrs) < 1 &&
len(c.TLSALPNOneAddrs) < 1 {
return fmt.Errorf(
"config must specify at least one HTTPOneAddrs entry, one HTTPSOneAddr " +
"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 {
c.Log = log.New(os.Stdout, "challtestsrv - ", log.LstdFlags)
}
return nil
}
// New constructs and returns a new ChallSrv instance with the given Config.
func New(config Config) (*ChallSrv, error) {
// Validate the provided configuration
if err := config.validate(); err != nil {
return nil, err
}
challSrv := &ChallSrv{
log: config.Log,
requestHistory: make(map[string]map[RequestEventType][]RequestEvent),
httpOne: make(map[string]string),
dnsOne: make(map[string][]string),
tlsALPNOne: make(map[string]string),
redirects: make(map[string]string),
dnsMocks: mockDNSData{
defaultIPv4: defaultIPv4,
defaultIPv6: defaultIPv6,
aRecords: make(map[string][]string),
aaaaRecords: make(map[string][]string),
caaRecords: make(map[string][]MockCAAPolicy),
cnameRecords: make(map[string]string),
servFailRecords: make(map[string]bool),
},
}
// If there are HTTP-01 addresses configured, create HTTP-01 servers with
// HTTPS disabled.
for _, address := range config.HTTPOneAddrs {
challSrv.log.Printf("Creating HTTP-01 challenge server on %s\n", address)
challSrv.servers = append(challSrv.servers, httpOneServer(address, challSrv, false))
}
// If there are HTTPS HTTP-01 addresses configured, create HTTP-01 servers
// with HTTPS enabled.
for _, address := range config.HTTPSOneAddrs {
challSrv.log.Printf("Creating HTTPS HTTP-01 challenge server on %s\n", address)
challSrv.servers = append(challSrv.servers, httpOneServer(address, challSrv, true))
}
// If there are DNS-01 addresses configured, create DNS-01 servers
for _, address := range config.DNSOneAddrs {
challSrv.log.Printf("Creating TCP and UDP DNS-01 challenge server on %s\n", address)
challSrv.servers = append(challSrv.servers,
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)
challSrv.servers = append(challSrv.servers, tlsALPNOneServer(address, challSrv))
}
return challSrv, nil
}
// Run starts each of the ChallSrv's challengeServers.
func (s *ChallSrv) Run() {
s.log.Printf("Starting challenge servers")
// Start each server in their own dedicated Go routine
for _, srv := range s.servers {
go func(srv challengeServer) {
err := srv.ListenAndServe()
if err != nil && !strings.Contains(err.Error(), "Server closed") {
s.log.Print(err)
}
}(srv)
}
}
// Shutdown gracefully stops each of the ChallSrv's challengeServers.
func (s *ChallSrv) Shutdown() {
for _, srv := range s.servers {
if err := srv.Shutdown(); err != nil {
s.log.Printf("err in Shutdown(): %s\n", err.Error())
}
}
}

264
challtestsrv/dns.go Normal file
View File

@ -0,0 +1,264 @@
package challtestsrv
import (
"fmt"
"io"
"net"
"net/http"
"github.com/miekg/dns"
)
// mockSOA returns a mock DNS SOA record with fake data.
func mockSOA() *dns.SOA {
return &dns.SOA{
Hdr: dns.RR_Header{
Name: "challtestsrv.invalid.",
Rrtype: dns.TypeSOA,
Class: dns.ClassINET,
},
Ns: "ns.challtestsrv.invalid.",
Mbox: "master.challtestsrv.invalid.",
Serial: 1,
Refresh: 1,
Retry: 1,
Expire: 1,
Minttl: 1,
}
}
// dnsAnswerFunc is a function that accepts a DNS question and returns one or
// more RRs for the response.
type dnsAnswerFunc func(question dns.Question) []dns.RR
// cnameAnswers is a dnsAnswerFunc that creates CNAME RR's for the given question
// using the ChallSrv's dns mock data. If there is no mock CNAME data for the
// given hostname in the question no RR's will be returned.
func (s *ChallSrv) cnameAnswers(q dns.Question) []dns.RR {
var records []dns.RR
if value := s.GetDNSCNAMERecord(q.Name); value != "" {
record := &dns.CNAME{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: value,
}
records = append(records, record)
}
return records
}
// txtAnswers is a dnsAnswerFunc that creates TXT RR's for the given question
// using the ChallSrv's dns mock data. If there is no mock TXT data for the
// given hostname in the question no RR's will be returned.
func (s *ChallSrv) txtAnswers(q dns.Question) []dns.RR {
var records []dns.RR
values := s.GetDNSOneChallenge(q.Name)
for _, resp := range values {
record := &dns.TXT{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
},
Txt: []string{resp},
}
records = append(records, record)
}
return records
}
// aAnswers is a dnsAnswerFunc that creates A RR's for the given question using
// the ChallSrv's dns mock data. If there is not a mock ipv4 A response added
// for the given hostname in the question the default IPv4 address will be used
// for the response.
func (s *ChallSrv) aAnswers(q dns.Question) []dns.RR {
var records []dns.RR
// Don't answer any questions for IP addresses with a fakeDNS response.
// These queries are invalid!
if ip := net.ParseIP(q.Name); ip != nil {
return records
}
values := s.GetDNSARecord(q.Name)
if defaultIPv4 := s.GetDefaultDNSIPv4(); len(values) == 0 && defaultIPv4 != "" {
values = []string{defaultIPv4}
}
for _, resp := range values {
ipAddr := net.ParseIP(resp)
if ipAddr == nil || ipAddr.To4() == nil {
// If the mock data isn't a valid IPv4 address, don't use it.
continue
}
record := &dns.A{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: ipAddr,
}
records = append(records, record)
}
return records
}
// aaaaAnswers is a dnsAnswerFunc that creates AAAA RR's for the given question
// using the ChallSrv's dns mock data. If there is not a mock IPv6 AAAA response
// added for the given hostname in the question the default IPv6 address will be
// used for the response.
func (s *ChallSrv) aaaaAnswers(q dns.Question) []dns.RR {
var records []dns.RR
values := s.GetDNSAAAARecord(q.Name)
if defaultIPv6 := s.GetDefaultDNSIPv6(); len(values) == 0 && defaultIPv6 != "" {
values = []string{defaultIPv6}
}
for _, resp := range values {
ipAddr := net.ParseIP(resp)
if ipAddr == nil || ipAddr.To4() != nil {
// If the mock data isn't a valid IPv6 address, don't use it.
continue
}
record := &dns.AAAA{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
},
AAAA: ipAddr,
}
records = append(records, record)
}
return records
}
// caaAnswers is a dnsAnswerFunc that creates CAA RR's for the given question
// using the ChallSrv's dns mock data. If there is not a mock CAA response
// added for the given hostname in the question no RRs will be returned.
func (s *ChallSrv) caaAnswers(q dns.Question) []dns.RR {
var records []dns.RR
values := s.GetDNSCAARecord(q.Name)
for _, resp := range values {
record := &dns.CAA{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeCAA,
Class: dns.ClassINET,
},
Tag: resp.Tag,
Value: resp.Value,
}
records = append(records, record)
}
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
// For each question, add answers based on the type of question
for _, q := range r.Question {
s.AddRequestEvent(DNSRequestEvent{
Question: q,
})
// If there is a ServFail mock set then ignore the question and set the
// SERVFAIL rcode and continue.
if s.GetDNSServFailRecord(q.Name) {
m.SetRcode(r, dns.RcodeServerFailure)
continue
}
// If a CNAME exists for the question include the CNAME record and modify
// the question to instead lookup based on that CNAME's target
if cname := s.GetDNSCNAMERecord(q.Name); cname != "" {
cnameRecords := s.cnameAnswers(q)
m.Answer = append(m.Answer, cnameRecords...)
q = dns.Question{Name: cname, Qtype: q.Qtype}
}
var answerFunc dnsAnswerFunc
switch q.Qtype {
case dns.TypeCNAME:
answerFunc = s.cnameAnswers
case dns.TypeTXT:
answerFunc = s.txtAnswers
case dns.TypeA:
answerFunc = s.aAnswers
case dns.TypeAAAA:
answerFunc = s.aaaaAnswers
case dns.TypeCAA:
answerFunc = s.caaAnswers
default:
m.SetRcode(r, dns.RcodeNotImplemented)
}
if answerFunc == nil {
break
}
if records := answerFunc(q); len(records) > 0 {
m.Answer = append(m.Answer, records...)
}
}
m.Ns = append(m.Ns, mockSOA())
_ = w.WriteMsg(m)
}

86
challtestsrv/dnsone.go Normal file
View File

@ -0,0 +1,86 @@
package challtestsrv
import (
"context"
"net/http"
"time"
"github.com/miekg/dns"
)
// AddDNSOneChallenge adds a TXT record for the given host with the given
// content.
func (s *ChallSrv) AddDNSOneChallenge(host, content string) {
s.challMu.Lock()
defer s.challMu.Unlock()
s.dnsOne[host] = append(s.dnsOne[host], content)
}
// DeleteDNSOneChallenge deletes a TXT record for the given host.
func (s *ChallSrv) DeleteDNSOneChallenge(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
delete(s.dnsOne, host)
}
// GetDNSOneChallenge returns a slice of TXT record values for the given host.
// If the host does not exist in the challenge response data then nil is
// returned.
func (s *ChallSrv) GetDNSOneChallenge(host string) []string {
s.challMu.RLock()
defer s.challMu.RUnlock()
return s.dnsOne[host]
}
type dnsHandler func(dns.ResponseWriter, *dns.Msg)
// dnsOneServer creates an ACME DNS-01 challenge server. The provided dns
// handler will be registered with the `miekg/dns` package to
// handle DNS requests. Because the DNS server runs both a UDP and a TCP
// listener two `server` objects are returned.
func dnsOneServer(address string, handler dnsHandler) []challengeServer {
// Register the dnsHandler
dns.HandleFunc(".", handler)
// Create a UDP DNS server
udpServer := &dns.Server{
Addr: address,
Net: "udp",
ReadTimeout: time.Second,
WriteTimeout: time.Second,
}
// Create a TCP DNS server
tcpServer := &dns.Server{
Addr: address,
Net: "tcp",
ReadTimeout: time.Second,
WriteTimeout: time.Second,
}
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
}

133
challtestsrv/event.go Normal file
View File

@ -0,0 +1,133 @@
package challtestsrv
import (
"net"
"strings"
"github.com/miekg/dns"
)
// RequestEventType indicates what type of event occurred.
type RequestEventType int
const (
// HTTP requests
HTTPRequestEventType RequestEventType = iota
// DNS requests
DNSRequestEventType
// TLS-ALPN-01 requests
TLSALPNRequestEventType
)
// A RequestEvent is anything that can identify its RequestEventType and a key
// for storing the request event in the history.
type RequestEvent interface {
Type() RequestEventType
Key() string
}
// HTTPRequestEvent corresponds to an HTTP request received by a httpOneServer.
// It implements the RequestEvent interface.
type HTTPRequestEvent struct {
// The full request URL (path and query arguments)
URL string
// The Host header from the request
Host string
// Whether the request was received over HTTPS or HTTP
HTTPS bool
// The ServerName from the ClientHello. May be empty if there was no SNI or if
// the request was not HTTPS
ServerName string
}
// HTTPRequestEvents always have type HTTPRequestEventType
func (e HTTPRequestEvent) Type() RequestEventType {
return HTTPRequestEventType
}
// HTTPRequestEvents use the HTTP Host as the storage key. Any explicit port
// will be removed.
func (e HTTPRequestEvent) Key() string {
if h, _, err := net.SplitHostPort(e.Host); err == nil {
return h
}
return e.Host
}
// DNSRequestEvent corresponds to a DNS request received by a dnsOneServer. It
// implements the RequestEvent interface.
type DNSRequestEvent struct {
// The DNS question received.
Question dns.Question
}
// DNSRequestEvents always have type DNSRequestEventType
func (e DNSRequestEvent) Type() RequestEventType {
return DNSRequestEventType
}
// DNSRequestEvents use the Question Name as the storage key. Any trailing `.`
// in the question name is removed.
func (e DNSRequestEvent) Key() string {
key := e.Question.Name
if strings.HasSuffix(key, ".") {
key = strings.TrimSuffix(key, ".")
}
return key
}
// TLSALPNRequestEvent corresponds to a TLS request received by
// a tlsALPNOneServer. It implements the RequestEvent interface.
type TLSALPNRequestEvent struct {
// ServerName from the TLS Client Hello.
ServerName string
// SupportedProtos from the TLS Client Hello.
SupportedProtos []string
}
// TLSALPNRequestEvents always have type TLSALPNRequestEventType
func (e TLSALPNRequestEvent) Type() RequestEventType {
return TLSALPNRequestEventType
}
// TLSALPNRequestEvents use the SNI value as the storage key
func (e TLSALPNRequestEvent) Key() string {
return e.ServerName
}
// AddRequestEvent adds a RequestEvent to the server's request history. It is
// appeneded to a list of RequestEvents indexed by the event's Type().
func (s *ChallSrv) AddRequestEvent(event RequestEvent) {
s.challMu.Lock()
defer s.challMu.Unlock()
typ := event.Type()
host := event.Key()
if s.requestHistory[host] == nil {
s.requestHistory[host] = make(map[RequestEventType][]RequestEvent)
}
s.requestHistory[host][typ] = append(s.requestHistory[host][typ], event)
}
// RequestHistory returns the server's request history for the given hostname
// and event type.
func (s *ChallSrv) RequestHistory(hostname string, typ RequestEventType) []RequestEvent {
s.challMu.RLock()
defer s.challMu.RUnlock()
if hostEvents, ok := s.requestHistory[hostname]; ok {
return hostEvents[typ]
}
return []RequestEvent{}
}
// ClearRequestHistory clears the server's request history for the given
// hostname and event type.
func (s *ChallSrv) ClearRequestHistory(hostname string, typ RequestEventType) {
s.challMu.Lock()
defer s.challMu.Unlock()
if hostEvents, ok := s.requestHistory[hostname]; ok {
hostEvents[typ] = []RequestEvent{}
}
}

203
challtestsrv/httpone.go Normal file
View File

@ -0,0 +1,203 @@
package challtestsrv
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math"
"math/big"
"net/http"
"strings"
"time"
)
// wellKnownPath is the IANA registered ACME HTTP-01 challenge path. See
// https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-9.2
const wellKnownPath = "/.well-known/acme-challenge/"
// cert is a self-signed certificate issued at startup for the HTTPS HTTP-01
// server.
var cert = selfSignedCert()
// selfSignedCert issues a self-signed CA certificate to use as the leaf
// certificate for an HTTPS server serving HTTP-01 challenges. This certificate
// will not be trusted by normal TLS clients but HTTP-01 redirects to HTTPS will
// ignore certificate validation.
func selfSignedCert() tls.Certificate {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(fmt.Sprintf("Unable to generate HTTPS ECDSA key: %v", err))
}
serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
if err != nil {
panic(fmt.Sprintf("Unable to generate HTTPS cert serial number: %v", err))
}
template := &x509.Certificate{
Subject: pkix.Name{
CommonName: "challenge test server",
},
SerialNumber: serial,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
if err != nil {
panic(fmt.Sprintf("Unable to issue HTTPS cert: %v", err))
}
return tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: key,
}
}
// AddHTTPOneChallenge adds a new HTTP-01 challenge for the given token and
// content.
func (s *ChallSrv) AddHTTPOneChallenge(token, content string) {
s.challMu.Lock()
defer s.challMu.Unlock()
s.httpOne[token] = content
}
// DeleteHTTPOneChallenge deletes a given HTTP-01 challenge token.
func (s *ChallSrv) DeleteHTTPOneChallenge(token string) {
s.challMu.Lock()
defer s.challMu.Unlock()
delete(s.httpOne, token)
}
// GetHTTPOneChallenge returns the HTTP-01 challenge content for the given token
// (if it exists) and a true bool. If the token does not exist then an empty
// string and a false bool are returned.
func (s *ChallSrv) GetHTTPOneChallenge(token string) (string, bool) {
s.challMu.RLock()
defer s.challMu.RUnlock()
content, present := s.httpOne[token]
return content, present
}
// AddHTTPRedirect adds a redirect for the given path to the given URL.
func (s *ChallSrv) AddHTTPRedirect(path, targetURL string) {
s.challMu.Lock()
defer s.challMu.Unlock()
s.redirects[path] = targetURL
}
// DeleteHTTPRedirect deletes a redirect for the given path.
func (s *ChallSrv) DeleteHTTPRedirect(path string) {
s.challMu.Lock()
defer s.challMu.Unlock()
delete(s.redirects, path)
}
// GetHTTPRedirect returns the redirect target for the given path
// (if it exists) and a true bool. If the path does not have a redirect target
// then an empty string and a false bool are returned.
func (s *ChallSrv) GetHTTPRedirect(path string) (string, bool) {
s.challMu.RLock()
defer s.challMu.RUnlock()
targetURL, present := s.redirects[path]
return targetURL, present
}
// ServeHTTP handles an HTTP request. If the request path has the ACME HTTP-01
// challenge well known prefix as a prefix and the token specified is known,
// then the challenge response contents are returned.
func (s *ChallSrv) ServeHTTP(w http.ResponseWriter, r *http.Request) {
requestPath := r.URL.Path
serverName := ""
if r.TLS != nil {
serverName = r.TLS.ServerName
}
s.AddRequestEvent(HTTPRequestEvent{
URL: r.URL.String(),
Host: r.Host,
HTTPS: r.TLS != nil,
ServerName: serverName,
})
// If the request was not over HTTPS and we have a redirect, serve it.
// Redirects are ignored over HTTPS so we can easily do an HTTP->HTTPS
// redirect for a token path without creating a loop.
if redirectTarget, found := s.GetHTTPRedirect(requestPath); found && r.TLS == nil {
http.Redirect(w, r, redirectTarget, http.StatusFound)
return
}
if strings.HasPrefix(requestPath, wellKnownPath) {
token := requestPath[len(wellKnownPath):]
if auth, found := s.GetHTTPOneChallenge(token); found {
fmt.Fprintf(w, "%s", auth)
}
}
}
// challHTTPServer is a *http.Server that has a Shutdown() func that doesn't
// take a context argument. This lets us treat the HTTP server the same as the
// DNS-01 servers (which use a `dns.Server` that has `Shutdown()` with no
// context arg) by having an http.Server that implements the challengeServer
// interface.
type challHTTPServer struct {
*http.Server
}
// ListenAndServe for a challHTTPServer will call the underlying http.Server's
// ListenAndServeTLS if the server has a non-nil TLSConfig, otherwise it will
// use the underlying http.Server's ListenAndServe(). This allows for
// a challHTTPServer to be both a normal HTTP based HTTP-01 challenge response
// server in one configuration (nil TLSConfig) and an HTTPS based HTTP-01
// challenge response server useful for redirect targets in another
// configuration.
func (c challHTTPServer) ListenAndServe() error {
if c.Server.TLSConfig != nil {
// This will use the certificate and key from TLSConfig.
return c.Server.ListenAndServeTLS("", "")
}
// Otherwise use HTTP
return c.Server.ListenAndServe()
}
func (c challHTTPServer) Shutdown() error {
return c.Server.Shutdown(context.Background())
}
// httpOneServer creates an ACME HTTP-01 challenge server. The
// server's handler will return configured HTTP-01 challenge responses for
// tokens that have been added to the challenge server. If HTTPS is true the
// resulting challengeServer will run a HTTPS server with a self-signed
// certificate useful for HTTP-01 -> HTTPS HTTP-01 redirect responses. If HTTPS
// is false the resulting challengeServer will run an HTTP server.
func httpOneServer(address string, handler http.Handler, https bool) challengeServer {
// If HTTPS is requested build a TLS Config that uses the self-signed
// certificate generated at startup.
var tlsConfig *tls.Config
if https {
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
}
}
// Create an HTTP Server for HTTP-01 challenges
srv := &http.Server{
Addr: address,
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
TLSConfig: tlsConfig,
}
srv.SetKeepAlivesEnabled(false)
return challHTTPServer{srv}
}

174
challtestsrv/mockdns.go Normal file
View File

@ -0,0 +1,174 @@
package challtestsrv
import (
"github.com/miekg/dns"
)
// SetDefaultDNSIPv4 sets the default IPv4 address used for A query responses
// that don't match hosts added with AddDNSARecord. Use "" to disable default
// A query responses.
func (s *ChallSrv) SetDefaultDNSIPv4(addr string) {
s.challMu.Lock()
defer s.challMu.Unlock()
s.dnsMocks.defaultIPv4 = addr
}
// SetDefaultDNSIPv6 sets the default IPv6 address used for AAAA query responses
// that don't match hosts added with AddDNSAAAARecord. Use "" to disable default
// AAAA query responses.
func (s *ChallSrv) SetDefaultDNSIPv6(addr string) {
s.challMu.Lock()
defer s.challMu.Unlock()
s.dnsMocks.defaultIPv6 = addr
}
// GetDefaultDNSIPv4 gets the default IPv4 address used for A query responses
// (in string form), or an empty string if no default is being used.
func (s *ChallSrv) GetDefaultDNSIPv4() string {
s.challMu.RLock()
defer s.challMu.RUnlock()
return s.dnsMocks.defaultIPv4
}
// GetDefaultDNSIPv6 gets the default IPv6 address used for AAAA query responses
// (in string form), or an empty string if no default is being used.
func (s *ChallSrv) GetDefaultDNSIPv6() string {
s.challMu.RLock()
defer s.challMu.RUnlock()
return s.dnsMocks.defaultIPv6
}
// AddDNSCNAMERecord sets a CNAME record that will be used like an alias when
// querying for other DNS records for the given host.
func (s *ChallSrv) AddDNSCNAMERecord(host string, value string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
value = dns.Fqdn(value)
s.dnsMocks.cnameRecords[host] = value
}
// GetDNSCNAMERecord returns a target host if a CNAME is set for the querying
// host and an empty string otherwise.
func (s *ChallSrv) GetDNSCNAMERecord(host string) string {
s.challMu.RLock()
host = dns.Fqdn(host)
defer s.challMu.RUnlock()
return s.dnsMocks.cnameRecords[host]
}
// DeleteDNSCAMERecord deletes any CNAME alias set for the given host.
func (s *ChallSrv) DeleteDNSCNAMERecord(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
delete(s.dnsMocks.cnameRecords, host)
}
// AddDNSARecord adds IPv4 addresses that will be returned when querying for
// A records for the given host.
func (s *ChallSrv) AddDNSARecord(host string, addresses []string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
s.dnsMocks.aRecords[host] = append(s.dnsMocks.aRecords[host], addresses...)
}
// DeleteDNSARecord deletes any IPv4 addresses that will be returned when
// querying for A records for the given host.record for the given host.
func (s *ChallSrv) DeleteDNSARecord(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
delete(s.dnsMocks.aRecords, host)
}
// GetDNSARecord returns a slice of IPv4 addresses (in string form) that will be
// returned when querying for A records for the given host.
func (s *ChallSrv) GetDNSARecord(host string) []string {
s.challMu.RLock()
host = dns.Fqdn(host)
defer s.challMu.RUnlock()
return s.dnsMocks.aRecords[host]
}
// AddDNSAAAARecord adds IPv6 addresses that will be returned when querying for
// AAAA records for the given host.
func (s *ChallSrv) AddDNSAAAARecord(host string, addresses []string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
s.dnsMocks.aaaaRecords[host] = append(s.dnsMocks.aaaaRecords[host], addresses...)
}
// DeleteDNSAAAARecord deletes any IPv6 addresses that will be returned when
// querying for A records for the given host.
func (s *ChallSrv) DeleteDNSAAAARecord(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
delete(s.dnsMocks.aaaaRecords, host)
}
// GetDNSAAAARecord returns a slice of IPv6 addresses (in string form) that will
// be returned when querying for A records for the given host.
func (s *ChallSrv) GetDNSAAAARecord(host string) []string {
s.challMu.RLock()
defer s.challMu.RUnlock()
host = dns.Fqdn(host)
return s.dnsMocks.aaaaRecords[host]
}
// AddDNSCAARecord adds mock CAA records that will be returned when querying
// CAA for the given host.
func (s *ChallSrv) AddDNSCAARecord(host string, policies []MockCAAPolicy) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
s.dnsMocks.caaRecords[host] = append(s.dnsMocks.caaRecords[host], policies...)
}
// DeleteDNSCAARecord deletes any CAA policies that will be returned when
// querying CAA for the given host.
func (s *ChallSrv) DeleteDNSCAARecord(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
delete(s.dnsMocks.caaRecords, host)
}
// GetDNSCAARecord returns a slice of mock CAA policies that will
// be returned when querying CAA for the given host.
func (s *ChallSrv) GetDNSCAARecord(host string) []MockCAAPolicy {
s.challMu.RLock()
defer s.challMu.RUnlock()
host = dns.Fqdn(host)
return s.dnsMocks.caaRecords[host]
}
// AddDNSServFailRecord configures the chall srv to return SERVFAIL responses
// for all queries for the given host.
func (s *ChallSrv) AddDNSServFailRecord(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
s.dnsMocks.servFailRecords[host] = true
}
// DeleteDNSServFailRecord configures the chall srv to no longer return SERVFAIL
// responses for all queries for the given host.
func (s *ChallSrv) DeleteDNSServFailRecord(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
host = dns.Fqdn(host)
delete(s.dnsMocks.servFailRecords, host)
}
// GetDNSServFailRecord returns true when the chall srv has been configured with
// AddDNSServFailRecord to return SERVFAIL for all queries to the given host.
func (s *ChallSrv) GetDNSServFailRecord(host string) bool {
s.challMu.RLock()
defer s.challMu.RUnlock()
host = dns.Fqdn(host)
return s.dnsMocks.servFailRecords[host]
}

126
challtestsrv/tlsalpnone.go Normal file
View File

@ -0,0 +1,126 @@
package challtestsrv
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"fmt"
"math/big"
"net/http"
"time"
)
// ALPN protocol ID for TLS-ALPN-01 challenge
// https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-5.2
const ACMETLS1Protocol = "acme-tls/1"
// IDPeAcmeIdentifier is the identifier defined in
// https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-04#section-5.1
// id-pe OID + 31 (acmeIdentifier)
var IDPeAcmeIdentifier = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31}
// AddTLSALPNChallenge adds a new TLS-ALPN-01 key authorization for the given host
func (s *ChallSrv) AddTLSALPNChallenge(host, content string) {
s.challMu.Lock()
defer s.challMu.Unlock()
s.tlsALPNOne[host] = content
}
// DeleteTLSALPNChallenge deletes the key authorization for a given host
func (s *ChallSrv) DeleteTLSALPNChallenge(host string) {
s.challMu.Lock()
defer s.challMu.Unlock()
delete(s.tlsALPNOne, host)
}
// GetTLSALPNChallenge checks the s.tlsALPNOne map for the given host.
// If it is present it returns the key authorization and true, if not
// it returns an empty string and false.
func (s *ChallSrv) GetTLSALPNChallenge(host string) (string, bool) {
s.challMu.RLock()
defer s.challMu.RUnlock()
content, present := s.tlsALPNOne[host]
return content, present
}
func (s *ChallSrv) ServeChallengeCertFunc(k *ecdsa.PrivateKey) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
s.AddRequestEvent(TLSALPNRequestEvent{
ServerName: hello.ServerName,
SupportedProtos: hello.SupportedProtos,
})
if len(hello.SupportedProtos) != 1 || hello.SupportedProtos[0] != ACMETLS1Protocol {
return nil, fmt.Errorf(
"ALPN failed, ClientHelloInfo.SupportedProtos: %s",
hello.SupportedProtos)
}
ka, found := s.GetTLSALPNChallenge(hello.ServerName)
if !found {
return nil, fmt.Errorf("unknown ClientHelloInfo.ServerName: %s", hello.ServerName)
}
kaHash := sha256.Sum256([]byte(ka))
extValue, err := asn1.Marshal(kaHash[:])
if err != nil {
return nil, fmt.Errorf("failed marshalling hash OCTET STRING: %s", err)
}
certTmpl := x509.Certificate{
SerialNumber: big.NewInt(1729),
DNSNames: []string{hello.ServerName},
ExtraExtensions: []pkix.Extension{
{
Id: IDPeAcmeIdentifier,
Critical: true,
Value: extValue,
},
},
}
certBytes, err := x509.CreateCertificate(rand.Reader, &certTmpl, &certTmpl, k.Public(), k)
if err != nil {
return nil, fmt.Errorf("failed creating challenge certificate: %s", err)
}
return &tls.Certificate{
Certificate: [][]byte{certBytes},
PrivateKey: k,
}, nil
}
}
type challTLSServer struct {
*http.Server
}
func (c challTLSServer) Shutdown() error {
return c.Server.Shutdown(context.Background())
}
func (c challTLSServer) ListenAndServe() error {
// Since we set TLSConfig.GetCertificate, the certfile and keyFile arguments
// are ignored and we leave them blank.
return c.Server.ListenAndServeTLS("", "")
}
func tlsALPNOneServer(address string, challSrv *ChallSrv) challengeServer {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
srv := &http.Server{
Addr: address,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
TLSConfig: &tls.Config{
NextProtos: []string{ACMETLS1Protocol},
GetCertificate: challSrv.ServeChallengeCertFunc(key),
},
}
srv.SetKeepAlivesEnabled(false)
return challTLSServer{srv}
}