Fix lints.

This commit is contained in:
Samantha 2026-02-24 14:11:48 -05:00
commit 07c36f6495
14 changed files with 137 additions and 52 deletions

View file

@ -59,6 +59,7 @@ type Challenge struct {
userSuppliedIssuerDomainName string
persistUntil *time.Time
recursiveNameservers []string
authoritativeNSPort string
propagationTimeout time.Duration
propagationInterval time.Duration
@ -72,6 +73,7 @@ func NewChallenge(core *api.Core, validate ValidateFunc, opts ...ChallengeOption
resolver: NewResolver(nil),
preCheck: newPreCheck(),
recursiveNameservers: DefaultNameservers(),
authoritativeNSPort: defaultAuthoritativeNSPort,
propagationTimeout: DefaultPropagationTimeout,
propagationInterval: DefaultPollingInterval,
@ -97,7 +99,9 @@ func WithResolver(resolver *Resolver) ChallengeOption {
if resolver == nil {
return errors.New("dnspersist01: resolver is nil")
}
chlg.resolver = resolver
return nil
}
}
@ -116,7 +120,9 @@ func WithDNSTimeout(timeout time.Duration) ChallengeOption {
if chlg.resolver == nil {
chlg.resolver = NewResolver(nil)
}
chlg.resolver.Timeout = timeout
return nil
}
}
@ -129,7 +135,9 @@ func WithAccountURI(accountURI string) ChallengeOption {
if accountURI == "" {
return errors.New("dnspersist01: ACME account URI cannot be empty")
}
chlg.accountURI = accountURI
return nil
}
}
@ -151,6 +159,7 @@ func WithIssuerDomainName(issuerDomainName string) ChallengeOption {
}
chlg.userSuppliedIssuerDomainName = normalized
return nil
}
}
@ -165,6 +174,7 @@ func WithPersistUntil(persistUntil time.Time) ChallengeOption {
ts := persistUntil.UTC().Truncate(time.Second)
chlg.persistUntil = &ts
return nil
}
}
@ -175,7 +185,9 @@ func WithPropagationTimeout(timeout time.Duration) ChallengeOption {
if timeout <= 0 {
return errors.New("dnspersist01: propagation timeout must be positive")
}
chlg.propagationTimeout = timeout
return nil
}
}
@ -186,7 +198,9 @@ func WithPropagationInterval(interval time.Duration) ChallengeOption {
if interval <= 0 {
return errors.New("dnspersist01: propagation interval must be positive")
}
chlg.propagationInterval = interval
return nil
}
}
@ -194,6 +208,8 @@ func WithPropagationInterval(interval time.Duration) ChallengeOption {
// 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.
//
//nolint:gocyclo // challenge flow has several required branches (reuse/manual/wait/propagation/validate).
func (c *Challenge) Solve(ctx context.Context, authz acme.Authorization) error {
if c.resolver == nil {
return errors.New("dnspersist01: resolver is nil")
@ -215,6 +231,7 @@ func (c *Challenge) Solve(ctx context.Context, authz acme.Authorization) error {
}
fqdn := GetAuthorizationDomainName(domain)
result, err := c.resolver.LookupTXT(fqdn)
if err != nil {
return err
@ -230,15 +247,16 @@ func (c *Challenge) Solve(ctx context.Context, authz acme.Authorization) error {
}
if !matcher(result.Records) {
info, err := GetChallengeInfo(domain, issuerDomainName, c.accountURI, authz.Wildcard, c.persistUntil)
if err != nil {
return err
info, infoErr := GetChallengeInfo(domain, issuerDomainName, c.accountURI, authz.Wildcard, c.persistUntil)
if infoErr != nil {
return infoErr
}
displayRecordCreationInstructions(info.FQDN, info.Value)
err = waitForUser()
if err != nil {
return err
waitErr := waitForUser()
if waitErr != nil {
return waitErr
}
} else {
fmt.Printf("dnspersist01: Found existing matching TXT record for %s, no need to create a new one\n", fqdn)
@ -250,15 +268,16 @@ func (c *Challenge) Solve(ctx context.Context, authz acme.Authorization) error {
log.Info("acme: Checking DNS-PERSIST-01 record propagation.",
log.DomainAttr(domain), slog.String("nameservers", strings.Join(c.getRecursiveNameservers(), ",")),
)
time.Sleep(interval)
err = wait.For("propagation", timeout, interval, func() (bool, error) {
ok, err := c.preCheck.call(domain, fqdn, matcher, c.checkDNSPropagation)
if !ok || err != nil {
ok, callErr := c.preCheck.call(domain, fqdn, matcher, c.checkDNSPropagation)
if !ok || callErr != nil {
log.Info("acme: Waiting for DNS-PERSIST-01 record propagation.", log.DomainAttr(domain))
}
return ok, err
return ok, callErr
})
if err != nil {
return err
@ -289,6 +308,7 @@ func GetChallengeInfo(domain, issuerDomainName, accountURI string, wildcard bool
if domain == "" {
return ChallengeInfo{}, errors.New("dnspersist01: domain cannot be empty")
}
if accountURI == "" {
return ChallengeInfo{}, errors.New("dnspersist01: ACME account URI cannot be empty")
}
@ -362,6 +382,7 @@ func (c *Challenge) selectIssuerDomainName(challIssuers []string, records []TXTR
return c.userSuppliedIssuerDomainName, nil
}
for _, issuerDomainName := range sortedIssuers {
if c.hasMatchingRecord(records, issuerDomainName, wildcard) {
return issuerDomainName, nil
@ -377,15 +398,19 @@ func (c *Challenge) hasMatchingRecord(records []TXTRecord, issuerDomainName stri
if err != nil {
continue
}
if parsed.IssuerDomainName != issuerDomainName {
continue
}
if parsed.AccountURI != c.accountURI {
continue
}
if wildcard && strings.ToLower(parsed.Policy) != policyWildcard {
if wildcard && !strings.EqualFold(parsed.Policy, policyWildcard) {
continue
}
if c.persistUntil == nil {
if parsed.PersistUntil != nil {
continue
@ -395,6 +420,7 @@ func (c *Challenge) hasMatchingRecord(records []TXTRecord, issuerDomainName stri
continue
}
}
return true
}

View file

@ -43,7 +43,8 @@ func splitTXTValue(value string) []string {
chunks = append(chunks, value[:maxTXTStringOctets])
value = value[maxTXTStringOctets:]
}
if len(value) > 0 {
if value != "" {
chunks = append(chunks, value)
}

View file

@ -139,6 +139,7 @@ func TestGetChallengeInfo(t *testing.T) {
if test.expectErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectErr)
return
}
@ -229,6 +230,7 @@ func TestWithIssuerDomainName(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
chlg := &Challenge{}
err := WithIssuerDomainName(test.input)(chlg)
if test.expectErr {
require.Error(t, err)

View file

@ -1,6 +1,7 @@
package dnspersist01
import (
"errors"
"fmt"
"strconv"
"strings"
@ -51,12 +52,14 @@ func trimWSP(s string) string {
// ParseIssueValues parses an issue-value string. Unknown parameters are
// preserved in Params.
//
//nolint:gocyclo // parsing and validating tagged parameters requires branching per field.
func ParseIssueValues(value string) (IssueValue, error) {
fields := strings.Split(value, ";")
issuerDomainName := trimWSP(fields[0])
if issuerDomainName == "" {
return IssueValue{}, fmt.Errorf("missing issuer-domain-name")
return IssueValue{}, errors.New("missing issuer-domain-name")
}
parsed := IssueValue{
@ -69,7 +72,7 @@ func ParseIssueValues(value string) (IssueValue, error) {
for _, raw := range fields[1:] {
part := trimWSP(raw)
if part == "" {
return IssueValue{}, fmt.Errorf("empty parameter or trailing semicolon provided")
return IssueValue{}, errors.New("empty parameter or trailing semicolon provided")
}
tagValue := strings.SplitN(part, "=", 2)
@ -79,6 +82,7 @@ func ParseIssueValues(value string) (IssueValue, error) {
tag := trimWSP(tagValue[0])
val := trimWSP(tagValue[1])
if tag == "" {
return IssueValue{}, fmt.Errorf("malformed parameter %q, empty tag", part)
}
@ -87,6 +91,7 @@ func ParseIssueValues(value string) (IssueValue, error) {
if seenTags[key] {
return IssueValue{}, fmt.Errorf("duplicate parameter %q", tag)
}
seenTags[key] = true
for _, r := range val {
@ -100,19 +105,22 @@ func ParseIssueValues(value string) (IssueValue, error) {
switch key {
case paramAccountURI:
if val == "" {
return IssueValue{}, fmt.Errorf("empty value provided for mandatory accounturi")
return IssueValue{}, errors.New("empty value provided for mandatory accounturi")
}
parsed.AccountURI = val
case paramPolicy:
if val != "" && strings.ToLower(val) != policyWildcard {
if val != "" && !strings.EqualFold(val, policyWildcard) {
val = ""
}
parsed.Policy = val
case paramPersistUntil:
ts, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return IssueValue{}, fmt.Errorf("malformed persistUntil timestamp %q", val)
}
persistUntil := time.Unix(ts, 0).UTC()
parsed.PersistUntil = &persistUntil
default:

View file

@ -190,10 +190,12 @@ func TestParseIssueValues(t *testing.T) {
if test.expectErrContains != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectErrContains)
return
}
require.NoError(t, err)
expected := test.expected
expected.PersistUntil = test.expectedPersistUTC
assert.Equal(t, expected, parsed)

View file

@ -1,12 +1,14 @@
package dnspersist01
import (
"errors"
"fmt"
"strings"
"golang.org/x/net/idna"
)
//nolint:gochecknoglobals // test seam for injecting IDNA conversion failures/variants.
var issuerDomainNameToASCII = idna.Lookup.ToASCII
// validateIssuerDomainName validates a single issuer-domain-name according to
@ -19,26 +21,31 @@ var issuerDomainNameToASCII = idna.Lookup.ToASCII
// - A-label (Punycode, RFC5890)
func validateIssuerDomainName(name string) error {
if name == "" {
return fmt.Errorf("issuer-domain-name cannot be empty")
return errors.New("issuer-domain-name cannot be empty")
}
if strings.ToLower(name) != name {
return fmt.Errorf("issuer-domain-name must be lowercase")
return errors.New("issuer-domain-name must be lowercase")
}
if strings.HasSuffix(name, ".") {
return fmt.Errorf("issuer-domain-name must not have a trailing dot")
return errors.New("issuer-domain-name must not have a trailing dot")
}
if len(name) > 253 {
return fmt.Errorf("issuer-domain-name exceeds maximum length of 253 octets")
return errors.New("issuer-domain-name exceeds maximum length of 253 octets")
}
labels := strings.SplitSeq(name, ".")
for label := range labels {
if label == "" {
return fmt.Errorf("issuer-domain-name contains an empty label")
return errors.New("issuer-domain-name contains an empty label")
}
if len(label) > 63 {
return fmt.Errorf("issuer-domain-name label exceeds maximum length of 63 octets")
return errors.New("issuer-domain-name label exceeds maximum length of 63 octets")
}
if !isLDHLabel(label) {
return fmt.Errorf("issuer-domain-name label %q must be a lowercase LDH label", label)
}
@ -48,26 +55,32 @@ func validateIssuerDomainName(name string) error {
if err != nil {
return fmt.Errorf("issuer-domain-name must be represented in A-label format: %w", err)
}
if ascii != name {
return fmt.Errorf("issuer-domain-name must be represented in A-label format")
return errors.New("issuer-domain-name must be represented in A-label format")
}
return nil
}
func isLDHLabel(label string) bool {
if len(label) == 0 {
if label == "" {
return false
}
if !isLowerAlphaNum(label[0]) || !isLowerAlphaNum(label[len(label)-1]) {
return false
}
for i := 0; i < len(label); i++ {
for i := range len(label) {
c := label[i]
if isLowerAlphaNum(c) || c == '-' {
continue
}
return false
}
return true
}
@ -86,5 +99,6 @@ func normalizeUserSuppliedIssuerDomainName(name string) (string, error) {
if err != nil {
return "", fmt.Errorf("normalizing supplied issuer-domain-name %q: %w", n, err)
}
return ascii, nil
}

View file

@ -48,6 +48,7 @@ func TestValidateIssuerDomainName_Errors(t *testing.T) {
func TestValidateIssuerDomainName_ErrorNonCanonicalALabel(t *testing.T) {
originalToASCII := issuerDomainNameToASCII
t.Cleanup(func() {
issuerDomainNameToASCII = originalToASCII
})
@ -63,6 +64,7 @@ func TestValidateIssuerDomainName_ErrorNonCanonicalALabel(t *testing.T) {
func TestValidateIssuerDomainName_Valid(t *testing.T) {
originalToASCII := issuerDomainNameToASCII
t.Cleanup(func() {
issuerDomainNameToASCII = originalToASCII
})
@ -76,12 +78,14 @@ func TestValidateIssuerDomainName_Valid(t *testing.T) {
}
func TestValidateIssuerDomainName_ErrorWrap(t *testing.T) {
sentinelErr := errors.New("sentinel idna failure")
originalToASCII := issuerDomainNameToASCII
t.Cleanup(func() {
issuerDomainNameToASCII = originalToASCII
})
sentinelErr := errors.New("sentinel idna failure")
issuerDomainNameToASCII = func(string) (string, error) {
return "", sentinelErr
}

View file

@ -33,19 +33,14 @@ func fakeTXT(name, value string, ttl uint32) *dns.TXT {
// mockResolver modifies the default DNS resolver to use a custom network address during the test execution.
// IMPORTANT: it modifies global variables.
func mockResolver(t *testing.T, addr net.Addr) {
func mockResolver(t *testing.T, addr net.Addr) string {
t.Helper()
_, port, err := net.SplitHostPort(addr.String())
require.NoError(t, err)
originalDefaultNameserverPort := defaultNameserverPort
t.Cleanup(func() {
defaultNameserverPort = originalDefaultNameserverPort
})
defaultNameserverPort = port
originalResolver := net.DefaultResolver
t.Cleanup(func() {
net.DefaultResolver = originalResolver
})
@ -58,4 +53,6 @@ func mockResolver(t *testing.T, addr net.Addr) {
return d.DialContext(ctx, network, addr.String())
},
}
return port
}

View file

@ -9,8 +9,7 @@ import (
"github.com/miekg/dns"
)
// defaultNameserverPort used by authoritative NS.
var defaultNameserverPort = "53"
const defaultAuthoritativeNSPort = "53"
// RecordMatcher returns true when the expected record is present.
type RecordMatcher func(records []TXTRecord) bool
@ -133,7 +132,7 @@ func (c *Challenge) checkDNSPropagation(fqdn string, matcher RecordMatcher) (boo
func (c *Challenge) checkNameserversPropagation(fqdn string, nameservers []string, addPort, recursive bool, matcher RecordMatcher) (bool, error) {
for _, ns := range nameservers {
if addPort {
ns = net.JoinHostPort(ns, defaultNameserverPort)
ns = net.JoinHostPort(ns, c.getAuthoritativeNSPort())
}
result, err := c.resolver.lookupTXT(fqdn, []string{ns}, recursive)
@ -149,6 +148,14 @@ func (c *Challenge) checkNameserversPropagation(fqdn string, nameservers []strin
return true, nil
}
func (c *Challenge) getAuthoritativeNSPort() string {
if c == nil || c.authoritativeNSPort == "" {
return defaultAuthoritativeNSPort
}
return c.authoritativeNSPort
}
func txtValues(records []TXTRecord) []string {
values := make([]string, 0, len(records))
for _, record := range records {

View file

@ -44,13 +44,14 @@ func Test_preCheck_checkDNSPropagation(t *testing.T) {
).
Build(t)
mockResolver(t, addr)
port := mockResolver(t, addr)
resolver := NewResolver([]string{addr.String()})
chlg := &Challenge{
resolver: resolver,
preCheck: newPreCheck(),
recursiveNameservers: ParseNameservers([]string{addr.String()}),
authoritativeNSPort: port,
}
testCases := []struct {

View file

@ -5,7 +5,6 @@ import (
"fmt"
"net"
"os"
"slices"
"strconv"
"strings"
"time"
@ -15,11 +14,6 @@ import (
const defaultResolvConf = "/etc/resolv.conf"
var defaultNameservers = []string{
"google-public-dns-a.google.com:53",
"google-public-dns-b.google.com:53",
}
// Resolver performs DNS lookups using the configured nameservers and timeout.
type Resolver struct {
Nameservers []string
@ -55,12 +49,19 @@ func NewResolver(nameservers []string) *Resolver {
func DefaultNameservers() []string {
config, err := dns.ClientConfigFromFile(defaultResolvConf)
if err != nil || len(config.Servers) == 0 {
return slices.Clone(defaultNameservers)
return defaultFallbackNameservers()
}
return ParseNameservers(config.Servers)
}
func defaultFallbackNameservers() []string {
return []string{
"google-public-dns-a.google.com:53",
"google-public-dns-b.google.com:53",
}
}
// ParseNameservers ensures all servers have a port number.
func ParseNameservers(servers []string) []string {
var resolvers []string
@ -109,6 +110,7 @@ func (r *Resolver) lookupTXT(fqdn string, nameservers []string, recursive bool)
if _, ok := seen[name]; ok {
return result, fmt.Errorf("CNAME loop detected for %s", name)
}
seen[name] = struct{}{}
msg, err := dnsQueryWithTimeout(name, dns.TypeTXT, nameservers, recursive, timeout)
@ -188,9 +190,11 @@ func dnsQueryWithTimeout(fqdn string, rtype uint16, nameservers []string, recurs
return nil, &DNSError{Message: "empty list of nameservers"}
}
var msg *dns.Msg
var err error
var errAll error
var (
msg *dns.Msg
err error
errAll error
)
for _, ns := range nameservers {
msg, err = sendDNSQuery(m, ns, timeout)
@ -267,6 +271,7 @@ func (d *DNSError) Error() string {
for _, question := range questions {
parts = append(parts, strings.ReplaceAll(strings.TrimPrefix(question.String(), ";"), "\t", " "))
}
return strings.Join(parts, ";")
}

View file

@ -30,9 +30,11 @@ func (a byType) Less(i, j int) bool {
if a[i].Type == string(challenge.DNS01) && a[j].Type == string(challenge.DNSPersist01) {
return true
}
if a[i].Type == string(challenge.DNSPersist01) && a[j].Type == string(challenge.DNS01) {
return false
}
return a[i].Type > a[j].Type
}

View file

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"log/slog"
"net"
@ -22,6 +23,7 @@ import (
"github.com/urfave/cli/v3"
)
//nolint:gocyclo // challenge setup dispatch is expected to branch by enabled challenge type.
func setupChallenges(cmd *cli.Command, client *lego.Client, account registration.User) {
if !cmd.Bool(flgHTTP) && !cmd.Bool(flgTLS) && !cmd.IsSet(flgDNS) && !cmd.Bool(flgDNSPersist) {
log.Fatal(fmt.Sprintf("No challenge selected. You must specify at least one challenge: `--%s`, `--%s`, `--%s`, `--%s`.", flgHTTP, flgTLS, flgDNS, flgDNSPersist))
@ -205,9 +207,10 @@ func setupDNS(cmd *cli.Command, client *lego.Client) error {
return err
}
//nolint:gocyclo // option assembly mirrors CLI flags and challenge configuration branches.
func setupDNSPersist(cmd *cli.Command, client *lego.Client, account registration.User) error {
if account == nil || account.GetRegistration() == nil || account.GetRegistration().URI == "" {
return fmt.Errorf("dns-persist-01 requires a registered account with an account URI")
return errors.New("dns-persist-01 requires a registered account with an account URI")
}
err := validateDNSPersistPropagationExclusiveOptions(cmd)
@ -233,8 +236,10 @@ func setupDNSPersist(cmd *cli.Command, client *lego.Client, account registration
if cmd.IsSet(flgDNSPersistResolvers) {
resolvers := cmd.StringSlice(flgDNSPersistResolvers)
if len(resolvers) > 0 {
opts = append(opts, dnspersist01.WithNameservers(resolvers))
opts = append(opts, dnspersist01.AddRecursiveNameservers(resolvers))
opts = append(opts,
dnspersist01.WithNameservers(resolvers),
dnspersist01.AddRecursiveNameservers(resolvers),
)
}
}

View file

@ -57,6 +57,7 @@ func setTXTRecordRaw(host, value string) error {
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
@ -76,6 +77,7 @@ func clearTXTRecord(t *testing.T, host string) {
resp, err := http.Post("http://localhost:8055/clear-txt", "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
@ -157,10 +159,12 @@ func waitForCLIAccountURI(ctx context.Context, email string) (string, error) {
if os.IsNotExist(err) {
continue
}
return "", err
}
var account accountFile
err = json.Unmarshal(content, &account)
if err != nil {
continue
@ -234,6 +238,7 @@ func TestChallengeDNSPersist_Run(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
accountURI := createCLIAccountState(t, testPersistCLIEmail)
@ -266,28 +271,32 @@ func TestChallengeDNSPersist_Run_NewAccount(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
txtHost := fmt.Sprintf("_validation-persist.%s", testPersistCLIDomain)
defer clearTXTRecord(t, txtHost)
stdinReader, stdinWriter := io.Pipe()
defer func() { _ = stdinReader.Close() }()
errChan := make(chan error, 1)
go func() {
defer func() { _ = stdinWriter.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
accountURI, err := waitForCLIAccountURI(ctx, testPersistCLIFreshEmail)
if err != nil {
errChan <- fmt.Errorf("wait for account URI: %w", err)
accountURI, waitErr := waitForCLIAccountURI(ctx, testPersistCLIFreshEmail)
if waitErr != nil {
errChan <- fmt.Errorf("wait for account URI: %w", waitErr)
return
}
txtValue := dnspersist01.BuildIssueValues(testPersistIssuer, accountURI, true, nil)
err = setTXTRecordRaw(txtHost, txtValue)
if err != nil {
errChan <- fmt.Errorf("set TXT record: %w", err)
@ -299,6 +308,7 @@ func TestChallengeDNSPersist_Run_NewAccount(t *testing.T) {
errChan <- fmt.Errorf("send enter to lego: %w", err)
return
}
errChan <- nil
}()
@ -325,6 +335,7 @@ func TestChallengeDNSPersist_Renew(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
accountURI := createCLIAccountState(t, testPersistCLIRenewEmail)