diff --git a/pkg/certmanager/provision_worker.go b/pkg/certmanager/provision_worker.go index e18def6e2..13cb865b5 100644 --- a/pkg/certmanager/provision_worker.go +++ b/pkg/certmanager/provision_worker.go @@ -21,7 +21,6 @@ import ( "strings" "time" - "codeberg.org/miekg/dns" "go.gearno.de/kit/log" "go.gearno.de/kit/pg" "go.gearno.de/kit/worker" @@ -31,7 +30,7 @@ import ( "go.opentelemetry.io/otel/trace" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" - "go.probo.inc/probo/pkg/dnsverify" + "go.probo.inc/probo/pkg/dnsclient" "go.probo.inc/probo/pkg/gid" "golang.org/x/crypto/acme" ) @@ -70,7 +69,7 @@ type ( cnameTarget string caaIssuerDomain string - resolverAddr string + dnsClient *dnsclient.Client managedBaseDomain string } @@ -113,7 +112,7 @@ func NewBeginChallengeWorker( }, cnameTarget: cnameTarget, caaIssuerDomain: caaIssuerDomain, - resolverAddr: resolverAddr, + dnsClient: dnsclient.NewClient(resolverAddr), managedBaseDomain: managedBaseDomain, } @@ -207,7 +206,10 @@ func (h *beginChallengeHandler) Process(ctx context.Context, certificate coredat dnsCtx, dnsSpan := h.tracer.Start(ctx, "certmanager.dns_check") dnsStarted := time.Now() - if err := h.checkDNSConfiguration(dnsCtx, certificate.Hostname); err != nil { + cnameCtx, cnameCancel := context.WithTimeout(dnsCtx, dnsExchangeTimeout) + err := h.dnsClient.CheckCNAME(cnameCtx, certificate.Hostname, h.cnameTarget) + cnameCancel() + if err != nil { h.acmeService.metrics.observeStep(provisionPhaseDNSCheck, provisionResultDNSError, dnsStarted) h.recordSpanError(dnsSpan, err, classifyProvisioningError(err)) dnsSpan.End() @@ -345,116 +347,17 @@ func (h *beginChallengeHandler) loadSkipDNSChecks(ctx context.Context, hostname return skip, nil } -func (h *beginChallengeHandler) checkDNSConfiguration(ctx context.Context, hostname string) error { - customerFQDN := dnsverify.ToFQDN(hostname) - expectedFQDN := dnsverify.ToFQDN(h.cnameTarget) - - msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} - msg.Question = []dns.RR{&dns.CNAME{Hdr: dns.Header{Name: customerFQDN, Class: dns.ClassINET}}} - - dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout) - defer cancel() - - client := dns.NewClient() - - resp, _, err := client.Exchange(dnsCtx, msg, "udp", h.resolverAddr) - if err != nil { - return fmt.Errorf("cannot exchange dns message: %w", err) - } - - if len(resp.Answer) == 0 { - return fmt.Errorf("no cname records found for domain %q", hostname) - } - - if len(resp.Answer) > 1 { - return fmt.Errorf("multiple cname records found for domain %q", hostname) - } - - resolvedRecord, ok := resp.Answer[0].(*dns.CNAME) - if !ok { - return fmt.Errorf("first answer is not a cname record for domain %q", hostname) - } - - if !dnsverify.EqualNames(resolvedRecord.Hdr.Name, customerFQDN) { - return fmt.Errorf( - "cname owner mismatch: domain %q has record owned by %q", - hostname, - strings.TrimSuffix(resolvedRecord.Hdr.Name, "."), - ) - } - - if !dnsverify.EqualNames(resolvedRecord.Target, expectedFQDN) { - return fmt.Errorf( - "cname target mismatch: domain %q resolves to %q, expected %q", - hostname, - resolvedRecord.Target, - expectedFQDN, - ) - } - - return nil -} - func (h *beginChallengeHandler) checkCAARecords(ctx context.Context, hostname string) error { - checkNames, err := dnsverify.CheckNames(hostname) - if err != nil { - return err + err := h.dnsClient.CheckCAA(ctx, hostname, h.caaIssuerDomain) + if err == nil { + return nil } - dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout) - defer cancel() - - client := dns.NewClient() - - for _, checkName := range checkNames { - fqdn := dnsverify.ToFQDN(checkName) - - msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} - msg.Question = []dns.RR{&dns.CAA{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}} - - resp, _, err := client.Exchange( - dnsCtx, - msg, - "udp", - h.resolverAddr, - ) - if err != nil { - return fmt.Errorf("cannot exchange dns message for caa records: %w", err) - } - - var caaRecords []*dns.CAA - - for _, rr := range resp.Answer { - caa, ok := rr.(*dns.CAA) - if !ok || !dnsverify.EqualNames(caa.Hdr.Name, fqdn) { - continue - } - - caaRecords = append(caaRecords, caa) - } - - if len(caaRecords) == 0 { - continue - } - - for _, caa := range caaRecords { - if caa.Tag == "issue" { - issuer, _, _ := strings.Cut(caa.Value, ";") - if strings.EqualFold(strings.TrimSpace(issuer), h.caaIssuerDomain) { - return nil - } - } - } - - return fmt.Errorf( - "%w: domain %q by %q", - ErrCAANotPermitted, - hostname, - h.caaIssuerDomain, - ) + if errors.Is(err, dnsclient.ErrCAADenied) { + return fmt.Errorf("%w: domain %q by %q", ErrCAANotPermitted, hostname, h.caaIssuerDomain) } - return nil + return err } func (h *beginChallengeHandler) skipsDNSChecks( diff --git a/pkg/dnsclient/caa.go b/pkg/dnsclient/caa.go new file mode 100644 index 000000000..580767c6a --- /dev/null +++ b/pkg/dnsclient/caa.go @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsclient + +import ( + "context" + "fmt" + "strings" + + "codeberg.org/miekg/dns" +) + +// CheckCAA verifies that CAA policy from hostname up through each parent +// toward the DNS root permits non-wildcard issuance by permittedIssuer +// (RFC 8659). Evaluation stops at the first non-empty CAA RRset. +func (c *Client) CheckCAA(ctx context.Context, hostname, permittedIssuer string) error { + checkNames, err := HostnamesForCAA(hostname) + if err != nil { + return err + } + + for _, checkName := range checkNames { + fqdn := ToFQDN(checkName) + + msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} + msg.Question = []dns.RR{&dns.CAA{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}} + + resp, err := c.query(ctx, msg) + if err != nil { + return fmt.Errorf("cannot exchange dns message for caa records: %w", err) + } + + if resp.Rcode != dns.RcodeSuccess { + return fmt.Errorf( + "cannot query caa records for %q: %s", + checkName, + dns.RcodeToString[resp.Rcode], + ) + } + + caaRecords := caaRecordsOwnedBy(resp, fqdn) + if len(caaRecords) == 0 { + continue + } + + if caaPermitsIssuer(caaRecords, permittedIssuer) { + return nil + } + + return fmt.Errorf("%w: domain %q by %q", ErrCAADenied, hostname, permittedIssuer) + } + + return nil +} + +func caaRecordsOwnedBy(resp *dns.Msg, owner string) []*dns.CAA { + var records []*dns.CAA + + for _, rr := range resp.Answer { + caa, ok := rr.(*dns.CAA) + if !ok || !EqualNames(caa.Hdr.Name, owner) { + continue + } + + records = append(records, caa) + } + + return records +} + +// caaPermitsIssuer reports whether a non-empty CAA RRset permits ordinary +// (non-wildcard) issuance by permittedIssuer per RFC 8659. +// +// Relevant rules for non-wildcard requests: +// - Critical unrecognized tags deny issuance. +// - Only "issue" properties authorize (case-insensitive tag match). +// - "issuewild" is ignored (neither authorizes nor denies). +// - Other non-critical tags (e.g. iodef) are ignored. +// - If no "issue" property is present, issuance is permitted. +// - An empty or malformed "issue" value is treated as an empty +// issuer-domain-name and does not authorize any issuer. +func caaPermitsIssuer(records []*dns.CAA, permittedIssuer string) bool { + var issueValues []string + + for _, caa := range records { + switch { + case strings.EqualFold(caa.Tag, "issue"): + issueValues = append(issueValues, caa.Value) + case strings.EqualFold(caa.Tag, "issuewild"): + // issuewild applies only to wildcard issuance. + default: + if caa.Flag&1 != 0 { + return false + } + } + } + + if len(issueValues) == 0 { + return true + } + + for _, value := range issueValues { + issuer, ok := parseCAAIssueValue(value) + if !ok || issuer == "" { + continue + } + + if strings.EqualFold(issuer, permittedIssuer) { + return true + } + } + + return false +} + +// parseCAAIssueValue parses an RFC 8659 issue / issuewild property value. +// Malformed values return ok=false and must be treated like an empty +// issuer-domain-name (no authorization from that property). +func parseCAAIssueValue(value string) (issuer string, ok bool) { + s := trimCAALeadingWSP(value) + + if s != "" && s[0] != ';' { + end := 0 + for end < len(s) && !isCAAWSP(s[end]) && s[end] != ';' { + end++ + } + + issuer = s[:end] + if !isCAAIssuerDomainName(issuer) { + return "", false + } + + s = trimCAALeadingWSP(s[end:]) + } + + if s == "" { + return issuer, true + } + + if s[0] != ';' { + return "", false + } + + s = trimCAALeadingWSP(s[1:]) + if s == "" { + return issuer, true + } + + if !consumeCAAParameters(s) { + return "", false + } + + return issuer, true +} + +func consumeCAAParameters(s string) bool { + for { + rest, ok := consumeCAAParameter(s) + if !ok { + return false + } + + s = trimCAALeadingWSP(rest) + if s == "" { + return true + } + + if s[0] != ';' { + return false + } + + s = trimCAALeadingWSP(s[1:]) + if s == "" { + // Trailing ";" with no following parameter is not in the ABNF. + return false + } + } +} + +func consumeCAAParameter(s string) (string, bool) { + if s == "" { + return "", false + } + + tagEnd := 0 + for tagEnd < len(s) && !isCAAWSP(s[tagEnd]) && s[tagEnd] != '=' { + tagEnd++ + } + + if tagEnd == 0 || !isCAAIssuerLabel(s[:tagEnd]) { + return "", false + } + + s = trimCAALeadingWSP(s[tagEnd:]) + if s == "" || s[0] != '=' { + return "", false + } + + s = trimCAALeadingWSP(s[1:]) + + valueEnd := 0 + for valueEnd < len(s) && isCAAParameterValueByte(s[valueEnd]) { + valueEnd++ + } + + return s[valueEnd:], true +} + +func isCAAIssuerDomainName(name string) bool { + if name == "" { + return false + } + + labels := strings.Split(name, ".") + for _, label := range labels { + if !isCAAIssuerLabel(label) { + return false + } + } + + return true +} + +func isCAAIssuerLabel(label string) bool { + if label == "" { + return false + } + + // label = (ALPHA / DIGIT) *( *("-") (ALPHA / DIGIT) ) + if !isCAAAlphaNum(label[0]) { + return false + } + + i := 1 + for i < len(label) { + for i < len(label) && label[i] == '-' { + i++ + } + + if i >= len(label) || !isCAAAlphaNum(label[i]) { + return false + } + + i++ + } + + return true +} + +func isCAAParameterValueByte(b byte) bool { + // value = *(%x21-3A / %x3C-7E) — printable ASCII except space and ";". + return (b >= 0x21 && b <= 0x3A) || (b >= 0x3C && b <= 0x7E) +} + +func isCAAAlphaNum(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') +} + +func isCAAWSP(b byte) bool { + return b == ' ' || b == '\t' +} + +func trimCAALeadingWSP(s string) string { + i := 0 + for i < len(s) && isCAAWSP(s[i]) { + i++ + } + + return s[i:] +} diff --git a/pkg/dnsclient/checks_test.go b/pkg/dnsclient/checks_test.go new file mode 100644 index 000000000..5bdf7a25f --- /dev/null +++ b/pkg/dnsclient/checks_test.go @@ -0,0 +1,496 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsclient + +import ( + "context" + "testing" + + "codeberg.org/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckCNAME(t *testing.T) { + t.Parallel() + + t.Run("accepts matching owner and target", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) { + cname := &dns.CNAME{Hdr: dns.Header{Name: msg.Question[0].Header().Name}} + cname.Target = "custom.getprobo.com." + + return &dns.Msg{Answer: []dns.RR{cname}}, nil + }, + } + + err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com") + + require.NoError(t, err) + }) + + t.Run("rejects apex owned record for subdomain query", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + cname := &dns.CNAME{Hdr: dns.Header{Name: "example.com."}} + cname.Target = "custom.getprobo.com." + + return &dns.Msg{Answer: []dns.RR{cname}}, nil + }, + } + + err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com") + + require.Error(t, err) + assert.Contains(t, err.Error(), "cname owner mismatch") + }) + + t.Run("retries over tcp when udp response is truncated", func(t *testing.T) { + t.Parallel() + + var networks []string + client := &Client{ + exchange: func(_ context.Context, msg *dns.Msg, network string) (*dns.Msg, error) { + networks = append(networks, network) + if network == "udp" { + return &dns.Msg{ + MsgHeader: dns.MsgHeader{ + Rcode: dns.RcodeSuccess, + Truncated: true, + }, + }, nil + } + + cname := &dns.CNAME{Hdr: dns.Header{Name: msg.Question[0].Header().Name}} + cname.Target = "custom.getprobo.com." + + return &dns.Msg{Answer: []dns.RR{cname}}, nil + }, + } + + err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com") + + require.NoError(t, err) + assert.Equal(t, []string{"udp", "tcp"}, networks) + }) + + t.Run("rejects response still truncated after tcp retry", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + cname := &dns.CNAME{Hdr: dns.Header{Name: "trust.example.com."}} + cname.Target = "custom.getprobo.com." + + return &dns.Msg{ + MsgHeader: dns.MsgHeader{ + Rcode: dns.RcodeSuccess, + Truncated: true, + }, + Answer: []dns.RR{cname}, + }, nil + }, + } + + err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com") + + require.Error(t, err) + assert.Contains(t, err.Error(), "truncated") + }) +} + +func TestCheckTXT(t *testing.T) { + t.Parallel() + + t.Run("ignores parent apex txt", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + txt := &dns.TXT{Hdr: dns.Header{Name: "example.com."}} + txt.Txt = []string{"probo-verification=token"} + + return &dns.Msg{Answer: []dns.RR{txt}}, nil + }, + } + + err := client.CheckTXT(context.Background(), "mail.example.com", "probo-verification=token") + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTXTMismatch) + }) + + t.Run("accepts txt on exact domain", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + txt := &dns.TXT{Hdr: dns.Header{Name: "example.com."}} + txt.Txt = []string{"probo-verification=token"} + + return &dns.Msg{Answer: []dns.RR{txt}}, nil + }, + } + + err := client.CheckTXT(context.Background(), "example.com", "probo-verification=token") + + require.NoError(t, err) + }) + + t.Run("maps nxdomain to ErrTXTNotFound", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeNameError}, + }, nil + }, + } + + err := client.CheckTXT(context.Background(), "mail.example.com", "probo-verification=token") + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTXTNotFound) + }) + + t.Run("retries over tcp when udp response is truncated", func(t *testing.T) { + t.Parallel() + + var networks []string + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, network string) (*dns.Msg, error) { + networks = append(networks, network) + if network == "udp" { + return &dns.Msg{ + MsgHeader: dns.MsgHeader{ + Rcode: dns.RcodeSuccess, + Truncated: true, + }, + }, nil + } + + txt := &dns.TXT{Hdr: dns.Header{Name: "example.com."}} + txt.Txt = []string{"probo-verification=token"} + + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}, + Answer: []dns.RR{txt}, + }, nil + }, + } + + err := client.CheckTXT(context.Background(), "example.com", "probo-verification=token") + + require.NoError(t, err) + assert.Equal(t, []string{"udp", "tcp"}, networks) + }) +} + +func TestCaaPermitsIssuer(t *testing.T) { + t.Parallel() + + t.Run("matching issue permits", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{caaRecord("issue", "letsencrypt.org; accounturi=https://example.com", 0)} + + assert.True(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("non-matching issue denies", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{caaRecord("issue", "letsencrypt.org", 0)} + + assert.False(t, caaPermitsIssuer(records, "digicert.com")) + }) + + t.Run("empty issue value denies", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{caaRecord("issue", ";", 0)} + + assert.False(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("issue tag is case insensitive", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{caaRecord("ISSUE", "LetsEncrypt.ORG", 0)} + + assert.True(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("only issuewild permits non-wildcard", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{caaRecord("issuewild", "letsencrypt.org", 0)} + + assert.True(t, caaPermitsIssuer(records, "digicert.com")) + }) + + t.Run("iodef non-critical ignored with matching issue", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{ + caaRecord("iodef", "mailto:security@example.com", 0), + caaRecord("issue", "letsencrypt.org", 0), + } + + assert.True(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("critical unknown tag denies even with matching issue", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{ + caaRecord("issue", "letsencrypt.org", 0), + caaRecord("unknown", "value", 1), + } + + assert.False(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("critical issuewild is recognized and ignored for non-wildcard", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{ + caaRecord("issuewild", "other.ca", 1), + caaRecord("issue", "letsencrypt.org", 0), + } + + assert.True(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("malformed issue value does not authorize issuer prefix", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{ + caaRecord("issue", "letsencrypt.org; accounturi", 0), + } + + assert.False(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("malformed issue value alone forbids issuance", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{caaRecord("issue", "%%%%%", 0)} + + assert.False(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) + + t.Run("valid issue alongside malformed still authorizes", func(t *testing.T) { + t.Parallel() + + records := []*dns.CAA{ + caaRecord("issue", "%%%%%", 0), + caaRecord("issue", "letsencrypt.org; accounturi=https://example.com", 0), + } + + assert.True(t, caaPermitsIssuer(records, "letsencrypt.org")) + }) +} + +func TestCheckCAA(t *testing.T) { + t.Parallel() + + t.Run("returns error on servfail", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeServerFailure}, + }, nil + }, + } + + err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org") + + require.Error(t, err) + assert.Contains(t, err.Error(), "SERVFAIL") + assert.NotErrorIs(t, err, ErrCAADenied) + }) + + t.Run("retries over tcp when udp response is truncated", func(t *testing.T) { + t.Parallel() + + var networks []string + client := &Client{ + exchange: func(_ context.Context, msg *dns.Msg, network string) (*dns.Msg, error) { + networks = append(networks, network) + if network == "udp" { + return &dns.Msg{ + MsgHeader: dns.MsgHeader{ + Rcode: dns.RcodeSuccess, + Truncated: true, + }, + }, nil + } + + name := msg.Question[0].Header().Name + caa := caaRecord("issue", "letsencrypt.org", 0) + caa.Hdr.Name = name + + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}, + Answer: []dns.RR{caa}, + }, nil + }, + } + + err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org") + + require.NoError(t, err) + assert.Equal(t, []string{"udp", "tcp"}, networks) + }) + + t.Run("returns error when truncated after tcp retry", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + return &dns.Msg{ + MsgHeader: dns.MsgHeader{ + Rcode: dns.RcodeSuccess, + Truncated: true, + }, + }, nil + }, + } + + err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org") + + require.Error(t, err) + assert.Contains(t, err.Error(), "truncated") + }) + + t.Run("returns error on nxdomain", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) { + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeNameError}, + }, nil + }, + } + + err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org") + + require.Error(t, err) + assert.Contains(t, err.Error(), "NXDOMAIN") + }) + + t.Run("permits when first non-empty rrset allows issuer", func(t *testing.T) { + t.Parallel() + + var queried []string + client := &Client{ + exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) { + name := msg.Question[0].Header().Name + queried = append(queried, name) + if name != "example.com." { + return &dns.Msg{MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}}, nil + } + + caa := caaRecord("issue", "letsencrypt.org", 0) + caa.Hdr.Name = name + + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}, + Answer: []dns.RR{caa}, + }, nil + }, + } + + err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org") + + require.NoError(t, err) + assert.Equal(t, []string{"trust.example.com.", "example.com."}, queried) + }) + + t.Run("denies when parent forbids after empty child", func(t *testing.T) { + t.Parallel() + + var queried []string + client := &Client{ + exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) { + name := msg.Question[0].Header().Name + queried = append(queried, name) + if name == "trust.example.com." { + return &dns.Msg{MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}}, nil + } + + caa := caaRecord("issue", "digicert.com", 0) + caa.Hdr.Name = name + + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}, + Answer: []dns.RR{caa}, + }, nil + }, + } + + err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org") + + require.Error(t, err) + assert.ErrorIs(t, err, ErrCAADenied) + assert.Equal(t, []string{"trust.example.com.", "example.com."}, queried) + }) + + t.Run("denies when first non-empty rrset forbids issuer", func(t *testing.T) { + t.Parallel() + + client := &Client{ + exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) { + name := msg.Question[0].Header().Name + caa := caaRecord("issue", "digicert.com", 0) + caa.Hdr.Name = name + + return &dns.Msg{ + MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}, + Answer: []dns.RR{caa}, + }, nil + }, + } + + err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org") + + require.Error(t, err) + assert.ErrorIs(t, err, ErrCAADenied) + }) +} + +func caaRecord(tag, value string, flag uint8) *dns.CAA { + caa := &dns.CAA{Hdr: dns.Header{Name: "example.com."}} + caa.Flag = flag + caa.Tag = tag + caa.Value = value + + return caa +} diff --git a/pkg/dnsclient/client.go b/pkg/dnsclient/client.go new file mode 100644 index 000000000..0f90e426e --- /dev/null +++ b/pkg/dnsclient/client.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsclient + +import ( + "context" + "fmt" + + "codeberg.org/miekg/dns" +) + +type ( + // Client performs DNS lookups used to verify domain ownership and + // certificate prerequisites. + Client struct { + ResolverAddr string + exchange exchangeFunc + } + + exchangeFunc func(ctx context.Context, msg *dns.Msg, network string) (*dns.Msg, error) +) + +// NewClient returns a client that resolves names through resolverAddr. +func NewClient(resolverAddr string) *Client { + return &Client{ResolverAddr: resolverAddr} +} + +func (c *Client) exchangeUDP(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { + if c.exchange != nil { + return c.exchange(ctx, msg, "udp") + } + + client := dns.NewClient() + + resp, _, err := client.Exchange(ctx, msg, "udp", c.ResolverAddr) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (c *Client) exchangeTCP(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { + if c.exchange != nil { + return c.exchange(ctx, msg, "tcp") + } + + client := dns.NewClient() + + resp, _, err := client.Exchange(ctx, msg, "tcp", c.ResolverAddr) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (c *Client) query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { + resp, err := c.exchangeUDP(ctx, msg) + if err != nil { + return nil, fmt.Errorf("cannot exchange dns message: %w", err) + } + + if resp.Truncated { + resp, err = c.exchangeTCP(ctx, msg) + if err != nil { + return nil, fmt.Errorf("cannot exchange dns message over TCP: %w", err) + } + } + + if resp.Truncated { + return nil, fmt.Errorf("cannot exchange dns message: truncated response") + } + + return resp, nil +} diff --git a/pkg/dnsclient/cname.go b/pkg/dnsclient/cname.go new file mode 100644 index 000000000..0fc1e5b43 --- /dev/null +++ b/pkg/dnsclient/cname.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsclient + +import ( + "context" + "fmt" + "strings" + + "codeberg.org/miekg/dns" +) + +// CheckCNAME verifies that hostname has a single CNAME record owned by that +// name and pointing at expectedTarget. +func (c *Client) CheckCNAME(ctx context.Context, hostname, expectedTarget string) error { + owner := ToFQDN(hostname) + target := ToFQDN(expectedTarget) + + msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} + msg.Question = []dns.RR{&dns.CNAME{Hdr: dns.Header{Name: owner, Class: dns.ClassINET}}} + + resp, err := c.query(ctx, msg) + if err != nil { + return err + } + + if len(resp.Answer) == 0 { + return fmt.Errorf("no cname records found for domain %q", hostname) + } + + if len(resp.Answer) > 1 { + return fmt.Errorf("multiple cname records found for domain %q", hostname) + } + + resolvedRecord, ok := resp.Answer[0].(*dns.CNAME) + if !ok { + return fmt.Errorf("first answer is not a cname record for domain %q", hostname) + } + + if !EqualNames(resolvedRecord.Hdr.Name, owner) { + return fmt.Errorf( + "cname owner mismatch: domain %q has record owned by %q", + hostname, + strings.TrimSuffix(resolvedRecord.Hdr.Name, "."), + ) + } + + if !EqualNames(resolvedRecord.Target, target) { + return fmt.Errorf( + "cname target mismatch: domain %q resolves to %q, expected %q", + hostname, + strings.TrimSuffix(resolvedRecord.Target, "."), + strings.TrimSuffix(expectedTarget, "."), + ) + } + + return nil +} diff --git a/pkg/dnsclient/errors.go b/pkg/dnsclient/errors.go new file mode 100644 index 000000000..21d0e65f9 --- /dev/null +++ b/pkg/dnsclient/errors.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsclient + +import "errors" + +var ( + // ErrCAADenied means a CAA policy at the hostname or a parent domain + // forbids issuance by the requested certificate authority. + ErrCAADenied = errors.New("caa records do not permit issuance") + + // ErrTXTNotFound means no TXT record exists at the queried domain name. + ErrTXTNotFound = errors.New("domain TXT record not found") + + // ErrTXTMismatch means TXT records exist at the domain but none match the + // expected verification value. + ErrTXTMismatch = errors.New("domain TXT record mismatch") +) diff --git a/pkg/dnsverify/names.go b/pkg/dnsclient/names.go similarity index 73% rename from pkg/dnsverify/names.go rename to pkg/dnsclient/names.go index e2f25548b..37e7cd2a8 100644 --- a/pkg/dnsverify/names.go +++ b/pkg/dnsclient/names.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025-2026 Probo Inc . +// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -18,13 +18,11 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package dnsverify +package dnsclient import ( "fmt" "strings" - - "golang.org/x/net/publicsuffix" ) // ToFQDN normalizes a DNS name to lowercase FQDN form with a trailing dot. @@ -45,33 +43,35 @@ func EqualNames(a, b string) bool { return ToFQDN(a) == ToFQDN(b) } -// CheckNames returns the DNS names to evaluate for CAA, starting at the exact -// hostname being verified and walking up through each parent to the -// registrable apex (eTLD+1). The first entry is always the requested hostname -// itself, not its apex. -func CheckNames(hostname string) ([]string, error) { +// HostnamesForCAA returns the DNS names to evaluate for CAA, starting at the +// exact hostname being verified and walking each parent label toward the root +// (RFC 8659 tree climbing). The walk includes public-suffix / TLD labels and +// stops after the final single label (e.g. "com"); the DNS root "." is omitted +// because ToFQDN maps an empty name to "." awkwardly for queries. The first +// entry is always the requested hostname itself. +func HostnamesForCAA(hostname string) ([]string, error) { hostname = strings.ToLower(strings.TrimSpace(hostname)) hostname = strings.TrimSuffix(hostname, ".") - if hostname == "" { - return nil, fmt.Errorf("cannot build DNS check names: empty hostname") - } - apex, err := publicsuffix.EffectiveTLDPlusOne(hostname) - if err != nil { - return nil, fmt.Errorf("cannot build DNS check names for %q: %w", hostname, err) + if hostname == "" { + return nil, fmt.Errorf("cannot build CAA hostnames: empty hostname") } names := []string{hostname} current := hostname - for !strings.EqualFold(current, apex) { + for { dot := strings.Index(current, ".") if dot < 0 { break } current = current[dot+1:] + if current == "" { + break + } + names = append(names, current) } diff --git a/pkg/dnsclient/names_test.go b/pkg/dnsclient/names_test.go new file mode 100644 index 000000000..4a15c9527 --- /dev/null +++ b/pkg/dnsclient/names_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsclient_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/dnsclient" +) + +func TestEqualNames(t *testing.T) { + t.Parallel() + + assert.True(t, dnsclient.EqualNames("trust.example.com", "trust.example.com.")) + assert.True(t, dnsclient.EqualNames("Trust.Example.COM", "trust.example.com")) + assert.False(t, dnsclient.EqualNames("trust.example.com", "example.com")) +} + +func TestHostnamesForCAA(t *testing.T) { + t.Parallel() + + t.Run("subdomain walks past etld plus one through tld", func(t *testing.T) { + t.Parallel() + + names, err := dnsclient.HostnamesForCAA("trust.example.com") + + require.NoError(t, err) + assert.Equal(t, []string{"trust.example.com", "example.com", "com"}, names) + }) + + t.Run("apex continues through tld", func(t *testing.T) { + t.Parallel() + + names, err := dnsclient.HostnamesForCAA("example.com") + + require.NoError(t, err) + assert.Equal(t, []string{"example.com", "com"}, names) + }) + + t.Run("nested subdomain walks each parent through tld", func(t *testing.T) { + t.Parallel() + + names, err := dnsclient.HostnamesForCAA("portal.trust.example.com") + + require.NoError(t, err) + assert.Equal( + t, + []string{ + "portal.trust.example.com", + "trust.example.com", + "example.com", + "com", + }, + names, + ) + }) + + t.Run("multi-label public suffix continues past etld plus one", func(t *testing.T) { + t.Parallel() + + names, err := dnsclient.HostnamesForCAA("app.example.co.uk") + + require.NoError(t, err) + assert.Equal( + t, + []string{"app.example.co.uk", "example.co.uk", "co.uk", "uk"}, + names, + ) + }) + + t.Run("single label is only entry", func(t *testing.T) { + t.Parallel() + + names, err := dnsclient.HostnamesForCAA("com") + + require.NoError(t, err) + assert.Equal(t, []string{"com"}, names) + }) + + t.Run("empty hostname errors", func(t *testing.T) { + t.Parallel() + + names, err := dnsclient.HostnamesForCAA(" ") + + require.Error(t, err) + assert.Nil(t, names) + }) +} diff --git a/pkg/dnsclient/txt.go b/pkg/dnsclient/txt.go new file mode 100644 index 000000000..1df6ff339 --- /dev/null +++ b/pkg/dnsclient/txt.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsclient + +import ( + "context" + "fmt" + "strings" + + "codeberg.org/miekg/dns" +) + +// CheckTXT verifies that domain has a TXT record owned by that exact name whose +// value equals expectedValue. Parent apex records are ignored. +func (c *Client) CheckTXT(ctx context.Context, domain, expectedValue string) error { + msg := dns.NewMsg(domain, dns.TypeTXT) + + resp, err := c.query(ctx, msg) + if err != nil { + return fmt.Errorf("cannot query TXT record for %q: %w", domain, err) + } + + if resp.Rcode == dns.RcodeNameError { + return fmt.Errorf("%w for %q", ErrTXTNotFound, domain) + } + + if resp.Rcode != dns.RcodeSuccess { + return fmt.Errorf( + "cannot query TXT record for %q: %s", + domain, + dns.RcodeToString[resp.Rcode], + ) + } + + if len(resp.Answer) == 0 { + return fmt.Errorf("%w for %q", ErrTXTNotFound, domain) + } + + for _, answer := range resp.Answer { + txt, ok := answer.(*dns.TXT) + if !ok || !EqualNames(txt.Hdr.Name, domain) { + continue + } + + if strings.Join(txt.Txt, "") == expectedValue { + return nil + } + } + + return fmt.Errorf("%w for %q", ErrTXTMismatch, domain) +} diff --git a/pkg/dnsverify/names_test.go b/pkg/dnsverify/names_test.go deleted file mode 100644 index a30d7a1bd..000000000 --- a/pkg/dnsverify/names_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package dnsverify_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.probo.inc/probo/pkg/dnsverify" -) - -func TestEqualNames(t *testing.T) { - t.Parallel() - - assert.True(t, dnsverify.EqualNames("trust.example.com", "trust.example.com.")) - assert.True(t, dnsverify.EqualNames("Trust.Example.COM", "trust.example.com")) - assert.False(t, dnsverify.EqualNames("trust.example.com", "example.com")) -} - -func TestCheckNames(t *testing.T) { - t.Parallel() - - t.Run("subdomain starts at child then walks to apex", func(t *testing.T) { - t.Parallel() - - names, err := dnsverify.CheckNames("trust.example.com") - - require.NoError(t, err) - assert.Equal(t, []string{"trust.example.com", "example.com"}, names) - }) - - t.Run("apex stays on apex", func(t *testing.T) { - t.Parallel() - - names, err := dnsverify.CheckNames("example.com") - - require.NoError(t, err) - assert.Equal(t, []string{"example.com"}, names) - }) - - t.Run("nested subdomain walks each parent", func(t *testing.T) { - t.Parallel() - - names, err := dnsverify.CheckNames("portal.trust.example.com") - - require.NoError(t, err) - assert.Equal( - t, - []string{"portal.trust.example.com", "trust.example.com", "example.com"}, - names, - ) - }) -} diff --git a/pkg/iam/saml_domain_verifier.go b/pkg/iam/saml_domain_verifier.go index b130c6223..5bc3a0aba 100644 --- a/pkg/iam/saml_domain_verifier.go +++ b/pkg/iam/saml_domain_verifier.go @@ -24,25 +24,23 @@ import ( "context" "errors" "fmt" - "strings" "time" - "codeberg.org/miekg/dns" "go.gearno.de/kit/log" "go.gearno.de/kit/pg" "go.opentelemetry.io/otel/trace" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/dnsverify" + "go.probo.inc/probo/pkg/dnsclient" "go.probo.inc/probo/pkg/gid" ) type ( SAMLDomainVerifier struct { - pg *pg.Client - interval time.Duration - resolverAddr string - logger *log.Logger - tracer trace.Tracer + pg *pg.Client + interval time.Duration + dnsClient *dnsclient.Client + logger *log.Logger + tracer trace.Tracer } ) @@ -63,11 +61,11 @@ func NewSAMLDomainVerifier( resolverAddr string, ) *SAMLDomainVerifier { return &SAMLDomainVerifier{ - pg: pgClient, - interval: interval, - resolverAddr: resolverAddr, - logger: logger.Named("saml-domain-verifier"), - tracer: tp.Tracer("go.probo.inc/probo/pkg/iam/saml_domain_verifier"), + pg: pgClient, + interval: interval, + dnsClient: dnsclient.NewClient(resolverAddr), + logger: logger.Named("saml-domain-verifier"), + tracer: tp.Tracer("go.probo.inc/probo/pkg/iam/saml_domain_verifier"), } } @@ -202,40 +200,18 @@ func (v *SAMLDomainVerifier) tryVerifyDomain(ctx context.Context, configID gid.G } func (v *SAMLDomainVerifier) checkDNSTXTRecord(ctx context.Context, emailDomain string, expectedValue string) error { - msg := dns.NewMsg(emailDomain, dns.TypeTXT) - - client := dns.NewClient() - - resp, _, err := client.Exchange(ctx, msg, "udp", v.resolverAddr) - if err != nil { - return fmt.Errorf("cannot query TXT record for %q: %w", emailDomain, err) + err := v.dnsClient.CheckTXT(ctx, emailDomain, expectedValue) + if err == nil { + return nil } - if resp.Truncated { - resp, _, err = client.Exchange(ctx, msg, "tcp", v.resolverAddr) - if err != nil { - return fmt.Errorf("cannot query TXT record for %q over TCP: %w", emailDomain, err) - } - } - - if resp.Rcode != dns.RcodeSuccess { - return fmt.Errorf("cannot query TXT record for %q: %s", emailDomain, dns.RcodeToString[resp.Rcode]) - } - - if len(resp.Answer) == 0 { + if errors.Is(err, dnsclient.ErrTXTNotFound) { return fmt.Errorf("%w for %q", errDomainTXTRecordNotFound, emailDomain) } - for _, answer := range resp.Answer { - txt, ok := answer.(*dns.TXT) - if !ok || !dnsverify.EqualNames(txt.Hdr.Name, emailDomain) { - continue - } - - if strings.Join(txt.Txt, "") == expectedValue { - return nil - } + if errors.Is(err, dnsclient.ErrTXTMismatch) { + return fmt.Errorf("%w for %q", errDomainTXTRecordMismatch, emailDomain) } - return fmt.Errorf("%w for %q", errDomainTXTRecordMismatch, emailDomain) + return err }