Skip to content

Commit f6e7802

Browse files
committed
Implement endpoints
1 parent 9c08d6c commit f6e7802

File tree

5 files changed

+183
-3
lines changed

5 files changed

+183
-3
lines changed

client_registry.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,37 @@ package caddydns01proxy
22

33
import (
44
"fmt"
5+
"net/http"
6+
"strings"
57

68
"github.com/caddyserver/caddy/v2"
79
"github.com/liujed/goutil/maps"
10+
"github.com/liujed/goutil/optionals"
11+
"github.com/smallstep/certificates/policy"
812
)
913

14+
type DenyReason string
15+
16+
const (
17+
// Indicates that authorization failed because the user's ID was not found in
18+
// the client registry.
19+
DenyUnknownUser DenyReason = "unknown user"
20+
21+
// Indicates that authorization failed because the user is not authorized to
22+
// answer challenges for the requested domain.
23+
DenyDomainNotAllowed DenyReason = "requested domain denied by policy"
24+
25+
// Indicates that authorization failed because the user requested an invalid
26+
// domain.
27+
DenyInvalidDomain DenyReason = "requested domain not valid"
28+
29+
// Indicates that an error occurred during authorization.
30+
DenyError DenyReason = "an error occurred"
31+
)
32+
33+
// DNS names for answering DNS-01 challenges are expected to have this prefix.
34+
const challengeDomainPrefix = "_acme-challenge."
35+
1036
// A registry of known users and their corresponding policy configuration.
1137
type ClientRegistry struct {
1238
// Maps each client's user ID to its policy configuration.
@@ -45,3 +71,54 @@ func (c *ClientRegistry) Provision(
4571

4672
return nil
4773
}
74+
75+
// Determines whether the current authenticated user is allowed to answer a
76+
// DNS-01 challenge at the given challenge domain. Returns None on success.
77+
// Otherwise, returns the reason for denial.
78+
func (r *ClientRegistry) AuthorizeUserChallengeDomain(
79+
req *http.Request,
80+
challengeDomain string,
81+
) (optionals.Optional[DenyReason], error) {
82+
// Get the authenticated user ID from the context.
83+
repl := req.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
84+
userID, exists := repl.GetString("http.auth.user.id")
85+
if !exists {
86+
// Authentication not configured?
87+
return optionals.Some(DenyError),
88+
fmt.Errorf("unable to determine user ID (is authentication configured?)")
89+
}
90+
91+
config, exists := r.Clients.Get(userID).Get()
92+
if !exists {
93+
return optionals.Some(DenyUnknownUser), nil
94+
}
95+
96+
// Deny if the challenge domain doesn't have the expected prefix.
97+
if !strings.HasPrefix(challengeDomain, challengeDomainPrefix) {
98+
return optionals.Some(DenyInvalidDomain), nil
99+
}
100+
101+
// Strip off the prefix and remove any trailing dot. If the result starts with
102+
// a dot, then the requested domain is invalid. Otherwise, check the result
103+
// against the domain policy.
104+
domain := strings.TrimPrefix(challengeDomain, challengeDomainPrefix)
105+
domain = strings.TrimSuffix(domain, ".")
106+
if strings.HasPrefix(domain, ".") {
107+
return optionals.Some(DenyInvalidDomain), nil
108+
}
109+
err := config.DomainPolicy.IsDNSAllowed(domain)
110+
if err != nil {
111+
if npe, ok := err.(*policy.NamePolicyError); ok {
112+
switch npe.Reason {
113+
case policy.NotAllowed:
114+
return optionals.Some(DenyDomainNotAllowed), nil
115+
case policy.CannotParseDomain:
116+
return optionals.Some(DenyInvalidDomain), nil
117+
}
118+
}
119+
return optionals.Some(DenyError),
120+
fmt.Errorf("unable to authorize challenge domain: %w", err)
121+
}
122+
123+
return optionals.None[DenyReason](), nil
124+
}

dns_config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ type DNSConfig struct {
1616

1717
// The TTL to use for the DNS TXT records when answering challenges.
1818
TTL optionals.Optional[caddy.Duration] `json:"ttl"`
19+
20+
// Custom DNS resolvers to prefer over system/built-in defaults. Often
21+
// necessary to configure when using split-horizon DNS.
22+
Resolvers []string `json:"resolvers,omitempty"`
1923
}
2024

2125
var _ caddy.Provisioner = (*Handler)(nil)

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.24.3
55
require (
66
github.com/caddyserver/caddy/v2 v2.10.0
77
github.com/caddyserver/certmagic v0.23.0
8+
github.com/libdns/libdns v1.0.0-beta.1
89
github.com/liujed/goutil v0.0.0
910
github.com/smallstep/certificates v0.26.1
1011
github.com/spf13/cobra v1.9.1
@@ -60,7 +61,6 @@ require (
6061
github.com/jackc/pgx/v4 v4.18.3 // indirect
6162
github.com/klauspost/compress v1.18.0 // indirect
6263
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
63-
github.com/libdns/libdns v1.0.0-beta.1 // indirect
6464
github.com/manifoldco/promptui v0.9.0 // indirect
6565
github.com/mattn/go-colorable v0.1.13 // indirect
6666
github.com/mattn/go-isatty v0.0.20 // indirect

handler.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ package caddydns01proxy
33
import (
44
"fmt"
55
"net/http"
6+
"time"
67

78
"github.com/caddyserver/caddy/v2"
89
"github.com/caddyserver/caddy/v2/caddyconfig"
910
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
1011
"github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth"
12+
"github.com/caddyserver/certmagic"
13+
"github.com/libdns/libdns"
1114
"github.com/liujed/caddy-dns01proxy/jsonutil"
1215
"github.com/liujed/goutil/optionals"
16+
"go.uber.org/zap"
1317
)
1418

1519
func init() {
@@ -32,6 +36,8 @@ type Handler struct {
3236
// Identifies the domains at which each client is allowed to answer DNS-01
3337
// challenges. Derived from [AccountsRaw].
3438
ClientRegistry ClientRegistry `json:"-"`
39+
40+
logger *zap.Logger
3541
}
3642

3743
var _ caddy.Module = (*Handler)(nil)
@@ -53,6 +59,8 @@ func (Handler) CaddyModule() caddy.ModuleInfo {
5359
}
5460

5561
func (h *Handler) Provision(ctx caddy.Context) error {
62+
h.logger = ctx.Logger()
63+
5664
// Provision DNS.
5765
err := h.DNS.Provision(ctx)
5866
if err != nil {
@@ -152,7 +160,79 @@ func (h *Handler) handleDNSRequest(
152160
req *http.Request,
153161
reqBody RequestBody,
154162
) (httpStatus int, respBody optionals.Optional[ResponseBody], err error) {
155-
// TODO
156-
return http.StatusInternalServerError, optionals.None[ResponseBody](), nil
163+
// Check that the user gave a valid request body.
164+
if !reqBody.IsValid() {
165+
return http.StatusBadRequest, optionals.None[ResponseBody](), nil
166+
}
167+
168+
// Log the challenge domain that appears in the request.
169+
addLogField(req, zap.String("domain", reqBody.ChallengeFQDN))
170+
171+
// Check that the user is authorized for the challenge domain in the
172+
// request.
173+
denyReasonOpt, err := h.ClientRegistry.AuthorizeUserChallengeDomain(
174+
req,
175+
reqBody.ChallengeFQDN,
176+
)
177+
if err != nil {
178+
return 0, optionals.None[ResponseBody](),
179+
fmt.Errorf("unable to authorize user for requested domain: %w", err)
180+
}
181+
if denyReason, denied := denyReasonOpt.Get(); denied {
182+
addLogField(req, zap.String(logAuthorizationFailure, string(denyReason)))
183+
return http.StatusForbidden, optionals.None[ResponseBody](), nil
184+
}
185+
186+
// Figure out the challenge domain's DNS zone.
187+
zone, err := certmagic.FindZoneByFQDN(
188+
req.Context(),
189+
h.logger,
190+
reqBody.ChallengeFQDN,
191+
certmagic.RecursiveNameservers(h.DNS.Resolvers),
192+
)
193+
if err != nil {
194+
return 0, optionals.None[ResponseBody](),
195+
fmt.Errorf(
196+
"unable to find DNS zone for %q: %w",
197+
reqBody.ChallengeFQDN,
198+
err,
199+
)
200+
}
201+
202+
// Build the DNS record to create/delete.
203+
ttl := time.Duration(h.DNS.TTL.GetOrDefault(0))
204+
if mode == hmCleanup {
205+
ttl = 0
206+
}
207+
records := []libdns.Record{
208+
libdns.TXT{
209+
Name: libdns.RelativeName(reqBody.ChallengeFQDN, zone),
210+
TTL: ttl,
211+
Text: `"` + reqBody.Value + `"`,
212+
},
213+
}
214+
215+
switch mode {
216+
case hmPresent:
217+
// Create the DNS record.
218+
_, err = h.DNS.Provider.AppendRecords(req.Context(), zone, records)
219+
if err != nil {
220+
return 0, optionals.None[ResponseBody](),
221+
fmt.Errorf("error creating DNS record: %w", err)
222+
}
223+
return http.StatusOK, optionals.Some(reqBody), nil
224+
225+
case hmCleanup:
226+
// Delete the DNS record.
227+
_, err = h.DNS.Provider.DeleteRecords(req.Context(), zone, records)
228+
if err != nil {
229+
return 0, optionals.None[ResponseBody](),
230+
fmt.Errorf("error deleting DNS record: %w", err)
231+
}
232+
return http.StatusOK, optionals.Some(reqBody), nil
233+
}
234+
235+
return 0, optionals.None[ResponseBody](),
236+
fmt.Errorf("unknown handler mode: %q", mode)
157237
}
158238
}

logging.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package caddydns01proxy
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
7+
"go.uber.org/zap"
8+
)
9+
10+
const (
11+
// Log key for reporting why a user failed authorization.
12+
logAuthorizationFailure = "deny_reason"
13+
)
14+
15+
// Adds the given field to the access logs for the given request.
16+
func addLogField(req *http.Request, field zap.Field) {
17+
extra := req.Context().Value(caddyhttp.ExtraLogFieldsCtxKey).(*caddyhttp.ExtraLogFields)
18+
extra.Add(field)
19+
}

0 commit comments

Comments
 (0)