Merge branch 'master'

This commit is contained in:
Fernandez Ludovic 2026-02-22 23:48:06 +01:00
commit f9455e84fc
122 changed files with 6218 additions and 376 deletions

View file

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
env:
GO_VERSION: stable
GOLANGCI_LINT_VERSION: v2.9.0
GOLANGCI_LINT_VERSION: v2.10
HUGO_VERSION: 0.148.2
CGO_ENABLED: 0
LEGO_E2E_TESTS: CI

View file

@ -6,6 +6,36 @@ Everybody thinks that the others will donate, but in the end, nobody does.
So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev).
## v4.32.0
- Release date: 2026-02-19
- Tag: [v4.32.0](https://github.com/go-acme/lego/releases/tag/v4.32.0)
### Added
- **[dnsprovider]** Add DNS provider for ArtFiles
- **[dnsprovider]** Add DNS provider for Leaseweb
- **[dnsprovider]** Add DNS provider for FusionLayer NameSurfer
- **[dnsprovider]** Add DNS provider for DDNSS
- **[dnsprovider]** Add DNS provider for Bluecat v2
- **[dnsprovider]** Add DNS provider for TodayNIC/时代互联
- **[dnsprovider]** Add DNS provider for DNSExit
- **[dnsprovider]** alidns: add line record option
### Changed
- **[dnsprovider]** azure: reinforces deprecation
- **[dnsprovider]** allinkl: detect zone through API
### Fixed
- **[ari]** fix: implement parsing for Retry-After header according to RFC 7231
- **[dnsprovider]** namesurfer: fix updateDNSHost
- **[dnsprovider]** timewebcloud: fix subdomain support
- **[dnsprovider]** fix: deduplicate authz for DNS01 challenge
- **[lib,cli]** fix: use IPs to define the main domain
- **[lib]** fix: preserve domain order
## v4.31.0
- Release date: 2026-01-08

View file

@ -5,7 +5,7 @@
# Lego
Let's Encrypt client and ACME library written in Go.
[ACME](https://www.rfc-editor.org/rfc/rfc8555.html) client and library for Let's Encrypt and other ACME CAs written in Go.
[![Go Reference](https://pkg.go.dev/badge/github.com/go-acme/lego/v5.svg)](https://pkg.go.dev/github.com/go-acme/lego/v5)
[![Build Status](https://github.com//go-acme/lego/workflows/Main/badge.svg?branch=master)](https://github.com//go-acme/lego/actions)
@ -73,117 +73,122 @@ If your DNS provider is not supported, please open an [issue](https://github.com
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/route53/">Amazon Route 53</a></td>
<td><a href="https://go-acme.github.io/lego/dns/anexia/">Anexia CloudDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/artfiles/">ArtFiles</a></td>
<td><a href="https://go-acme.github.io/lego/dns/arvancloud/">ArvanCloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/auroradns/">Aurora DNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/auroradns/">Aurora DNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/autodns/">Autodns</a></td>
<td><a href="https://go-acme.github.io/lego/dns/axelname/">Axelname</a></td>
<td><a href="https://go-acme.github.io/lego/dns/azion/">Azion</a></td>
<td><a href="https://go-acme.github.io/lego/dns/azure/">Azure (deprecated)</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/azure/">Azure (deprecated)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/azuredns/">Azure DNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/baiducloud/">Baidu Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/beget/">Beget.com</a></td>
<td><a href="https://go-acme.github.io/lego/dns/binarylane/">Binary Lane</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/binarylane/">Binary Lane</a></td>
<td><a href="https://go-acme.github.io/lego/dns/bindman/">Bindman</a></td>
<td><a href="https://go-acme.github.io/lego/dns/bluecat/">Bluecat</a></td>
<td><a href="https://go-acme.github.io/lego/dns/bluecatv2/">Bluecat v2</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/bookmyname/">BookMyName</a></td>
<td><a href="https://go-acme.github.io/lego/dns/brandit/">Brandit (deprecated)</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/bunny/">Bunny</a></td>
<td><a href="https://go-acme.github.io/lego/dns/checkdomain/">Checkdomain</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/civo/">Civo</a></td>
<td><a href="https://go-acme.github.io/lego/dns/cloudru/">Cloud.ru</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/clouddns/">CloudDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/cloudflare/">Cloudflare</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/cloudns/">ClouDNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/cloudxns/">CloudXNS (Deprecated)</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/conoha/">ConoHa v2</a></td>
<td><a href="https://go-acme.github.io/lego/dns/conohav3/">ConoHa v3</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/constellix/">Constellix</a></td>
<td><a href="https://go-acme.github.io/lego/dns/corenetworks/">Core-Networks</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/cpanel/">CPanel/WHM</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ddnss/">DDnss (DynDNS Service)</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/derak/">Derak Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/desec/">deSEC.io</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/designate/">Designate DNSaaS for Openstack</a></td>
<td><a href="https://go-acme.github.io/lego/dns/digitalocean/">Digital Ocean</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/directadmin/">DirectAdmin</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnsmadeeasy/">DNS Made Easy</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dnsexit/">DNSExit</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnshomede/">dnsHome.de</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dnsimple/">DNSimple</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dnspod/">DNSPod (deprecated)</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dode/">Domain Offensive (do.de)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/domeneshop/">Domeneshop</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dreamhost/">DreamHost</a></td>
<td><a href="https://go-acme.github.io/lego/dns/duckdns/">Duck DNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dyn/">Dyn</a></td>
<td><a href="https://go-acme.github.io/lego/dns/dyndnsfree/">DynDnsFree.de</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/dynu/">Dynu</a></td>
<td><a href="https://go-acme.github.io/lego/dns/easydns/">EasyDNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/edgecenter/">EdgeCenter</a></td>
<td><a href="https://go-acme.github.io/lego/dns/efficientip/">Efficient IP</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/epik/">Epik</a></td>
<td><a href="https://go-acme.github.io/lego/dns/exoscale/">Exoscale</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/exec/">External program</a></td>
<td><a href="https://go-acme.github.io/lego/dns/f5xc/">F5 XC</a></td>
<td><a href="https://go-acme.github.io/lego/dns/freemyip/">freemyip.com</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gcore/">G-Core</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/freemyip/">freemyip.com</a></td>
<td><a href="https://go-acme.github.io/lego/dns/namesurfer/">FusionLayer NameSurfer</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gcore/">G-Core</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gandi/">Gandi</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/gandiv5/">Gandi Live DNS (v5)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gigahostno/">Gigahost.no</a></td>
<td><a href="https://go-acme.github.io/lego/dns/glesys/">Glesys</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/godaddy/">Go Daddy</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/gcloud/">Google Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/googledomains/">Google Domains</a></td>
<td><a href="https://go-acme.github.io/lego/dns/gravity/">Gravity</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/hetzner/">Hetzner</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/hostingde/">Hosting.de</a></td>
<td><a href="https://go-acme.github.io/lego/dns/hostingnl/">Hosting.nl</a></td>
<td><a href="https://go-acme.github.io/lego/dns/hostinger/">Hostinger</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/hosttech/">Hosttech</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/httpreq/">HTTP request</a></td>
<td><a href="https://go-acme.github.io/lego/dns/httpnet/">http.net</a></td>
<td><a href="https://go-acme.github.io/lego/dns/huaweicloud/">Huawei Cloud</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/hurricane/">Hurricane Electric DNS</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/hyperone/">HyperOne</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ibmcloud/">IBM Cloud (SoftLayer)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/iijdpf/">IIJ DNS Platform Service</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/infoblox/">Infoblox</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/infomaniak/">Infomaniak</a></td>
<td><a href="https://go-acme.github.io/lego/dns/iij/">Internet Initiative Japan</a></td>
<td><a href="https://go-acme.github.io/lego/dns/internetbs/">Internet.bs</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/inwx/">INWX</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/ionos/">Ionos</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ionoscloud/">Ionos Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/ipv64/">IPv64</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/ispconfig/">ISPConfig 3</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/ispconfigddns/">ISPConfig 3 - Dynamic DNS (DDNS) Module</a></td>
<td><a href="https://go-acme.github.io/lego/dns/iwantmyname/">iwantmyname (Deprecated)</a></td>
<td><a href="https://go-acme.github.io/lego/dns/jdcloud/">JD Cloud</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/joker/">Joker</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/acmedns/">Joohoi&#39;s ACME-DNS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/keyhelp/">KeyHelp</a></td>
<td><a href="https://go-acme.github.io/lego/dns/leaseweb/">Leaseweb</a></td>
<td><a href="https://go-acme.github.io/lego/dns/liara/">Liara</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/limacity/">Lima-City</a></td>

View file

@ -2,7 +2,6 @@ package api
import (
"cmp"
"maps"
"net"
"slices"
@ -10,7 +9,9 @@ import (
)
func createIdentifiers(domains []string) []acme.Identifier {
uniqIdentifiers := make(map[string]acme.Identifier)
uniqIdentifiers := make(map[string]struct{})
var identifiers []acme.Identifier
for _, domain := range domains {
if _, ok := uniqIdentifiers[domain]; ok {
@ -23,10 +24,12 @@ func createIdentifiers(domains []string) []acme.Identifier {
ident.Type = "ip"
}
uniqIdentifiers[domain] = ident
identifiers = append(identifiers, ident)
uniqIdentifiers[domain] = struct{}{}
}
return slices.AppendSeq(make([]acme.Identifier, 0, len(uniqIdentifiers)), maps.Values(uniqIdentifiers))
return identifiers
}
// compareIdentifiers compares 2 slices of [acme.Identifier].

View file

@ -4,7 +4,7 @@ package sender
const (
// ourUserAgent is the User-Agent of this underlying library package.
ourUserAgent = "xenolf-acme/4.31.0"
ourUserAgent = "xenolf-acme/4.32.0"
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release

View file

@ -1,8 +1,11 @@
package api
import (
"fmt"
"net/http"
"regexp"
"strconv"
"time"
)
type service struct {
@ -56,3 +59,29 @@ func getRetryAfter(resp *http.Response) string {
return resp.Header.Get("Retry-After")
}
// ParseRetryAfter parses the Retry-After header value according to RFC 7231.
// The header can be either delay-seconds (numeric) or HTTP-date (RFC 1123 format).
// https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3
// Returns the duration until the retry time.
// TODO(ldez): unexposed this function in v5.
func ParseRetryAfter(value string) (time.Duration, error) {
if value == "" {
return 0, nil
}
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.Duration(seconds) * time.Second, nil
}
if retryTime, err := time.Parse(time.RFC1123, value); err == nil {
duration := time.Until(retryTime)
if duration < 0 {
return 0, nil
}
return duration, nil
}
return 0, fmt.Errorf("invalid Retry-After value: %q", value)
}

View file

@ -3,8 +3,10 @@ package api
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_getLink(t *testing.T) {
@ -53,3 +55,38 @@ func Test_getLink(t *testing.T) {
})
}
}
func TestParseRetryAfter(t *testing.T) {
testCases := []struct {
desc string
value string
expected time.Duration
}{
{
desc: "empty header value",
value: "",
expected: time.Duration(0),
},
{
desc: "delay-seconds",
value: "123",
expected: 123 * time.Second,
},
{
desc: "HTTP-date",
value: time.Now().Add(3 * time.Second).Format(time.RFC1123),
expected: 3 * time.Second,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
rt, err := ParseRetryAfter(test.value)
require.NoError(t, err)
assert.InDelta(t, test.expected.Seconds(), rt.Seconds(), 1)
})
}
}

View file

@ -29,18 +29,18 @@ type ProblemDetails struct {
}
func (p *ProblemDetails) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("acme: error: %d", p.HTTPStatus))
_, _ = fmt.Fprintf(msg, "acme: error: %d", p.HTTPStatus)
if p.Method != "" || p.URL != "" {
msg.WriteString(fmt.Sprintf(" :: %s :: %s", p.Method, p.URL))
_, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Method, p.URL)
}
msg.WriteString(fmt.Sprintf(" :: %s :: %s", p.Type, p.Detail))
_, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Type, p.Detail)
for _, sub := range p.SubProblems {
msg.WriteString(fmt.Sprintf(", problem: %q :: %s", sub.Type, sub.Detail))
_, _ = fmt.Fprintf(msg, ", problem: %q :: %s", sub.Type, sub.Detail)
}
if p.Instance != "" {

View file

@ -220,15 +220,15 @@ func ParsePEMCertificate(cert []byte) (*x509.Certificate, error) {
}
func GetCertificateMainDomain(cert *x509.Certificate) (string, error) {
return getMainDomain(cert.Subject, cert.DNSNames)
return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses)
}
func GetCSRMainDomain(cert *x509.CertificateRequest) (string, error) {
return getMainDomain(cert.Subject, cert.DNSNames)
return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses)
}
func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) {
if subject.CommonName == "" && len(dnsNames) == 0 {
func getMainDomain(subject pkix.Name, dnsNames []string, ips []net.IP) (string, error) {
if subject.CommonName == "" && len(dnsNames) == 0 && len(ips) == 0 {
return "", errors.New("missing domain")
}
@ -236,7 +236,11 @@ func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) {
return subject.CommonName, nil
}
return dnsNames[0], nil
if len(dnsNames) > 0 {
return dnsNames[0], nil
}
return ips[0].String(), nil
}
func ExtractDomains(cert *x509.Certificate) []string {

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/acme/api"
)
// RenewalInfoRequest contains the necessary renewal information.
@ -93,9 +94,9 @@ func (c *Certifier) GetRenewalInfo(ctx context.Context, req RenewalInfoRequest)
}
if retry := resp.Header.Get("Retry-After"); retry != "" {
info.RetryAfter, err = time.ParseDuration(retry + "s")
info.RetryAfter, err = api.ParseRetryAfter(retry)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to parse Retry-After header: %w", err)
}
}

View file

@ -74,6 +74,42 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {
assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter)
}
func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID,
servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
}`).
WithHeader("Content-Type", "application/json").
WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
ri, err := certifier.GetRenewalInfo(t.Context(), RenewalInfoRequest{leaf})
require.NoError(t, err)
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001)
}
func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)

View file

@ -100,11 +100,26 @@ func (p *Prober) Solve(ctx context.Context, authorizations []acme.Authorization)
}
func sequentialSolve(ctx context.Context, authSolvers []*selectedAuthSolver, failures obtainError) {
// Some CA are using the same token,
// this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
// In the sequential mode, this is not a problem because we can solve the challenges in order.
// But it can reduce the number of call the DNS provider APIs.
uniq := make(map[string]struct{})
for i, authSolver := range authSolvers {
// Submit the challenge
domain := challenge.GetTargetedDomain(authSolver.authz)
chlg, _ := challenge.FindChallenge(challenge.DNS01, authSolver.authz)
if solvr, ok := authSolver.solver.(preSolver); ok {
if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok && chlg.Token != "" {
log.Debug("acme: duplicate token (DNS-01); skipping pre-solve.",
slog.String("identifier", authSolver.authz.Identifier.Value))
continue
}
err := solvr.PreSolve(ctx, authSolver.authz)
if err != nil {
failures[domain] = err
@ -113,6 +128,8 @@ func sequentialSolve(ctx context.Context, authSolvers []*selectedAuthSolver, fai
continue
}
uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{}
}
// Solve the challenge
@ -125,22 +142,46 @@ func sequentialSolve(ctx context.Context, authSolvers []*selectedAuthSolver, fai
continue
}
// Clean challenge
cleanUp(ctx, authSolver.solver, authSolver.authz)
if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok || chlg.Token == "" {
// Clean challenge
cleanUp(ctx, authSolver.solver, authSolver.authz)
if len(authSolvers)-1 > i {
solvr := authSolver.solver.(sequential)
_, interval := solvr.Sequential()
log.Info("sequence: wait.", slog.Duration("interval", interval))
time.Sleep(interval)
if len(authSolvers)-1 > i {
solvr := authSolver.solver.(sequential)
_, interval := solvr.Sequential()
log.Info("sequence: wait.", slog.Duration("interval", interval))
time.Sleep(interval)
}
delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
} else {
log.Debug("acme: duplicate token (DNS-01); skipping cleanup.",
slog.String("identifier", authSolver.authz.Identifier.Value))
}
}
}
func parallelSolve(ctx context.Context, authSolvers []*selectedAuthSolver, failures obtainError) {
// Some CA are using the same token,
// this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
uniq := make(map[string]struct{})
// For all valid preSolvers, first submit the challenges, so they have max time to propagate
for _, authSolver := range authSolvers {
authz := authSolver.authz
chlg, err := challenge.FindChallenge(challenge.DNS01, authz)
if err == nil {
if _, ok := uniq[authz.Identifier.Value+chlg.Token]; ok {
log.Debug("acme: duplicate token (DNS-01); skipping pre-solve.",
slog.String("identifier", authSolver.authz.Identifier.Value))
continue
}
uniq[authz.Identifier.Value+chlg.Token] = struct{}{}
}
if solvr, ok := authSolver.solver.(preSolver); ok {
err := solvr.PreSolve(ctx, authz)
if err != nil {
@ -152,6 +193,18 @@ func parallelSolve(ctx context.Context, authSolvers []*selectedAuthSolver, failu
defer func() {
// Clean all created TXT records
for _, authSolver := range authSolvers {
chlg, err := challenge.FindChallenge(challenge.DNS01, authSolver.authz)
if err == nil {
if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok {
delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
} else {
log.Debug("acme: duplicate token (DNS-01); skipping cleanup.",
slog.String("identifier", authSolver.authz.Identifier.Value))
continue
}
}
cleanUp(ctx, authSolver.solver, authSolver.authz)
}
}()

View file

@ -2,6 +2,7 @@ package resolver
import (
"context"
"fmt"
"time"
"github.com/go-acme/lego/v5/acme"
@ -12,34 +13,68 @@ type preSolverMock struct {
preSolve map[string]error
solve map[string]error
cleanUp map[string]error
preSolveCounter int
solveCounter int
cleanUpCounter int
}
func (s *preSolverMock) PreSolve(ctx context.Context, authorization acme.Authorization) error {
s.preSolveCounter++
return s.preSolve[authorization.Identifier.Value]
}
func (s *preSolverMock) Solve(ctx context.Context, authorization acme.Authorization) error {
s.solveCounter++
return s.solve[authorization.Identifier.Value]
}
func (s *preSolverMock) CleanUp(authorization acme.Authorization) error {
func (s *preSolverMock) CleanUp(ctx context.Context, authorization acme.Authorization) error {
s.cleanUpCounter++
return s.cleanUp[authorization.Identifier.Value]
}
func (s *preSolverMock) String() string {
return fmt.Sprintf("PreSolve: %d, Solve: %d, CleanUp: %d", s.preSolveCounter, s.solveCounter, s.cleanUpCounter)
}
func createStubAuthorizationHTTP01(domain, status string) acme.Authorization {
return createStubAuthorization(domain, status, false, acme.Challenge{
Type: challenge.HTTP01.String(),
Validated: time.Now(),
})
}
func createStubAuthorizationDNS01(domain string, wildcard bool) acme.Authorization {
var chlgs []acme.Challenge
if wildcard {
chlgs = append(chlgs, acme.Challenge{
Type: challenge.HTTP01.String(),
Validated: time.Now(),
})
}
chlgs = append(chlgs, acme.Challenge{
Type: challenge.DNS01.String(),
Validated: time.Now(),
})
return createStubAuthorization(domain, acme.StatusProcessing, wildcard, chlgs...)
}
func createStubAuthorization(domain, status string, wildcard bool, chlgs ...acme.Challenge) acme.Authorization {
return acme.Authorization{
Status: status,
Expires: time.Now(),
Wildcard: wildcard,
Status: status,
Expires: time.Now(),
Identifier: acme.Identifier{
Type: challenge.HTTP01.String(),
Type: "dns",
Value: domain,
},
Challenges: []acme.Challenge{
{
Type: challenge.HTTP01.String(),
Validated: time.Now(),
Error: nil,
},
},
Challenges: chlgs,
}
}

View file

@ -2,19 +2,22 @@ package resolver
import (
"errors"
"fmt"
"testing"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/challenge"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProber_Solve(t *testing.T) {
testCases := []struct {
desc string
solvers map[challenge.Type]solver
authz []acme.Authorization
expectedError string
desc string
solvers map[challenge.Type]solver
authz []acme.Authorization
expectedError string
expectedCounters map[challenge.Type]string
}{
{
desc: "success",
@ -30,6 +33,30 @@ func TestProber_Solve(t *testing.T) {
createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
},
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 3, Solve: 3, CleanUp: 3",
},
},
{
desc: "DNS-01 deduplicate",
solvers: map[challenge.Type]solver{
challenge.DNS01: &preSolverMock{
preSolve: map[string]error{},
solve: map[string]error{},
cleanUp: map[string]error{},
},
},
authz: []acme.Authorization{
createStubAuthorizationDNS01("a.example", false),
createStubAuthorizationDNS01("a.example", true),
createStubAuthorizationDNS01("b.example", false),
createStubAuthorizationDNS01("b.example", true),
createStubAuthorizationDNS01("c.example", true),
createStubAuthorizationDNS01("d.example", false),
},
expectedCounters: map[challenge.Type]string{
challenge.DNS01: "PreSolve: 4, Solve: 6, CleanUp: 4",
},
},
{
desc: "already valid",
@ -45,6 +72,9 @@ func TestProber_Solve(t *testing.T) {
createStubAuthorizationHTTP01("example.org", acme.StatusValid),
createStubAuthorizationHTTP01("example.net", acme.StatusValid),
},
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 0, Solve: 0, CleanUp: 0",
},
},
{
desc: "when preSolve fail, auth is flagged as error and skipped",
@ -69,6 +99,9 @@ func TestProber_Solve(t *testing.T) {
expectedError: `error: one or more domains had a problem:
[example.com] preSolve error example.com
`,
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
},
},
{
desc: "errors at different stages",
@ -95,6 +128,9 @@ func TestProber_Solve(t *testing.T) {
[example.com] preSolve error example.com
[example.org] solve error example.org
`,
expectedCounters: map[challenge.Type]string{
challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
},
},
}
@ -112,6 +148,10 @@ func TestProber_Solve(t *testing.T) {
} else {
require.NoError(t, err)
}
for n, s := range test.solvers {
assert.Equal(t, test.expectedCounters[n], fmt.Sprintf("%s", s))
}
})
}
}

View file

@ -6,7 +6,6 @@ import (
"fmt"
"log/slog"
"sort"
"strconv"
"time"
"github.com/cenkalti/backoff/v5"
@ -95,20 +94,18 @@ func validate(ctx context.Context, core *api.Core, domain string, chlg acme.Chal
return nil
}
ra, err := strconv.Atoi(chlng.RetryAfter)
if err != nil {
retryAfter, err := api.ParseRetryAfter(chlng.RetryAfter)
if err != nil || retryAfter == 0 {
// The ACME server MUST return a Retry-After.
// If it doesn't, we'll just poll hard.
// If it doesn't, or if it's invalid, we'll just poll hard.
// Boulder does not implement the ability to retry challenges or the Retry-After header.
// https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md#section-82
ra = 5
retryAfter = 5 * time.Second
}
initialInterval := time.Duration(ra) * time.Second
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = initialInterval
bo.MaxInterval = 10 * initialInterval
bo.InitialInterval = retryAfter
bo.MaxInterval = 10 * retryAfter
// After the path is sent, the ACME server will access our server.
// Repeatedly check the server for an updated status on our request.
@ -133,7 +130,7 @@ func validate(ctx context.Context, core *api.Core, domain string, chlg acme.Chal
return wait.Retry(ctx, operation,
backoff.WithBackOff(bo),
backoff.WithMaxElapsedTime(100*initialInterval))
backoff.WithMaxElapsedTime(100*retryAfter))
}
func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) {

View file

@ -463,26 +463,26 @@ func (f FormattableDuration) String() string {
seconds := int(d.Seconds()) % 60
ns := int(d.Nanoseconds()) % int(time.Second)
var s strings.Builder
s := new(strings.Builder)
if days > 0 {
s.WriteString(fmt.Sprintf("%dd", days))
_, _ = fmt.Fprintf(s, "%dd", days)
}
if hours > 0 {
s.WriteString(fmt.Sprintf("%dh", hours))
_, _ = fmt.Fprintf(s, "%dh", hours)
}
if minutes > 0 {
s.WriteString(fmt.Sprintf("%dm", minutes))
_, _ = fmt.Fprintf(s, "%dm", minutes)
}
if seconds > 0 {
s.WriteString(fmt.Sprintf("%ds", seconds))
_, _ = fmt.Fprintf(s, "%ds", seconds)
}
if ns > 0 {
s.WriteString(fmt.Sprintf("%dns", ns))
_, _ = fmt.Fprintf(s, "%dns", ns)
}
return s.String()

View file

@ -19,6 +19,7 @@ func allDNSCodes() string {
"allinkl",
"alwaysdata",
"anexia",
"artfiles",
"arvancloud",
"auroradns",
"autodns",
@ -31,6 +32,7 @@ func allDNSCodes() string {
"binarylane",
"bindman",
"bluecat",
"bluecatv2",
"bookmyname",
"brandit",
"bunny",
@ -110,6 +112,7 @@ func allDNSCodes() string {
"jdcloud",
"joker",
"keyhelp",
"leaseweb",
"liara",
"lightsail",
"limacity",
@ -130,6 +133,7 @@ func allDNSCodes() string {
"namecheap",
"namedotcom",
"namesilo",
"namesurfer",
"nearlyfreespeech",
"neodigit",
"netcup",
@ -262,8 +266,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
ew.writeln(` - "ALICLOUD_LINE": Line (Default: default)`)
ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "ALICLOUD_REGION_ID": Region ID (Default: cn-hangzhou)`)
ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
@ -354,6 +360,27 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/anexia`)
case "artfiles":
// generated from: providers/dns/artfiles/artfiles.toml
ew.writeln(`Configuration for ArtFiles.`)
ew.writeln(`Code: 'artfiles'`)
ew.writeln(`Since: 'v4.32.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
ew.writeln(` - "ARTFILES_PASSWORD": API password`)
ew.writeln(` - "ARTFILES_USERNAME": API username`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "ARTFILES_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "ARTFILES_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "ARTFILES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`)
ew.writeln(` - "ARTFILES_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/artfiles`)
case "arvancloud":
// generated from: providers/dns/arvancloud/arvancloud.toml
ew.writeln(`Configuration for ArvanCloud.`)
@ -623,6 +650,31 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecat`)
case "bluecatv2":
// generated from: providers/dns/bluecatv2/bluecatv2.toml
ew.writeln(`Configuration for Bluecat v2.`)
ew.writeln(`Code: 'bluecatv2'`)
ew.writeln(`Since: 'v4.32.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
ew.writeln(` - "BLUECATV2_CONFIG_NAME": Configuration name`)
ew.writeln(` - "BLUECATV2_PASSWORD": API password`)
ew.writeln(` - "BLUECATV2_USERNAME": API username`)
ew.writeln(` - "BLUECATV2_VIEW_NAME": DNS View Name`)
ew.writeln(` - "BLUECAT_SERVER_URL": The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "BLUECATV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "BLUECATV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "BLUECATV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "BLUECATV2_SKIP_DEPLOY": Skip quick deployements`)
ew.writeln(` - "BLUECATV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecatv2`)
case "bookmyname":
// generated from: providers/dns/bookmyname/bookmyname.toml
ew.writeln(`Configuration for BookMyName.`)
@ -2307,6 +2359,26 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/keyhelp`)
case "leaseweb":
// generated from: providers/dns/leaseweb/leaseweb.toml
ew.writeln(`Configuration for Leaseweb.`)
ew.writeln(`Code: 'leaseweb'`)
ew.writeln(`Since: 'v4.32.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
ew.writeln(` - "LEASEWEB_API_KEY": API key`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "LEASEWEB_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "LEASEWEB_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "LEASEWEB_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "LEASEWEB_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/leaseweb`)
case "liara":
// generated from: providers/dns/liara/liara.toml
ew.writeln(`Configuration for Liara.`)
@ -2322,6 +2394,7 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "LIARA_TEAM_ID": The team ID to access services in a team`)
ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
@ -2714,6 +2787,30 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesilo`)
case "namesurfer":
// generated from: providers/dns/namesurfer/namesurfer.toml
ew.writeln(`Configuration for FusionLayer NameSurfer.`)
ew.writeln(`Code: 'namesurfer'`)
ew.writeln(`Since: 'v4.32.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
ew.writeln(` - "NAMESURFER_API_KEY": API key name`)
ew.writeln(` - "NAMESURFER_API_SECRET": API secret`)
ew.writeln(` - "NAMESURFER_BASE_URL": The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "NAMESURFER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "NAMESURFER_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`)
ew.writeln(` - "NAMESURFER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "NAMESURFER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln(` - "NAMESURFER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln(` - "NAMESURFER_VIEW": DNS view name (optional, default: empty string)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesurfer`)
case "nearlyfreespeech":
// generated from: providers/dns/nearlyfreespeech/nearlyfreespeech.toml
ew.writeln(`Configuration for NearlyFreeSpeech.NET.`)

View file

@ -58,8 +58,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ALICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
| `ALICLOUD_LINE` | Line (Default: default) |
| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `ALICLOUD_REGION_ID` | Region ID (Default: cn-hangzhou) |
| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.

69
docs/content/dns/zz_gen_artfiles.md generated Normal file
View file

@ -0,0 +1,69 @@
---
title: "ArtFiles"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: artfiles
dnsprovider:
since: "v4.32.0"
code: "artfiles"
url: "https://www.artfiles.de/extras/domains/"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/artfiles/artfiles.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [ArtFiles](https://www.artfiles.de/extras/domains/).
<!--more-->
- Code: `artfiles`
- Since: v4.32.0
Here is an example bash command using the ArtFiles provider:
```bash
ARTFILES_USERNAME="xxx" \
ARTFILES_PASSWORD="yyy" \
lego --dns artfiles -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ARTFILES_PASSWORD` | API password |
| `ARTFILES_USERNAME` | API username |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ARTFILES_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `ARTFILES_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ARTFILES_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) |
| `ARTFILES_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://support.artfiles.de/DCP-API#dns)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/artfiles/artfiles.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

76
docs/content/dns/zz_gen_bluecatv2.md generated Normal file
View file

@ -0,0 +1,76 @@
---
title: "Bluecat v2"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: bluecatv2
dnsprovider:
since: "v4.32.0"
code: "bluecatv2"
url: "https://www.bluecatnetworks.com"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/bluecatv2/bluecatv2.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [Bluecat v2](https://www.bluecatnetworks.com).
<!--more-->
- Code: `bluecatv2`
- Since: v4.32.0
Here is an example bash command using the Bluecat v2 provider:
```bash
BLUECATV2_SERVER_URL="https://example.com" \
BLUECATV2_USERNAME="xxx" \
BLUECATV2_PASSWORD="yyy" \
BLUECATV2_CONFIG_NAME="myConfiguration" \
BLUECATV2_VIEW_NAME="myView" \
lego --dns bluecatv2 -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `BLUECATV2_CONFIG_NAME` | Configuration name |
| `BLUECATV2_PASSWORD` | API password |
| `BLUECATV2_USERNAME` | API username |
| `BLUECATV2_VIEW_NAME` | DNS View Name |
| `BLUECAT_SERVER_URL` | The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `BLUECATV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `BLUECATV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `BLUECATV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `BLUECATV2_SKIP_DEPLOY` | Skip quick deployements |
| `BLUECATV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/bluecatv2/bluecatv2.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

67
docs/content/dns/zz_gen_leaseweb.md generated Normal file
View file

@ -0,0 +1,67 @@
---
title: "Leaseweb"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: leaseweb
dnsprovider:
since: "v4.32.0"
code: "leaseweb"
url: "https://www.leaseweb.com/en/"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/leaseweb/leaseweb.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [Leaseweb](https://www.leaseweb.com/en/).
<!--more-->
- Code: `leaseweb`
- Since: v4.32.0
Here is an example bash command using the Leaseweb provider:
```bash
LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
lego --dns leaseweb -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `LEASEWEB_API_KEY` | API key |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `LEASEWEB_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `LEASEWEB_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `LEASEWEB_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `LEASEWEB_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://developer.leaseweb.com/docs/#tag/DNS)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/leaseweb/leaseweb.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View file

@ -50,6 +50,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| `LIARA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `LIARA_TEAM_ID` | The team ID to access services in a team |
| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.

73
docs/content/dns/zz_gen_namesurfer.md generated Normal file
View file

@ -0,0 +1,73 @@
---
title: "FusionLayer NameSurfer"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: namesurfer
dnsprovider:
since: "v4.32.0"
code: "namesurfer"
url: "https://www.fusionlayer.com/"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/namesurfer/namesurfer.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [FusionLayer NameSurfer](https://www.fusionlayer.com/).
<!--more-->
- Code: `namesurfer`
- Since: v4.32.0
Here is an example bash command using the FusionLayer NameSurfer provider:
```bash
NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \
NAMESURFER_API_KEY=xxx \
NAMESURFER_API_SECRET=yyy \
lego --dns namesurfer -d '*.example.com' -d example.com run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `NAMESURFER_API_KEY` | API key name |
| `NAMESURFER_API_SECRET` | API secret |
| `NAMESURFER_BASE_URL` | The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `NAMESURFER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `NAMESURFER_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate |
| `NAMESURFER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `NAMESURFER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
| `NAMESURFER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
| `NAMESURFER_VIEW` | DNS view name (optional, default: empty string) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/namesurfer/namesurfer.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View file

@ -285,7 +285,7 @@ To display the documentation for a specific DNS provider, run:
$ lego dnshelp -c code
Supported DNS providers:
acmedns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnamesca, webnamesru, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
acmedns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, leaseweb, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnamesca, webnamesru, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""

109
go.mod
View file

@ -5,7 +5,7 @@ go 1.25.0
require (
cloud.google.com/go/compute/metadata v0.9.0
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0
@ -15,29 +15,29 @@ require (
github.com/Azure/go-autorest/autorest/to v0.4.1
github.com/BurntSushi/toml v1.6.0
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15
github.com/alibabacloud-go/tea v1.4.0
github.com/aliyun/credentials-go v1.4.7
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.8
github.com/aws/aws-sdk-go-v2/credentials v1.19.8
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6
github.com/aziontech/azionapi-go-sdk v0.144.0
github.com/baidubce/bce-sdk-go v0.9.256
github.com/baidubce/bce-sdk-go v0.9.260
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf
github.com/cenkalti/backoff/v5 v5.0.3
github.com/dnsimple/dnsimple-go/v4 v4.0.0
github.com/exoscale/egoscale/v3 v3.1.33
github.com/go-acme/alidns-20150109/v4 v4.7.0
github.com/go-acme/esa-20240910/v2 v2.44.0
github.com/go-acme/esa-20240910/v2 v2.48.0
github.com/go-acme/jdcloud-sdk-go v1.64.0
github.com/go-acme/tencentclouddnspod v1.1.25
github.com/go-acme/tencentedgdeone v1.1.48
github.com/go-acme/tencentclouddnspod v1.3.24
github.com/go-acme/tencentedgdeone v1.3.38
github.com/go-jose/go-jose/v4 v4.1.3
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/go-cmp v0.7.0
github.com/google/go-querystring v1.2.0
github.com/google/uuid v1.6.0
@ -45,19 +45,19 @@ require (
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/hashicorp/go-version v1.8.0
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.182
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df
github.com/infobloxopen/infoblox-go-client/v2 v2.10.0
github.com/labbsr0x/bindman-dns-webhook v1.0.2
github.com/ldez/grignotin v0.10.1
github.com/linode/linodego v1.64.0
github.com/linode/linodego v1.65.0
github.com/liquidweb/liquidweb-go v1.6.4
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-zglob v0.0.6
github.com/miekg/dns v1.1.69
github.com/miekg/dns v1.1.72
github.com/mimuret/golang-iij-dpf v0.9.1
github.com/namedotcom/go/v4 v4.0.2
github.com/nrdcg/auroradns v1.1.0
github.com/nrdcg/auroradns v1.2.0
github.com/nrdcg/bunny-go v0.1.0
github.com/nrdcg/desec v0.11.1
github.com/nrdcg/dnspod-go v0.4.0
@ -67,8 +67,8 @@ require (
github.com/nrdcg/mailinabox v0.3.0
github.com/nrdcg/namesilo v0.5.0
github.com/nrdcg/nodion v0.1.0
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2
github.com/nrdcg/porkbun v0.4.0
github.com/nrdcg/vegadns v0.3.0
github.com/nzdjb/go-metaname v1.0.0
@ -82,30 +82,30 @@ require (
github.com/selectel/go-selvpcclient/v4 v4.1.0
github.com/softlayer/softlayer-go v1.2.1
github.com/stretchr/testify v1.11.1
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.28
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48
github.com/transip/gotransip/v6 v6.26.1
github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419
github.com/urfave/cli/v3 v3.6.2
github.com/vinyldns/go-vinyldns v0.9.17
github.com/volcengine/volc-sdk-golang v1.0.233
github.com/vultr/govultr/v3 v3.26.1
github.com/yandex-cloud/go-genproto v0.43.0
github.com/yandex-cloud/go-sdk/services/dns v0.0.25
github.com/yandex-cloud/go-sdk/v2 v2.37.0
github.com/volcengine/volc-sdk-golang v1.0.237
github.com/vultr/govultr/v3 v3.27.0
github.com/yandex-cloud/go-genproto v0.54.0
github.com/yandex-cloud/go-sdk/services/dns v0.0.36
github.com/yandex-cloud/go-sdk/v2 v2.56.0
gitlab.com/greyxor/slogor v1.6.6
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
golang.org/x/oauth2 v0.34.0
golang.org/x/text v0.32.0
golang.org/x/crypto v0.48.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/text v0.34.0
golang.org/x/time v0.14.0
google.golang.org/api v0.259.0
gopkg.in/ns1/ns1-go.v2 v2.16.0
google.golang.org/api v0.267.0
gopkg.in/ns1/ns1-go.v2 v2.17.2
gopkg.in/yaml.v2 v2.4.0
software.sslmate.com/src/go-pkcs12 v0.7.0
)
require (
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
@ -121,22 +121,23 @@ require (
github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
@ -161,8 +162,8 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
@ -204,22 +205,22 @@ require (
go.mongodb.org/mongo-driver v1.13.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

233
go.sum
View file

@ -13,8 +13,8 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@ -42,8 +42,8 @@ github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYs
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
@ -121,8 +121,10 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 h1:Mubp9hXZMTPWZK+WxrR+kKOVFp4Q/PDZrIIM7ByXI9Y=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
@ -169,54 +171,54 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0=
github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10 h1:MQuZZ6Tq1qQabPlkVxrCMdyVl70Ogl4AERZKo+y9Wzo=
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10/go.mod h1:U5C3JME1ibKESmpzBAqlRpTYZfVbTqrb5ICJm+sVVd8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0/go.mod h1:6EZUGGNLPLh5Unt30uEoA+KQcByERfXIkax9qrc80nA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 h1:VM5e5M39zRSs+aT0O9SoxHjUXqXxhbw3Yi0FdMQWPIc=
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11/go.mod h1:0jvzYPIQGCpnY/dmdaotTk2JH4QuBlnW0oeyrcGLWJ4=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aziontech/azionapi-go-sdk v0.144.0 h1:T+/w18o+FCiZsk3Z0ACBVVe7c/5EGLG15S3P8JfuPfo=
github.com/aziontech/azionapi-go-sdk v0.144.0/go.mod h1:OKxP/R0iVXnJJakYwMhh2BGAXnud8Ruy55Ak9ANuWoU=
github.com/baidubce/bce-sdk-go v0.9.256 h1:/6UwBzDp+dRFpKRIb5WsvxfSiG4SLOIOghvagOK/q4Y=
github.com/baidubce/bce-sdk-go v0.9.256/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg=
github.com/baidubce/bce-sdk-go v0.9.260 h1:1v1+2GTP+NGK3L24rJ+bnoiTaDaIy2YoaUM+ot2GTcw=
github.com/baidubce/bce-sdk-go v0.9.260/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@ -241,6 +243,8 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@ -315,14 +319,14 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQLAuuSdLjjQaQ=
github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0=
github.com/go-acme/esa-20240910/v2 v2.44.0 h1:ACi2uFb7ig4ousFs/YiFBR+aw3A4SHtOxvkMWB2Hbcs=
github.com/go-acme/esa-20240910/v2 v2.44.0/go.mod h1:ZYdN9EN9ikn26SNapxCVjZ65pHT/1qm4fzuJ7QGVX6g=
github.com/go-acme/esa-20240910/v2 v2.48.0 h1:muSDyhjDTejxUGe3FTthCPCqRaEdYY9cG3N/AmU52Lc=
github.com/go-acme/esa-20240910/v2 v2.48.0/go.mod h1:shPb6hzc1rJL15IJBY8HQ4GZk4E8RC52+52twutEwIg=
github.com/go-acme/jdcloud-sdk-go v1.64.0 h1:AW9j5khk8tRYbpBJPxKmqdwIqgLs2Fz3HUK3hn2YXjs=
github.com/go-acme/jdcloud-sdk-go v1.64.0/go.mod h1:qc/m8HNX1Zgd7GAv2DSEinup8fwy3Ted3/VVx7LB5bU=
github.com/go-acme/tencentclouddnspod v1.1.25 h1:7H3ZKshkaHzCXfRpAHVB5nvxeDDl2XLeNZfrNHiZj/s=
github.com/go-acme/tencentclouddnspod v1.1.25/go.mod h1:XXfzp0AYV7UAUsHKT6R0KAUJFhqAUXmWGF07Elpa5cE=
github.com/go-acme/tencentedgdeone v1.1.48 h1:WLyLBsRVhSLFmtbEFXk0naLODSQn7X6J0Fc/qR8xVUk=
github.com/go-acme/tencentedgdeone v1.1.48/go.mod h1:mu6tA+bPhlSd+CKUfzRikE0mfxmTlBI6dVTn9LY9dRI=
github.com/go-acme/tencentclouddnspod v1.3.24 h1:uCSiOW1EJttcnOON+MVVyVDJguFL/Q4NIGkq1CrT9p8=
github.com/go-acme/tencentclouddnspod v1.3.24/go.mod h1:RKcB2wSoZncjBA0OEFj59s1ko1XDy+ZsAtk+9uMxUF0=
github.com/go-acme/tencentedgdeone v1.3.38 h1:5YsVl0H4A+cwtiUqR1eZbKFdr4OWfYp2KYJopifzKyQ=
github.com/go-acme/tencentedgdeone v1.3.38/go.mod h1:yyjTKVmGpMtFv5HqGODqehHnZJ4KWAbG6dAiwWDgCDY=
github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@ -366,8 +370,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8Wd
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
@ -467,12 +471,12 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw=
github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
@ -537,8 +541,8 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.182 h1:B3W9acgpqu5XsN8v+W8SOTfqn/6n4JsjgoKBsm30HFY=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.182/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@ -612,8 +616,8 @@ github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufp
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/linode/linodego v1.64.0 h1:If6pULIwHuQytgogtpQaBdVLX7z2TTHUF5u1tj2TPiY=
github.com/linode/linodego v1.64.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE=
github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7k=
github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8=
github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs=
github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM=
github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ=
@ -651,8 +655,8 @@ github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3N
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34=
github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M=
github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
@ -693,8 +697,8 @@ github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1t
github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nrdcg/auroradns v1.1.0 h1:KekGh8kmf2MNwqZVVYo/fw/ZONt8QMEmbMFOeljteWo=
github.com/nrdcg/auroradns v1.1.0/go.mod h1:O7tViUZbAcnykVnrGkXzIJTHoQCHcgalgAe6X1mzHfk=
github.com/nrdcg/auroradns v1.2.0 h1:Jg407vTdXZvZKsART9CNWMp8rQOyhBk04q0MsOf0YR4=
github.com/nrdcg/auroradns v1.2.0/go.mod h1:hnByA4Z7MOmV4EPRw5eOmEaNRFavcCIz6kONpNxp9LI=
github.com/nrdcg/bunny-go v0.1.0 h1:GAHTRpHaG/TxfLZlqoJ8OJFzw8rI74+jOTkzxWh0uHA=
github.com/nrdcg/bunny-go v0.1.0/go.mod h1:u+C9dgsspgtWVaAz6QkyV17s9fxD8viwwKoxb9XMz1A=
github.com/nrdcg/desec v0.11.1 h1:ilpKmCr4gGsLcyq3RHfHNmlRzm9fzT2XbWxoVaUCS0s=
@ -713,10 +717,10 @@ github.com/nrdcg/namesilo v0.5.0 h1:6QNxT/XxE+f5B+7QlfWorthNzOzcGlBLRQxqi6YeBrE=
github.com/nrdcg/namesilo v0.5.0/go.mod h1:4UkwlwQfDt74kSGmhLaDylnBrD94IfflnpoEaj6T2qw=
github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw=
github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 h1:l0tH15ACQADZAzC+LZ+mo2tIX4H6uZu0ulrVmG5Tqz0=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 h1:gzB4c6ztb38C/jYiqEaFC+mCGcWFHDji9e6jwymY9d4=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2/go.mod h1:l1qIPIq2uRV5WTSvkbhbl/ndbeOu7OCb3UZ+0+2ZSb8=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 h1:OWijzl3nHUApvTivl+3+78dbBwmyEHOnb+W9m6ixGbk=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 h1:9LsjN/zaIN7H8JE61NHpbWhxF0UGY96+kMlk3g8OvGU=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2/go.mod h1:32vZH06TuwZSn+IDMO1qcDvC2vHVlzUALCwXGWPA+dc=
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
github.com/nrdcg/vegadns v0.3.0 h1:11FQMw7xVIRUWO9o5+Z/5YZhmPWlm4oxUUH3F6EVqQU=
@ -902,10 +906,10 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.25/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.28 h1:Rj1WXXNPm9AsPf0PJhWCvlsqfcKPUYdyVnkmEc3O8sI=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.28/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.24/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.38/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 h1:bCs+z6dxRaHWm/C1D/XkSOcCZ0+W2+/6HmIXjpAj+fY=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
@ -920,22 +924,22 @@ github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/vinyldns/go-vinyldns v0.9.17 h1:hfPZfCaxcRBX6Gsgl42rLCeoal58/BH8kkvJShzjjdI=
github.com/vinyldns/go-vinyldns v0.9.17/go.mod h1:pwWhE9K/leGDOIduVhRGvQ3ecVMHWRfEnKYUTEU3gB4=
github.com/volcengine/volc-sdk-golang v1.0.233 h1:Hh2pzwu/Wq19rsZgNo3HdpjQB28D/F0+m6EjLVggmhM=
github.com/volcengine/volc-sdk-golang v1.0.233/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM=
github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw=
github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/volcengine/volc-sdk-golang v1.0.237 h1:hpLKiS2BwDcSBtZWSz034foCbd0h3FrHTKlUMqHIdc4=
github.com/volcengine/volc-sdk-golang v1.0.237/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM=
github.com/vultr/govultr/v3 v3.27.0 h1:J8etMyu/Jh5+idMsu2YZpOWmDXXHeW4VZnkYXmJYHx8=
github.com/vultr/govultr/v3 v3.27.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yandex-cloud/go-genproto v0.43.0 h1:HjBesEmCN8ZOhjjh8gs605vvi9/MBJAW3P20OJ4iQnw=
github.com/yandex-cloud/go-genproto v0.43.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
github.com/yandex-cloud/go-sdk/services/dns v0.0.25 h1:BcGEuOnwq2X3LS2kvFC6BOdZkOq4Lc7XAYvzap/SJJY=
github.com/yandex-cloud/go-sdk/services/dns v0.0.25/go.mod h1:B4QHijALUHIjRxL3aqmOwDrHYUI2XdeeG4WKItth3jI=
github.com/yandex-cloud/go-sdk/v2 v2.37.0 h1:WvttW6p9xcWag9j+GQv+GJXPggggXGwOlIJNfkWmFWw=
github.com/yandex-cloud/go-sdk/v2 v2.37.0/go.mod h1:Dt4a81enjRsm4xMJyW5E1Y/vaUYwXJvUGRdDLuM2k6I=
github.com/yandex-cloud/go-genproto v0.54.0 h1:LjEwDPBAtF39HvcPQe8I+ImCnFasCPCOVh2b2Sr2eAg=
github.com/yandex-cloud/go-genproto v0.54.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
github.com/yandex-cloud/go-sdk/services/dns v0.0.36 h1:sD622+baDvJ2ujhCfoFsCH0XeNsaZNW6loRqvRavjtE=
github.com/yandex-cloud/go-sdk/services/dns v0.0.36/go.mod h1:Hh7IKJxULaRzmyM19lQZw+yUDyMM8M3Qrk1LbWqhCkc=
github.com/yandex-cloud/go-sdk/v2 v2.56.0 h1:rihPAZbPbHU/BKTLuT64nU1uhbBrO20HhdlLR3Hyoz0=
github.com/yandex-cloud/go-sdk/v2 v2.56.0/go.mod h1:jzVBQgamNHoiDsmjog2dPZHMXuGZqmxf/epH+Qb7Emc=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
@ -967,16 +971,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
@ -1029,8 +1033,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -1074,8 +1078,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -1133,16 +1137,16 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -1246,8 +1250,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -1262,8 +1266,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1282,8 +1286,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -1349,8 +1353,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -1379,8 +1383,8 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -1419,12 +1423,12 @@ google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@ -1473,11 +1477,12 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/ns1/ns1-go.v2 v2.16.0 h1:mUczKFnrCystSV7yIODzVSbENoud3T7DwstmyVZfqg4=
gopkg.in/ns1/ns1-go.v2 v2.16.0/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs=
gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=

View file

@ -10,7 +10,7 @@ import (
const (
// ourUserAgent is the User-Agent of this underlying library package.
ourUserAgent = "goacme-lego/4.31.0"
ourUserAgent = "goacme-lego/4.32.0"
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release

View file

@ -27,6 +27,7 @@ const (
EnvSecretKey = envNamespace + "SECRET_KEY"
EnvSecurityToken = envNamespace + "SECURITY_TOKEN"
EnvRegionID = envNamespace + "REGION_ID"
EnvLine = envNamespace + "LINE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@ -45,6 +46,7 @@ type Config struct {
SecretKey string
SecurityToken string
RegionID string
Line string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@ -74,6 +76,7 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.RegionID = env.GetOrFile(EnvRegionID)
config.Line = env.GetOrFile(EnvLine)
values, err := env.Get(EnvRAMRole)
if err == nil {
@ -250,12 +253,18 @@ func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) (*alidns.AddDomainR
return nil, err
}
return new(alidns.AddDomainRecordRequest).
adrr := new(alidns.AddDomainRecordRequest).
SetType("TXT").
SetDomainName(zone).
SetRR(rr).
SetValue(value).
SetTTL(int64(d.config.TTL)), nil
SetTTL(int64(d.config.TTL))
if d.config.Line != "" {
adrr.SetLine(d.config.Line)
}
return adrr, nil
}
func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord, error) {

View file

@ -23,6 +23,8 @@ lego --dns alidns - -d '*.example.com' -d example.com run
ALICLOUD_SECRET_KEY = "Access Key secret"
ALICLOUD_SECURITY_TOKEN = "STS Security Token (optional)"
[Configuration.Additional]
ALICLOUD_REGION_ID = "Region ID (Default: cn-hangzhou)"
ALICLOUD_LINE = "Line (Default: default)"
ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"

View file

@ -5,12 +5,14 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-acme/lego/v5/challenge"
"github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/log"
"github.com/go-acme/lego/v5/platform/config/env"
"github.com/go-acme/lego/v5/providers/dns/allinkl/internal"
"github.com/go-acme/lego/v5/providers/dns/internal/clientdebug"
@ -121,11 +123,6 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("allinkl: could not find zone for domain %q: %w", domain, err)
}
credential, err := d.identifier.Authentication(ctx, 60, true)
if err != nil {
return fmt.Errorf("allinkl: authentication: %w", err)
@ -133,6 +130,11 @@ func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string
ctxAuth := internal.WithContext(ctx, credential)
authZone, err := d.findZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("allinkl: %w", err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("allinkl: %w", err)
@ -188,3 +190,17 @@ func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string
return nil
}
func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) {
for z := range dns01.DomainsSeq(fqdn) {
_, errG := d.client.GetDNSSettings(ctx, z, "")
if errG != nil {
log.Debug("get DNS settings zone", slog.String("zone", z), log.ErrorAttr(errG))
continue
}
return z, nil
}
return "", fmt.Errorf("unable to find auth zone for '%s'", fqdn)
}

View file

@ -1,9 +1,18 @@
package allinkl
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-acme/lego/v5/platform/tester"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/go-acme/lego/v5/providers/dns/allinkl/internal"
"github.com/stretchr/testify/require"
)
@ -143,3 +152,108 @@ func TestLiveCleanUp(t *testing.T) {
err = provider.CleanUp(t.Context(), envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func mockBuilder() *servermock.Builder[*DNSProvider] {
return servermock.NewBuilder(
func(server *httptest.Server) (*DNSProvider, error) {
config := NewDefaultConfig()
config.Login = "user"
config.Password = "secret"
config.HTTPClient = server.Client()
p, err := NewDNSProviderConfig(config)
if err != nil {
return nil, err
}
p.client.BaseURL, _ = url.Parse(server.URL)
p.identifier.BaseURL, _ = url.Parse(server.URL)
return p, err
},
).Route("POST /KasAuth.php",
servermock.ResponseFromInternal("auth.xml"),
servermock.CheckRequestBodyFromInternal("auth-request.xml").
IgnoreWhitespace(),
)
}
func extractKasRequest(reader io.Reader) (*internal.KasRequest, error) {
type ReqEnvelope struct {
XMLName xml.Name `xml:"Envelope"`
Body struct {
KasAPI struct {
Params string `xml:"Params"`
} `xml:"KasApi"`
} `xml:"Body"`
}
raw, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
reqEnvelope := ReqEnvelope{}
err = xml.Unmarshal(raw, &reqEnvelope)
if err != nil {
return nil, err
}
var kReq internal.KasRequest
err = json.Unmarshal([]byte(reqEnvelope.Body.KasAPI.Params), &kReq)
if err != nil {
return nil, err
}
return &kReq, nil
}
func TestDNSProvider_Present(t *testing.T) {
provider := mockBuilder().
Route("POST /KasApi.php",
http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
kReq, err := extractKasRequest(req.Body)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
switch kReq.Action {
case "get_dns_settings":
params := kReq.RequestParams.(map[string]any)
if params["zone_host"] == "_acme-challenge.example.com." {
servermock.ResponseFromInternal("get_dns_settings_not_found.xml").ServeHTTP(rw, req)
} else {
servermock.ResponseFromInternal("get_dns_settings.xml").ServeHTTP(rw, req)
}
case "add_dns_settings":
servermock.ResponseFromInternal("add_dns_settings.xml").ServeHTTP(rw, req)
default:
http.Error(rw, fmt.Sprintf("unknown action: %v", kReq.Action), http.StatusBadRequest)
}
}),
).
Build(t)
err := provider.Present(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
provider := mockBuilder().
Route("POST /KasApi.php",
servermock.ResponseFromInternal("delete_dns_settings.xml"),
servermock.CheckRequestBodyFromInternal("delete_dns_settings-request.xml").
IgnoreWhitespace()).
Build(t)
provider.recordIDs["abc"] = "57347450"
err := provider.CleanUp(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}

View file

@ -6,16 +6,21 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v5/internal/errutils"
"github.com/go-acme/lego/v5/platform/wait"
"github.com/go-viper/mapstructure/v2"
)
const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php"
const defaultBaseURL = "https://kasapi.kasserver.com/soap/"
const apiPath = "KasApi.php"
type Authentication interface {
Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error)
@ -28,16 +33,21 @@ type Client struct {
floodTime time.Time
muFloodTime sync.Mutex
baseURL string
maxElapsedTime time.Duration
BaseURL *url.URL
HTTPClient *http.Client
}
// NewClient creates a new Client.
func NewClient(login string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
login: login,
baseURL: apiEndpoint,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
login: login,
BaseURL: baseURL,
maxElapsedTime: 3 * time.Minute,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
@ -51,14 +61,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R
requestParams["record_id"] = recordID
}
req, err := c.newRequest(ctx, "get_dns_settings", requestParams)
if err != nil {
return nil, err
}
var g APIResponse[GetDNSSettingsResponse]
var g GetDNSSettingsAPIResponse
err = c.do(req, &g)
err := c.doRequest(ctx, "get_dns_settings", requestParams, &g)
if err != nil {
return nil, err
}
@ -70,14 +75,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R
// AddDNSSettings Creation of a DNS resource record.
func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) {
req, err := c.newRequest(ctx, "add_dns_settings", record)
if err != nil {
return "", err
}
var g APIResponse[AddDNSSettingsResponse]
var g AddDNSSettingsAPIResponse
err = c.do(req, &g)
err := c.doRequest(ctx, "add_dns_settings", record, &g)
if err != nil {
return "", err
}
@ -91,14 +91,9 @@ func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string,
func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (string, error) {
requestParams := map[string]string{"record_id": recordID}
req, err := c.newRequest(ctx, "delete_dns_settings", requestParams)
if err != nil {
return "", err
}
var g APIResponse[DeleteDNSSettingsResponse]
var g DeleteDNSSettingsAPIResponse
err = c.do(req, &g)
err := c.doRequest(ctx, "delete_dns_settings", requestParams, &g)
if err != nil {
return "", err
}
@ -124,7 +119,9 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an
payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body)))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload))
endpoint := c.BaseURL.JoinPath(apiPath)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
@ -132,6 +129,21 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an
return req, nil
}
func (c *Client) doRequest(ctx context.Context, action string, requestParams, result any) error {
return wait.Retry(ctx,
func() error {
req, err := c.newRequest(ctx, action, requestParams)
if err != nil {
return backoff.Permanent(err)
}
return c.do(req, result)
},
backoff.WithBackOff(&backoff.ZeroBackOff{}),
backoff.WithMaxElapsedTime(c.maxElapsedTime),
)
}
func (c *Client) do(req *http.Request, result any) error {
c.muFloodTime.Lock()
time.Sleep(time.Until(c.floodTime))
@ -139,29 +151,40 @@ func (c *Client) do(req *http.Request, result any) error {
resp, err := c.HTTPClient.Do(req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
return backoff.Permanent(errutils.NewHTTPDoError(req, err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
return backoff.Permanent(errutils.NewUnexpectedResponseStatusCodeError(req, resp))
}
envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body)
if err != nil {
return err
return backoff.Permanent(err)
}
if envlp.Body.Fault != nil {
return envlp.Body.Fault
if envlp.Body.Fault.Message == "flood_protection" {
ft, errP := strconv.ParseFloat(envlp.Body.Fault.Detail, 64)
if errP != nil {
return fmt.Errorf("parse flood protection delay: %w", envlp.Body.Fault)
}
c.updateFloodTime(ft)
return envlp.Body.Fault
}
return backoff.Permanent(envlp.Body.Fault)
}
raw := getValue(envlp.Body.KasAPIResponse.Return)
err = mapstructure.Decode(raw, result)
if err != nil {
return fmt.Errorf("response struct decode: %w", err)
return backoff.Permanent(fmt.Errorf("response struct decode: %w", err))
}
return nil

View file

@ -2,7 +2,9 @@ package internal
import (
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/assert"
@ -11,15 +13,17 @@ import (
func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("user")
client.baseURL = server.URL
client.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
client.maxElapsedTime = 1 * time.Second
return client, nil
}
func TestClient_GetDNSSettings(t *testing.T) {
client := servermock.NewBuilder[*Client](setupClient).
Route("POST /", servermock.ResponseFromFixture("get_dns_settings.xml"),
Route("POST /KasApi.php", servermock.ResponseFromFixture("get_dns_settings.xml"),
servermock.CheckRequestBodyFromFixture("get_dns_settings-request.xml").
IgnoreWhitespace()).
Build(t)
@ -96,9 +100,24 @@ func TestClient_GetDNSSettings(t *testing.T) {
assert.Equal(t, expected, records)
}
func TestClient_GetDNSSettings_error_flood_protection(t *testing.T) {
client := servermock.NewBuilder[*Client](setupClient).
Route("POST /KasApi.php",
servermock.ResponseFromFixture("flood_protection.xml"),
).
Build(t)
assert.Zero(t, client.floodTime)
_, err := client.GetDNSSettings(mockContext(t), "example.com", "")
require.EqualError(t, err, "KasApi: SOAP-ENV:Server: flood_protection: 0.0688529014587")
assert.NotZero(t, client.floodTime)
}
func TestClient_AddDNSSettings(t *testing.T) {
client := servermock.NewBuilder[*Client](setupClient).
Route("POST /", servermock.ResponseFromFixture("add_dns_settings.xml"),
Route("POST /KasApi.php", servermock.ResponseFromFixture("add_dns_settings.xml"),
servermock.CheckRequestBodyFromFixture("add_dns_settings-request.xml").
IgnoreWhitespace()).
Build(t)
@ -118,7 +137,7 @@ func TestClient_AddDNSSettings(t *testing.T) {
func TestClient_DeleteDNSSettings(t *testing.T) {
client := servermock.NewBuilder[*Client](setupClient).
Route("POST /", servermock.ResponseFromFixture("delete_dns_settings.xml"),
Route("POST /KasApi.php", servermock.ResponseFromFixture("delete_dns_settings.xml"),
servermock.CheckRequestBodyFromFixture("delete_dns_settings-request.xml").
IgnoreWhitespace()).
Build(t)

View file

@ -0,0 +1,7 @@
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<KasAuth xmlns="https://kasserver.com/">
<Params>{"kas_login":"user","kas_auth_data":"secret","kas_auth_type":"plain","session_lifetime":60,"session_update_lifetime":"Y"}</Params>
</KasAuth>
</Body>
</Envelope>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Body>
<SOAP-ENV:Fault>
<faultcode>SOAP-ENV:Server</faultcode>
<faultstring>flood_protection</faultstring>
<faultactor>KasApi</faultactor>
<detail>0.0688529014587</detail>
</SOAP-ENV:Fault>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Body>
<SOAP-ENV:Fault>
<faultcode>SOAP-ENV:Server</faultcode>
<faultstring>zone_not_found</faultstring>
<faultactor>KasApi</faultactor>
<detail>example.com</detail>
</SOAP-ENV:Fault>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Body>
<SOAP-ENV:Fault>
<faultcode>SOAP-ENV:Server</faultcode>
<faultstring>zone_syntax_incorrect</faultstring>
<faultactor>KasApi</faultactor>
<detail>_acme-challenge.example.com</detail>
</SOAP-ENV:Fault>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

View file

@ -6,14 +6,14 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-acme/lego/v5/internal/errutils"
)
// authEndpoint represents the Identity API endpoint to call.
const authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php"
const authPath = "KasAuth.php"
type token string
@ -24,17 +24,19 @@ type Identifier struct {
login string
password string
authEndpoint string
HTTPClient *http.Client
BaseURL *url.URL
HTTPClient *http.Client
}
// NewIdentifier creates a new Identifier.
func NewIdentifier(login, password string) *Identifier {
baseURL, _ := url.Parse(defaultBaseURL)
return &Identifier{
login: login,
password: password,
authEndpoint: authEndpoint,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
login: login,
password: password,
BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
@ -62,7 +64,9 @@ func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, se
payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body)))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authEndpoint, bytes.NewReader(payload))
endpoint := c.BaseURL.JoinPath(authPath)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("unable to create request: %w", err)
}

View file

@ -3,6 +3,7 @@ package internal
import (
"context"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-acme/lego/v5/platform/tester/servermock"
@ -12,7 +13,7 @@ import (
func setupIdentifierClient(server *httptest.Server) (*Identifier, error) {
client := NewIdentifier("user", "secret")
client.authEndpoint = server.URL
client.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
@ -26,10 +27,13 @@ func mockContext(t *testing.T) context.Context {
func TestIdentifier_Authentication(t *testing.T) {
client := servermock.NewBuilder[*Identifier](setupIdentifierClient).
Route("POST /", servermock.ResponseFromFixture("auth.xml")).
Route("POST /KasAuth.php",
servermock.ResponseFromFixture("auth.xml"),
servermock.CheckRequestBodyFromFixture("auth-request.xml").
IgnoreWhitespace()).
Build(t)
credentialToken, err := client.Authentication(t.Context(), 60, false)
credentialToken, err := client.Authentication(t.Context(), 60, true)
require.NoError(t, err)
assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken)
@ -37,7 +41,7 @@ func TestIdentifier_Authentication(t *testing.T) {
func TestIdentifier_Authentication_error(t *testing.T) {
client := servermock.NewBuilder[*Identifier](setupIdentifierClient).
Route("POST /", servermock.ResponseFromFixture("auth_fault.xml")).
Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth_fault.xml")).
Build(t)
_, err := client.Authentication(t.Context(), 60, false)

View file

@ -26,10 +26,11 @@ type Fault struct {
Code string `xml:"faultcode"`
Message string `xml:"faultstring"`
Actor string `xml:"faultactor"`
Detail string `xml:"detail"`
}
func (f Fault) Error() string {
return fmt.Sprintf("%s: %s: %s", f.Actor, f.Code, f.Message)
func (f *Fault) Error() string {
return fmt.Sprintf("%s: %s: %s: %s", f.Actor, f.Code, f.Message, f.Detail)
}
// KasResponse a KAS SOAP response.

View file

@ -53,8 +53,8 @@ type DNSRequest struct {
// ---
type GetDNSSettingsAPIResponse struct {
Response GetDNSSettingsResponse `json:"Response" mapstructure:"Response"`
type APIResponse[T any] struct {
Response T `json:"Response" mapstructure:"Response"`
}
type GetDNSSettingsResponse struct {
@ -73,20 +73,12 @@ type ReturnInfo struct {
Aux int `json:"record_aux,omitempty" mapstructure:"record_aux"`
}
type AddDNSSettingsAPIResponse struct {
Response AddDNSSettingsResponse `json:"Response" mapstructure:"Response"`
}
type AddDNSSettingsResponse struct {
KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"`
ReturnInfo string `json:"ReturnInfo" mapstructure:"ReturnInfo"`
ReturnString string `json:"ReturnString" mapstructure:"ReturnString"`
}
type DeleteDNSSettingsAPIResponse struct {
Response DeleteDNSSettingsResponse `json:"Response"`
}
type DeleteDNSSettingsResponse struct {
KasFloodDelay float64 `json:"KasFloodDelay"`
ReturnString string `json:"ReturnString"`

View file

@ -0,0 +1,200 @@
// Package artfiles implements a DNS provider for solving the DNS-01 challenge using ArtFiles.
package artfiles
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"time"
"github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/platform/config/env"
"github.com/go-acme/lego/v5/providers/dns/artfiles/internal"
"github.com/go-acme/lego/v5/providers/dns/internal/clientdebug"
)
// Environment variables names.
const (
envNamespace = "ARTFILES_"
EnvUsername = envNamespace + "USERNAME"
EnvPassword = envNamespace + "PASSWORD"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
Username string
Password string
PropagationTimeout time.Duration
PollingInterval time.Duration
HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
client *internal.Client
}
// NewDNSProvider returns a DNSProvider instance configured for ArtFiles.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvUsername, EnvPassword)
if err != nil {
return nil, fmt.Errorf("artfiles: %w", err)
}
config := NewDefaultConfig()
config.Username = values[EnvUsername]
config.Password = values[EnvPassword]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for ArtFiles.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("artfiles: the configuration of the DNS provider is nil")
}
client, err := internal.NewClient(config.Username, config.Password)
if err != nil {
return nil, fmt.Errorf("artfiles: %w", err)
}
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
return &DNSProvider{
config: config,
client: client,
}, nil
}
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
zone, err := d.findZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("artfiles: %w", err)
}
records, err := d.client.GetRecords(ctx, zone)
if err != nil {
return fmt.Errorf("artfiles: get records: %w", err)
}
rv := internal.RecordValue{}
if len(records["TXT"]) > 0 {
var raw string
err = json.Unmarshal(records["TXT"], &raw)
if err != nil {
return fmt.Errorf("artfiles: unmarshal TXT records: %w", err)
}
rv = internal.ParseRecordValue(raw)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
if err != nil {
return fmt.Errorf("artfiles: %w", err)
}
rv.Add(subDomain, info.Value)
err = d.client.SetRecords(ctx, zone, "TXT", rv)
if err != nil {
return fmt.Errorf("artfiles: set TXT records: %w", err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
zone, err := d.findZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("artfiles: %w", err)
}
records, err := d.client.GetRecords(ctx, zone)
if err != nil {
return fmt.Errorf("artfiles: get records: %w", err)
}
var raw string
err = json.Unmarshal(records["TXT"], &raw)
if err != nil {
return fmt.Errorf("artfiles: unmarshal TXT records: %w", err)
}
rv := internal.ParseRecordValue(raw)
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
if err != nil {
return fmt.Errorf("artfiles: %w", err)
}
rv.RemoveValue(subDomain, info.Value)
err = d.client.SetRecords(ctx, zone, "TXT", rv)
if err != nil {
return fmt.Errorf("artfiles: set TXT records: %w", err)
}
return nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) {
domains, err := d.client.GetDomains(ctx)
if err != nil {
return "", fmt.Errorf("artfiles: get domains: %w", err)
}
var zone string
for s := range dns01.UnFqdnDomainsSeq(fqdn) {
if slices.Contains(domains, s) {
zone = s
}
}
if zone == "" {
return "", fmt.Errorf("artfiles: could not find the zone for domain %q", fqdn)
}
return zone, nil
}

View file

@ -0,0 +1,24 @@
Name = "ArtFiles"
Description = ''''''
URL = "https://www.artfiles.de/extras/domains/"
Code = "artfiles"
Since = "v4.32.0"
Example = '''
ARTFILES_USERNAME="xxx" \
ARTFILES_PASSWORD="yyy" \
lego --dns artfiles -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
ARTFILES_USERNAME = "API username"
ARTFILES_PASSWORD = "API password"
[Configuration.Additional]
ARTFILES_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
ARTFILES_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)"
ARTFILES_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
ARTFILES_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://support.artfiles.de/DCP-API#dns"

View file

@ -0,0 +1,228 @@
package artfiles
import (
"net/http/httptest"
"net/url"
"testing"
"github.com/go-acme/lego/v5/platform/tester"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
expected string
}{
{
desc: "success",
envVars: map[string]string{
EnvUsername: "user",
EnvPassword: "secret",
},
},
{
desc: "missing username",
envVars: map[string]string{
EnvUsername: "",
EnvPassword: "secret",
},
expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME",
},
{
desc: "missing password",
envVars: map[string]string{
EnvUsername: "user",
EnvPassword: "",
},
expected: "artfiles: some credentials information are missing: ARTFILES_PASSWORD",
},
{
desc: "missing credentials",
envVars: map[string]string{},
expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME,ARTFILES_PASSWORD",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
username string
password string
expected string
}{
{
desc: "success",
username: "user",
password: "secret",
},
{
desc: "missing username",
password: "secret",
expected: "artfiles: credentials missing",
},
{
desc: "missing Example",
username: "user",
expected: "artfiles: credentials missing",
},
{
desc: "missing credentials",
expected: "artfiles: credentials missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
config.Username = test.username
config.Password = test.password
p, err := NewDNSProviderConfig(config)
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.Present(t.Context(), envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func TestLiveCleanUp(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.CleanUp(t.Context(), envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func mockBuilder() *servermock.Builder[*DNSProvider] {
return servermock.NewBuilder(
func(server *httptest.Server) (*DNSProvider, error) {
config := NewDefaultConfig()
config.Username = "user"
config.Password = "secret"
config.HTTPClient = server.Client()
p, err := NewDNSProviderConfig(config)
if err != nil {
return nil, err
}
p.client.BaseURL, _ = url.Parse(server.URL)
return p, nil
},
servermock.CheckHeader().
WithBasicAuth("user", "secret"),
)
}
func TestDNSProvider_Present(t *testing.T) {
provider := mockBuilder().
Route("GET /domain/get_domains.html",
servermock.ResponseFromInternal("domains.txt"),
).
Route("GET /dns/get_dns.html",
servermock.ResponseFromInternal("get_dns.json"),
servermock.CheckQueryParameter().Strict().
With("domain", "example.com"),
).
Route("POST /dns/set_dns.html",
servermock.ResponseFromInternal("set_dns.json"),
servermock.CheckQueryParameter().Strict().
With("TXT", `@ "v=spf1 a mx ~all"
_acme-challenge "TheAcmeChallenge"
_acme-challenge "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`).
With("domain", "example.com"),
).
Build(t)
err := provider.Present(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
provider := mockBuilder().
Route("GET /domain/get_domains.html",
servermock.ResponseFromInternal("domains.txt"),
).
Route("GET /dns/get_dns.html",
servermock.ResponseFromInternal("get_dns.json"),
servermock.CheckQueryParameter().Strict().
With("domain", "example.com"),
).
Route("POST /dns/set_dns.html",
servermock.ResponseFromInternal("set_dns.json"),
servermock.CheckQueryParameter().Strict().
With("TXT", `@ "v=spf1 a mx ~all"
_acme-challenge "TheAcmeChallenge"
_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`).
With("domain", "example.com"),
).
Build(t)
err := provider.CleanUp(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}

View file

@ -0,0 +1,133 @@
package internal
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/go-acme/lego/v5/internal/errutils"
"github.com/go-acme/lego/v5/internal/useragent"
)
const defaultBaseURL = "https://dcp.c.artfiles.de/api/"
// Client the ArtFiles API client.
type Client struct {
username string
password string
BaseURL *url.URL
HTTPClient *http.Client
}
// NewClient creates a new Client.
func NewClient(username, password string) (*Client, error) {
if username == "" || password == "" {
return nil, errors.New("credentials missing")
}
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
username: username,
password: password,
BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}, nil
}
func (c *Client) GetDomains(ctx context.Context) ([]string, error) {
endpoint := c.BaseURL.JoinPath("domain", "get_domains.html")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
raw, err := c.do(req)
if err != nil {
return nil, err
}
return parseDomains(string(raw))
}
func (c *Client) GetRecords(ctx context.Context, domain string) (map[string]json.RawMessage, error) {
endpoint := c.BaseURL.JoinPath("dns", "get_dns.html")
query := endpoint.Query()
query.Set("domain", domain)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
raw, err := c.do(req)
if err != nil {
return nil, err
}
var result Records
err = json.Unmarshal(raw, &result)
if err != nil {
return nil, errutils.NewUnmarshalError(req, http.StatusOK, raw, err)
}
return result.Data, nil
}
func (c *Client) SetRecords(ctx context.Context, domain, rType string, value RecordValue) error {
endpoint := c.BaseURL.JoinPath("dns", "set_dns.html")
query := endpoint.Query()
query.Set("domain", domain)
query.Set(rType, value.String())
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil)
if err != nil {
return fmt.Errorf("unable to create request: %w", err)
}
_, err = c.do(req)
return err
}
func (c *Client) do(req *http.Request) ([]byte, error) {
useragent.SetHeader(req.Header)
req.SetBasicAuth(c.username, c.password)
if req.Method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
}
if resp.StatusCode/100 != 2 {
return nil, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
}
return raw, nil
}

View file

@ -0,0 +1,89 @@
package internal
import (
"encoding/json"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mockBuilder() *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
client, err := NewClient("user", "secret")
if err != nil {
return nil, err
}
client.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
},
servermock.CheckHeader().
WithBasicAuth("user", "secret"),
)
}
func TestClient_GetDomains(t *testing.T) {
client := mockBuilder().
Route("GET /domain/get_domains.html",
servermock.ResponseFromFixture("domains.txt"),
).
Build(t)
zones, err := client.GetDomains(t.Context())
require.NoError(t, err)
expected := []string{"example.com", "example.org", "example.net"}
assert.Equal(t, expected, zones)
}
func TestClient_GetRecords(t *testing.T) {
client := mockBuilder().
Route("GET /dns/get_dns.html",
servermock.ResponseFromFixture("get_dns.json"),
servermock.CheckQueryParameter().Strict().
With("domain", "example.com"),
).
Build(t)
records, err := client.GetRecords(t.Context(), "example.com")
require.NoError(t, err)
expected := map[string]json.RawMessage{
"A": json.RawMessage(strconv.Quote("sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4")),
"AAAA": json.RawMessage(strconv.Quote("")),
"CAA": json.RawMessage(strconv.Quote("@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"")),
"CName": json.RawMessage(strconv.Quote("some cname.to.example.tld.")),
"MX": json.RawMessage(strconv.Quote("10 mail.example.tld.")),
"SRV": json.RawMessage(strconv.Quote("_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .")),
"TLSA": json.RawMessage(strconv.Quote("_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2")),
"TXT": json.RawMessage(strconv.Quote("_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"")),
"TTL": json.RawMessage("3600"),
"comment": json.RawMessage(strconv.Quote("TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php")),
"nameserver": json.RawMessage(strconv.Quote("auth1.artfiles.de.\nauth2.artfiles.de.")),
}
assert.Equal(t, expected, records)
}
func TestClient_SetRecords(t *testing.T) {
client := mockBuilder().
Route("POST /dns/set_dns.html",
servermock.ResponseFromFixture("set_dns.json"),
servermock.CheckQueryParameter().Strict().
With("TXT", "a b\nc \"d\"").
With("domain", "example.com"),
).
Build(t)
err := client.SetRecords(t.Context(), "example.com", "TXT", RecordValue{"c": []string{`"d"`}, "a": []string{"b"}})
require.NoError(t, err)
}

View file

@ -0,0 +1,3 @@
example.com normal 2026-10-01 2017-09-18 163477
example.org normal 2026-08-01 2016-07-07 156216
example.net normal 2026-07-01 2017-06-06 162462

View file

@ -0,0 +1,16 @@
{
"data": {
"SRV": "_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .",
"AAAA": "",
"MX": "10 mail.example.tld.",
"CAA": "@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"",
"TTL": 3600,
"comment": "TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php",
"TXT": "_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"",
"A": "sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4",
"nameserver": "auth1.artfiles.de.\nauth2.artfiles.de.",
"CName": "some cname.to.example.tld.",
"TLSA": "_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2"
},
"status": "OK"
}

View file

@ -0,0 +1,4 @@
{
"status": "OK",
"error": ""
}

View file

@ -0,0 +1,8 @@
_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
@ "v=spf1 a mx ~all"
selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"
_acme-challenge "xxx"
_acme-challenge "yyy"

View file

@ -0,0 +1,7 @@
_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
@ "v=spf1 a mx ~all"
selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"
_acme-challenge "TheAcmeChallenge"

View file

@ -0,0 +1,109 @@
package internal
import (
"encoding/csv"
"encoding/json"
"errors"
"io"
"maps"
"slices"
"strconv"
"strings"
"unicode"
)
type Records struct {
Data map[string]json.RawMessage `json:"data"`
Status string `json:"status"`
}
type RecordValue map[string][]string
func (r RecordValue) Set(key, value string) {
r[key] = []string{strconv.Quote(value)}
}
func (r RecordValue) Add(key, value string) {
r[key] = append(r[key], strconv.Quote(value))
}
func (r RecordValue) Delete(key string) {
delete(r, key)
}
func (r RecordValue) RemoveValue(key, value string) {
if len(r[key]) == 0 {
return
}
quotedValue := strconv.Quote(value)
var data []string
for _, s := range r[key] {
if s != quotedValue {
data = append(data, s)
}
}
r[key] = data
if len(r[key]) == 0 {
r.Delete(key)
}
}
func (r RecordValue) String() string {
var parts []string
for _, key := range slices.Sorted(maps.Keys(r)) {
for _, s := range r[key] {
parts = append(parts, key+" "+s)
}
}
return strings.Join(parts, "\n")
}
func ParseRecordValue(lines string) RecordValue {
data := make(RecordValue)
for line := range strings.Lines(lines) {
line = strings.TrimSpace(line)
idx := strings.IndexFunc(line, unicode.IsSpace)
data[line[:idx]] = append(data[line[:idx]], line[idx+1:])
}
return data
}
func parseDomains(input string) ([]string, error) {
reader := csv.NewReader(strings.NewReader(input))
reader.Comma = '\t'
reader.TrimLeadingSpace = true
reader.LazyQuotes = true
var data []string
for {
record, err := reader.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if len(record) < 1 {
// Malformed line
continue
}
data = append(data, record[0])
}
return data, nil
}

View file

@ -0,0 +1,183 @@
package internal
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRecordValue_Set(t *testing.T) {
rv := make(RecordValue)
rv.Set("a", "1")
rv.Set("b", "2")
rv.Set("b", "3")
assert.Equal(t, "a \"1\"\nb \"3\"", rv.String())
}
func TestRecordValue_Add(t *testing.T) {
rv := make(RecordValue)
rv.Add("a", "1")
rv.Add("b", "2")
rv.Add("b", "3")
assert.Equal(t, "a \"1\"\nb \"2\"\nb \"3\"", rv.String())
}
func TestRecordValue_Delete(t *testing.T) {
rv := make(RecordValue)
rv.Set("a", "1")
rv.Add("b", "2")
rv.Delete("b")
assert.Equal(t, "a \"1\"", rv.String())
}
func TestRecordValue_RemoveValue(t *testing.T) {
testCases := []struct {
desc string
data map[string][]string
toRemove map[string][]string
expected string
}{
{
desc: "remove the only value",
data: map[string][]string{
"a": {"1"},
},
toRemove: map[string][]string{
"a": {"1"},
},
expected: ``,
},
{
desc: "remove value in the middle",
data: map[string][]string{
"a": {"1", "2", "3"},
},
toRemove: map[string][]string{
"a": {"2"},
},
expected: "a \"1\"\na \"3\"",
},
{
desc: "remove value at the beginning",
data: map[string][]string{
"a": {"1", "2", "3"},
},
toRemove: map[string][]string{
"a": {"1"},
},
expected: "a \"2\"\na \"3\"",
},
{
desc: "remove value at the end",
data: map[string][]string{
"a": {"1", "2", "3"},
},
toRemove: map[string][]string{
"a": {"3"},
},
expected: "a \"1\"\na \"2\"",
},
{
desc: "remove all (delete)",
data: map[string][]string{
"a": {"1", "2", "3"},
},
toRemove: map[string][]string{
"a": {"1", "2", "3"},
},
expected: ``,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
rv := make(RecordValue)
for k, values := range test.data {
for _, v := range values {
rv.Add(k, v)
}
}
for k, values := range test.toRemove {
for _, v := range values {
rv.RemoveValue(k, v)
}
}
assert.Equal(t, test.expected, rv.String())
})
}
}
func TestParseRecordValue(t *testing.T) {
testCases := []struct {
desc string
filename string
expected RecordValue
}{
{
desc: "simple",
filename: "txt_record.txt",
expected: RecordValue{
"@": []string{"\"v=spf1 a mx ~all\""},
"_acme-challenge": []string{"\"TheAcmeChallenge\""},
"_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""},
"_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""},
"_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""},
"selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""},
"selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""},
},
},
{
desc: "multiple values with the same key",
filename: "txt_record-multiple.txt",
expected: RecordValue{
"@": []string{"\"v=spf1 a mx ~all\""},
"_acme-challenge": []string{"\"xxx\"", "\"yyy\""},
"_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""},
"_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""},
"_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""},
"selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""},
"selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""},
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
file, err := os.ReadFile(filepath.Join("fixtures", test.filename))
require.NoError(t, err)
data := ParseRecordValue(string(file))
assert.Equal(t, test.expected, data)
})
}
}
func Test_parseDomains(t *testing.T) {
file, err := os.ReadFile(filepath.FromSlash("./fixtures/domains.txt"))
require.NoError(t, err)
domains, err := parseDomains(string(file))
require.NoError(t, err)
expected := []string{"example.com", "example.org", "example.net"}
assert.Equal(t, expected, domains)
}

View file

@ -15,12 +15,12 @@ type APIError struct {
}
func (a *APIError) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance))
_, _ = fmt.Fprintf(msg, "%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance)
for s, values := range a.Errors {
msg.WriteString(fmt.Sprintf(": %s: %s", s, strings.Join(values, ", ")))
_, _ = fmt.Fprintf(msg, ": %s: %s", s, strings.Join(values, ", "))
}
return msg.String()

View file

@ -0,0 +1,249 @@
// Package bluecatv2 implements a DNS provider for solving the DNS-01 challenge using Bluecat v2.
package bluecatv2
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/platform/config/env"
"github.com/go-acme/lego/v5/providers/dns/bluecatv2/internal"
"github.com/go-acme/lego/v5/providers/dns/internal/clientdebug"
)
// Environment variables names.
const (
envNamespace = "BLUECATV2_"
EnvServerURL = envNamespace + "SERVER_URL"
EnvUsername = envNamespace + "USERNAME"
EnvPassword = envNamespace + "PASSWORD"
EnvConfigName = envNamespace + "CONFIG_NAME"
EnvViewName = envNamespace + "VIEW_NAME"
EnvSkipDeploy = envNamespace + "SKIP_DEPLOY"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
ServerURL string
Username string
Password string
ConfigName string
ViewName string
SkipDeploy bool
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
SkipDeploy: env.GetOrDefaultBool(EnvSkipDeploy, false),
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
client *internal.Client
zoneIDs map[string]int64
recordIDs map[string]int64
recordIDsMu sync.Mutex
}
// NewDNSProvider returns a DNSProvider instance configured for Bluecat v2.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword, EnvConfigName, EnvViewName)
if err != nil {
return nil, fmt.Errorf("bluecatv2: %w", err)
}
config := NewDefaultConfig()
config.ServerURL = values[EnvServerURL]
config.Username = values[EnvUsername]
config.Password = values[EnvPassword]
config.ConfigName = values[EnvConfigName]
config.ViewName = values[EnvViewName]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for Bluecat v2.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("bluecatv2: the configuration of the DNS provider is nil")
}
if config.ServerURL == "" {
return nil, errors.New("bluecatv2: missing server URL")
}
if config.ConfigName == "" {
return nil, errors.New("bluecatv2: missing configuration name")
}
if config.ViewName == "" {
return nil, errors.New("bluecatv2: missing view name")
}
client, err := internal.NewClient(config.ServerURL, config.Username, config.Password)
if err != nil {
return nil, fmt.Errorf("bluecatv2: %w", err)
}
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
return &DNSProvider{
config: config,
client: client,
recordIDs: make(map[string]int64),
zoneIDs: make(map[string]int64),
}, nil
}
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
ctxAuth, err := d.client.CreateAuthenticatedContext(context.Background())
if err != nil {
return fmt.Errorf("bluecatv2: %w", err)
}
zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("bluecatv2: %w", err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.AbsoluteName)
if err != nil {
return fmt.Errorf("bluecatv2: %w", err)
}
record := internal.RecordTXT{
CommonResource: internal.CommonResource{
Type: "TXTRecord",
Name: subDomain,
},
Text: info.Value,
TTL: d.config.TTL,
RecordType: "TXT",
}
newRecord, err := d.client.CreateZoneResourceRecord(ctxAuth, zone.ID, record)
if err != nil {
return fmt.Errorf("bluecatv2: create resource record: %w", err)
}
d.recordIDsMu.Lock()
d.zoneIDs[token] = zone.ID
d.recordIDs[token] = newRecord.ID
d.recordIDsMu.Unlock()
if d.config.SkipDeploy {
return nil
}
_, err = d.client.CreateZoneDeployment(ctxAuth, zone.ID)
if err != nil {
return fmt.Errorf("bluecat: deploy zone: %w", err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
d.recordIDsMu.Lock()
recordID, recordOK := d.recordIDs[token]
zoneID, zoneOK := d.zoneIDs[token]
d.recordIDsMu.Unlock()
if !recordOK {
return fmt.Errorf("bluecatv2: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
if !zoneOK {
return fmt.Errorf("bluecatv2: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token)
}
ctxAuth, err := d.client.CreateAuthenticatedContext(context.Background())
if err != nil {
return fmt.Errorf("bluecatv2: %w", err)
}
err = d.client.DeleteResourceRecord(ctxAuth, recordID)
if err != nil {
return fmt.Errorf("bluecatv2: delete resource record: %w", err)
}
if d.config.SkipDeploy {
return nil
}
_, err = d.client.CreateZoneDeployment(ctxAuth, zoneID)
if err != nil {
return fmt.Errorf("bluecat: deploy zone: %w", err)
}
return nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.ZoneResource, error) {
for name := range dns01.UnFqdnDomainsSeq(fqdn) {
opts := &internal.CollectionOptions{
Fields: "id,absoluteName,configuration.id,configuration.name,view.id,view.name",
Filter: internal.And(
internal.Eq("absoluteName", name),
internal.Eq("configuration.name", d.config.ConfigName),
internal.Eq("view.name", d.config.ViewName),
).String(),
}
zones, err := d.client.RetrieveZones(ctx, opts)
if err != nil {
// TODO(ldez) maybe add a log in v5.
continue
}
for _, zone := range zones {
if zone.AbsoluteName == name {
return &zone, nil
}
}
}
return nil, fmt.Errorf("no zone found for fqdn: %s", fqdn)
}

View file

@ -0,0 +1,33 @@
Name = "Bluecat v2"
Description = ''''''
URL = "https://www.bluecatnetworks.com"
Code = "bluecatv2"
Since = "v4.32.0"
Example = '''
BLUECATV2_SERVER_URL="https://example.com" \
BLUECATV2_USERNAME="xxx" \
BLUECATV2_PASSWORD="yyy" \
BLUECATV2_CONFIG_NAME="myConfiguration" \
BLUECATV2_VIEW_NAME="myView" \
lego --dns bluecatv2 -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
BLUECAT_SERVER_URL = "The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve"
BLUECATV2_USERNAME = "API username"
BLUECATV2_PASSWORD = "API password"
BLUECATV2_CONFIG_NAME = "Configuration name"
BLUECATV2_VIEW_NAME = "DNS View Name"
[Configuration.Additional]
BLUECATV2_SKIP_DEPLOY = "Skip quick deployements"
BLUECATV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
BLUECATV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
BLUECATV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
BLUECATV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0"
Swagger = "http://{Address_Manager_IP}/api/openapi.json"
SwaggerDump = "https://github.com/go-acme/lego/discussions/2218#discussioncomment-13060545"

View file

@ -0,0 +1,414 @@
package bluecatv2
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-acme/lego/v5/platform/tester"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/go-acme/lego/v5/providers/dns/bluecatv2/internal"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(
EnvServerURL,
EnvUsername,
EnvPassword,
EnvConfigName,
EnvViewName,
EnvSkipDeploy,
).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
expected string
}{
{
desc: "success",
envVars: map[string]string{
EnvServerURL: "https://example.com/",
EnvUsername: "userA",
EnvPassword: "secret",
EnvConfigName: "myConfig",
EnvViewName: "myView",
},
},
{
desc: "missing server URL",
envVars: map[string]string{
EnvServerURL: "",
EnvUsername: "userA",
EnvPassword: "secret",
EnvConfigName: "myConfig",
EnvViewName: "myView",
},
expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL",
},
{
desc: "missing username",
envVars: map[string]string{
EnvServerURL: "https://example.com/",
EnvUsername: "",
EnvPassword: "secret",
EnvConfigName: "myConfig",
EnvViewName: "myView",
},
expected: "bluecatv2: some credentials information are missing: BLUECATV2_USERNAME",
},
{
desc: "missing password",
envVars: map[string]string{
EnvServerURL: "https://example.com/",
EnvUsername: "userA",
EnvPassword: "",
EnvConfigName: "myConfig",
EnvViewName: "myView",
},
expected: "bluecatv2: some credentials information are missing: BLUECATV2_PASSWORD",
},
{
desc: "missing configuration name",
envVars: map[string]string{
EnvServerURL: "https://example.com/",
EnvUsername: "userA",
EnvPassword: "secret",
EnvConfigName: "",
EnvViewName: "myView",
},
expected: "bluecatv2: some credentials information are missing: BLUECATV2_CONFIG_NAME",
},
{
desc: "missing view name",
envVars: map[string]string{
EnvServerURL: "https://example.com/",
EnvUsername: "userA",
EnvPassword: "secret",
EnvConfigName: "myConfig",
EnvViewName: "",
},
expected: "bluecatv2: some credentials information are missing: BLUECATV2_VIEW_NAME",
},
{
desc: "missing credentials",
envVars: map[string]string{},
expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL,BLUECATV2_USERNAME,BLUECATV2_PASSWORD,BLUECATV2_CONFIG_NAME,BLUECATV2_VIEW_NAME",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
serverURL string
username string
password string
configName string
viewName string
expected string
}{
{
desc: "success",
serverURL: "https://example.com/",
username: "userA",
password: "secret",
configName: "myConfig",
viewName: "myView",
},
{
desc: "missing server URL",
username: "userA",
password: "secret",
configName: "myConfig",
viewName: "myView",
expected: "bluecatv2: missing server URL",
},
{
desc: "missing username",
serverURL: "https://example.com/",
password: "secret",
configName: "myConfig",
viewName: "myView",
expected: "bluecatv2: credentials missing",
},
{
desc: "missing password",
serverURL: "https://example.com/",
username: "userA",
configName: "myConfig",
viewName: "myView",
expected: "bluecatv2: credentials missing",
},
{
desc: "missing configuration name",
serverURL: "https://example.com/",
username: "userA",
password: "secret",
viewName: "myView",
expected: "bluecatv2: missing configuration name",
},
{
desc: "missing view name",
serverURL: "https://example.com/",
username: "userA",
password: "secret",
configName: "myConfig",
expected: "bluecatv2: missing view name",
},
{
desc: "missing credentials",
expected: "bluecatv2: missing server URL",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
config.ServerURL = test.serverURL
config.Username = test.username
config.Password = test.password
config.ConfigName = test.configName
config.ViewName = test.viewName
p, err := NewDNSProviderConfig(config)
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.Present(t.Context(), envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func TestLiveCleanUp(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.CleanUp(t.Context(), envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func mockBuilder() *servermock.Builder[*DNSProvider] {
return servermock.NewBuilder(
func(server *httptest.Server) (*DNSProvider, error) {
config := NewDefaultConfig()
config.ServerURL = server.URL
config.Username = "userA"
config.Password = "secret"
config.ConfigName = "myConfiguration"
config.ViewName = "myView"
config.HTTPClient = server.Client()
p, err := NewDNSProviderConfig(config)
if err != nil {
return nil, err
}
return p, nil
},
servermock.CheckHeader().
WithJSONHeaders(),
)
}
func TestDNSProvider_Present(t *testing.T) {
provider := mockBuilder().
Route("POST /api/v2/sessions",
servermock.ResponseFromInternal("postSession.json"),
servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
).
Route("GET /api/v2/configurations",
servermock.ResponseFromInternal("configurations.json"),
servermock.CheckQueryParameter().Strict().
With("filter", "name:eq('myConfiguration')"),
).
Route("GET /api/v2/configurations/12345/views",
servermock.ResponseFromInternal("views.json"),
servermock.CheckQueryParameter().Strict().
With("filter", "name:eq('myView')"),
).
Route("GET /api/v2/zones",
http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
filter := req.URL.Query().Get("filter")
if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) {
servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req)
return
}
servermock.ResponseFromInternal("error.json").
WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req)
}),
).
Route("POST /api/v2/zones/12345/resourceRecords",
servermock.ResponseFromInternal("postZoneResourceRecord.json"),
servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"),
).
Route("POST /api/v2/zones/12345/deployments",
servermock.ResponseFromInternal("postZoneDeployment.json").
WithStatusCode(http.StatusCreated),
servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"),
).
Build(t)
err := provider.Present(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_Present_skipDeploy(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(map[string]string{
EnvSkipDeploy: "true",
})
provider := mockBuilder().
Route("POST /api/v2/sessions",
servermock.ResponseFromInternal("postSession.json"),
servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
).
Route("GET /api/v2/configurations",
servermock.ResponseFromInternal("configurations.json"),
servermock.CheckQueryParameter().Strict().
With("filter", "name:eq('myConfiguration')"),
).
Route("GET /api/v2/configurations/12345/views",
servermock.ResponseFromInternal("views.json"),
servermock.CheckQueryParameter().Strict().
With("filter", "name:eq('myView')"),
).
Route("GET /api/v2/zones",
http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
filter := req.URL.Query().Get("filter")
if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) {
servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req)
return
}
servermock.ResponseFromInternal("error.json").
WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req)
}),
).
Route("POST /api/v2/zones/12345/resourceRecords",
servermock.ResponseFromInternal("postZoneResourceRecord.json"),
servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"),
).
Route("POST /api/v2/zones/456789/deployments",
servermock.Noop().
WithStatusCode(http.StatusUnauthorized),
).
Build(t)
err := provider.Present(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
provider := mockBuilder().
Route("POST /api/v2/sessions",
servermock.ResponseFromInternal("postSession.json"),
servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
).
Route("DELETE /api/v2/resourceRecords/12345",
servermock.ResponseFromInternal("deleteResourceRecord.json"),
).
Route("POST /api/v2/zones/456789/deployments",
servermock.ResponseFromInternal("postZoneDeployment.json").
WithStatusCode(http.StatusCreated),
servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"),
).
Build(t)
provider.zoneIDs["abc"] = 456789
provider.recordIDs["abc"] = 12345
err := provider.CleanUp(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp_skipDeploy(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(map[string]string{
EnvSkipDeploy: "true",
})
provider := mockBuilder().
Route("POST /api/v2/sessions",
servermock.ResponseFromInternal("postSession.json"),
servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
).
Route("DELETE /api/v2/resourceRecords/12345",
servermock.ResponseFromInternal("deleteResourceRecord.json"),
).
Route("POST /api/v2/zones/456789/deployments",
servermock.Noop().
WithStatusCode(http.StatusUnauthorized),
).
Build(t)
provider.zoneIDs["abc"] = 456789
provider.recordIDs["abc"] = 12345
err := provider.CleanUp(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}

View file

@ -0,0 +1,221 @@
package internal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-acme/lego/v5/internal/errutils"
"github.com/go-acme/lego/v5/internal/useragent"
querystring "github.com/google/go-querystring/query"
)
// Client the Bluecat v2 API client.
type Client struct {
username string
password string
baseURL *url.URL
HTTPClient *http.Client
}
// NewClient creates a new Client.
func NewClient(serverURL, username, password string) (*Client, error) {
if serverURL == "" {
return nil, errors.New("server URL missing")
}
if username == "" || password == "" {
return nil, errors.New("credentials missing")
}
baseURL, err := url.Parse(serverURL)
if err != nil {
return nil, err
}
return &Client{
username: username,
password: password,
baseURL: baseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}, nil
}
// RetrieveZones retrieves all zones.
func (c *Client) RetrieveZones(ctx context.Context, opts *CollectionOptions) ([]ZoneResource, error) {
endpoint := c.baseURL.JoinPath("api", "v2", "zones")
collection, err := retrieveCollection[ZoneResource](ctx, c, endpoint, opts)
if err != nil {
return nil, err
}
return collection.Data, nil
}
// RetrieveZoneDeployments retrieves all deployments for a zone.
func (c *Client) RetrieveZoneDeployments(ctx context.Context, zoneID int64, opts *CollectionOptions) ([]QuickDeployment, error) {
endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments")
collection, err := retrieveCollection[QuickDeployment](ctx, c, endpoint, opts)
if err != nil {
return nil, err
}
return collection.Data, nil
}
// CreateZoneDeployment creates a new deployment for a zone.
func (c *Client) CreateZoneDeployment(ctx context.Context, zoneID int64) (*QuickDeployment, error) {
endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments")
payload := CommonResource{
Type: "QuickDeployment",
}
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
if err != nil {
return nil, err
}
result := new(QuickDeployment)
err = c.doAuthenticated(ctx, req, result)
if err != nil {
return nil, err
}
return result, nil
}
// CreateZoneResourceRecord creates a new TXT record in a zone.
func (c *Client) CreateZoneResourceRecord(ctx context.Context, zoneID int64, record RecordTXT) (*RecordTXT, error) {
endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "resourceRecords")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
if err != nil {
return nil, err
}
result := new(RecordTXT)
err = c.doAuthenticated(ctx, req, result)
if err != nil {
return nil, err
}
return result, nil
}
// DeleteResourceRecord deletes a resource record.
func (c *Client) DeleteResourceRecord(ctx context.Context, recordID int64) error {
endpoint := c.baseURL.JoinPath("api", "v2", "resourceRecords", strconv.FormatInt(recordID, 10))
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
return c.doAuthenticated(ctx, req, nil)
}
func (c *Client) do(req *http.Request, result any) error {
useragent.SetHeader(req.Header)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return parseError(req, resp)
}
if result == nil {
return nil
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errutils.NewReadResponseError(req, resp.StatusCode, err)
}
err = json.Unmarshal(raw, result)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
}
return nil
}
func retrieveCollection[T any](ctx context.Context, client *Client, endpoint *url.URL, opts *CollectionOptions) (*Collection[T], error) {
if opts != nil {
values, err := querystring.Values(opts)
if err != nil {
return nil, err
}
endpoint.RawQuery = values.Encode()
}
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
result := &Collection[T]{}
err = client.doAuthenticated(ctx, req, result)
if err != nil {
return nil, err
}
return result, nil
}
func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
err := json.NewEncoder(buf).Encode(payload)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}
func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
}
return &errAPI
}

View file

@ -0,0 +1,208 @@
package internal
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mockBuilderAuthenticated() *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
client, err := NewClient(server.URL, "userA", "secret")
if err != nil {
return nil, err
}
client.baseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
},
servermock.CheckHeader().
WithJSONHeaders(),
servermock.CheckHeader().
WithAuthorization("Basic secretToken"),
)
}
func TestClient_RetrieveZones(t *testing.T) {
client := mockBuilderAuthenticated().
Route("GET /api/v2/zones",
servermock.ResponseFromFixture("zones.json"),
servermock.CheckQueryParameter().Strict().
With(
"filter",
"absoluteName:eq('example.com') and configuration.name:eq('myConfiguration') and view.name:eq('myView')",
),
).
Build(t)
opts := &CollectionOptions{
Filter: And(
Eq("absoluteName", "example.com"),
Eq("configuration.name", "myConfiguration"),
Eq("view.name", "myView"),
).String(),
}
result, err := client.RetrieveZones(mockToken(t.Context()), opts)
require.NoError(t, err)
expected := []ZoneResource{
{
CommonResource: CommonResource{ID: 12345, Type: "ENUMZone", Name: "5678"},
AbsoluteName: "string",
},
{
CommonResource: CommonResource{ID: 12345, Type: "ExternalHostsZone", Name: "name"},
},
{
CommonResource: CommonResource{ID: 12345, Type: "InternalRootZone", Name: "name"},
},
{
CommonResource: CommonResource{ID: 12345, Type: "ResponsePolicyZone", Name: "name"},
},
{
CommonResource: CommonResource{ID: 12345, Type: "Zone", Name: "example.com"},
AbsoluteName: "example.com",
},
}
assert.Equal(t, expected, result)
}
func TestClient_RetrieveZones_error(t *testing.T) {
client := mockBuilderAuthenticated().
Route("GET /api/v2/zones",
servermock.ResponseFromFixture("error.json").
WithStatusCode(http.StatusUnauthorized),
).
Build(t)
opts := &CollectionOptions{
Filter: And(
Eq("absoluteName", "example.com"),
Eq("configuration.name", "myConfiguration"),
Eq("view.name", "myView"),
).String(),
}
_, err := client.RetrieveZones(mockToken(t.Context()), opts)
require.EqualError(t, err, "401: Unauthorized: InvalidAuthorizationToken: The provided authorization token is invalid")
}
func TestClient_RetrieveZoneDeployments(t *testing.T) {
client := mockBuilderAuthenticated().
Route("GET /api/v2/zones/456789/deployments",
servermock.ResponseFromFixture("getZoneDeployments.json"),
servermock.CheckQueryParameter().Strict().
With("filter", "id:eq('12345')"),
).
Build(t)
opts := &CollectionOptions{
Filter: Eq("id", "12345").String(),
}
result, err := client.RetrieveZoneDeployments(mockToken(t.Context()), 456789, opts)
require.NoError(t, err)
expected := []QuickDeployment{
{
CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment", Name: ""},
State: "PENDING",
Status: "CANCEL",
Message: "string",
PercentComplete: 50,
CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC),
StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC),
CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC),
Method: "SCHEDULED",
},
}
assert.Equal(t, expected, result)
}
func TestClient_CreateZoneDeployment(t *testing.T) {
client := mockBuilderAuthenticated().
Route("POST /api/v2/zones/12345/deployments",
servermock.ResponseFromFixture("postZoneDeployment.json").
WithStatusCode(http.StatusCreated),
servermock.CheckRequestJSONBodyFromFixture("postZoneDeployment-request.json"),
).
Build(t)
quickDeployment, err := client.CreateZoneDeployment(mockToken(t.Context()), 12345)
require.NoError(t, err)
expected := &QuickDeployment{
CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment"},
State: "PENDING",
Status: "CANCEL",
Message: "string",
PercentComplete: 50,
CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC),
StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC),
CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC),
Method: "SCHEDULED",
}
assert.Equal(t, expected, quickDeployment)
}
func TestClient_CreateZoneResourceRecord(t *testing.T) {
client := mockBuilderAuthenticated().
Route("POST /api/v2/zones/12345/resourceRecords",
servermock.ResponseFromFixture("postZoneResourceRecord.json"),
servermock.CheckRequestJSONBodyFromFixture("postZoneResourceRecord-request.json"),
).
Build(t)
record := RecordTXT{
CommonResource: CommonResource{
Type: "TXTRecord",
Name: "_acme-challenge",
},
Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
TTL: 120,
RecordType: "TXT",
}
result, err := client.CreateZoneResourceRecord(mockToken(t.Context()), 12345, record)
require.NoError(t, err)
expected := &RecordTXT{
CommonResource: CommonResource{
ID: 12345,
Type: "ResourceRecord",
Name: "name",
},
TTL: 3600,
AbsoluteName: "host1.example.com",
Comment: "Sample comment.",
Dynamic: true,
RecordType: "CNAME",
Text: "",
}
assert.Equal(t, expected, result)
}
func TestClient_DeleteResourceRecord(t *testing.T) {
client := mockBuilderAuthenticated().
Route("DELETE /api/v2/resourceRecords/12345",
servermock.ResponseFromFixture("deleteResourceRecord.json"),
).
Build(t)
err := client.DeleteResourceRecord(mockToken(t.Context()), 12345)
require.NoError(t, err)
}

View file

@ -0,0 +1,75 @@
{
"id": 12345,
"type": "WorkflowRequest",
"state": "APPROVED",
"operation": "ADD_ALIAS_RECORD",
"creator": {
"id": 103307,
"type": "User",
"name": "admin",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"authenticator": {
"id": 12345,
"type": "Authenticator",
"name": "LDAP authenticator"
},
"email": "user@example.com",
"phoneNumber": "555-1234",
"securityPrivilege": "NO_ACCESS",
"historyPrivilege": "HIDE",
"accessType": "GUI",
"passwordResetRequired": true,
"accountLocked": true,
"x509Required": true,
"administrativeAccessRights": [
{
"resourceType": "Event",
"accessLevel": "HIDE"
}
]
},
"resourceId": 0,
"resourceType": "ACL",
"fieldUpdates": [
{
"name": "string",
"value": {},
"previousValue": {}
}
],
"dependentRequest": "string",
"modifier": {
"id": 103307,
"type": "User",
"name": "admin",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"authenticator": {
"id": 12345,
"type": "Authenticator",
"name": "LDAP authenticator"
},
"email": "user@example.com",
"phoneNumber": "555-1234",
"securityPrivilege": "NO_ACCESS",
"historyPrivilege": "HIDE",
"accessType": "GUI",
"passwordResetRequired": true,
"accountLocked": true,
"x509Required": true,
"administrativeAccessRights": [
{
"resourceType": "Event",
"accessLevel": "HIDE"
}
]
},
"creationDateTime": "2022-10-17T19:11:45Z",
"modificationDateTime": "2022-10-18T19:11:45Z",
"comment": "Sample comment."
}

View file

@ -0,0 +1,6 @@
{
"status": 401,
"reason": "Unauthorized",
"code": "InvalidAuthorizationToken",
"message": "The provided authorization token is invalid"
}

View file

@ -0,0 +1,46 @@
{
"count": 0,
"totalCount": 0,
"data": [
{
"id": 12345,
"type": "QuickDeployment",
"state": "PENDING",
"status": "CANCEL",
"message": "string",
"percentComplete": 50,
"creationDateTime": "2022-11-23T02:53:00Z",
"startDateTime": "2022-11-23T02:53:03Z",
"completionDateTime": "2022-11-23T02:54:05Z",
"user": {
"id": 103307,
"type": "User",
"name": "admin",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"authenticator": {
"id": 12345,
"type": "Authenticator",
"name": "LDAP authenticator"
},
"email": "user@example.com",
"phoneNumber": "555-1234",
"securityPrivilege": "NO_ACCESS",
"historyPrivilege": "HIDE",
"accessType": "GUI",
"passwordResetRequired": true,
"accountLocked": true,
"x509Required": true,
"administrativeAccessRights": [
{
"resourceType": "Event",
"accessLevel": "HIDE"
}
]
},
"method": "SCHEDULED"
}
]
}

View file

@ -0,0 +1,4 @@
{
"username": "userA",
"password": "secret"
}

View file

@ -0,0 +1,50 @@
{
"id": 12345,
"type": "UserSession",
"apiToken": "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez",
"apiTokenExpirationDateTime": "2022-09-15T17:52:07Z",
"basicAuthenticationCredentials": "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=",
"remoteAddress": "192.168.1.1",
"readOnly": true,
"loginDateTime": "2022-09-14T17:45:03Z",
"logoutDateTime": "2022-09-14T19:45:03Z",
"state": "LOGGED_IN",
"response": "Authentication Error: Ensure that your username and password are correct.",
"user": {
"id": 103307,
"type": "User",
"name": "admin",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"authenticator": {
"id": 12345,
"type": "Authenticator",
"name": "LDAP authenticator"
},
"email": "user@example.com",
"phoneNumber": "555-1234",
"securityPrivilege": "NO_ACCESS",
"historyPrivilege": "HIDE",
"accessType": "GUI",
"passwordResetRequired": true,
"accountLocked": true,
"x509Required": true,
"administrativeAccessRights": [
{
"resourceType": "Event",
"accessLevel": "HIDE"
}
]
},
"authenticator": {
"id": 12345,
"type": "Authenticator",
"name": "LDAP authenticator",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
}
}
}

View file

@ -0,0 +1,3 @@
{
"type": "QuickDeployment"
}

View file

@ -0,0 +1,40 @@
{
"id": 12345,
"type": "QuickDeployment",
"state": "PENDING",
"status": "CANCEL",
"message": "string",
"percentComplete": 50,
"creationDateTime": "2022-11-23T02:53:00Z",
"startDateTime": "2022-11-23T02:53:03Z",
"completionDateTime": "2022-11-23T02:54:05Z",
"user": {
"id": 103307,
"type": "User",
"name": "admin",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"authenticator": {
"id": 12345,
"type": "Authenticator",
"name": "LDAP authenticator"
},
"email": "user@example.com",
"phoneNumber": "555-1234",
"securityPrivilege": "NO_ACCESS",
"historyPrivilege": "HIDE",
"accessType": "GUI",
"passwordResetRequired": true,
"accountLocked": true,
"x509Required": true,
"administrativeAccessRights": [
{
"resourceType": "Event",
"accessLevel": "HIDE"
}
]
},
"method": "SCHEDULED"
}

View file

@ -0,0 +1,7 @@
{
"type": "TXTRecord",
"name": "_acme-challenge",
"ttl": 120,
"recordType": "TXT",
"text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
}

View file

@ -0,0 +1,25 @@
{
"id": 12345,
"type": "ResourceRecord",
"name": "name",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"ttl": 3600,
"absoluteName": "host1.example.com",
"comment": "Sample comment.",
"dynamic": true,
"recordType": "CNAME",
"linkedRecord": {
"id": 12345,
"type": "ResourceRecord",
"name": "name",
"absoluteName": "host1.example.com"
}
}

View file

@ -0,0 +1,185 @@
{
"count": 0,
"totalCount": 0,
"data": [
{
"id": 12345,
"type": "ENUMZone",
"name": "5678",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"view": {
"id": 12345,
"type": "View",
"name": "default",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"deviceRegistrationEnabled": true,
"deviceRegistrationPortalAddress": "10.10.10.10"
},
"deploymentEnabled": true,
"absoluteName": "string"
},
{
"id": 12345,
"type": "ExternalHostsZone",
"name": "name",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"view": {
"id": 12345,
"type": "View",
"name": "default",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"deviceRegistrationEnabled": true,
"deviceRegistrationPortalAddress": "10.10.10.10"
}
},
{
"id": 12345,
"type": "InternalRootZone",
"name": "name",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"view": {
"id": 12345,
"type": "View",
"name": "default",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"deviceRegistrationEnabled": true,
"deviceRegistrationPortalAddress": "10.10.10.10"
},
"deploymentEnabled": true
},
{
"id": 12345,
"type": "ResponsePolicyZone",
"name": "name",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"view": {
"id": 12345,
"type": "View",
"name": "default",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"deviceRegistrationEnabled": true,
"deviceRegistrationPortalAddress": "10.10.10.10"
},
"responsePolicyZoneType": "LOCAL",
"responsePolicy": {
"id": 12345,
"type": "ResponsePolicy",
"name": "Block Response Policy"
},
"overridePolicyType": "ALLOWLIST",
"overrideRefreshTime": "string",
"redirectTarget": "string",
"feedCategories": [
"string"
]
},
{
"id": 12345,
"type": "Zone",
"name": "example.com",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"view": {
"id": 12345,
"type": "View",
"name": "default",
"userDefinedFields": {
"udf1": "value1",
"udf2": "value2"
},
"configuration": {
"id": 12345,
"type": "Configuration",
"name": "name"
},
"deviceRegistrationEnabled": true,
"deviceRegistrationPortalAddress": "10.10.10.10"
},
"deploymentEnabled": true,
"dynamicUpdateEnabled": true,
"template": {
"id": 12345,
"type": "ZoneTemplate",
"name": "name"
},
"signed": true,
"signingPolicy": {
"id": 12345,
"type": "DNSSECSigningPolicy",
"name": "name"
},
"absoluteName": "example.com"
}
]
}

View file

@ -0,0 +1,60 @@
package internal
import (
"context"
"fmt"
"net/http"
)
type token string
const tokenKey token = "token"
const authorizationHeader = "Authorization"
// CreateSession creates a new session.
func (c *Client) CreateSession(ctx context.Context, info LoginInfo) (*Session, error) {
endpoint := c.baseURL.JoinPath("api", "v2", "sessions")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, info)
if err != nil {
return nil, err
}
result := new(Session)
err = c.do(req, result)
if err != nil {
return nil, err
}
return result, nil
}
// CreateAuthenticatedContext creates a new authenticated context.
func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
tok, err := c.CreateSession(ctx, LoginInfo{Username: c.username, Password: c.password})
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
return context.WithValue(ctx, tokenKey, tok.BasicAuthenticationCredentials), nil
}
func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result any) error {
tok := getToken(ctx)
if tok != "" {
req.Header.Set(authorizationHeader, "Basic "+tok)
}
return c.do(req, result)
}
func getToken(ctx context.Context) string {
tok, ok := ctx.Value(tokenKey).(string)
if !ok {
return ""
}
return tok
}

View file

@ -0,0 +1,82 @@
package internal
import (
"context"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mockBuilder() *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
client, err := NewClient(server.URL, "userA", "secret")
if err != nil {
return nil, err
}
client.baseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
},
servermock.CheckHeader().
WithJSONHeaders(),
)
}
func mockToken(ctx context.Context) context.Context {
return context.WithValue(ctx, tokenKey, "secretToken")
}
func TestClient_CreateSession(t *testing.T) {
client := mockBuilder().
Route("POST /api/v2/sessions",
servermock.ResponseFromFixture("postSession.json"),
servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"),
).
Build(t)
info := LoginInfo{
Username: "userA",
Password: "secret",
}
result, err := client.CreateSession(mockToken(t.Context()), info)
require.NoError(t, err)
expected := &Session{
ID: 12345,
Type: "UserSession",
APIToken: "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez",
APITokenExpirationDateTime: time.Date(2022, time.September, 15, 17, 52, 7, 0, time.UTC),
BasicAuthenticationCredentials: "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=",
RemoteAddress: "192.168.1.1",
ReadOnly: true,
LoginDateTime: time.Date(2022, time.September, 14, 17, 45, 3, 0, time.UTC),
LogoutDateTime: time.Date(2022, time.September, 14, 19, 45, 3, 0, time.UTC),
State: "LOGGED_IN",
Response: "Authentication Error: Ensure that your username and password are correct.",
}
assert.Equal(t, expected, result)
}
func TestClient_CreateAuthenticatedContext(t *testing.T) {
client := mockBuilder().
Route("POST /api/v2/sessions",
servermock.ResponseFromFixture("postSession.json"),
servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"),
).
Build(t)
ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
assert.Equal(t, "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", getToken(ctx))
}

View file

@ -0,0 +1,64 @@
package internal
import (
"fmt"
"strings"
)
type Predicate struct {
field string
operator string
values []string
}
func (p *Predicate) String() string {
var values []string
for _, v := range p.values {
values = append(values, fmt.Sprintf("'%s'", v))
}
return fmt.Sprintf("%s:%s(%s)", p.field, p.operator, strings.Join(values, ", "))
}
func Eq(field, value string) *Predicate {
return &Predicate{field: field, operator: "eq", values: []string{value}}
}
func Contains(field, value string) *Predicate {
return &Predicate{field: field, operator: "contains", values: []string{value}}
}
func StartsWith(field, value string) *Predicate {
return &Predicate{field: field, operator: "startsWith", values: []string{value}}
}
func EndsWith(field, value string) *Predicate {
return &Predicate{field: field, operator: "endsWith", values: []string{value}}
}
func In(field string, values ...string) *Predicate {
return &Predicate{field: field, operator: "in", values: values}
}
type Combined struct {
predicates []*Predicate
operator string
}
func (o *Combined) String() string {
var parts []string
for _, predicate := range o.predicates {
parts = append(parts, predicate.String())
}
return strings.Join(parts, " "+o.operator+" ")
}
func And(predicates ...*Predicate) *Combined {
return &Combined{predicates: predicates, operator: "and"}
}
func Or(predicates ...*Predicate) *Combined {
return &Combined{predicates: predicates, operator: "or"}
}

View file

@ -0,0 +1,78 @@
package internal
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPredicate(t *testing.T) {
testCases := []struct {
desc string
predicate fmt.Stringer
expected string
}{
{
desc: "Equals",
predicate: Eq("foo", "bar"),
expected: "foo:eq('bar')",
},
{
desc: "Contains",
predicate: Contains("foo", "bar"),
expected: "foo:contains('bar')",
},
{
desc: "Starts with",
predicate: StartsWith("foo", "bar"),
expected: "foo:startsWith('bar')",
},
{
desc: "Ends with",
predicate: EndsWith("foo", "bar"),
expected: "foo:endsWith('bar')",
},
{
desc: "Match a list of values",
predicate: In("foo", "bar", "bir"),
expected: "foo:in('bar', 'bir')",
},
{
desc: "Combined: and",
predicate: And(Eq("foo", "bar"), Eq("fii", "bir")),
expected: "foo:eq('bar') and fii:eq('bir')",
},
{
desc: "Combined: multiple and",
predicate: And(
Eq("foo", "bar"),
Eq("fii", "bir"),
Eq("fuu", "bur"),
),
expected: "foo:eq('bar') and fii:eq('bir') and fuu:eq('bur')",
},
{
desc: "Combined: or",
predicate: Or(Eq("foo", "bar"), Eq("foo", "bir")),
expected: "foo:eq('bar') or foo:eq('bir')",
},
{
desc: "Combined: multiple or",
predicate: Or(
Eq("foo", "bar"),
Eq("foo", "bir"),
Eq("foo", "bur"),
),
expected: "foo:eq('bar') or foo:eq('bir') or foo:eq('bur')",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
assert.Equal(t, test.expected, test.predicate.String())
})
}
}

View file

@ -0,0 +1,122 @@
package internal
import (
"fmt"
"time"
)
// Quick deployment states.
//
//nolint:misspell // US vs UK
const (
QDStatePending = "PENDING"
QDStateQueued = "QUEUED"
QDStateRunning = "RUNNING"
QDStateCancelled = "CANCELLED"
QDStateCancelling = "CANCELLING"
QDStateCompleted = "COMPLETED"
QDStateCompletedWithErrors = "COMPLETED_WITH_ERRORS"
QDStateCompletedWithWarnings = "COMPLETED_WITH_WARNINGS"
QDStateFailed = "FAILED"
QDStateUnknown = "UNKNOWN"
)
// APIError represents an error.
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Errors/9.6.0
type APIError struct {
Status int `json:"status"`
Reason string `json:"reason"`
Code string `json:"code"`
Message string `json:"message"`
}
func (a *APIError) Error() string {
return fmt.Sprintf("%d: %s: %s: %s", a.Status, a.Reason, a.Code, a.Message)
}
// CommonResource represents the common resource fields.
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Resources/9.6.0
type CommonResource struct {
ID int64 `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
}
// Collection represents a collection of resources.
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Collections/9.6.0
type Collection[T any] struct {
Count int64 `json:"count"`
TotalCount int64 `json:"totalCount"`
Data []T `json:"data"`
}
type CollectionOptions struct {
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Fields/9.6.0
Fields string `url:"fields,omitempty"`
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Pagination/9.6.0
Limit int `url:"limit,omitempty"`
Offset int `url:"offset,omitempty"`
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Filter/9.6.0
Filter string `url:"filter,omitempty"`
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Ordering/9.6.0
OrderBy string `url:"orderBy,omitempty"`
// Should return or not the total number of resources matching the query.
Total bool `url:"total,omitempty"`
}
type RecordTXT struct {
CommonResource
TTL int `json:"ttl,omitempty"`
AbsoluteName string `json:"absoluteName,omitempty"`
Comment string `json:"comment,omitempty"`
Dynamic bool `json:"dynamic,omitempty"`
RecordType string `json:"recordType,omitempty"`
Text string `json:"text,omitempty"`
}
type ZoneResource struct {
CommonResource
AbsoluteName string `json:"absoluteName,omitempty"`
}
type QuickDeployment struct {
CommonResource
State string `json:"state,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
PercentComplete int `json:"percentComplete,omitempty"`
CreationDateTime time.Time `json:"creationDateTime,omitzero"`
StartDateTime time.Time `json:"startDateTime,omitzero"`
CompletionDateTime time.Time `json:"completionDateTime,omitzero"`
Method string `json:"method,omitempty"`
}
// LoginInfo represents the login information.
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0
type LoginInfo struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Session represents the session.
// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0
type Session struct {
ID int `json:"id"`
Type string `json:"type"`
APIToken string `json:"apiToken"`
APITokenExpirationDateTime time.Time `json:"apiTokenExpirationDateTime"`
BasicAuthenticationCredentials string `json:"basicAuthenticationCredentials"`
RemoteAddress string `json:"remoteAddress"`
ReadOnly bool `json:"readOnly"`
LoginDateTime time.Time `json:"loginDateTime"`
LogoutDateTime time.Time `json:"logoutDateTime"`
State string `json:"state"`
Response string `json:"response"`
}

View file

@ -42,13 +42,13 @@ type ErrorChain struct {
type Errors []Message
func (e Errors) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
for _, item := range e {
msg.WriteString(fmt.Sprintf("%d: %s", item.Code, item.Message))
_, _ = fmt.Fprintf(msg, "%d: %s", item.Code, item.Message)
for _, link := range item.ErrorChain {
msg.WriteString(fmt.Sprintf("; %d: %s", link.Code, link.Message))
_, _ = fmt.Fprintf(msg, "; %d: %s", link.Code, link.Message)
}
}

View file

@ -29,12 +29,12 @@ type APIResponse struct {
}
func (a APIResponse) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("%s (code=%d)", a.Message, a.Code))
_, _ = fmt.Fprintf(msg, "%s (code=%d)", a.Message, a.Code)
for _, detail := range a.Details {
msg.WriteString(fmt.Sprintf(", %s", detail))
_, _ = fmt.Fprintf(msg, ", %s", detail)
}
return msg.String()

View file

@ -26,9 +26,9 @@ type APIError struct {
}
func (a APIError) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("%s: %s", a.Code, a.Message))
_, _ = fmt.Fprintf(msg, "%s: %s", a.Code, a.Message)
for _, field := range a.Fields {
msg.WriteString(" ")

View file

@ -13,17 +13,17 @@ type APIError struct {
}
func (a *APIError) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("status: %s, error: %s", a.Status, a.ErrorMsg))
_, _ = fmt.Fprintf(msg, "status: %s, error: %s", a.Status, a.ErrorMsg)
if a.Code != 0 {
msg.WriteString(fmt.Sprintf(", code: %d", a.Code))
_, _ = fmt.Fprintf(msg, ", code: %d", a.Code)
}
if len(a.Context) != 0 {
for k, v := range a.Context {
msg.WriteString(fmt.Sprintf(", %s: %s", k, v))
_, _ = fmt.Fprintf(msg, ", %s: %s", k, v)
}
}

View file

@ -16,20 +16,20 @@ type ErrorInfo struct {
}
func (i *ErrorInfo) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("%s: %s", i.Code, i.Message))
_, _ = fmt.Fprintf(msg, "%s: %s", i.Code, i.Message)
if i.Details.Announcement != "" {
msg.WriteString(fmt.Sprintf(": %s", i.Details.Announcement))
_, _ = fmt.Fprintf(msg, ": %s", i.Details.Announcement)
}
for _, limit := range i.Details.Limits {
msg.WriteString(fmt.Sprintf("limit: %s", limit.Name))
_, _ = fmt.Fprintf(msg, "limit: %s", limit.Name)
}
for _, field := range i.Details.Fields {
msg.WriteString(fmt.Sprintf("field: %s: %s", field.Name, strings.Join(field.Messages, ", ")))
_, _ = fmt.Fprintf(msg, "field: %s: %s", field.Name, strings.Join(field.Messages, ", "))
}
return msg.String()

View file

@ -12,12 +12,12 @@ type APIError struct {
}
func (a *APIError) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("%s: %s", a.CorrelationID, a.Message))
_, _ = fmt.Fprintf(msg, "%s: %s", a.CorrelationID, a.Message)
for field, values := range a.Errors {
msg.WriteString(fmt.Sprintf(": %s: %s", field, strings.Join(values, ", ")))
_, _ = fmt.Fprintf(msg, ": %s: %s", field, strings.Join(values, ", "))
}
return msg.String()

View file

@ -16,12 +16,12 @@ type APIError struct {
}
func (a APIError) Error() string {
var msg strings.Builder
msg := new(strings.Builder)
msg.WriteString(fmt.Sprintf("%d: %s", a.StatusCode, a.Message))
_, _ = fmt.Fprintf(msg, "%d: %s", a.StatusCode, a.Message)
for k, v := range a.Errors {
msg.WriteString(fmt.Sprintf(" %s: %v", k, v))
_, _ = fmt.Fprintf(msg, " %s: %v", k, v)
}
return msg.String()

View file

@ -0,0 +1,216 @@
package internal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-acme/lego/v5/internal/errutils"
"github.com/go-acme/lego/v5/internal/useragent"
)
const defaultBaseURL = "https://api.leaseweb.com/hosting/v2"
const AuthHeader = "X-LSW-Auth"
// Client the Leaseweb API client.
type Client struct {
apiKey string
BaseURL *url.URL
HTTPClient *http.Client
}
// NewClient creates a new Client.
func NewClient(apiKey string) (*Client, error) {
if apiKey == "" {
return nil, errors.New("credentials missing")
}
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
apiKey: apiKey,
BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}, nil
}
// CreateRRSet creates a resource record set.
// https://developer.leaseweb.com/docs/#tag/DNS/operation/createResourceRecordSet
func (c *Client) CreateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) {
endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, rrset)
if err != nil {
return nil, err
}
result := &RRSet{}
err = c.do(req, result)
if err != nil {
return nil, err
}
return result, nil
}
// GetRRSet gets a resource record set.
// https://developer.leaseweb.com/docs/#tag/DNS/operation/getResourceRecordSet
func (c *Client) GetRRSet(ctx context.Context, domainName, name, rType string) (*RRSet, error) {
endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
result := &RRSet{}
err = c.do(req, result)
if err != nil {
return nil, err
}
return result, nil
}
// UpdateRRSet updates a resource record set.
// https://developer.leaseweb.com/docs/#tag/DNS/operation/updateResourceRecordSet
func (c *Client) UpdateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) {
endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", rrset.Name, rrset.Type)
// Reset values that are not allowed to be updated.
rrset.Name = ""
rrset.Type = ""
rrset.Editable = false
req, err := newJSONRequest(ctx, http.MethodPut, endpoint, rrset)
if err != nil {
return nil, err
}
result := &RRSet{}
err = c.do(req, result)
if err != nil {
return nil, err
}
return result, nil
}
// DeleteRRSet deletes a resource record set.
// https://developer.leaseweb.com/docs/#tag/DNS/operation/deleteResourceRecordSet
func (c *Client) DeleteRRSet(ctx context.Context, domainName, name, rType string) error {
endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) do(req *http.Request, result any) error {
useragent.SetHeader(req.Header)
req.Header.Add(AuthHeader, c.apiKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return parseError(req, resp)
}
if result == nil {
return nil
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errutils.NewReadResponseError(req, resp.StatusCode, err)
}
err = json.Unmarshal(raw, result)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
}
return nil
}
func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
err := json.NewEncoder(buf).Encode(payload)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}
func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
err := json.Unmarshal(raw, &errAPI)
if err != nil {
if resp.StatusCode == http.StatusNotFound {
return &NotFoundError{APIError{
CorrelationID: resp.Header.Get("Correlation-Id"),
ErrorCode: strconv.Itoa(http.StatusNotFound),
ErrorMessage: string(raw),
}}
}
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
}
if errAPI.ErrorCode == strconv.Itoa(http.StatusNotFound) {
return &NotFoundError{APIError: errAPI}
}
return &errAPI
}
// TTLRounder rounds the given TTL in seconds to the next accepted value.
// Accepted TTL values are: 60, 300, 1800, 3600, 14400, 28800, 43200, 86400.
func TTLRounder(ttl int) int {
for _, validTTL := range []int{60, 300, 1800, 3600, 14400, 28800, 43200, 86400} {
if ttl <= validTTL {
return validTTL
}
}
return 3600
}

View file

@ -0,0 +1,149 @@
package internal
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mockBuilder() *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
client, err := NewClient("secret")
if err != nil {
return nil, err
}
client.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
},
servermock.CheckHeader().
WithJSONHeaders().
With(AuthHeader, "secret"),
)
}
func TestClient_CreateRRSet(t *testing.T) {
client := mockBuilder().
Route("POST /domains/example.com/resourceRecordSets",
servermock.ResponseFromFixture("createResourceRecordSet.json"),
servermock.CheckRequestJSONBodyFromFixture("createResourceRecordSet-request.json"),
).
Build(t)
rrset := RRSet{
Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
Name: "_acme-challenge.example.com.",
TTL: 300,
Type: "TXT",
}
result, err := client.CreateRRSet(t.Context(), "example.com", rrset)
require.NoError(t, err)
expected := &RRSet{
Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
Name: "_acme-challenge.example.com.",
Editable: true,
TTL: 300,
Type: "TXT",
}
assert.Equal(t, expected, result)
}
func TestClient_GetRRSet(t *testing.T) {
client := mockBuilder().
Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromFixture("getResourceRecordSet.json"),
).
Build(t)
result, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
require.NoError(t, err)
expected := &RRSet{
Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"},
Name: "_acme-challenge.example.com.",
Editable: true,
TTL: 3600,
Type: "TXT",
}
assert.Equal(t, expected, result)
}
func TestClient_GetRRSet_error_404(t *testing.T) {
client := mockBuilder().
Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromFixture("error_404.json").
WithStatusCode(http.StatusNotFound),
).
Build(t)
_, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
require.EqualError(t, err, "404: Resource not found (289346a1-3eaf-4da4-b707-62ef12eb08be)")
target := &NotFoundError{}
require.ErrorAs(t, err, &target)
}
func TestClient_UpdateRRSet(t *testing.T) {
client := mockBuilder().
Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromFixture("updateResourceRecordSet.json"),
servermock.CheckRequestJSONBodyFromFixture("updateResourceRecordSet-request.json"),
).
Build(t)
rrset := RRSet{
Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
Name: "_acme-challenge.example.com.",
TTL: 3600,
Type: "TXT",
}
result, err := client.UpdateRRSet(t.Context(), "example.com", rrset)
require.NoError(t, err)
expected := &RRSet{
Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
Name: "_acme-challenge.example.com.",
Editable: true,
TTL: 3600,
Type: "TXT",
}
assert.Equal(t, expected, result)
}
func TestClient_DeleteRRSet(t *testing.T) {
client := mockBuilder().
Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.Noop().
WithStatusCode(http.StatusNoContent),
).
Build(t)
err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
require.NoError(t, err)
}
func TestClient_DeleteRRSet_error(t *testing.T) {
client := mockBuilder().
Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromFixture("error_401.json").
WithStatusCode(http.StatusUnauthorized),
).
Build(t)
err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
require.EqualError(t, err, "401: You are not authorized to view this resource. (289346a1-3eaf-4da4-b707-62ef12eb08be)")
}

View file

@ -0,0 +1,8 @@
{
"content": [
"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
],
"name": "_acme-challenge.example.com.",
"ttl": 300,
"type": "TXT"
}

View file

@ -0,0 +1,17 @@
{
"_links": {
"self": {
"href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
},
"collection": {
"href": "/domains/example.com/resourceRecordSets"
}
},
"content": [
"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
],
"editable": true,
"name": "_acme-challenge.example.com.",
"ttl": 300,
"type": "TXT"
}

View file

@ -0,0 +1,6 @@
{
"correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be",
"errorCode": "400",
"errorDetails": {},
"errorMessage": "The API could not interpret your request correctly."
}

View file

@ -0,0 +1,5 @@
{
"correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be",
"errorCode": "401",
"errorMessage": "You are not authorized to view this resource."
}

View file

@ -0,0 +1,5 @@
{
"correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be",
"errorCode": "404",
"errorMessage": "Resource not found"
}

View file

@ -0,0 +1,18 @@
{
"_links": {
"self": {
"href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
},
"collection": {
"href": "/domains/example.com/resourceRecordSets"
}
},
"content": [
"foo",
"Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"
],
"editable": true,
"name": "_acme-challenge.example.com.",
"ttl": 3600,
"type": "TXT"
}

View file

@ -0,0 +1,17 @@
{
"_links": {
"self": {
"href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
},
"collection": {
"href": "/domains/example.com/resourceRecordSets"
}
},
"content": [
"Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"
],
"editable": true,
"name": "_acme-challenge.example.com.",
"ttl": 3600,
"type": "TXT"
}

View file

@ -0,0 +1,8 @@
{
"content": [
"foo",
"Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo",
"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
],
"ttl": 3600
}

View file

@ -0,0 +1,6 @@
{
"content": [
"foo"
],
"ttl": 3600
}

View file

@ -0,0 +1,19 @@
{
"_links": {
"self": {
"href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
},
"collection": {
"href": "/domains/example.com/resourceRecordSets"
}
},
"content": [
"foo",
"Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo",
"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
],
"editable": true,
"name": "_acme-challenge.example.com.",
"ttl": 3600,
"type": "TXT"
}

View file

@ -0,0 +1,35 @@
package internal
import (
"encoding/json"
"fmt"
)
type NotFoundError struct {
APIError
}
type APIError struct {
CorrelationID string `json:"correlationId,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
ErrorDetails json.RawMessage `json:"errorDetails,omitempty"`
}
func (a *APIError) Error() string {
msg := fmt.Sprintf("%s: %s (%s)", a.ErrorCode, a.ErrorMessage, a.CorrelationID)
if len(a.ErrorDetails) > 0 {
msg += fmt.Sprintf(": %s", string(a.ErrorDetails))
}
return msg
}
type RRSet struct {
Content []string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
Editable bool `json:"editable,omitempty"`
TTL int `json:"ttl,omitempty"`
Type string `json:"type,omitempty"`
}

View file

@ -0,0 +1,183 @@
// Package leaseweb implements a DNS provider for solving the DNS-01 challenge using Leaseweb.
package leaseweb
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/platform/config/env"
"github.com/go-acme/lego/v5/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v5/providers/dns/leaseweb/internal"
)
// Environment variables names.
const (
envNamespace = "LEASEWEB_"
EnvAPIKey = envNamespace + "API_KEY"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
APIKey string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
client *internal.Client
}
// NewDNSProvider returns a DNSProvider instance configured for Leaseweb.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvAPIKey)
if err != nil {
return nil, fmt.Errorf("leaseweb: %w", err)
}
config := NewDefaultConfig()
config.APIKey = values[EnvAPIKey]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for Leaseweb.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("leaseweb: the configuration of the DNS provider is nil")
}
client, err := internal.NewClient(config.APIKey)
if err != nil {
return nil, fmt.Errorf("leaseweb: %w", err)
}
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
return &DNSProvider{
config: config,
client: client,
}, nil
}
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err)
}
existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT")
if err != nil {
notfoundErr := &internal.NotFoundError{}
if !errors.As(err, &notfoundErr) {
return fmt.Errorf("leaseweb: get RRSet: %w", err)
}
// Create the RRSet.
rrset := internal.RRSet{
Content: []string{info.Value},
Name: info.EffectiveFQDN,
TTL: internal.TTLRounder(d.config.TTL),
Type: "TXT",
}
_, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), rrset)
if err != nil {
return fmt.Errorf("leaseweb: create RRSet: %w", err)
}
return nil
}
// Update the RRSet.
existingRRSet.Content = append(existingRRSet.Content, info.Value)
_, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet)
if err != nil {
return fmt.Errorf("leaseweb: update RRSet: %w", err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err)
}
existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT")
if err != nil {
return fmt.Errorf("leaseweb: get RRSet: %w", err)
}
var content []string
for _, s := range existingRRSet.Content {
if s != info.Value {
content = append(content, s)
}
}
if len(content) == 0 {
err = d.client.DeleteRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT")
if err != nil {
return fmt.Errorf("leaseweb: delete RRSet: %w", err)
}
return nil
}
existingRRSet.Content = content
_, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet)
if err != nil {
return fmt.Errorf("leaseweb: update RRSet: %w", err)
}
return nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}

View file

@ -0,0 +1,22 @@
Name = "Leaseweb"
Description = ''''''
URL = "https://www.leaseweb.com/en/"
Code = "leaseweb"
Since = "v4.32.0"
Example = '''
LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
lego --dns leaseweb -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
LEASEWEB_API_KEY = "API key"
[Configuration.Additional]
LEASEWEB_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
LEASEWEB_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
LEASEWEB_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
LEASEWEB_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developer.leaseweb.com/docs/#tag/DNS"

View file

@ -0,0 +1,204 @@
package leaseweb
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-acme/lego/v5/platform/tester"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/go-acme/lego/v5/providers/dns/leaseweb/internal"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
expected string
}{
{
desc: "success",
envVars: map[string]string{
EnvAPIKey: "secret",
},
},
{
desc: "missing credentials",
envVars: map[string]string{},
expected: "leaseweb: some credentials information are missing: LEASEWEB_API_KEY",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
apiKey string
expected string
}{
{
desc: "success",
apiKey: "secret",
},
{
desc: "missing credentials",
expected: "leaseweb: credentials missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
config.APIKey = test.apiKey
p, err := NewDNSProviderConfig(config)
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.Present(t.Context(), envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func TestLiveCleanUp(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.CleanUp(t.Context(), envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func mockBuilder() *servermock.Builder[*DNSProvider] {
return servermock.NewBuilder(
func(server *httptest.Server) (*DNSProvider, error) {
config := NewDefaultConfig()
config.APIKey = "secret"
config.HTTPClient = server.Client()
p, err := NewDNSProviderConfig(config)
if err != nil {
return nil, err
}
p.client.BaseURL, _ = url.Parse(server.URL)
return p, nil
},
servermock.CheckHeader().
WithJSONHeaders().
With(internal.AuthHeader, "secret"),
)
}
func TestDNSProvider_Present_create(t *testing.T) {
provider := mockBuilder().
Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromInternal("error_404.json").
WithStatusCode(http.StatusNotFound),
).
Route("POST /domains/example.com/resourceRecordSets",
servermock.ResponseFromInternal("createResourceRecordSet.json"),
servermock.CheckRequestJSONBodyFromInternal("createResourceRecordSet-request.json"),
).
Build(t)
err := provider.Present(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_Present_update(t *testing.T) {
provider := mockBuilder().
Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromInternal("getResourceRecordSet.json"),
).
Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromInternal("updateResourceRecordSet.json"),
servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request.json"),
).
Build(t)
err := provider.Present(t.Context(), "example.com", "abc", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp_delete(t *testing.T) {
provider := mockBuilder().
Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromInternal("getResourceRecordSet2.json"),
).
Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.Noop().
WithStatusCode(http.StatusNoContent),
).
Build(t)
err := provider.CleanUp(t.Context(), "example.com", "abc", "1234d==")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp_update(t *testing.T) {
provider := mockBuilder().
Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromInternal("getResourceRecordSet.json"),
).
Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
servermock.ResponseFromInternal("updateResourceRecordSet.json"),
servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request2.json"),
).
Build(t)
err := provider.CleanUp(t.Context(), "example.com", "abc", "1234d==")
require.NoError(t, err)
}

View file

@ -20,17 +20,23 @@ const defaultBaseURL = "https://dns-service.iran.liara.ir"
type Client struct {
baseURL *url.URL
httpClient *http.Client
teamID string
}
// NewClient creates a new Client.
func NewClient(hc *http.Client) *Client {
func NewClient(hc *http.Client, teamID string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
return &Client{httpClient: hc, baseURL: baseURL}
return &Client{
httpClient: hc,
baseURL: baseURL,
teamID: teamID,
}
}
// GetRecords gets the records of a domain.
@ -38,7 +44,7 @@ func NewClient(hc *http.Client) *Client {
func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@ -73,7 +79,7 @@ func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, e
func (c *Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@ -108,7 +114,7 @@ func (c *Client) CreateRecord(ctx context.Context, domainName string, record Rec
func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@ -143,7 +149,7 @@ func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*R
func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) error {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
@ -162,7 +168,14 @@ func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string)
return nil
}
func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
if c.teamID != "" {
query := endpoint.Query()
query.Set("teamID", c.teamID)
endpoint.RawQuery = query.Encode()
}
buf := new(bytes.Buffer)
if payload != nil {

View file

@ -13,10 +13,10 @@ import (
const apiKey = "key"
func mockBuilder() *servermock.Builder[*Client] {
func mockBuilder(teamID string) *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey))
client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey), teamID)
client.baseURL, _ = url.Parse(server.URL)
return client, nil
@ -26,7 +26,7 @@ func mockBuilder() *servermock.Builder[*Client] {
}
func TestClient_GetRecords(t *testing.T) {
client := mockBuilder().
client := mockBuilder("").
Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")).
Build(t)
@ -50,7 +50,7 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecord(t *testing.T) {
client := mockBuilder().
client := mockBuilder("").
Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")).
Build(t)
@ -72,7 +72,7 @@ func TestClient_GetRecord(t *testing.T) {
}
func TestClient_CreateRecord(t *testing.T) {
client := mockBuilder().
client := mockBuilder("").
Route("POST /api/v1/zones/example.com/dns-records",
servermock.ResponseFromFixture("RecordResponse.json").
WithStatusCode(http.StatusCreated),
@ -108,8 +108,47 @@ func TestClient_CreateRecord(t *testing.T) {
assert.Equal(t, expected, record)
}
func TestClient_CreateRecord_withTeamID(t *testing.T) {
client := mockBuilder("123").
Route("POST /api/v1/zones/example.com/dns-records",
servermock.ResponseFromFixture("RecordResponse.json").
WithStatusCode(http.StatusCreated),
servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`),
servermock.CheckQueryParameter().Strict().With("teamID", "123"),
).
Build(t)
data := Record{
Type: "string",
Name: "string",
Contents: []Content{
{
Text: "string",
},
},
TTL: 3600,
}
record, err := client.CreateRecord(t.Context(), "example.com", data)
require.NoError(t, err)
expected := &Record{
ID: "string",
Type: "string",
Name: "string",
Contents: []Content{
{
Text: "string",
},
},
TTL: 3600,
}
assert.Equal(t, expected, record)
}
func TestClient_DeleteRecord(t *testing.T) {
client := mockBuilder().
client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.Noop().
WithStatusCode(http.StatusNoContent)).
@ -120,7 +159,7 @@ func TestClient_DeleteRecord(t *testing.T) {
}
func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
client := mockBuilder().
client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.Noop().
WithStatusCode(http.StatusNotFound)).
@ -131,7 +170,7 @@ func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
}
func TestClient_DeleteRecord_error(t *testing.T) {
client := mockBuilder().
client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.ResponseFromFixture("error.json").
WithStatusCode(http.StatusUnauthorized)).

View file

@ -23,6 +23,7 @@ const (
envNamespace = "LIARA_"
EnvAPIKey = envNamespace + "API_KEY"
EnvTeamID = envNamespace + "TEAM_ID"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@ -39,7 +40,9 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
APIKey string
APIKey string
TeamID string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
@ -77,6 +80,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.APIKey = values[EnvAPIKey]
config.TeamID = env.GetOrFile(EnvTeamID)
return NewDNSProviderConfig(config)
}
@ -112,6 +116,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
clientdebug.Wrap(
internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey),
),
config.TeamID,
)
return &DNSProvider{

Some files were not shown because too many files have changed in this diff Show more