@marcel91200 / caddy Mirror · read-only / commits / e1aa862e
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 12, 17:00 UTC

acmeserver: support specifying the allowed challenge types (#5794)

* acmeserver: support specifying the allowed challenge types * add caddyfile adapt tests * introduce basic acme_server test * skip acme test on unsuitable environments * skip integration tests of ACME * documentation * add negative-scenario test for mismatched allowed challenges * a bit more docs * fix tests for ACME challenges * appease the linter * skip ACME tests on s390x * enable ACME challenge tests on all machines * Apply suggestions from code review Co-authored-by: Matt Holt <mholt@users.noreply.github.com> --------- Co-authored-by: Matt Holt <mholt@users.noreply.github.com>

Mohammed Al Sahaf committed Feb 8, 2024 at 11:42 UTC e1aa862e6a951d4dc9c0e89b6d42f7fea1c5aba7
7 files changed +549 -4
caddytest/integration/acme_test.go new
+261
@@ -0,0 +1,261 @@
1 +package integration
2 +
3 +import (
4 + "context"
5 + "crypto/ecdsa"
6 + "crypto/elliptic"
7 + "crypto/rand"
8 + "crypto/tls"
9 + "crypto/x509"
10 + "fmt"
11 + "net"
12 + "net/http"
13 + "os"
14 + "path/filepath"
15 + "strings"
16 + "testing"
17 +
18 + "github.com/caddyserver/caddy/v2"
19 + "github.com/caddyserver/caddy/v2/caddytest"
20 + "github.com/mholt/acmez"
21 + "github.com/mholt/acmez/acme"
22 + smallstepacme "github.com/smallstep/certificates/acme"
23 + "go.uber.org/zap"
24 +)
25 +
26 +const acmeChallengePort = 8080
27 +
28 +// Test the basic functionality of Caddy's ACME server
29 +func TestACMEServerWithDefaults(t *testing.T) {
30 + ctx := context.Background()
31 + logger, err := zap.NewDevelopment()
32 + if err != nil {
33 + t.Error(err)
34 + return
35 + }
36 +
37 + tester := caddytest.NewTester(t)
38 + tester.InitServer(`
39 + {
40 + skip_install_trust
41 + admin localhost:2999
42 + http_port 9080
43 + https_port 9443
44 + local_certs
45 + }
46 + acme.localhost {
47 + acme_server
48 + }
49 + `, "caddyfile")
50 +
51 + datadir := caddy.AppDataDir()
52 + rootCertsGlob := filepath.Join(datadir, "pki", "authorities", "local", "*.crt")
53 + matches, err := filepath.Glob(rootCertsGlob)
54 + if err != nil {
55 + t.Errorf("could not find root certs: %s", err)
56 + return
57 + }
58 + certPool := x509.NewCertPool()
59 + for _, m := range matches {
60 + certPem, err := os.ReadFile(m)
61 + if err != nil {
62 + t.Errorf("reading cert file '%s' error: %s", m, err)
63 + return
64 + }
65 + if !certPool.AppendCertsFromPEM(certPem) {
66 + t.Errorf("failed to append the cert: %s", m)
67 + return
68 + }
69 + }
70 +
71 + client := acmez.Client{
72 + Client: &acme.Client{
73 + Directory: "https://acme.localhost:9443/acme/local/directory",
74 + HTTPClient: &http.Client{
75 + Transport: &http.Transport{
76 + TLSClientConfig: &tls.Config{
77 + RootCAs: certPool,
78 + },
79 + },
80 + },
81 + Logger: logger,
82 + },
83 + ChallengeSolvers: map[string]acmez.Solver{
84 + acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
85 + },
86 + }
87 +
88 + accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
89 + if err != nil {
90 + t.Errorf("generating account key: %v", err)
91 + }
92 + account := acme.Account{
93 + Contact: []string{"mailto:you@example.com"},
94 + TermsOfServiceAgreed: true,
95 + PrivateKey: accountPrivateKey,
96 + }
97 + account, err = client.NewAccount(ctx, account)
98 + if err != nil {
99 + t.Errorf("new account: %v", err)
100 + return
101 + }
102 +
103 + // Every certificate needs a key.
104 + certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
105 + if err != nil {
106 + t.Errorf("generating certificate key: %v", err)
107 + return
108 + }
109 +
110 + certs, err := client.ObtainCertificate(ctx, account, certPrivateKey, []string{"localhost"})
111 + if err != nil {
112 + t.Errorf("obtaining certificate: %v", err)
113 + return
114 + }
115 +
116 + // ACME servers should usually give you the entire certificate chain
117 + // in PEM format, and sometimes even alternate chains! It's up to you
118 + // which one(s) to store and use, but whatever you do, be sure to
119 + // store the certificate and key somewhere safe and secure, i.e. don't
120 + // lose them!
121 + for _, cert := range certs {
122 + t.Logf("Certificate %q:\n%s\n\n", cert.URL, cert.ChainPEM)
123 + }
124 +}
125 +
126 +func TestACMEServerWithMismatchedChallenges(t *testing.T) {
127 + ctx := context.Background()
128 + logger := caddy.Log().Named("acmez")
129 +
130 + tester := caddytest.NewTester(t)
131 + tester.InitServer(`
132 + {
133 + skip_install_trust
134 + admin localhost:2999
135 + http_port 9080
136 + https_port 9443
137 + local_certs
138 + }
139 + acme.localhost {
140 + acme_server {
141 + challenges tls-alpn-01
142 + }
143 + }
144 + `, "caddyfile")
145 +
146 + datadir := caddy.AppDataDir()
147 + rootCertsGlob := filepath.Join(datadir, "pki", "authorities", "local", "*.crt")
148 + matches, err := filepath.Glob(rootCertsGlob)
149 + if err != nil {
150 + t.Errorf("could not find root certs: %s", err)
151 + return
152 + }
153 + certPool := x509.NewCertPool()
154 + for _, m := range matches {
155 + certPem, err := os.ReadFile(m)
156 + if err != nil {
157 + t.Errorf("reading cert file '%s' error: %s", m, err)
158 + return
159 + }
160 + if !certPool.AppendCertsFromPEM(certPem) {
161 + t.Errorf("failed to append the cert: %s", m)
162 + return
163 + }
164 + }
165 +
166 + client := acmez.Client{
167 + Client: &acme.Client{
168 + Directory: "https://acme.localhost:9443/acme/local/directory",
169 + HTTPClient: &http.Client{
170 + Transport: &http.Transport{
171 + TLSClientConfig: &tls.Config{
172 + RootCAs: certPool,
173 + },
174 + },
175 + },
176 + Logger: logger,
177 + },
178 + ChallengeSolvers: map[string]acmez.Solver{
179 + acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
180 + },
181 + }
182 +
183 + accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
184 + if err != nil {
185 + t.Errorf("generating account key: %v", err)
186 + }
187 + account := acme.Account{
188 + Contact: []string{"mailto:you@example.com"},
189 + TermsOfServiceAgreed: true,
190 + PrivateKey: accountPrivateKey,
191 + }
192 + account, err = client.NewAccount(ctx, account)
193 + if err != nil {
194 + t.Errorf("new account: %v", err)
195 + return
196 + }
197 +
198 + // Every certificate needs a key.
199 + certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
200 + if err != nil {
201 + t.Errorf("generating certificate key: %v", err)
202 + return
203 + }
204 +
205 + certs, err := client.ObtainCertificate(ctx, account, certPrivateKey, []string{"localhost"})
206 + if len(certs) > 0 {
207 + t.Errorf("expected '0' certificates, but received '%d'", len(certs))
208 + }
209 + if err == nil {
210 + t.Error("expected errors, but received none")
211 + }
212 + const expectedErrMsg = "no solvers available for remaining challenges (configured=[http-01] offered=[tls-alpn-01] remaining=[tls-alpn-01])"
213 + if !strings.Contains(err.Error(), expectedErrMsg) {
214 + t.Errorf(`received error message does not match expectation: expected="%s" received="%s"`, expectedErrMsg, err.Error())
215 + }
216 +}
217 +
218 +// naiveHTTPSolver is a no-op acmez.Solver for example purposes only.
219 +type naiveHTTPSolver struct {
220 + srv *http.Server
221 + logger *zap.Logger
222 +}
223 +
224 +func (s *naiveHTTPSolver) Present(ctx context.Context, challenge acme.Challenge) error {
225 + smallstepacme.InsecurePortHTTP01 = acmeChallengePort
226 + s.srv = &http.Server{
227 + Addr: fmt.Sprintf("localhost:%d", acmeChallengePort),
228 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229 + host, _, err := net.SplitHostPort(r.Host)
230 + if err != nil {
231 + host = r.Host
232 + }
233 + if r.Method == "GET" && r.URL.Path == challenge.HTTP01ResourcePath() && strings.EqualFold(host, challenge.Identifier.Value) {
234 + w.Header().Add("Content-Type", "text/plain")
235 + w.Write([]byte(challenge.KeyAuthorization))
236 + r.Close = true
237 + s.logger.Info("served key authentication",
238 + zap.String("identifier", challenge.Identifier.Value),
239 + zap.String("challenge", "http-01"),
240 + zap.String("remote", r.RemoteAddr),
241 + )
242 + }
243 + }),
244 + }
245 + l, err := net.Listen("tcp", fmt.Sprintf(":%d", acmeChallengePort))
246 + if err != nil {
247 + return err
248 + }
249 + s.logger.Info("present challenge", zap.Any("challenge", challenge))
250 + go s.srv.Serve(l)
251 + return nil
252 +}
253 +
254 +func (s naiveHTTPSolver) CleanUp(ctx context.Context, challenge acme.Challenge) error {
255 + smallstepacme.InsecurePortHTTP01 = 0
256 + s.logger.Info("cleanup", zap.Any("challenge", challenge))
257 + if s.srv != nil {
258 + s.srv.Close()
259 + }
260 + return nil
261 +}
caddytest/integration/caddyfile_adapt/acme_server_custom_challenges.txt new
+65
@@ -0,0 +1,65 @@
1 +{
2 + pki {
3 + ca custom-ca {
4 + name "Custom CA"
5 + }
6 + }
7 +}
8 +
9 +acme.example.com {
10 + acme_server {
11 + ca custom-ca
12 + challenges dns-01
13 + }
14 +}
15 +----------
16 +{
17 + "apps": {
18 + "http": {
19 + "servers": {
20 + "srv0": {
21 + "listen": [
22 + ":443"
23 + ],
24 + "routes": [
25 + {
26 + "match": [
27 + {
28 + "host": [
29 + "acme.example.com"
30 + ]
31 + }
32 + ],
33 + "handle": [
34 + {
35 + "handler": "subroute",
36 + "routes": [
37 + {
38 + "handle": [
39 + {
40 + "ca": "custom-ca",
41 + "challenges": [
42 + "dns-01"
43 + ],
44 + "handler": "acme_server"
45 + }
46 + ]
47 + }
48 + ]
49 + }
50 + ],
51 + "terminal": true
52 + }
53 + ]
54 + }
55 + }
56 + },
57 + "pki": {
58 + "certificate_authorities": {
59 + "custom-ca": {
60 + "name": "Custom CA"
61 + }
62 + }
63 + }
64 + }
65 +}
caddytest/integration/caddyfile_adapt/acme_server_default_challenges.txt new
+62
@@ -0,0 +1,62 @@
1 +{
2 + pki {
3 + ca custom-ca {
4 + name "Custom CA"
5 + }
6 + }
7 +}
8 +
9 +acme.example.com {
10 + acme_server {
11 + ca custom-ca
12 + challenges
13 + }
14 +}
15 +----------
16 +{
17 + "apps": {
18 + "http": {
19 + "servers": {
20 + "srv0": {
21 + "listen": [
22 + ":443"
23 + ],
24 + "routes": [
25 + {
26 + "match": [
27 + {
28 + "host": [
29 + "acme.example.com"
30 + ]
31 + }
32 + ],
33 + "handle": [
34 + {
35 + "handler": "subroute",
36 + "routes": [
37 + {
38 + "handle": [
39 + {
40 + "ca": "custom-ca",
41 + "handler": "acme_server"
42 + }
43 + ]
44 + }
45 + ]
46 + }
47 + ],
48 + "terminal": true
49 + }
50 + ]
51 + }
52 + }
53 + },
54 + "pki": {
55 + "certificate_authorities": {
56 + "custom-ca": {
57 + "name": "Custom CA"
58 + }
59 + }
60 + }
61 + }
62 +}
caddytest/integration/caddyfile_adapt/acme_server_multi_custom_challenges.txt new
+66
@@ -0,0 +1,66 @@
1 +{
2 + pki {
3 + ca custom-ca {
4 + name "Custom CA"
5 + }
6 + }
7 +}
8 +
9 +acme.example.com {
10 + acme_server {
11 + ca custom-ca
12 + challenges dns-01 http-01
13 + }
14 +}
15 +----------
16 +{
17 + "apps": {
18 + "http": {
19 + "servers": {
20 + "srv0": {
21 + "listen": [
22 + ":443"
23 + ],
24 + "routes": [
25 + {
26 + "match": [
27 + {
28 + "host": [
29 + "acme.example.com"
30 + ]
31 + }
32 + ],
33 + "handle": [
34 + {
35 + "handler": "subroute",
36 + "routes": [
37 + {
38 + "handle": [
39 + {
40 + "ca": "custom-ca",
41 + "challenges": [
42 + "dns-01",
43 + "http-01"
44 + ],
45 + "handler": "acme_server"
46 + }
47 + ]
48 + }
49 + ]
50 + }
51 + ],
52 + "terminal": true
53 + }
54 + ]
55 + }
56 + }
57 + },
58 + "pki": {
59 + "certificate_authorities": {
60 + "custom-ca": {
61 + "name": "Custom CA"
62 + }
63 + }
64 + }
65 + }
66 +}
modules/caddypki/acmeserver/acmeserver.go
+13 -2
@@ -91,6 +91,11 @@ type Handler struct {
91 // than 1 resolver address, one is chosen at random.
92 Resolvers []string `json:"resolvers,omitempty"`
93
94 + // Specify the set of enabled ACME challenges. An empty or absent value
95 + // means all challenges are enabled. Accepted values are:
96 + // "http-01", "dns-01", "tls-alpn-01"
97 + Challenges ACMEChallenges `json:"challenges,omitempty" `
98 +
99 logger *zap.Logger
100 resolvers []caddy.NetworkAddress
101 ctx caddy.Context
@@ -125,6 +130,11 @@ func (ash *Handler) Provision(ctx caddy.Context) error {
130 if ash.Lifetime == 0 {
131 ash.Lifetime = caddy.Duration(12 * time.Hour)
132 }
133 + if len(ash.Challenges) > 0 {
134 + if err := ash.Challenges.validate(); err != nil {
135 + return err
136 + }
137 + }
138
139 // get a reference to the configured CA
140 appModule, err := ctx.App("pki")
@@ -153,8 +163,9 @@ func (ash *Handler) Provision(ctx caddy.Context) error {
163 AuthConfig: &authority.AuthConfig{
164 Provisioners: provisioner.List{
165 &provisioner.ACME{
156 - Name: ash.CA,
157 - Type: provisioner.TypeACME.String(),
166 + Name: ash.CA,
167 + Challenges: ash.Challenges.toSmallstepType(),
168 + Type: provisioner.TypeACME.String(),
169 Claims: &provisioner.Claims{
170 MinTLSDur: &provisioner.Duration{Duration: 5 * time.Minute},
171 MaxTLSDur: &provisioner.Duration{Duration: 24 * time.Hour * 365},
modules/caddypki/acmeserver/caddyfile.go
+5 -2
@@ -32,6 +32,7 @@ func init() {
32 // ca <id>
33 // lifetime <duration>
34 // resolvers <addresses...>
35 +// challenges <challenges...>
36 // }
37 func parseACMEServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) {
38 h.Next() // consume directive name
@@ -73,14 +74,16 @@ func parseACMEServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error
74 if d := time.Duration(ca.IntermediateLifetime); d > 0 && dur > d {
75 return nil, h.Errf("certificate lifetime (%s) exceeds intermediate certificate lifetime (%s)", dur, d)
76 }
76 -
77 acmeServer.Lifetime = caddy.Duration(dur)
78 -
78 case "resolvers":
79 acmeServer.Resolvers = h.RemainingArgs()
80 if len(acmeServer.Resolvers) == 0 {
81 return nil, h.Errf("must specify at least one resolver address")
82 }
83 + case "challenges":
84 + acmeServer.Challenges = append(acmeServer.Challenges, stringToChallenges(h.RemainingArgs())...)
85 + default:
86 + return nil, h.Errf("unrecognized ACME server directive: %s", h.Val())
87 }
88 }
89
modules/caddypki/acmeserver/challenges.go new
+77
@@ -0,0 +1,77 @@
1 +package acmeserver
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "strings"
7 +
8 + "github.com/smallstep/certificates/authority/provisioner"
9 +)
10 +
11 +// ACMEChallenge is an opaque string that represents supported ACME challenges.
12 +type ACMEChallenge string
13 +
14 +const (
15 + HTTP_01 ACMEChallenge = "http-01"
16 + DNS_01 ACMEChallenge = "dns-01"
17 + TLS_ALPN_01 ACMEChallenge = "tls-alpn-01"
18 +)
19 +
20 +// validate checks if the given challenge is supported.
21 +func (c ACMEChallenge) validate() error {
22 + switch c {
23 + case HTTP_01, DNS_01, TLS_ALPN_01:
24 + return nil
25 + default:
26 + return fmt.Errorf("acme challenge %q is not supported", c)
27 + }
28 +}
29 +
30 +// The unmarshaller first marshals the value into a string. Then it
31 +// trims any space around it and lowercase it for normaliztion. The
32 +// method does not and should not validate the value within accepted enums.
33 +func (c *ACMEChallenge) UnmarshalJSON(b []byte) error {
34 + var s string
35 + if err := json.Unmarshal(b, &s); err != nil {
36 + return err
37 + }
38 + *c = ACMEChallenge(strings.ToLower(strings.TrimSpace(s)))
39 + return nil
40 +}
41 +
42 +// String returns a string representation of the challenge.
43 +func (c ACMEChallenge) String() string {
44 + return strings.ToLower(string(c))
45 +}
46 +
47 +// ACMEChallenges is a list of ACME challenges.
48 +type ACMEChallenges []ACMEChallenge
49 +
50 +// validate checks if the given challenges are supported.
51 +func (c ACMEChallenges) validate() error {
52 + for _, ch := range c {
53 + if err := ch.validate(); err != nil {
54 + return err
55 + }
56 + }
57 + return nil
58 +}
59 +
60 +func (c ACMEChallenges) toSmallstepType() []provisioner.ACMEChallenge {
61 + if len(c) == 0 {
62 + return nil
63 + }
64 + ac := make([]provisioner.ACMEChallenge, len(c))
65 + for i, ch := range c {
66 + ac[i] = provisioner.ACMEChallenge(ch)
67 + }
68 + return ac
69 +}
70 +
71 +func stringToChallenges(chs []string) ACMEChallenges {
72 + challenges := make(ACMEChallenges, len(chs))
73 + for i, ch := range chs {
74 + challenges[i] = ACMEChallenge(ch)
75 + }
76 + return challenges
77 +}