refactor: extract challenge options

This commit is contained in:
Fernandez Ludovic 2026-02-25 02:05:44 +01:00
commit 8412635bd8
4 changed files with 260 additions and 246 deletions

View file

@ -31,9 +31,6 @@ const (
// ValidateFunc validates a challenge with the ACME server.
type ValidateFunc func(ctx context.Context, core *api.Core, domain string, chlng acme.Challenge) error
// ChallengeOption configures the dns-persist-01 challenge.
type ChallengeOption func(*Challenge) error
// ChallengeInfo contains the information used to create a dns-persist-01 TXT
// record.
type ChallengeInfo struct {
@ -94,143 +91,6 @@ func NewChallenge(core *api.Core, validate ValidateFunc, opts ...ChallengeOption
return chlg, nil
}
// CondOptions Conditional challenge options.
func CondOptions(condition bool, opt ...ChallengeOption) ChallengeOption {
if !condition {
// NoOp options
return func(*Challenge) error {
return nil
}
}
return func(chlg *Challenge) error {
for _, opt := range opt {
err := opt(chlg)
if err != nil {
return err
}
}
return nil
}
}
// WithResolver overrides the resolver used for DNS lookups.
func WithResolver(resolver *Resolver) ChallengeOption {
return func(chlg *Challenge) error {
if resolver == nil {
return errors.New("resolver is nil")
}
chlg.resolver = resolver
return nil
}
}
// WithNameservers overrides resolver nameservers using the default timeout.
func WithNameservers(nameservers []string) ChallengeOption {
return func(chlg *Challenge) error {
chlg.resolver = NewResolver(nameservers)
return nil
}
}
// WithDNSTimeout overrides the default DNS resolver timeout.
func WithDNSTimeout(timeout time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
if chlg.resolver == nil {
chlg.resolver = NewResolver(nil)
}
chlg.resolver.Timeout = timeout
return nil
}
}
// WithAccountURI sets the ACME account URI bound to dns-persist-01 records. It
// is required both to construct the `accounturi=` parameter and to match
// already-provisioned TXT records that should be updated.
func WithAccountURI(accountURI string) ChallengeOption {
return func(chlg *Challenge) error {
if accountURI == "" {
return errors.New("ACME account URI cannot be empty")
}
chlg.accountURI = accountURI
return nil
}
}
// WithIssuerDomainName forces the issuer-domain-name used for dns-persist-01.
// When set, it overrides automatic issuer selection and must match one of the
// issuer-domain-names offered in the ACME challenge. User input is normalized
// and validated at configuration time.
func WithIssuerDomainName(issuerDomainName string) ChallengeOption {
return func(chlg *Challenge) error {
if issuerDomainName == "" {
return nil
}
normalized, err := normalizeUserSuppliedIssuerDomainName(issuerDomainName)
if err != nil {
return err
}
err = validateIssuerDomainName(normalized)
if err != nil {
return err
}
chlg.userSuppliedIssuerDomainName = normalized
return nil
}
}
// WithPersistUntil sets the optional persistUntil value used when constructing
// dns-persist-01 TXT records.
func WithPersistUntil(persistUntil time.Time) ChallengeOption {
return func(chlg *Challenge) error {
if persistUntil.IsZero() {
return errors.New("persistUntil cannot be zero")
}
chlg.persistUntil = persistUntil.UTC().Truncate(time.Second)
return nil
}
}
// WithPropagationTimeout overrides the propagation timeout duration.
func WithPropagationTimeout(timeout time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
if timeout <= 0 {
return errors.New("propagation timeout must be positive")
}
chlg.propagationTimeout = timeout
return nil
}
}
// WithPropagationInterval overrides the propagation polling interval.
func WithPropagationInterval(interval time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
if interval <= 0 {
return errors.New("propagation interval must be positive")
}
chlg.propagationInterval = interval
return nil
}
}
// Solve validates the dns-persist-01 challenge by prompting the user to create
// the required TXT record (if necessary) then performing propagation checks (or
// a wait-only delay) before notifying the ACME server.
@ -320,69 +180,6 @@ func (c *Challenge) getRecursiveNameservers() []string {
return slices.Clone(c.recursiveNameservers)
}
// GetAuthorizationDomainName returns the fully-qualified DNS label used by the
// dns-persist-01 challenge for the given domain.
func GetAuthorizationDomainName(domain string) string {
return dns.Fqdn(validationLabel + "." + domain)
}
// GetChallengeInfo returns information used to create a DNS TXT record which
// can fulfill the `dns-persist-01` challenge. Domain, issuerDomainName, and
// accountURI parameters are required. Wildcard and persistUntil parameters are
// optional.
func GetChallengeInfo(domain, issuerDomainName, accountURI string, wildcard bool, persistUntil time.Time) (ChallengeInfo, error) {
if domain == "" {
return ChallengeInfo{}, errors.New("dnspersist01: domain cannot be empty")
}
value, err := BuildIssueValue(issuerDomainName, accountURI, wildcard, persistUntil)
if err != nil {
return ChallengeInfo{}, err
}
return ChallengeInfo{
FQDN: GetAuthorizationDomainName(domain),
Value: value,
IssuerDomainName: issuerDomainName,
}, nil
}
// validateIssuerDomainNames validates the ACME challenge "issuer-domain-names"
// array for dns-persist-01.
//
// Rules enforced:
// - The array is required and must contain at least 1 entry.
// - The array must not contain more than 10 entries; larger arrays are
// treated as malformed challenges and rejected.
//
// Each issuer-domain-name must be a normalized domain name:
// - represented in A-label (Punycode, RFC5890) form
// - all lowercase
// - no trailing dot
// - maximum total length of 253 octets
//
// The returned list is intended for issuer selection when constructing or
// matching dns-persist-01 TXT records. The challenge can be satisfied by using
// any one valid issuer-domain-name from this list.
func validateIssuerDomainNames(chlng acme.Challenge) error {
if len(chlng.IssuerDomainNames) == 0 {
return errors.New("issuer-domain-names missing from the challenge")
}
if len(chlng.IssuerDomainNames) > 10 {
return errors.New(" issuer-domain-names exceeds maximum length of 10")
}
for _, issuerDomainName := range chlng.IssuerDomainNames {
err := validateIssuerDomainName(issuerDomainName)
if err != nil {
return err
}
}
return nil
}
// selectIssuerDomainName selects the issuer-domain-name to use for a
// dns-persist-01 challenge. If the user has supplied an issuer-domain-name, it
// is used after verifying that it is offered by the ACME challenge. Otherwise,
@ -446,3 +243,66 @@ func (c *Challenge) hasMatchingRecord(records []TXTRecord, issuerDomainName stri
return false
}
// GetAuthorizationDomainName returns the fully-qualified DNS label used by the
// dns-persist-01 challenge for the given domain.
func GetAuthorizationDomainName(domain string) string {
return dns.Fqdn(validationLabel + "." + domain)
}
// GetChallengeInfo returns information used to create a DNS TXT record which
// can fulfill the `dns-persist-01` challenge. Domain, issuerDomainName, and
// accountURI parameters are required. Wildcard and persistUntil parameters are
// optional.
func GetChallengeInfo(domain, issuerDomainName, accountURI string, wildcard bool, persistUntil time.Time) (ChallengeInfo, error) {
if domain == "" {
return ChallengeInfo{}, errors.New("dnspersist01: domain cannot be empty")
}
value, err := BuildIssueValue(issuerDomainName, accountURI, wildcard, persistUntil)
if err != nil {
return ChallengeInfo{}, err
}
return ChallengeInfo{
FQDN: GetAuthorizationDomainName(domain),
Value: value,
IssuerDomainName: issuerDomainName,
}, nil
}
// validateIssuerDomainNames validates the ACME challenge "issuer-domain-names"
// array for dns-persist-01.
//
// Rules enforced:
// - The array is required and must contain at least 1 entry.
// - The array must not contain more than 10 entries; larger arrays are
// treated as malformed challenges and rejected.
//
// Each issuer-domain-name must be a normalized domain name:
// - represented in A-label (Punycode, RFC5890) form
// - all lowercase
// - no trailing dot
// - maximum total length of 253 octets
//
// The returned list is intended for issuer selection when constructing or
// matching dns-persist-01 TXT records. The challenge can be satisfied by using
// any one valid issuer-domain-name from this list.
func validateIssuerDomainNames(chlng acme.Challenge) error {
if len(chlng.IssuerDomainNames) == 0 {
return errors.New("issuer-domain-names missing from the challenge")
}
if len(chlng.IssuerDomainNames) > 10 {
return errors.New(" issuer-domain-names exceeds maximum length of 10")
}
for _, issuerDomainName := range chlng.IssuerDomainNames {
err := validateIssuerDomainName(issuerDomainName)
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,146 @@
package dnspersist01
import (
"errors"
"time"
)
// ChallengeOption configures the dns-persist-01 challenge.
type ChallengeOption func(*Challenge) error
// CondOptions Conditional challenge options.
func CondOptions(condition bool, opt ...ChallengeOption) ChallengeOption {
if !condition {
// NoOp options
return func(*Challenge) error {
return nil
}
}
return func(chlg *Challenge) error {
for _, opt := range opt {
err := opt(chlg)
if err != nil {
return err
}
}
return nil
}
}
// WithResolver overrides the resolver used for DNS lookups.
func WithResolver(resolver *Resolver) ChallengeOption {
return func(chlg *Challenge) error {
if resolver == nil {
return errors.New("resolver is nil")
}
chlg.resolver = resolver
return nil
}
}
// WithNameservers overrides resolver nameservers using the default timeout.
func WithNameservers(nameservers []string) ChallengeOption {
return func(chlg *Challenge) error {
chlg.resolver = NewResolver(nameservers)
return nil
}
}
// WithDNSTimeout overrides the default DNS resolver timeout.
func WithDNSTimeout(timeout time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
if chlg.resolver == nil {
chlg.resolver = NewResolver(nil)
}
chlg.resolver.Timeout = timeout
return nil
}
}
// WithAccountURI sets the ACME account URI bound to dns-persist-01 records. It
// is required both to construct the `accounturi=` parameter and to match
// already-provisioned TXT records that should be updated.
func WithAccountURI(accountURI string) ChallengeOption {
return func(chlg *Challenge) error {
if accountURI == "" {
return errors.New("ACME account URI cannot be empty")
}
chlg.accountURI = accountURI
return nil
}
}
// WithIssuerDomainName forces the issuer-domain-name used for dns-persist-01.
// When set, it overrides automatic issuer selection and must match one of the
// issuer-domain-names offered in the ACME challenge. User input is normalized
// and validated at configuration time.
func WithIssuerDomainName(issuerDomainName string) ChallengeOption {
return func(chlg *Challenge) error {
if issuerDomainName == "" {
return nil
}
normalized, err := normalizeUserSuppliedIssuerDomainName(issuerDomainName)
if err != nil {
return err
}
err = validateIssuerDomainName(normalized)
if err != nil {
return err
}
chlg.userSuppliedIssuerDomainName = normalized
return nil
}
}
// WithPersistUntil sets the optional persistUntil value used when constructing
// dns-persist-01 TXT records.
func WithPersistUntil(persistUntil time.Time) ChallengeOption {
return func(chlg *Challenge) error {
if persistUntil.IsZero() {
return errors.New("persistUntil cannot be zero")
}
chlg.persistUntil = persistUntil.UTC().Truncate(time.Second)
return nil
}
}
// WithPropagationTimeout overrides the propagation timeout duration.
func WithPropagationTimeout(timeout time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
if timeout <= 0 {
return errors.New("propagation timeout must be positive")
}
chlg.propagationTimeout = timeout
return nil
}
}
// WithPropagationInterval overrides the propagation polling interval.
func WithPropagationInterval(interval time.Duration) ChallengeOption {
return func(chlg *Challenge) error {
if interval <= 0 {
return errors.New("propagation interval must be positive")
}
chlg.propagationInterval = interval
return nil
}
}

View file

@ -0,0 +1,50 @@
package dnspersist01
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithIssuerDomainName(t *testing.T) {
testCases := []struct {
desc string
input string
expected string
expectErr bool
}{
{
desc: "normalizes uppercase and trailing dot",
input: "CA.EXAMPLE.",
expected: "ca.example",
},
{
desc: "normalizes idna issuer",
input: "BÜCHER.example",
expected: "xn--bcher-kva.example",
},
{
desc: "rejects invalid issuer",
input: "ca_.example",
expectErr: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
chlg := &Challenge{}
err := WithIssuerDomainName(test.input)(chlg)
if test.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, chlg.userSuppliedIssuerDomainName)
})
}
}

View file

@ -152,7 +152,7 @@ func TestGetChallengeInfo(t *testing.T) {
}
}
func TestValidateIssuerDomainNames(t *testing.T) {
func Test_validateIssuerDomainNames(t *testing.T) {
testCases := []struct {
desc string
issuers []string
@ -204,48 +204,6 @@ func TestValidateIssuerDomainNames(t *testing.T) {
}
}
func TestWithIssuerDomainName(t *testing.T) {
testCases := []struct {
desc string
input string
expected string
expectErr bool
}{
{
desc: "normalizes uppercase and trailing dot",
input: "CA.EXAMPLE.",
expected: "ca.example",
},
{
desc: "normalizes idna issuer",
input: "BÜCHER.example",
expected: "xn--bcher-kva.example",
},
{
desc: "rejects invalid issuer",
input: "ca_.example",
expectErr: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
chlg := &Challenge{}
err := WithIssuerDomainName(test.input)(chlg)
if test.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, chlg.userSuppliedIssuerDomainName)
})
}
}
func TestChallenge_selectIssuerDomainName(t *testing.T) {
testCases := []struct {
desc string