diff --git a/dns01/dns_challenge_manual.go b/dns01/dns_challenge_manual.go index 6f211fb..c00d640 100644 --- a/dns01/dns_challenge_manual.go +++ b/dns01/dns_challenge_manual.go @@ -25,7 +25,7 @@ func (*DNSProviderManual) Present(domain, token, keyAuth string) error { authZone, err := FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return err + return fmt.Errorf("manual: could not find zone: %w", err) } fmt.Printf("lego: Please create the following TXT record in your %s zone:\n", authZone) @@ -33,8 +33,11 @@ func (*DNSProviderManual) Present(domain, token, keyAuth string) error { fmt.Printf("lego: Press 'Enter' when you are done\n") _, err = bufio.NewReader(os.Stdin).ReadBytes('\n') + if err != nil { + return fmt.Errorf("manual: %w", err) + } - return err + return nil } // CleanUp prints instructions for manually removing the TXT record. @@ -43,7 +46,7 @@ func (*DNSProviderManual) CleanUp(domain, token, keyAuth string) error { authZone, err := FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return err + return fmt.Errorf("manual: could not find zone: %w", err) } fmt.Printf("lego: You can now remove this TXT record from your %s zone:\n", authZone) diff --git a/go.mod b/go.mod index 36340a9..099e12b 100644 --- a/go.mod +++ b/go.mod @@ -1,133 +1,163 @@ module github.com/entrustcorporation/dv -go 1.20 +go 1.21.7 require ( cloud.google.com/go/compute/metadata v0.2.3 - github.com/Azure/azure-sdk-for-go v32.4.0+incompatible - github.com/Azure/go-autorest/autorest v0.11.24 - github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0 + github.com/Azure/go-autorest/autorest v0.11.29 + github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 github.com/Azure/go-autorest/autorest/to v0.4.0 github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 - github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.1 + github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 - github.com/aws/aws-sdk-go v1.39.0 - github.com/cenkalti/backoff/v4 v4.2.0 + github.com/aws/aws-sdk-go-v2 v1.24.1 + github.com/aws/aws-sdk-go-v2/config v1.26.6 + github.com/aws/aws-sdk-go-v2/credentials v1.16.16 + github.com/aws/aws-sdk-go-v2/service/lightsail v1.34.0 + github.com/aws/aws-sdk-go-v2/service/route53 v1.37.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 + github.com/cenkalti/backoff/v4 v4.2.1 github.com/civo/civogo v0.3.11 - github.com/cloudflare/cloudflare-go v0.49.0 + github.com/cloudflare/cloudflare-go v0.86.0 github.com/cpu/goacmedns v0.1.1 - github.com/dnsimple/dnsimple-go v0.71.1 + github.com/dnsimple/dnsimple-go v1.2.0 github.com/entrustcorporation/entrust v0.0.0-20230314134457-b6b1cf0dd3bb - github.com/exoscale/egoscale v0.90.0 - github.com/go-acme/lego/v4 v4.11.0 - github.com/go-jose/go-jose/v3 v3.0.0 + github.com/exoscale/egoscale v0.102.3 + github.com/go-acme/lego/v4 v4.16.1 + github.com/go-jose/go-jose/v4 v4.0.1 + github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 github.com/google/go-querystring v1.1.0 github.com/gophercloud/gophercloud v1.0.0 github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae - github.com/hashicorp/go-retryablehttp v0.7.1 + github.com/hashicorp/go-retryablehttp v0.7.5 github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df github.com/infobloxopen/infoblox-go-client v1.1.1 github.com/labbsr0x/bindman-dns-webhook v1.0.2 - github.com/linode/linodego v1.9.1 - github.com/liquidweb/liquidweb-go v1.6.3 - github.com/miekg/dns v1.1.50 - github.com/mimuret/golang-iij-dpf v0.7.1 - github.com/mitchellh/mapstructure v1.5.0 + github.com/linode/linodego v1.28.0 + github.com/liquidweb/liquidweb-go v1.6.4 + github.com/miekg/dns v1.1.58 + github.com/mimuret/golang-iij-dpf v0.9.1 github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 github.com/nrdcg/auroradns v1.1.0 - github.com/nrdcg/desec v0.6.0 + github.com/nrdcg/bunny-go v0.0.0-20230728143221-c9dda82568d9 + github.com/nrdcg/desec v0.7.0 github.com/nrdcg/dnspod-go v0.4.0 github.com/nrdcg/freemyip v0.2.0 - github.com/nrdcg/goinwx v0.8.1 + github.com/nrdcg/goinwx v0.10.0 + github.com/nrdcg/mailinabox v0.2.0 github.com/nrdcg/namesilo v0.2.1 github.com/nrdcg/nodion v0.1.0 - github.com/nrdcg/porkbun v0.1.1 + github.com/nrdcg/porkbun v0.3.0 + github.com/nzdjb/go-metaname v1.0.0 github.com/oracle/oci-go-sdk v24.3.0+incompatible - github.com/ovh/go-ovh v1.1.0 - github.com/pquerna/otp v1.3.0 - github.com/sacloud/api-client-go v0.2.1 - github.com/sacloud/iaas-api-go v1.3.2 - github.com/scaleway/scaleway-sdk-go v1.0.0-beta.9 - github.com/simplesurance/bunny-go v0.0.0-20221115111006-e11d9dc91f04 - github.com/sirupsen/logrus v1.9.0 - github.com/softlayer/softlayer-go v1.0.6 - github.com/stretchr/testify v1.8.1 + github.com/ovh/go-ovh v1.4.3 + github.com/pquerna/otp v1.4.0 + github.com/sacloud/api-client-go v0.2.8 + github.com/sacloud/iaas-api-go v1.11.1 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.22 + github.com/sirupsen/logrus v1.9.3 + github.com/softlayer/softlayer-go v1.1.3 + github.com/stretchr/testify v1.8.4 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490 - github.com/transip/gotransip/v6 v6.17.0 - github.com/ultradns/ultradns-go-sdk v1.4.0-20221107152238-f3f1d1d + github.com/transip/gotransip/v6 v6.23.0 + github.com/ultradns/ultradns-go-sdk v1.6.1-20231103022937-8589b6a github.com/vinyldns/go-vinyldns v0.9.16 github.com/vultr/govultr/v2 v2.17.2 - github.com/weppos/publicsuffix-go v0.30.0 + github.com/weppos/publicsuffix-go v0.30.1 github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997 - golang.org/x/net v0.8.0 - golang.org/x/oauth2 v0.6.0 - golang.org/x/time v0.3.0 - google.golang.org/api v0.111.0 - gopkg.in/ns1/ns1-go.v2 v2.6.5 + golang.org/x/net v0.20.0 + golang.org/x/oauth2 v0.16.0 + golang.org/x/time v0.5.0 + google.golang.org/api v0.126.0 + gopkg.in/ns1/ns1-go.v2 v2.7.13 gopkg.in/yaml.v2 v2.4.0 ) require ( - cloud.google.com/go/compute v1.18.0 // indirect + cloud.google.com/go/compute v1.20.1 // indirect + github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.18 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect - github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 // indirect github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect + github.com/aws/smithy-go v1.19.0 // indirect + github.com/boombuler/barcode v1.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deepmap/oapi-codegen v1.9.1 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/fatih/structs v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-errors/errors v1.0.1 // indirect - github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 // indirect - github.com/golang-jwt/jwt/v4 v4.2.0 // indirect - github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect - github.com/golang/protobuf v1.5.2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/go-resty/resty/v2 v2.11.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/s2a-go v0.1.4 // indirect + github.com/google/uuid v1.3.1 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect - github.com/googleapis/gax-go/v2 v2.7.0 // indirect + github.com/googleapis/gax-go/v2 v2.11.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect - github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b // indirect + github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/labbsr0x/goh v1.0.1 // indirect - github.com/liquidweb/go-lwApi v0.0.5 // indirect github.com/liquidweb/liquidweb-cli v0.6.9 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sacloud/go-http v0.1.2 // indirect - github.com/sacloud/packages-go v0.0.5 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/sacloud/go-http v0.1.6 // indirect + github.com/sacloud/packages-go v0.0.9 // indirect github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect github.com/spf13/cast v1.3.1 // indirect - github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/objx v0.5.1 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/ratelimit v0.2.0 // indirect - golang.org/x/crypto v0.7.0 // indirect - golang.org/x/mod v0.8.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.8.0 // indirect - golang.org/x/tools v0.6.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.17.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect - google.golang.org/grpc v1.53.0 // indirect - google.golang.org/protobuf v1.28.1 // indirect - gopkg.in/ini.v1 v1.66.6 // indirect + google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect + google.golang.org/grpc v1.55.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f1d6c31..190b56f 100644 --- a/go.sum +++ b/go.sum @@ -5,49 +5,68 @@ cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6A cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.107.0 h1:qkj22L7bgkl6vIeZDlOY2po43Mx/TIa2Wsa7VR+PEww= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY= -cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= +cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg= +cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/Azure/azure-sdk-for-go v32.4.0+incompatible h1:1JP8SKfroEakYiQU2ZyPDosh8w2Tg9UopKt88VyQPt4= -github.com/Azure/azure-sdk-for-go v32.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYsMyFh9qoE= +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.6.0 h1:8kDqDngH+DmVBiCtIjCFTGa7MBnsIOkF9IccInFEbjk= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 h1:vcYCAze6p19qBW7MhZybIsqD8sMV8js0NyQM8JDnVtg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0 h1:8iR6OLffWWorFdzL2JFCab5xpD8VKEE2DUBBl+HNTDY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0/go.mod h1:copqlcjMWc/wgQ1N2fzsJFQxDdqKGg1EQt8T5wJMOGE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2/go.mod h1:FbdwsQ2EzwvXxOPcMFYO8ogEc9uMMIj3YkmCdXdAFmk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0 h1:rR8ZW79lE/ppfXTfiYSnMFv5EzmVuY4pfZWIkscIJ64= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0/go.mod h1:y2zXtLSMM/X5Mfawq0lOftpWn3f4V6OCsRdINsvWBPI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0 h1:ECsQtyERDVz3NP3kvDOTLvbQhqWp/x9EsGKtb4ogUr8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0/go.mod h1:s1tW/At+xHqjNFvWU4G0c0Qv33KOhvbGNj0RCTQDV8s= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.24 h1:1fIGgHKqVm54KIPT+q8Zmd1QlVsmHqeUGso5qm2BqqE= github.com/Azure/go-autorest/autorest v0.11.24/go.mod h1:G6kyRlFnTuSbEYkQGawPfsCswgme4iYf6rfSKUDzbCc= -github.com/Azure/go-autorest/autorest/adal v0.9.18 h1:kLnPsRjzZZUF3K5REu/Kc+qMQrvuza2bwSnNdhmzLfQ= +github.com/Azure/go-autorest/autorest v0.11.29 h1:I4+HL/JDvErx2LjyzaVxllw2lRDB5/BT2Bm4g20iqYw= +github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 h1:P6bYXFoao05z5uhOQzbC3Qd8JqF3jUoocoTeIxkp2cA= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.11/go.mod h1:84w/uV8E37feW2NCJ08uT9VBfjfUHpgLVnG2InYD6cg= +github.com/Azure/go-autorest/autorest/adal v0.9.22 h1:/GblQdIudfEM3AWWZ0mrYJQSd7JS4S/Mbzh6F0ov0Xc= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 h1:wkAZRgT/pn8HhFyzfe9UnqOjJYqlembgCTi72Bm/xKk= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.12/go.mod h1:84w/uV8E37feW2NCJ08uT9VBfjfUHpgLVnG2InYD6cg= github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 h1:0W/yGmFdTIT77fvdlGZ0LMISoLHFJ7Tx4U0yeB+uFs4= github.com/Azure/go-autorest/autorest/azure/cli v0.4.5/go.mod h1:ADQAXrkgm7acgWVUNamOgh8YNrv4p27l3Wc55oVfpzg= github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.1 h1:K0laFcLE6VLTOwNgSxaGbUcLPuGXlNkbVvq4cW4nIHk= github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= github.com/Azure/go-autorest/autorest/to v0.4.0 h1:oXVqrxakqqV1UZdSazDOPOLvOIz+XA683u8EctwboHk= github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= -github.com/Azure/go-autorest/autorest/validation v0.3.1 h1:AgyqjAd94fwNAoTjl/WQXg4VvFeRFpO+UhNyRXqF1ac= -github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E= github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 h1:OBhqkivkhkMqLPymWEppkm7vgPQY2XsHoEkaMQ0AdZY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24= github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.1 h1:5BIsppVPdWJA29Yb5cYawQYeh5geN413WxAgBZvEtdA= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.1/go.mod h1:kX6YddBkXqqywAe8c9LyvgTCyFuZCTMF4cRPQhc3Fy8= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY= +github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 h1:J45/QHgrzUdqe/Vco/Vxk0wRvdS2nKUxmf/zLgvfass= @@ -58,19 +77,49 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/aws/aws-sdk-go v1.39.0 h1:74BBwkEmiqBbi2CGflEh34l0YNtIibTjZsibGarkNjo= -github.com/aws/aws-sdk-go v1.39.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= +github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o= +github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.34.0 h1:LvWkxBi/bsWHqj3bFTUuDLl4OAlbaM1HDZ9YPhj5+jg= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.34.0/go.mod h1:35MKNS46RX7Lb9EIFP2bPy3WrJu+bxU6QgLis8K1aa4= +github.com/aws/aws-sdk-go-v2/service/route53 v1.37.0 h1:f3hBZWtpn9clZGXJoqahQeec9ZPZnu22g8pg+zNyif0= +github.com/aws/aws-sdk-go-v2/service/route53 v1.37.0/go.mod h1:8qqfpG4mug2JLlEyWPSFhEGvJiaZ9iPmMDDMYc5Xtas= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw= github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= -github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= -github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -80,12 +129,16 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/civo/civogo v0.3.11 h1:mON/fyrV946Sbk6paRtOSGsN+asCgCmHCgArf5xmGxM= github.com/civo/civogo v0.3.11/go.mod h1:7+GeeFwc4AYTULaEshpT2vIcl3Qq8HPoxA17viX3l6g= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.49.0 h1:KqJYk/YQ5ZhmyYz1oa4kGDskfF1gVuZfqesaJ/XDLto= -github.com/cloudflare/cloudflare-go v0.49.0/go.mod h1:h0QgcIZ3qEXwFiwfBO8sQxjVdYsLX+PfD7NFEnANaKg= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/cloudflare-go v0.86.0 h1:jEKN5VHNYNYtfDL2lUFLTRo+nOVNPFxpXTstVx0rqHI= +github.com/cloudflare/cloudflare-go v0.86.0/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -107,8 +160,10 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= -github.com/dnsimple/dnsimple-go v0.71.1 h1:1hGoBA3CIjpjZj5DM3081xfxr4e2jYmYnkO2VuBF8Qc= -github.com/dnsimple/dnsimple-go v0.71.1/go.mod h1:F9WHww9cC76hrnwGFfAfrqdW99j3MOYasQcIwTS/aUk= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dnsimple/dnsimple-go v1.2.0 h1:ddTGyLVKly5HKb5L65AkLqFqwZlWo3WnR0BlFZlIddM= +github.com/dnsimple/dnsimple-go v1.2.0/go.mod h1:z/cs26v/eiRvUyXsHQBLd8lWF8+cD6GbmkPH84plM4U= github.com/entrustcorporation/entrust v0.0.0-20230314134457-b6b1cf0dd3bb h1:xQBHq0AL4UXflRL7EH/t4FrVygprNA0C0X+BqWjK9i0= github.com/entrustcorporation/entrust v0.0.0-20230314134457-b6b1cf0dd3bb/go.mod h1:NyddGpZcZoMDrK0AfWPiFn74sGdwBfyjK3nqZYb/Q6w= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -118,31 +173,32 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/exoscale/egoscale v0.90.0 h1:DZBXVU3iHqu5Ju5lQ5jWVlPo0IpI98SUo8Aa1UQVrmo= -github.com/exoscale/egoscale v0.90.0/go.mod h1:wyXE5zrnFynMXA0jMhwQqSe24CfUhmBk2WI5wFZcq6Y= +github.com/exoscale/egoscale v0.102.3 h1:DYqN2ipoLKpiFoprRGQkp2av/Ze7sUYYlGhi1N62tfY= +github.com/exoscale/egoscale v0.102.3/go.mod h1:RPf2Gah6up+6kAEayHTQwqapzXlm93f0VQas/UEGU5c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= -github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/getkin/kin-openapi v0.87.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= -github.com/go-acme/lego/v4 v4.11.0 h1:oIPoU7zBJoTfoVrbqk62+/2NsGCSgCVK1JtZSZZ28SU= -github.com/go-acme/lego/v4 v4.11.0/go.mod h1:dENL0J3/WughN2NLy0T35otK5k1EWCmXTwCw0+X5ZaE= +github.com/go-acme/lego/v4 v4.16.1 h1:JxZ93s4KG0jL27rZ30UsIgxap6VGzKuREsSkkyzeoCQ= +github.com/go-acme/lego/v4 v4.16.1/go.mod h1:AVvwdPned/IWpD/ihHhMsKnveF7HHYAz/CmtXi7OZoE= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= 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= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= -github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -155,25 +211,33 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= -github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 h1:JVrqSeQfdhYRFk24TvhTZWU0q8lfCojxZQFi3Ou7+uY= -github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= +github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= +github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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= github.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -190,8 +254,9 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -204,9 +269,12 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= @@ -216,16 +284,19 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= +github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= 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.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ= -github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= +github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4= +github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI= github.com/gophercloud/gophercloud v0.15.1-0.20210202035223-633d73521055/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075O6i+LY+pXsKCBsb4= github.com/gophercloud/gophercloud v1.0.0 h1:9nTGx0jizmHxDobe4mck89FyQHVyA3CaXLIUSGJjP9k= github.com/gophercloud/gophercloud v1.0.0/go.mod h1:Q8fZtyi5zZxPS/j9aj3sSxtvj41AdQMDwyo1myduD5c= @@ -250,14 +321,14 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= +github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= -github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ= -github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= @@ -279,9 +350,9 @@ github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhK github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/infobloxopen/infoblox-go-client v1.1.1 h1:728A6LbLjptj/7kZjHyIxQnm768PWHfGFm0HH8FnbtU= github.com/infobloxopen/infoblox-go-client v1.1.1/go.mod h1:BXiw7S2b9qJoM8MS40vfgCNB2NLHGusk1DtO16BD9zI= -github.com/jarcoal/httpmock v1.0.5/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= -github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k= github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= +github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= +github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= @@ -301,8 +372,8 @@ github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcM github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b h1:DzHy0GlWeF0KAglaTMY7Q+khIFoG8toHP+wLFBVBQJc= -github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ= +github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= +github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -314,6 +385,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labbsr0x/bindman-dns-webhook v1.0.2 h1:I7ITbmQPAVwrDdhd6dHKi+MYJTJqPCK0jE6YNBAevnk= github.com/labbsr0x/bindman-dns-webhook v1.0.2/go.mod h1:p6b+VCXIR8NYKpDr8/dg1HKfQoRHCdcsROXKvmoehKA= github.com/labbsr0x/goh v1.0.1 h1:97aBJkDjpyBZGPbQuOK5/gHcSFbcr5aRsq3RSRJFpPk= @@ -329,15 +402,13 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++ github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= github.com/lestrrat-go/jwx v1.2.7/go.mod h1:bw24IXWbavc0R2RsOtpXL7RtMyP589yZ1+L7kd09ZGA= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/linode/linodego v1.9.1 h1:29UpEPpYcGFnbwiJW8mbk/bjBZpgd/pv68io2IKTo34= -github.com/linode/linodego v1.9.1/go.mod h1:h6AuFR/JpqwwM/vkj7s8KV3iGN8/jxn+zc437F8SZ8w= +github.com/linode/linodego v1.28.0 h1:lzxxJebsYg5cCWRNDLyL2StW3sfMyAwf/FYfxFjFrlk= +github.com/linode/linodego v1.28.0/go.mod h1:5oAsx+uinHtVo6U77nXXXtox7MWzUW6aEkTOKXxA9uo= github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs= -github.com/liquidweb/go-lwApi v0.0.5 h1:CT4cdXzJXmo0bon298kS7NeSk+Gt8/UHpWBBol1NGCA= -github.com/liquidweb/go-lwApi v0.0.5/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= -github.com/liquidweb/liquidweb-go v1.6.3 h1:NVHvcnX3eb3BltiIoA+gLYn15nOpkYkdizOEYGSKrk4= -github.com/liquidweb/liquidweb-go v1.6.3/go.mod h1:SuXXp+thr28LnjEw18AYtWwIbWMHSUiajPQs8T9c/Rc= +github.com/liquidweb/liquidweb-go v1.6.4 h1:6S0m3hHSpiLqGD7AFSb7lH/W/qr1wx+tKil9fgIbjMc= +github.com/liquidweb/liquidweb-go v1.6.4/go.mod h1:B934JPIIcdA+uTq2Nz5PgOtG6CuCaEvQKe/Ge/5GgZ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -349,23 +420,26 @@ github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= +github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= -github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/mimuret/golang-iij-dpf v0.7.1 h1:MHEZKx6gNGTvq1+3PYUNfTZ/qtGNNK4+zo+0Rdo4jY4= -github.com/mimuret/golang-iij-dpf v0.7.1/go.mod h1:IXWYcQVIHYzuM+W7kDWX0mseHDfUoqMuarxMXHVTir0= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +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/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -393,23 +467,29 @@ github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uY 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/desec v0.6.0 h1:kZ9JtsYEW3LNfuPIM+2tXoxoQlF9koWfQTWTQsA7Sr8= -github.com/nrdcg/desec v0.6.0/go.mod h1:wybWg5cRrNmtXLYpUCPCLvz4jfFNEGZQEnoUiX9WqcY= +github.com/nrdcg/bunny-go v0.0.0-20230728143221-c9dda82568d9 h1:qpB3wZR4+MPK92cTC9zZPnndkJgDgPvQqPUAgVc1NXU= +github.com/nrdcg/bunny-go v0.0.0-20230728143221-c9dda82568d9/go.mod h1:HUoHXDrFvidN1NK9Wb/mZKNOfDNutKkzF2Pg71M9hHA= +github.com/nrdcg/desec v0.7.0 h1:iuGhi4pstF3+vJWwt292Oqe2+AsSPKDynQna/eu1fDs= +github.com/nrdcg/desec v0.7.0/go.mod h1:e1uRqqKv1mJdd5+SQROAhmy75lKMphLzWIuASLkpeFY= github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U= github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= github.com/nrdcg/freemyip v0.2.0 h1:/GscavT4GVqAY13HExl5UyoB4wlchv6Cg5NYDGsUoJ8= github.com/nrdcg/freemyip v0.2.0/go.mod h1:HjF0Yz0lSb37HD2ihIyGz9esyGcxbCrrGFLPpKevbx4= -github.com/nrdcg/goinwx v0.8.1 h1:20EQ/JaGFnSKwiDH2JzjIpicffl3cPk6imJBDqVBVtU= -github.com/nrdcg/goinwx v0.8.1/go.mod h1:tILVc10gieBp/5PMvbcYeXM6pVQ+c9jxDZnpaR1UW7c= +github.com/nrdcg/goinwx v0.10.0 h1:6W630bjDxQD6OuXKqrFRYVpTt0G/9GXXm3CeOrN0zJM= +github.com/nrdcg/goinwx v0.10.0/go.mod h1:mnMSTi7CXBu2io4DzdOBoGFA1XclD0sEPWJaDhNgkA4= +github.com/nrdcg/mailinabox v0.2.0 h1:IKq8mfKiVwNW2hQii/ng1dJ4yYMMv3HAP3fMFIq2CFk= +github.com/nrdcg/mailinabox v0.2.0/go.mod h1:0yxqeYOiGyxAu7Sb94eMxHPIOsPYXAjTeA9ZhePhGnc= github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg= github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw= 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/porkbun v0.1.1 h1:gxVzQYfFUGXhnBax/aVugoE3OIBAdHgrJgyMPyY5Sjo= -github.com/nrdcg/porkbun v0.1.1/go.mod h1:JWl/WKnguWos4mjfp4YizvvToigk9qpQwrodOk+CPoA= +github.com/nrdcg/porkbun v0.3.0 h1:jnRV7j2zd3hmh+tSDOGetJyy3+WklaMxbs7HtTTmWMs= +github.com/nrdcg/porkbun v0.3.0/go.mod h1:jh1DKz96jGHW+NCdG3AmTbbnQeBlNUz1KeSgeN/cBVw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nzdjb/go-metaname v1.0.0 h1:sNASlZC1RM3nSudtBTE1a3ZVTDyTpjqI5WXRPrdZ9Hg= +github.com/nzdjb/go-metaname v1.0.0/go.mod h1:0GR0LshZax1Lz4VrOrfNSE4dGvTp7HGjiemdczXT2H4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= @@ -425,13 +505,15 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/oracle/oci-go-sdk v24.3.0+incompatible h1:x4mcfb4agelf1O4/1/auGlZ1lr97jXRSSN5MxTgG/zU= github.com/oracle/oci-go-sdk v24.3.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888= -github.com/ovh/go-ovh v1.1.0 h1:bHXZmw8nTgZin4Nv7JuaLs0KG5x54EQR7migYTd1zrk= -github.com/ovh/go-ovh v1.1.0/go.mod h1:AxitLZ5HBRPyUd+Zl60Ajaag+rNTdVXWIkzfrVuTXWA= +github.com/ovh/go-ovh v1.4.3 h1:Gs3V823zwTFpzgGLZNI6ILS4rmxZgJwJCz54Er9LwD0= +github.com/ovh/go-ovh v1.4.3/go.mod h1:AkPXVtgwB6xlKblMjRKJJmjRp+ogrE7fz2lVgcQY8SY= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -442,8 +524,8 @@ github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= -github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -465,35 +547,36 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sacloud/api-client-go v0.2.1 h1:jl02ZG6cM+mcH4eDYg0cxCFFuTOVTOjUCLYL4UbP09U= -github.com/sacloud/api-client-go v0.2.1/go.mod h1:8fmYy5OpT3W8ltV5ZxF8evultNwKpduGN4YKmU9Af7w= -github.com/sacloud/go-http v0.1.2 h1:a84HkeDHxDD1vIA6HiOT72a3fwwJueZBwuGP6zVtEJU= -github.com/sacloud/go-http v0.1.2/go.mod h1:gvWaT8LFBFnSBFVrznOQXC62uad46bHZQM8w+xoH3eE= -github.com/sacloud/iaas-api-go v1.3.2 h1:03obrdVdv/bGHK9p6CV7Uzg+ot2gLsddUMevm9DDZqQ= -github.com/sacloud/iaas-api-go v1.3.2/go.mod h1:CoqpRYBG2NRB5xfqTfZNyh2lVLKyLkE/HV9ISqmbhGc= -github.com/sacloud/packages-go v0.0.5 h1:NXTQNyyp/3ugM4CANtLBJLejFESzfWu4GPUURN4NJrA= -github.com/sacloud/packages-go v0.0.5/go.mod h1:XWMBSNHT9YKY3lCh6yJsx1o1RRQQGpuhNqJA6bSHdD4= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.9 h1:0roa6gXKgyta64uqh52AQG3wzZXH21unn+ltzQSXML0= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.9/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= +github.com/sacloud/api-client-go v0.2.8 h1:tIY6PZNBX900K66TqEPa4d6UIbedUczfCBnPJkzi8kw= +github.com/sacloud/api-client-go v0.2.8/go.mod h1:0CV/kWNYlS1hCNdnk6Wx7Wdg8DPFCnv0zOIzdXjeAeY= +github.com/sacloud/go-http v0.1.6 h1:lJGXDt9xrxJiDszRPaN9NIP8MVj10YKMzmnyzdSfI8w= +github.com/sacloud/go-http v0.1.6/go.mod h1:oLAHoDJRkptf8sq4fE8oERLkdCh0kJWfWu+paoJY7I0= +github.com/sacloud/iaas-api-go v1.11.1 h1:2MsFZ4H1uRdRVx2nVXuERWQ3swoFc3XreIV5hJ3Nsws= +github.com/sacloud/iaas-api-go v1.11.1/go.mod h1:uBDSa06F/V0OnoR66jGdbH0PVnCJw+NeE9RVbVgMfss= +github.com/sacloud/packages-go v0.0.9 h1:GbinkBLC/eirFhHpLjoDW6JV7+95Rnd2d8RWj7Afeks= +github.com/sacloud/packages-go v0.0.9/go.mod h1:k+EEUMF2LlncjbNIJNOqLyZ9wjTESPIWIk1OA7x9j2Q= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.22 h1:wJrcTdddKOI8TFxs8cemnhKP2EmKy3yfUKHj3ZdfzYo= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.22/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/simplesurance/bunny-go v0.0.0-20221115111006-e11d9dc91f04 h1:ZTzdx88+AcnjqUfJwnz89UBrMSBQ1NEysg9u5d+dU9c= -github.com/simplesurance/bunny-go v0.0.0-20221115111006-e11d9dc91f04/go.mod h1:5KS21fpch8TIMyAUv/qQqTa3GZfBDYgjaZbd2KXKYfg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= -github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= +github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 h1:hp2CYQUINdZMHdvTdXtPOY2ainKl4IoMcpAXEf2xj3Q= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/gunit v1.0.4 h1:tpTjnuH7MLlqhoD21vRoMZbMIi5GmBsAJDFyF67GhZA= -github.com/softlayer/softlayer-go v1.0.6 h1:wMyWmnTm0y3iNwwUJLacgSpMjxAW42MaVqWW4CwYb3c= -github.com/softlayer/softlayer-go v1.0.6/go.mod h1:6HepcfAXROz0Rf63krk5hPZyHT6qyx2MNvYyHof7ik4= +github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= +github.com/softlayer/softlayer-go v1.1.3 h1:dfFzt5eOKIAyB/b78fHMyDu5ICx0ZtxL9NRhBlf831A= +github.com/softlayer/softlayer-go v1.1.3/go.mod h1:Pc7F57OgUKaAam7TtpqkUeqL7QyKknfiUI4R49h41/U= github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ= github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= @@ -513,8 +596,9 @@ github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= +github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -523,32 +607,32 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490 h1:mmz27tVi2r70JYnm5y0Zk8w0Qzsx+vfUw3oqSyrEfP8= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490 h1:g9SWTaTy/rEuhMErC2jWq9Qt5ci+jBYSvXnJsLq4adg= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490/go.mod h1:l9q4vc1QiawUB1m3RU+87yLvrrxe54jc0w/kEl4DbSQ= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/transip/gotransip/v6 v6.17.0 h1:2RCyqYqz5+Ej8z96EyE4sf6tQrrfEBaFDO0LliSl6+8= -github.com/transip/gotransip/v6 v6.17.0/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4qdgLdRzmtY+g= +github.com/transip/gotransip/v6 v6.23.0 h1:PsTdjortrEZ8IFFifEryzjVjOy9SgK4ahlnhKBBIQgA= +github.com/transip/gotransip/v6 v6.23.0/go.mod h1:nzv9eN2tdsUrm5nG5ZX6AugYIU4qgsMwIn2c0EZLk8c= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw= -github.com/ultradns/ultradns-go-sdk v1.4.0-20221107152238-f3f1d1d h1:pLMpEtrkiaeA2NY6CzA2+K75YnY6c5ka02SbxQ9YgSo= -github.com/ultradns/ultradns-go-sdk v1.4.0-20221107152238-f3f1d1d/go.mod h1:IgdoVzrGYzq4H4IGI0DAVnM3CbcuQDSxEP4s/j6cztI= +github.com/ultradns/ultradns-go-sdk v1.6.1-20231103022937-8589b6a h1:w4PK5/N9kq8PfNxBv8a5t1bqlYRrVT7XzT7iTPTtiPk= +github.com/ultradns/ultradns-go-sdk v1.6.1-20231103022937-8589b6a/go.mod h1:Xwz7o+ExFtxR/i0aJDnTXuiccQJlOxDgNe6FsZC4TzQ= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vinyldns/go-vinyldns v0.9.16 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ= github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q= github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs= github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI= -github.com/weppos/publicsuffix-go v0.12.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= -github.com/weppos/publicsuffix-go v0.30.0 h1:QHPZ2GRu/YE7cvejH9iyavPOkVCB4dNxp2ZvtT+vQLY= -github.com/weppos/publicsuffix-go v0.30.0/go.mod h1:kBi8zwYnR0zrbm8RcuN1o9Fzgpnnn+btVN8uWPMyXAY= -github.com/weppos/publicsuffix-go/publicsuffix/generator v0.0.0-20220927085643-dc0d00c92642/go.mod h1:GHfoeIdZLdZmLjMlzBftbTDntahTttUMWjxZwQJhULE= +github.com/weppos/publicsuffix-go v0.30.1 h1:8q+QwBS1MY56Zjfk/50ycu33NN8aa1iCCEQwo/71Oos= +github.com/weppos/publicsuffix-go v0.30.1/go.mod h1:s41lQh6dIsDWIC1OWh7ChWJXLH0zkJ9KHZVqA7vHyuQ= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= @@ -557,7 +641,6 @@ github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f h1:cG+ehP github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE= github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997 h1:2wzke3JH7OtN20WsNDZx2VH/TCmsbqtDEbXzjF+i05E= github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997/go.mod h1:2CHKs/YGbCcNn/BPaCkEBwKz/FNCELi+MLILjR9RaTA= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -580,7 +663,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -591,8 +673,14 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -611,13 +699,13 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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= @@ -634,9 +722,6 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -649,15 +734,20 @@ golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 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-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= 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= @@ -668,7 +758,9 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -705,27 +797,36 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 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= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 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= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -734,15 +835,21 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -762,14 +869,14 @@ golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= 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= @@ -779,8 +886,8 @@ google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.111.0 h1:bwKi+z2BsdwYFRKrqwutM+axAlYLz83gt5pDSXCJT+0= -google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= +google.golang.org/api v0.126.0 h1:q4GJq+cAdMAC7XP7njvQ4tvohGLiSlytuL4BQxbIZ+o= +google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw= 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= @@ -799,8 +906,12 @@ google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvx google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 h1:znp6mq/drrY+6khTAlJUDNFFcDGV2ENLYKpMq8SyCds= -google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= +google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc h1:8DyZCyvI8mE1IdLy/60bS+52xfymkE72wv1asokgtao= +google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64= +google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc h1:kVKPf/IiYSBWEWtkIn6wZXwWGCnLKcC8oWfZvXjsGnM= +google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc h1:XSJ8Vk1SWuNr8S18z1NZSziL0CPIXLCCMDOEFtHBOFc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= 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= @@ -812,8 +923,9 @@ google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= -google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= -google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag= +google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -826,8 +938,10 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -841,13 +955,12 @@ gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0= gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI= -gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ns1/ns1-go.v2 v2.6.5 h1:nzf3RXP4TEZLeZl7q9t6eav4htlNlWuYX+pXVUitlf0= -gopkg.in/ns1/ns1-go.v2 v2.6.5/go.mod h1:GMnKY+ZuoJ+lVLL+78uSTjwTz2jMazq6AfGKQOYhsPk= +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/ns1/ns1-go.v2 v2.7.13 h1:r07CLALg18f/L1KIK1ZJdbirBV349UtYT1rDWGjnaTk= +gopkg.in/ns1/ns1-go.v2 v2.7.13/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= diff --git a/providers/acmedns/acmedns.go b/providers/acmedns/acmedns.go index 44ff6a7..696952a 100644 --- a/providers/acmedns/acmedns.go +++ b/providers/acmedns/acmedns.go @@ -28,10 +28,10 @@ const ( type acmeDNSClient interface { // UpdateTXTRecord updates the provided account's TXT record // to the given value or returns an error. - UpdateTXTRecord(goacmedns.Account, string) error + UpdateTXTRecord(account goacmedns.Account, value string) error // RegisterAccount registers and returns a new account // with the given allowFrom restriction or returns an error. - RegisterAccount([]string) (goacmedns.Account, error) + RegisterAccount(allowFrom []string) (goacmedns.Account, error) } // DNSProvider implements the challenge.Provider interface. diff --git a/providers/alidns/alidns.go b/providers/alidns/alidns.go index b945a2e..03f923e 100644 --- a/providers/alidns/alidns.go +++ b/providers/alidns/alidns.go @@ -108,7 +108,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { case config.APIKey != "" && config.SecretKey != "": credential = credentials.NewAccessKeyCredential(config.APIKey, config.SecretKey) default: - return nil, fmt.Errorf("alicloud: ram role or credentials missing") + return nil, errors.New("alicloud: ram role or credentials missing") } conf := sdk.NewConfig().WithTimeout(config.HTTPTimeout) @@ -198,7 +198,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { - return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) + return "", fmt.Errorf("could not find zone: %w", err) } var hostedZone alidns.DomainInDescribeDomains diff --git a/providers/allinkl/allinkl.go b/providers/allinkl/allinkl.go index 59fd635..1de8458 100644 --- a/providers/allinkl/allinkl.go +++ b/providers/allinkl/allinkl.go @@ -115,7 +115,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("allinkl: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("allinkl: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() diff --git a/providers/allinkl/internal/client.go b/providers/allinkl/internal/client.go index 06ee291..1a1426a 100644 --- a/providers/allinkl/internal/client.go +++ b/providers/allinkl/internal/client.go @@ -12,7 +12,7 @@ import ( "time" "github.com/entrustcorporation/dv/providers/internal/errutils" - "github.com/mitchellh/mapstructure" + "github.com/go-viper/mapstructure/v2" ) const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php" diff --git a/providers/allinkl/internal/types_api.go b/providers/allinkl/internal/types_api.go index 9207dc1..145163c 100644 --- a/providers/allinkl/internal/types_api.go +++ b/providers/allinkl/internal/types_api.go @@ -54,7 +54,7 @@ type DNSRequest struct { // --- type GetDNSSettingsAPIResponse struct { - Response GetDNSSettingsResponse `json:"Response" mapstructure:"Response"` + Response GetDNSSettingsResponse `json:"Response" mapstructure:"Response"` } type GetDNSSettingsResponse struct { diff --git a/providers/arvancloud/arvancloud.go b/providers/arvancloud/arvancloud.go index 2e2eca8..1496407 100644 --- a/providers/arvancloud/arvancloud.go +++ b/providers/arvancloud/arvancloud.go @@ -99,8 +99,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { }, nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// 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 } @@ -111,7 +111,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("arvancloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) @@ -152,7 +152,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("arvancloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) diff --git a/providers/auroradns/auroradns.go b/providers/auroradns/auroradns.go index fa90941..e1bb7b6 100644 --- a/providers/auroradns/auroradns.go +++ b/providers/auroradns/auroradns.go @@ -108,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("aurora: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("aurora: could not find zone for domain %q: %w", domain, err) } // 1. Aurora will happily create the TXT record when it is provided a fqdn, @@ -160,7 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) if err != nil { - return fmt.Errorf("aurora: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("aurora: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) diff --git a/providers/autodns/autodns.go b/providers/autodns/autodns.go index 65568b9..660cf6b 100644 --- a/providers/autodns/autodns.go +++ b/providers/autodns/autodns.go @@ -122,8 +122,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Value: info.Value, }} - // TODO(ldez) replace domain by FQDN to follow CNAME. - _, err := d.client.AddTxtRecords(context.Background(), domain, records) + _, err := d.client.AddTxtRecords(context.Background(), info.EffectiveFQDN, records) if err != nil { return fmt.Errorf("autodns: %w", err) } @@ -142,8 +141,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { Value: info.Value, }} - // TODO(ldez) replace domain by FQDN to follow CNAME. - if err := d.client.RemoveTXTRecords(context.Background(), domain, records); err != nil { + if err := d.client.RemoveTXTRecords(context.Background(), info.EffectiveFQDN, records); err != nil { return fmt.Errorf("autodns: %w", err) } diff --git a/providers/autodns/autodns_test.go b/providers/autodns/autodns_test.go index 900efbd..bc9f306 100644 --- a/providers/autodns/autodns_test.go +++ b/providers/autodns/autodns_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -133,10 +132,10 @@ func TestLivePresent(t *testing.T) { envTest.RestoreEnv() provider, err := NewDNSProvider() - assert.NoError(t, err) + require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { @@ -146,8 +145,8 @@ func TestLiveCleanUp(t *testing.T) { envTest.RestoreEnv() provider, err := NewDNSProvider() - assert.NoError(t, err) + require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } diff --git a/providers/azure/azure.go b/providers/azure/azure.go index 502abc2..f651949 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -84,6 +84,7 @@ type DNSProvider struct { // If the credentials are _not_ set via the environment, // then it will attempt to get a bearer token via the instance metadata service. // see: https://github.com/Azure/go-autorest/blob/v10.14.0/autorest/azure/auth/auth.go#L38-L42 +// Deprecated: use azuredns instead. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() @@ -118,6 +119,7 @@ func NewDNSProvider() (*DNSProvider, error) { } // NewDNSProviderConfig return a DNSProvider instance configured for Azure. +// Deprecated: use azuredns instead. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("azure: the configuration of the DNS provider is nil") @@ -202,7 +204,7 @@ func getAuthorizer(config *Config) (autorest.Authorizer, error) { return auth.NewAuthorizerFromEnvironment() } -// Fetches metadata from environment or he instance metadata service. +// Fetches metadata from environment or the instance metadata service. // borrowed from https://github.com/Microsoft/azureimds/blob/master/imdssample.go func getMetadata(config *Config, field string) (string, error) { metadataEndpoint := config.MetadataEndpoint diff --git a/providers/azure/azure.toml b/providers/azure/azure.toml index 164512e..c4e3b67 100644 --- a/providers/azure/azure.toml +++ b/providers/azure/azure.toml @@ -1,4 +1,4 @@ -Name = "Azure" +Name = "Azure (deprecated)" Description = '''''' URL = "https://azure.microsoft.com/services/dns/" Code = "azure" diff --git a/providers/azure/private.go b/providers/azure/private.go index f936707..e1d5bc0 100644 --- a/providers/azure/private.go +++ b/providers/azure/private.go @@ -7,7 +7,7 @@ import ( "net/http" "time" - "github.com/Azure/azure-sdk-for-go/services/privatedns/mgmt/2018-09-01/privatedns" + "github.com/Azure/azure-sdk-for-go/profiles/latest/privatedns/mgmt/privatedns" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" "github.com/entrustcorporation/dv/dns01" @@ -118,7 +118,7 @@ func (d *dnsProviderPrivate) getHostedZoneID(ctx context.Context, fqdn string) ( authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) + return "", fmt.Errorf("could not find zone: %w", err) } dc := privatedns.NewPrivateZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) diff --git a/providers/azure/public.go b/providers/azure/public.go index 4efcd0b..b0ec505 100644 --- a/providers/azure/public.go +++ b/providers/azure/public.go @@ -7,7 +7,7 @@ import ( "net/http" "time" - "github.com/Azure/azure-sdk-for-go/services/dns/mgmt/2017-09-01/dns" + "github.com/Azure/azure-sdk-for-go/profiles/latest/dns/mgmt/dns" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" "github.com/entrustcorporation/dv/dns01" @@ -118,7 +118,7 @@ func (d *dnsProviderPublic) getHostedZoneID(ctx context.Context, fqdn string) (s authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) + return "", fmt.Errorf("could not find zone: %w", err) } dc := dns.NewZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) diff --git a/providers/azuredns/azuredns.go b/providers/azuredns/azuredns.go new file mode 100644 index 0000000..5978ce2 --- /dev/null +++ b/providers/azuredns/azuredns.go @@ -0,0 +1,264 @@ +// Package azuredns implements a DNS provider for solving the DNS-01 challenge using azure DNS. +// Azure doesn't like trailing dots on domain names, most of the acme code does. +package azuredns + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/platform/config/env" +) + +// Environment variables names. +const ( + envNamespace = "AZURE_" + + EnvEnvironment = envNamespace + "ENVIRONMENT" + EnvSubscriptionID = envNamespace + "SUBSCRIPTION_ID" + EnvResourceGroup = envNamespace + "RESOURCE_GROUP" + EnvZoneName = envNamespace + "ZONE_NAME" + EnvPrivateZone = envNamespace + "PRIVATE_ZONE" + + EnvTenantID = envNamespace + "TENANT_ID" + EnvClientID = envNamespace + "CLIENT_ID" + EnvClientSecret = envNamespace + "CLIENT_SECRET" + + EnvOIDCToken = envNamespace + "OIDC_TOKEN" + EnvOIDCTokenFilePath = envNamespace + "OIDC_TOKEN_FILE_PATH" + EnvOIDCRequestURL = envNamespace + "OIDC_REQUEST_URL" + EnvOIDCRequestToken = envNamespace + "OIDC_REQUEST_TOKEN" + + EnvAuthMethod = envNamespace + "AUTH_METHOD" + EnvAuthMSITimeout = envNamespace + "AUTH_MSI_TIMEOUT" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + + EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" + EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + SubscriptionID string + ResourceGroup string + PrivateZone bool + + Environment cloud.Configuration + + // optional if using default Azure credentials + ClientID string + ClientSecret string + TenantID string + + OIDCToken string + OIDCTokenFilePath string + OIDCRequestURL string + OIDCRequestToken string + + AuthMethod string + AuthMSITimeout time.Duration + + 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, 60), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + Environment: cloud.AzurePublic, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + provider challenge.ProviderTimeout +} + +// NewDNSProvider returns a DNSProvider instance configured for azuredns. +func NewDNSProvider() (*DNSProvider, error) { + config := NewDefaultConfig() + + environmentName := env.GetOrFile(EnvEnvironment) + if environmentName != "" { + switch environmentName { + case "china": + config.Environment = cloud.AzureChina + case "public": + config.Environment = cloud.AzurePublic + case "usgovernment": + config.Environment = cloud.AzureGovernment + default: + return nil, fmt.Errorf("azuredns: unknown environment %s", environmentName) + } + } else { + config.Environment = cloud.AzurePublic + } + + config.SubscriptionID = env.GetOrFile(EnvSubscriptionID) + config.ResourceGroup = env.GetOrFile(EnvResourceGroup) + config.PrivateZone = env.GetOrDefaultBool(EnvPrivateZone, false) + + config.ClientID = env.GetOrFile(EnvClientID) + config.ClientSecret = env.GetOrFile(EnvClientSecret) + config.TenantID = env.GetOrFile(EnvTenantID) + + config.OIDCToken = env.GetOrFile(EnvOIDCToken) + config.OIDCTokenFilePath = env.GetOrFile(EnvOIDCTokenFilePath) + + oidcValues, _ := env.GetWithFallback( + []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL}, + []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken}, + ) + + config.OIDCRequestURL = oidcValues[EnvOIDCRequestURL] + config.OIDCRequestToken = oidcValues[EnvOIDCRequestToken] + + config.AuthMethod = env.GetOrFile(EnvAuthMethod) + config.AuthMSITimeout = env.GetOrDefaultSecond(EnvAuthMSITimeout, 2*time.Second) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Azure. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("azuredns: the configuration of the DNS provider is nil") + } + + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: 5 * time.Second} + } + + credentials, err := getCredentials(config) + if err != nil { + return nil, fmt.Errorf("azuredns: Unable to retrieve valid credentials: %w", err) + } + + if config.SubscriptionID == "" { + return nil, errors.New("azuredns: SubscriptionID is missing") + } + + if config.ResourceGroup == "" { + return nil, errors.New("azuredns: ResourceGroup is missing") + } + + var dnsProvider challenge.ProviderTimeout + if config.PrivateZone { + dnsProvider, err = NewDNSProviderPrivate(config, credentials) + if err != nil { + return nil, fmt.Errorf("azuredns: %w", err) + } + } else { + dnsProvider, err = NewDNSProviderPublic(config, credentials) + if err != nil { + return nil, fmt.Errorf("azuredns: %w", err) + } + } + + return &DNSProvider{provider: dnsProvider}, 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.provider.Timeout() +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + return d.provider.Present(domain, token, keyAuth) +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + return d.provider.CleanUp(domain, token, keyAuth) +} + +func getCredentials(config *Config) (azcore.TokenCredential, error) { + clientOptions := azcore.ClientOptions{Cloud: config.Environment} + + switch strings.ToLower(config.AuthMethod) { + case "env": + if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { + return azidentity.NewClientSecretCredential(config.TenantID, config.ClientID, config.ClientSecret, + &azidentity.ClientSecretCredentialOptions{ClientOptions: clientOptions}) + } + + return azidentity.NewEnvironmentCredential(&azidentity.EnvironmentCredentialOptions{ClientOptions: clientOptions}) + + case "wli": + return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: clientOptions}) + + case "msi": + cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ClientOptions: clientOptions}) + if err != nil { + return nil, err + } + + return &timeoutTokenCredential{cred: cred, timeout: config.AuthMSITimeout}, nil + + case "cli": + return azidentity.NewAzureCLICredential(nil) + + case "oidc": + err := checkOIDCConfig(config) + if err != nil { + return nil, err + } + + return azidentity.NewClientAssertionCredential(config.TenantID, config.ClientID, getOIDCAssertion(config), &azidentity.ClientAssertionCredentialOptions{ClientOptions: clientOptions}) + + default: + return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ClientOptions: clientOptions}) + } +} + +// timeoutTokenCredential wraps a TokenCredential to add a timeout. +type timeoutTokenCredential struct { + cred azcore.TokenCredential + timeout time.Duration +} + +// GetToken implements the azcore.TokenCredential interface. +func (w *timeoutTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + if w.timeout <= 0 { + return w.cred.GetToken(ctx, opts) + } + + ctxTimeout, cancel := context.WithTimeout(ctx, w.timeout) + defer cancel() + + tk, err := w.cred.GetToken(ctxTimeout, opts) + if ce := ctxTimeout.Err(); errors.Is(ce, context.DeadlineExceeded) { + return tk, azidentity.NewCredentialUnavailableError("managed identity timed out") + } + + w.timeout = 0 + + return tk, err +} + +func deref[T string | int | int32 | int64](v *T) T { + if v == nil { + var zero T + return zero + } + + return *v +} diff --git a/providers/azuredns/azuredns.toml b/providers/azuredns/azuredns.toml new file mode 100644 index 0000000..745b443 --- /dev/null +++ b/providers/azuredns/azuredns.toml @@ -0,0 +1,186 @@ +Name = "Azure DNS" +Description = '''''' +URL = "https://azure.microsoft.com/services/dns/" +Code = "azuredns" +Since = "v4.13.0" + +Example = ''' +### Using client secret + +AZURE_CLIENT_ID= \ +AZURE_TENANT_ID= \ +AZURE_CLIENT_SECRET= \ +lego --domains example.com --email your_example@email.com --dns azuredns run + +### Using client certificate + +AZURE_CLIENT_ID= \ +AZURE_TENANT_ID= \ +AZURE_CLIENT_CERTIFICATE_PATH= \ +lego --domains example.com --email your_example@email.com --dns azuredns run + +### Using Azure CLI + +az login \ +lego --domains example.com --email your_example@email.com --dns azuredns run + +### Using Managed Identity (Azure VM) + +AZURE_TENANT_ID= \ +AZURE_SUBSCRIPTION_ID= \ +AZURE_RESOURCE_GROUP= \ +lego --domains example.com --email your_example@email.com --dns azuredns run + +### Using Managed Identity (Azure Arc) + +AZURE_TENANT_ID= \ +AZURE_SUBSCRIPTION_ID= \ +AZURE_RESOURCE_GROUP= \ +IMDS_ENDPOINT=http://localhost:40342 \ +IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token \ +lego --domains example.com --email your_example@email.com --dns azuredns run + +''' + +Additional = ''' +## Description + +Several authentication methods can be used to authenticate against Azure DNS API. + +### Default Azure Credentials (default option) + +Default Azure Credentials automatically detects in the following locations and prioritized in the following order: + +1. Environment variables for client secret: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET` +2. Environment variables for client certificate: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_CERTIFICATE_PATH` +3. Workload identity for resources hosted in Azure environment (see below) +4. Shared credentials (defaults to `~/.azure` folder), used by Azure CLI + +Link: +- [Azure Authentication](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication) + +### Environment variables + +#### Client secret + +The Azure Credentials can be configured using the following environment variables: +* AZURE_CLIENT_ID = "Client ID" +* AZURE_CLIENT_SECRET = "Client secret" +* AZURE_TENANT_ID = "Tenant ID" + +This authentication method can be specificaly used by setting the `AZURE_AUTH_METHOD` environment variable to `env`. + +#### Client certificate + +The Azure Credentials can be configured using the following environment variables: +* AZURE_CLIENT_ID = "Client ID" +* AZURE_CLIENT_CERTIFICATE_PATH = "Client certificate path" +* AZURE_TENANT_ID = "Tenant ID" + +This authentication method can be specificaly used by setting the `AZURE_AUTH_METHOD` environment variable to `env`. + +### Workload identity + +Workload identity allows workloads running Azure Kubernetes Services (AKS) clusters to authenticate as an Azure AD application identity using federated credentials. + +This must be configured in kubernetes workload deployment in one hand and on the Azure AD application registration in the other hand. + +Here is a summary of the steps to follow to use it : +* create a `ServiceAccount` resource, add following annotations to reference the targeted Azure AD application registration : `azure.workload.identity/client-id` and `azure.workload.identity/tenant-id`. +* on the `Deployment` resource you must reference the previous `ServiceAccount` and add the following label : `azure.workload.identity/use: "true"`. +* create a fedreated credentials of type `Kubernetes accessing Azure resources`, add the cluster issuer URL and add the namespace and name of your kubernetes service account. + +Link : +- [Azure AD Workload identity](https://azure.github.io/azure-workload-identity/docs/topics/service-account-labels-and-annotations.html) + +This authentication method can be specificaly used by setting the `AZURE_AUTH_METHOD` environment variable to `wli`. + +### Azure Managed Identity + +#### Azure Managed Identity (with Azure workload) + +The Azure Managed Identity service allows linking Azure AD identities to Azure resources, without needing to manually manage client IDs and secrets. + +Workloads with a Managed Identity can manage their own certificates, with permissions on specific domain names set using IAM assignments. +For this to work, the Managed Identity requires the **Reader** role on the target DNS Zone, +and the **DNS Zone Contributor** on the relevant `_acme-challenge` TXT records. + +For example, to allow a Managed Identity to create a certificate for "fw01.lab.example.com", using Azure CLI: + +```bash +export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" +export AZURE_RESOURCE_GROUP="rg1" +export SERVICE_PRINCIPAL_ID="00000000-0000-0000-0000-000000000000" + +export AZURE_DNS_ZONE="lab.example.com" +export AZ_HOSTNAME="fw01" +export AZ_RECORD_SET="_acme-challenge.${AZ_HOSTNAME}" + +az role assignment create \ +--assignee "${SERVICE_PRINCIPAL_ID}" \ +--role "Reader" \ +--scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${AZURE_DNS_ZONE}" + +az role assignment create \ +--assignee "${SERVICE_PRINCIPAL_ID}" \ +--role "DNS Zone Contributor" \ +--scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${AZURE_DNS_ZONE}/TXT/${AZ_RECORD_SET}" +``` + +A timeout wrapper is configured for this authentication method. +The duraction can be configured by setting the `AZURE_AUTH_MSI_TIMEOUT`. +The default timeout is 2 seconds. +This authentication method can be specificaly used by setting the `AZURE_AUTH_METHOD` environment variable to `msi`. + +#### Azure Managed Identity (with Azure Arc) + +The Azure Arc agent provides the ability to use a Managed Identity on resources hosted outside of Azure +(such as on-prem virtual machines, or VMs in another cloud provider). + +While the upstream `azidentity` SDK will try to automatically identify and use the Azure Arc metadata service, +if you get `azuredns: DefaultAzureCredential: failed to acquire a token.` error messages, +you may need to set the environment variables: +* `IMDS_ENDPOINT=http://localhost:40342` +* `IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token` + +A timeout wrapper is configured for this authentication method. +The duraction can be configured by setting the `AZURE_AUTH_MSI_TIMEOUT`. +The default timeout is 2 seconds. +This authentication method can be specificaly used by setting the `AZURE_AUTH_METHOD` environment variable to `msi`. + +### Azure CLI + +The Azure CLI is a command-line tool provided by Microsoft to interact with Azure resources. +It provides an easy way to authenticate by simply running `az login` command. +The generated token will be cached by default in the `~/.azure` folder. + +This authentication method can be specificaly used by setting the `AZURE_AUTH_METHOD` environment variable to `cli`. + +### Open ID Connect + +Open ID Connect is a mechanism that establish a trust relationship between a running environment and the Azure AD identity provider. +It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oidc`. + +''' + +[Configuration] + [Configuration.Credentials] + AZURE_CLIENT_ID = "Client ID" + AZURE_CLIENT_SECRET = "Client secret" + AZURE_TENANT_ID = "Tenant ID" + AZURE_CLIENT_CERTIFICATE_PATH = "Client certificate path" + AZURE_SUBSCRIPTION_ID = "DNS zone subscription ID" + AZURE_RESOURCE_GROUP = "DNS zone resource group" + [Configuration.Additional] + AZURE_ENVIRONMENT = "Azure environment, one of: public, usgovernment, and china" + AZURE_PRIVATE_ZONE = "Set to true to use Azure Private DNS Zones and not public" + AZURE_ZONE_NAME = "Zone name to use inside Azure DNS service to add the TXT record in" + AZURE_AUTH_METHOD = "Specify which authentication method to use" + AZURE_AUTH_MSI_TIMEOUT = "Managed Identity timeout duration" + AZURE_TTL = "The TTL of the TXT record used for the DNS challenge" + AZURE_POLLING_INTERVAL = "Time between DNS propagation check" + AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + +[Links] + API = "https://docs.microsoft.com/en-us/go/azure/" + GoClient = "https://github.com/Azure/azure-sdk-for-go" diff --git a/providers/azuredns/azuredns_test.go b/providers/azuredns/azuredns_test.go new file mode 100644 index 0000000..cf15055 --- /dev/null +++ b/providers/azuredns/azuredns_test.go @@ -0,0 +1,168 @@ +package azuredns + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvEnvironment, + EnvSubscriptionID, + EnvResourceGroup). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvEnvironment: "", + EnvSubscriptionID: "A", + EnvResourceGroup: "B", + }, + }, + { + desc: "unknown environment", + envVars: map[string]string{ + EnvEnvironment: "test", + EnvSubscriptionID: "A", + EnvResourceGroup: "B", + }, + expected: "azuredns: unknown environment test", + }, + } + + 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.EqualError(t, err, test.expected) + return + } + + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.provider) + + assert.IsType(t, p.provider, new(DNSProviderPublic)) + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + subscriptionID string + resourceGroup string + privateZone bool + handler func(w http.ResponseWriter, r *http.Request) + expected string + }{ + { + desc: "success (public)", + subscriptionID: "A", + resourceGroup: "B", + privateZone: false, + }, + { + desc: "success (private)", + subscriptionID: "A", + resourceGroup: "B", + privateZone: true, + }, + { + desc: "SubscriptionID missing", + subscriptionID: "", + resourceGroup: "", + expected: "azuredns: SubscriptionID is missing", + }, + { + desc: "ResourceGroup missing", + subscriptionID: "A", + resourceGroup: "", + expected: "azuredns: ResourceGroup is missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.SubscriptionID = test.subscriptionID + config.ResourceGroup = test.resourceGroup + config.PrivateZone = test.privateZone + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + if test.handler == nil { + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {}) + } else { + mux.HandleFunc("/", test.handler) + } + + p, err := NewDNSProviderConfig(config) + + if test.expected != "" { + require.EqualError(t, err, test.expected) + return + } + + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.provider) + + if test.privateZone { + assert.IsType(t, p.provider, new(DNSProviderPrivate)) + } else { + assert.IsType(t, p.provider, new(DNSProviderPublic)) + } + }) + } +} + +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(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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/azuredns/oidc.go b/providers/azuredns/oidc.go new file mode 100644 index 0000000..aa98d55 --- /dev/null +++ b/providers/azuredns/oidc.go @@ -0,0 +1,105 @@ +package azuredns + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" +) + +func checkOIDCConfig(config *Config) error { + if config.TenantID == "" { + return errors.New("azuredns: TenantID is missing") + } + + if config.ClientID == "" { + return errors.New("azuredns: ClientID is missing") + } + + if config.OIDCToken == "" && config.OIDCTokenFilePath == "" && (config.OIDCRequestURL == "" || config.OIDCRequestToken == "") { + return errors.New("azuredns: OIDCToken, OIDCTokenFilePath or OIDCRequestURL and OIDCRequestToken must be set") + } + + return nil +} + +func getOIDCAssertion(config *Config) func(ctx context.Context) (string, error) { + return func(ctx context.Context) (string, error) { + var token string + if config.OIDCToken != "" { + token = strings.TrimSpace(config.OIDCToken) + } + + if config.OIDCTokenFilePath != "" { + fileTokenRaw, err := os.ReadFile(config.OIDCTokenFilePath) + if err != nil { + return "", fmt.Errorf("azuredns: error retrieving token file with path %s: %w", config.OIDCTokenFilePath, err) + } + + fileToken := strings.TrimSpace(string(fileTokenRaw)) + if config.OIDCToken != fileToken { + return "", fmt.Errorf("azuredns: token file with path %s does not match token from environment variable", config.OIDCTokenFilePath) + } + + token = fileToken + } + + if token == "" && config.OIDCRequestURL != "" && config.OIDCRequestToken != "" { + return getOIDCToken(config) + } + + return token, nil + } +} + +func getOIDCToken(config *Config) (string, error) { + req, err := http.NewRequest(http.MethodGet, config.OIDCRequestURL, http.NoBody) + if err != nil { + return "", fmt.Errorf("azuredns: failed to build OIDC request: %w", err) + } + + query, err := url.ParseQuery(req.URL.RawQuery) + if err != nil { + return "", errors.New("azuredns: cannot parse OIDC request URL query") + } + + if query.Get("audience") == "" { + query.Set("audience", "api://AzureADTokenExchange") + req.URL.RawQuery = query.Encode() + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.OIDCRequestToken)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := config.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("azuredns: cannot request OIDC token: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("azuredns: cannot parse OIDC token response: %w", err) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusNoContent { + return "", fmt.Errorf("azuredns: OIDC token request received HTTP status %d with response: %s", resp.StatusCode, body) + } + + var returnedToken struct { + Count int `json:"count"` + Value string `json:"value"` + } + if err := json.Unmarshal(body, &returnedToken); err != nil { + return "", fmt.Errorf("azuredns: cannot unmarshal OIDC token response: %w", err) + } + + return returnedToken.Value, nil +} diff --git a/providers/azuredns/private.go b/providers/azuredns/private.go new file mode 100644 index 0000000..7c184be --- /dev/null +++ b/providers/azuredns/private.go @@ -0,0 +1,154 @@ +package azuredns + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" +) + +// DNSProviderPrivate implements the challenge.Provider interface for Azure Private Zone DNS. +type DNSProviderPrivate struct { + config *Config + zoneClient *armprivatedns.PrivateZonesClient + recordClient *armprivatedns.RecordSetsClient +} + +// NewDNSProviderPrivate creates a DNSProviderPrivate structure with initialized Azure clients. +func NewDNSProviderPrivate(config *Config, credentials azcore.TokenCredential) (*DNSProviderPrivate, error) { + options := arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: config.Environment, + }, + } + + zoneClient, err := armprivatedns.NewPrivateZonesClient(config.SubscriptionID, credentials, &options) + if err != nil { + return nil, err + } + + recordClient, err := armprivatedns.NewRecordSetsClient(config.SubscriptionID, credentials, &options) + if err != nil { + return nil, err + } + + return &DNSProviderPrivate{ + config: config, + zoneClient: zoneClient, + recordClient: recordClient, + }, 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 *DNSProviderPrivate) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProviderPrivate) Present(domain, _, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + // Get existing record set + rset, err := d.recordClient.Get(ctx, d.config.ResourceGroup, zone, armprivatedns.RecordTypeTXT, subDomain, nil) + if err != nil { + var respErr *azcore.ResponseError + if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusNotFound { + return fmt.Errorf("azuredns: %w", err) + } + } + + // Construct unique TXT records using map + uniqRecords := map[string]struct{}{info.Value: {}} + if rset.RecordSet.Properties != nil && rset.RecordSet.Properties.TxtRecords != nil { + for _, txtRecord := range rset.RecordSet.Properties.TxtRecords { + // Assume Value doesn't contain multiple strings + if len(txtRecord.Value) > 0 { + uniqRecords[deref(txtRecord.Value[0])] = struct{}{} + } + } + } + + var txtRecords []*armprivatedns.TxtRecord + for txt := range uniqRecords { + txtRecord := txt + txtRecords = append(txtRecords, &armprivatedns.TxtRecord{Value: []*string{&txtRecord}}) + } + + ttlInt64 := int64(d.config.TTL) + rec := armprivatedns.RecordSet{ + Name: &subDomain, + Properties: &armprivatedns.RecordSetProperties{ + TTL: &ttlInt64, + TxtRecords: txtRecords, + }, + } + + _, err = d.recordClient.CreateOrUpdate(ctx, d.config.ResourceGroup, zone, armprivatedns.RecordTypeTXT, subDomain, rec, nil) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProviderPrivate) CleanUp(domain, _, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + _, err = d.recordClient.Delete(ctx, d.config.ResourceGroup, zone, armprivatedns.RecordTypeTXT, subDomain, nil) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + return nil +} + +// Checks that azure has a zone for this domain name. +func (d *DNSProviderPrivate) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { + if zone := env.GetOrFile(EnvZoneName); zone != "" { + return zone, nil + } + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", fmt.Errorf("could not find zone: %w", err) + } + + zone, err := d.zoneClient.Get(ctx, d.config.ResourceGroup, dns01.UnFqdn(authZone), nil) + if err != nil { + return "", err + } + + // zone.Name shouldn't have a trailing dot(.) + return dns01.UnFqdn(deref(zone.Name)), nil +} diff --git a/providers/azuredns/public.go b/providers/azuredns/public.go new file mode 100644 index 0000000..381234d --- /dev/null +++ b/providers/azuredns/public.go @@ -0,0 +1,154 @@ +package azuredns + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" +) + +// DNSProviderPublic implements the challenge.Provider interface for Azure Public Zone DNS. +type DNSProviderPublic struct { + config *Config + zoneClient *armdns.ZonesClient + recordClient *armdns.RecordSetsClient +} + +// NewDNSProviderPublic creates a DNSProviderPublic structure with intialised Azure clients. +func NewDNSProviderPublic(config *Config, credentials azcore.TokenCredential) (*DNSProviderPublic, error) { + options := arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: config.Environment, + }, + } + + zoneClient, err := armdns.NewZonesClient(config.SubscriptionID, credentials, &options) + if err != nil { + return nil, err + } + + recordClient, err := armdns.NewRecordSetsClient(config.SubscriptionID, credentials, &options) + if err != nil { + return nil, err + } + + return &DNSProviderPublic{ + config: config, + zoneClient: zoneClient, + recordClient: recordClient, + }, 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 *DNSProviderPublic) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProviderPublic) Present(domain, _, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + // Get existing record set + rset, err := d.recordClient.Get(ctx, d.config.ResourceGroup, zone, subDomain, armdns.RecordTypeTXT, nil) + if err != nil { + var respErr *azcore.ResponseError + if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusNotFound { + return fmt.Errorf("azuredns: %w", err) + } + } + + // Construct unique TXT records using map + uniqRecords := map[string]struct{}{info.Value: {}} + if rset.RecordSet.Properties != nil && rset.RecordSet.Properties.TxtRecords != nil { + for _, txtRecord := range rset.RecordSet.Properties.TxtRecords { + // Assume Value doesn't contain multiple strings + if len(txtRecord.Value) > 0 { + uniqRecords[deref(txtRecord.Value[0])] = struct{}{} + } + } + } + + var txtRecords []*armdns.TxtRecord + for txt := range uniqRecords { + txtRecord := txt + txtRecords = append(txtRecords, &armdns.TxtRecord{Value: []*string{&txtRecord}}) + } + + ttlInt64 := int64(d.config.TTL) + rec := armdns.RecordSet{ + Name: &subDomain, + Properties: &armdns.RecordSetProperties{ + TTL: &ttlInt64, + TxtRecords: txtRecords, + }, + } + + _, err = d.recordClient.CreateOrUpdate(ctx, d.config.ResourceGroup, zone, subDomain, armdns.RecordTypeTXT, rec, nil) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProviderPublic) CleanUp(domain, _, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + _, err = d.recordClient.Delete(ctx, d.config.ResourceGroup, zone, subDomain, armdns.RecordTypeTXT, nil) + if err != nil { + return fmt.Errorf("azuredns: %w", err) + } + + return nil +} + +// Checks that azure has a zone for this domain name. +func (d *DNSProviderPublic) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { + if zone := env.GetOrFile(EnvZoneName); zone != "" { + return zone, nil + } + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", fmt.Errorf("could not find zone: %w", err) + } + + zone, err := d.zoneClient.Get(ctx, d.config.ResourceGroup, dns01.UnFqdn(authZone), nil) + if err != nil { + return "", err + } + + // zone.Name shouldn't have a trailing dot(.) + return dns01.UnFqdn(deref(zone.Name)), nil +} diff --git a/providers/bluecat/internal/client.go b/providers/bluecat/internal/client.go index 7568ba5..b92bd06 100644 --- a/providers/bluecat/internal/client.go +++ b/providers/bluecat/internal/client.go @@ -200,7 +200,7 @@ func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) return view.ID, nil } -// LookupParentZoneID Return the entityId of the parent zone by recursing from the root view. +// LookupParentZoneID returns the entityId of the parent zone by iterating through the root labels. // Also return the simple name of the host. func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) { if fqdn == "" { diff --git a/providers/brandit/brandit.go b/providers/brandit/brandit.go index 14dd52b..47c92be 100644 --- a/providers/brandit/brandit.go +++ b/providers/brandit/brandit.go @@ -108,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("brandit: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("brandit: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -155,7 +155,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("brandit: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("brandit: could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID diff --git a/providers/brandit/internal/client.go b/providers/brandit/internal/client.go index 0bde2dd..7f697c7 100644 --- a/providers/brandit/internal/client.go +++ b/providers/brandit/internal/client.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -58,7 +59,7 @@ func (c *Client) ListRecords(ctx context.Context, account, dnsZone string) (*Lis } for len(result.Response.RR) < result.Response.Total[0] { - query.Add("first", fmt.Sprint(result.Response.Last[0]+1)) + query.Add("first", strconv.Itoa(result.Response.Last[0]+1)) tmp := &Response[*ListRecordsResponse]{} err := c.do(ctx, query, tmp) @@ -76,7 +77,7 @@ func (c *Client) ListRecords(ctx context.Context, account, dnsZone string) (*Lis // AddRecord adds a DNS record. // https://portal.brandit.com/apidocv3#addDNSRR func (c *Client) AddRecord(ctx context.Context, domainName, account, newRecordID string, record Record) (*AddRecord, error) { - value := strings.Join([]string{record.Name, fmt.Sprint(record.TTL), "IN", record.Type, record.Content}, " ") + value := strings.Join([]string{record.Name, strconv.Itoa(record.TTL), "IN", record.Type, record.Content}, " ") query := url.Values{} query.Add("command", "addDNSRR") diff --git a/providers/bunny/bunny.go b/providers/bunny/bunny.go index 8719ea3..f40768a 100644 --- a/providers/bunny/bunny.go +++ b/providers/bunny/bunny.go @@ -9,7 +9,7 @@ import ( "github.com/entrustcorporation/dv/dns01" "github.com/go-acme/lego/v4/platform/config/env" - "github.com/simplesurance/bunny-go" + "github.com/nrdcg/bunny-go" ) const minTTL = 60 @@ -81,8 +81,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return &DNSProvider{config: config, client: client}, nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// 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 } @@ -93,7 +93,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := getZone(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("bunny: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err) + return fmt.Errorf("bunny: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -128,7 +128,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := getZone(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("bunny: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err) + return fmt.Errorf("bunny: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -190,7 +190,9 @@ func getZone(fqdn string) (string, error) { return "", err } - return dns01.UnFqdn(authZone), nil + zone := dns01.UnFqdn(authZone) + + return zone, nil } func pointer[T string | int | int32 | int64](v T) *T { return &v } diff --git a/providers/bunny/bunny.toml b/providers/bunny/bunny.toml index 3290786..93ccfad 100644 --- a/providers/bunny/bunny.toml +++ b/providers/bunny/bunny.toml @@ -19,4 +19,4 @@ lego --email you@example.com --dns bunny --domains my.example.org run [Links] API = "https://docs.bunny.net/reference/dnszonepublic_index" - bunny-go = "https://github.com/simplesurance/bunny-go" + bunny-go = "https://github.com/nrdcg/bunny-go" diff --git a/providers/checkdomain/checkdomain.go b/providers/checkdomain/checkdomain.go index 8163c3e..991c9bf 100644 --- a/providers/checkdomain/checkdomain.go +++ b/providers/checkdomain/checkdomain.go @@ -115,7 +115,6 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Type: "TXT", Value: info.Value, }) - if err != nil { return fmt.Errorf("checkdomain: %w", err) } diff --git a/providers/checkdomain/checkdomain_test.go b/providers/checkdomain/checkdomain_test.go index c36cccc..c42020b 100644 --- a/providers/checkdomain/checkdomain_test.go +++ b/providers/checkdomain/checkdomain_test.go @@ -6,7 +6,6 @@ import ( "github.com/go-acme/lego/v4/platform/tester" "github.com/entrustcorporation/dv/providers/checkdomain/internal" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -110,10 +109,10 @@ func TestLivePresent(t *testing.T) { envTest.RestoreEnv() provider, err := NewDNSProvider() - assert.NoError(t, err) + require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { @@ -123,8 +122,8 @@ func TestLiveCleanUp(t *testing.T) { envTest.RestoreEnv() provider, err := NewDNSProvider() - assert.NoError(t, err) + require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } diff --git a/providers/civo/civo.go b/providers/civo/civo.go index 700d7d5..4f2d431 100644 --- a/providers/civo/civo.go +++ b/providers/civo/civo.go @@ -95,7 +95,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("civo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) @@ -129,7 +129,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("civo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) diff --git a/providers/clouddns/clouddns.go b/providers/clouddns/clouddns.go index c82ba41..0ca7eda 100644 --- a/providers/clouddns/clouddns.go +++ b/providers/clouddns/clouddns.go @@ -105,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("clouddns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("clouddns: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) @@ -127,7 +127,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("clouddns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("clouddns: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) diff --git a/providers/cloudflare/cloudflare.go b/providers/cloudflare/cloudflare.go index 253890b..a273d46 100644 --- a/providers/cloudflare/cloudflare.go +++ b/providers/cloudflare/cloudflare.go @@ -63,7 +63,7 @@ type DNSProvider struct { // For a more paranoid setup, provide CLOUDFLARE_DNS_API_TOKEN and CLOUDFLARE_ZONE_API_TOKEN. // // The email and API key should be avoided, if possible. -// Instead setup a API token with both Zone:Read and DNS:Edit permission, and pass the CLOUDFLARE_DNS_API_TOKEN environment variable. +// Instead, set up an API token with both Zone:Read and DNS:Edit permission, and pass the CLOUDFLARE_DNS_API_TOKEN environment variable. // You can split the Zone:Read and DNS:Edit permissions across multiple API tokens: // in this case pass both CLOUDFLARE_ZONE_API_TOKEN and CLOUDFLARE_DNS_API_TOKEN accordingly. func NewDNSProvider() (*DNSProvider, error) { @@ -126,7 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("cloudflare: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.client.ZoneIDByName(authZone) @@ -134,7 +134,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err) } - dnsRecord := cloudflare.DNSRecord{ + dnsRecord := cloudflare.CreateDNSRecordParams{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), Content: info.Value, @@ -146,15 +146,11 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("cloudflare: failed to create TXT record: %w", err) } - if !response.Success { - return fmt.Errorf("cloudflare: failed to create TXT record: %+v %+v", response.Errors, response.Messages) - } - d.recordIDsMu.Lock() - d.recordIDs[token] = response.Result.ID + d.recordIDs[token] = response.ID d.recordIDsMu.Unlock() - log.Infof("cloudflare: new record for %s, ID %s", domain, response.Result.ID) + log.Infof("cloudflare: new record for %s, ID %s", domain, response.ID) return nil } @@ -165,7 +161,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("cloudflare: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.client.ZoneIDByName(authZone) diff --git a/providers/cloudflare/cloudflare.toml b/providers/cloudflare/cloudflare.toml index 9852a2f..514790b 100644 --- a/providers/cloudflare/cloudflare.toml +++ b/providers/cloudflare/cloudflare.toml @@ -33,7 +33,7 @@ very specific access can be granted to your resources at Cloudflare. See this [Cloudflare announcement](https://blog.cloudflare.com/api-tokens-general-availability/) for details. The main resources Lego cares for are the DNS entries for your Zones. -It also need to resolve a domain name to an internal Zone ID in order to manipulate DNS entries. +It also needs to resolve a domain name to an internal Zone ID in order to manipulate DNS entries. Hence, you should create an API token with the following permissions: diff --git a/providers/cloudflare/wrapper.go b/providers/cloudflare/wrapper.go index 79cc41d..c92da5b 100644 --- a/providers/cloudflare/wrapper.go +++ b/providers/cloudflare/wrapper.go @@ -59,16 +59,16 @@ func newClient(config *Config) (*metaClient, error) { }, nil } -func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { - return m.clientEdit.CreateDNSRecord(ctx, zoneID, rr) +func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) { + return m.clientEdit.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), rr) } -func (m *metaClient) DNSRecords(ctx context.Context, zoneID string, rr cloudflare.DNSRecord) ([]cloudflare.DNSRecord, error) { - return m.clientEdit.DNSRecords(ctx, zoneID, rr) +func (m *metaClient) DNSRecords(ctx context.Context, zoneID string, rr cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) { + return m.clientEdit.ListDNSRecords(ctx, cloudflare.ZoneIdentifier(zoneID), rr) } func (m *metaClient) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { - return m.clientEdit.DeleteDNSRecord(ctx, zoneID, recordID) + return m.clientEdit.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), recordID) } func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { diff --git a/providers/cloudns/internal/client.go b/providers/cloudns/internal/client.go index a4c97c1..f922e69 100644 --- a/providers/cloudns/internal/client.go +++ b/providers/cloudns/internal/client.go @@ -55,7 +55,7 @@ func NewClient(authID, subAuthID, authPassword string) (*Client, error) { func (c *Client) GetZone(ctx context.Context, authFQDN string) (*Zone, error) { authZone, err := dns01.FindZoneByFqdn(authFQDN) if err != nil { - return nil, fmt.Errorf("could not find zone for FQDN %q: %w", authFQDN, err) + return nil, fmt.Errorf("could not find zone: %w", err) } authZoneName := dns01.UnFqdn(authZone) diff --git a/providers/cloudns/internal/client_test.go b/providers/cloudns/internal/client_test.go index 554bf00..999bd14 100644 --- a/providers/cloudns/internal/client_test.go +++ b/providers/cloudns/internal/client_test.go @@ -382,62 +382,62 @@ func TestClient_AddTxtRecord(t *testing.T) { { desc: "sub-zone", authID: "myAuthID", - zoneName: "bar.com", - authFQDN: "_acme-challenge.foo.bar.com.", + zoneName: "example.com", + authFQDN: "_acme-challenge.foo.example.com.", value: "txtTXTtxtTXTtxtTXTtxtTXT", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`, + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`, }, }, { desc: "main zone (authID)", authID: "myAuthID", - zoneName: "bar.com", - authFQDN: "_acme-challenge.bar.com.", + zoneName: "example.com", + authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`, + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`, }, }, { desc: "main zone (subAuthID)", subAuthID: "mySubAuthID", - zoneName: "bar.com", - authFQDN: "_acme-challenge.bar.com.", + zoneName: "example.com", + authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`, + query: `auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`, }, }, { desc: "invalid status", authID: "myAuthID", - zoneName: "bar.com", - authFQDN: "_acme-challenge.bar.com.", + zoneName: "example.com", + authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 120, apiResponse: `{"status":"Failed","statusDescription":"Invalid TTL. Choose from the list of the values we support."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, errorMsg: "failed to add TXT record: Failed Invalid TTL. Choose from the list of the values we support.", }, }, { desc: "invalid json response", authID: "myAuthID", - zoneName: "bar.com", - authFQDN: "_acme-challenge.bar.com.", + zoneName: "example.com", + authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 120, apiResponse: `[{}]`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, diff --git a/providers/cloudru/cloudru.go b/providers/cloudru/cloudru.go new file mode 100644 index 0000000..76df6c2 --- /dev/null +++ b/providers/cloudru/cloudru.go @@ -0,0 +1,200 @@ +// Package cloudru implements a DNS provider for solving the DNS-01 challenge using cloud.ru DNS. +package cloudru + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/cloudru/internal" +) + +// Environment variables names. +const ( + envNamespace = "CLOUDRU_" + + EnvServiceInstanceID = envNamespace + "SERVICE_INSTANCE_ID" + EnvKeyID = envNamespace + "KEY_ID" + EnvSecret = envNamespace + "SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + ServiceInstanceID string + KeyID string + Secret string + + PropagationTimeout time.Duration + PollingInterval time.Duration + SequenceInterval time.Duration + HTTPClient *http.Client + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +type DNSProvider struct { + config *Config + client *internal.Client + records map[string]*internal.Record + recordsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for cloud.ru. +// Credentials must be passed in the environment variables: +// CLOUDRU_SERVICE_INSTANCE_ID, CLOUDRU_KEY_ID, and CLOUDRU_SECRET. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvServiceInstanceID, EnvKeyID, EnvSecret) + if err != nil { + return nil, fmt.Errorf("cloudru: %w", err) + } + + config := NewDefaultConfig() + config.ServiceInstanceID = values[EnvServiceInstanceID] + config.KeyID = values[EnvKeyID] + config.Secret = values[EnvSecret] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for cloud.ru. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("cloudru: the configuration of the DNS provider is nil") + } + + if config.ServiceInstanceID == "" || config.KeyID == "" || config.Secret == "" { + return nil, errors.New("cloudru: some credentials information are missing") + } + + client := internal.NewClient(config.KeyID, config.Secret) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + config: config, + client: client, + records: make(map[string]*internal.Record), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("cloudru: could not find zone for domain %q: %w", domain, err) + } + + authZone = dns01.UnFqdn(authZone) + + ctx, err := d.client.CreateAuthenticatedContext(context.Background()) + if err != nil { + return fmt.Errorf("cloudru: %w", err) + } + + zone, err := d.getZoneInformationByName(ctx, d.config.ServiceInstanceID, authZone) + if err != nil { + return fmt.Errorf("cloudru: could not find zone information (ServiceInstanceID: %s, zone: %s): %w", d.config.ServiceInstanceID, authZone, err) + } + + record := internal.Record{ + Name: info.EffectiveFQDN, + Type: "TXT", + Values: []string{info.Value}, + TTL: strconv.Itoa(d.config.TTL), + } + + newRecord, err := d.client.CreateRecord(ctx, zone.ID, record) + if err != nil { + return fmt.Errorf("cloudru: could not create record: %w", err) + } + + d.recordsMu.Lock() + d.records[token] = newRecord + d.recordsMu.Unlock() + + return nil +} + +// CleanUp removes a given record that was generated by Present. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordsMu.Lock() + record, ok := d.records[token] + d.recordsMu.Unlock() + + if !ok { + return fmt.Errorf("cloudru: unknown recordID for %q", info.EffectiveFQDN) + } + + ctx, err := d.client.CreateAuthenticatedContext(context.Background()) + if err != nil { + return fmt.Errorf("cloudru: %w", err) + } + + err = d.client.DeleteRecord(ctx, record.ZoneID, record.Name, "TXT") + if err != nil { + return fmt.Errorf("cloudru: %w", err) + } + + d.recordsMu.Lock() + delete(d.records, token) + d.recordsMu.Unlock() + + return nil +} + +// Sequential All DNS challenges for this provider will be resolved sequentially. +// Returns the interval between each iteration. +func (d *DNSProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} + +// 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) getZoneInformationByName(ctx context.Context, parentID, name string) (internal.Zone, error) { + zs, err := d.client.GetZones(ctx, parentID) + if err != nil { + return internal.Zone{}, err + } + + for _, element := range zs { + if element.Name == name { + return element, nil + } + } + + return internal.Zone{}, errors.New("could not find Zone record") +} diff --git a/providers/cloudru/cloudru.toml b/providers/cloudru/cloudru.toml new file mode 100644 index 0000000..19faf8d --- /dev/null +++ b/providers/cloudru/cloudru.toml @@ -0,0 +1,27 @@ +Name = "Cloud.ru" +Description = '''''' +URL = "https://cloud.ru" +Code = "cloudru" +Since = "v4.14.0" + +Example = ''' +CLOUDRU_SERVICE_INSTANCE_ID=ppp \ +CLOUDRU_KEY_ID=xxx \ +CLOUDRU_SECRET=yyy \ +lego --email you@example.com --dns cloudru --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + CLOUDRU_SERVICE_INSTANCE_ID = "Service Instance ID (parentId)" + CLOUDRU_KEY_ID = "Key ID (login)" + CLOUDRU_SECRET = "Key Secret" + [Configuration.Additional] + CLOUDRU_POLLING_INTERVAL = "Time between DNS propagation check" + CLOUDRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + CLOUDRU_TTL = "The TTL of the TXT record used for the DNS challenge" + CLOUDRU_HTTP_TIMEOUT = "API request timeout" + CLOUDRU_SEQUENCE_INTERVAL = "Time between sequential requests" + +[Links] + API = "https://cloud.ru/ru/docs/clouddns/ug/topics/api-ref.html" diff --git a/providers/cloudru/cloudru_test.go b/providers/cloudru/cloudru_test.go new file mode 100644 index 0000000..88addde --- /dev/null +++ b/providers/cloudru/cloudru_test.go @@ -0,0 +1,176 @@ +package cloudru + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvServiceInstanceID, + EnvKeyID, + EnvSecret). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvServiceInstanceID: "123", + EnvKeyID: "user", + EnvSecret: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "cloudru: some credentials information are missing: CLOUDRU_SERVICE_INSTANCE_ID,CLOUDRU_KEY_ID,CLOUDRU_SECRET", + }, + { + desc: "missing service instance ID", + envVars: map[string]string{ + EnvServiceInstanceID: "", + EnvKeyID: "user", + EnvSecret: "secret", + }, + expected: "cloudru: some credentials information are missing: CLOUDRU_SERVICE_INSTANCE_ID", + }, + { + desc: "missing key ID", + envVars: map[string]string{ + EnvServiceInstanceID: "123", + EnvKeyID: "", + EnvSecret: "secret", + }, + expected: "cloudru: some credentials information are missing: CLOUDRU_KEY_ID", + }, + { + desc: "missing secret", + envVars: map[string]string{ + EnvServiceInstanceID: "123", + EnvKeyID: "user", + EnvSecret: "", + }, + expected: "cloudru: some credentials information are missing: CLOUDRU_SECRET", + }, + } + + 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 + serviceInstanceID string + keyID string + secret string + expected string + }{ + { + desc: "success", + serviceInstanceID: "123", + keyID: "user", + secret: "secret", + }, + { + desc: "missing credentials", + expected: "cloudru: some credentials information are missing", + }, + { + desc: "missing service instance ID", + serviceInstanceID: "", + keyID: "user", + secret: "secret", + expected: "cloudru: some credentials information are missing", + }, + { + desc: "missing key ID", + serviceInstanceID: "123", + keyID: "", + secret: "secret", + expected: "cloudru: some credentials information are missing", + }, + { + desc: "missing secret", + serviceInstanceID: "123", + keyID: "user", + secret: "", + expected: "cloudru: some credentials information are missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ServiceInstanceID = test.serviceInstanceID + config.KeyID = test.keyID + config.Secret = test.secret + + 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(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) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/cloudru/internal/client.go b/providers/cloudru/internal/client.go new file mode 100644 index 0000000..02d172e --- /dev/null +++ b/providers/cloudru/internal/client.go @@ -0,0 +1,175 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" +) + +// Default API endpoints. +const ( + APIBaseURL = "https://console.cloud.ru/api/clouddns/v1" + AuthBaseURL = "https://auth.iam.cloud.ru/auth/system/openid/token" +) + +// Client the Cloud.ru API client. +type Client struct { + keyID string + secret string + + APIEndpoint *url.URL + AuthEndpoint *url.URL + HTTPClient *http.Client + + token *Token + muToken sync.Mutex +} + +// NewClient Creates a new Client. +func NewClient(login, secret string) *Client { + apiEndpoint, _ := url.Parse(APIBaseURL) + authEndpoint, _ := url.Parse(AuthBaseURL) + + return &Client{ + keyID: login, + secret: secret, + APIEndpoint: apiEndpoint, + AuthEndpoint: authEndpoint, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +func (c *Client) GetZones(ctx context.Context, parentID string) ([]Zone, error) { + endpoint := c.APIEndpoint.JoinPath("zones") + + query := endpoint.Query() + query.Set("parentId", parentID) + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var zones APIResponse[Zone] + err = c.do(req, &zones) + if err != nil { + return nil, err + } + + return zones.Items, nil +} + +func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]Record, error) { + endpoint := c.APIEndpoint.JoinPath("zones", zoneID, "records") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var records APIResponse[Record] + err = c.do(req, &records) + if err != nil { + return nil, err + } + + return records.Items, nil +} + +func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { + endpoint := c.APIEndpoint.JoinPath("zones", zoneID, "records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var result Record + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c *Client) DeleteRecord(ctx context.Context, zoneID, name, recordType string) error { + endpoint := c.APIEndpoint.JoinPath("zones", zoneID, "records", name, recordType) + + 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 { + tok := getToken(req.Context()) + if tok != nil { + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + } else { + return errors.New("not logged in") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if result == nil { + return nil + } + + 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 +} diff --git a/providers/cloudru/internal/client_test.go b/providers/cloudru/internal/client_test.go new file mode 100644 index 0000000..d96183d --- /dev/null +++ b/providers/cloudru/internal/client_test.go @@ -0,0 +1,159 @@ +package internal + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, handler) + + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.APIEndpoint, _ = url.Parse(server.URL) + client.token = &Token{ + AccessToken: "secret", + ExpiresIn: 60, + TokenType: "Bearer", + Deadline: time.Now().Add(1 * time.Minute), + } + + return client +} + +func writeFixtureHandler(method, filename string) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) + return + } + + file, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, _ = io.Copy(rw, file) + } +} + +func TestClient_GetZones(t *testing.T) { + client := setupTest(t, "/zones", writeFixtureHandler(http.MethodGet, "zones.json")) + + ctx := mockContext() + + zones, err := client.GetZones(ctx, "xxx") + require.NoError(t, err) + + expected := []Zone{ + { + ID: "59556fcd-95ff-451f-b49b-9732f21f944a", + ParentID: "2d7b6194-2b83-4f71-86fd-a1e727e347b2", + Name: "example.com", + Valid: true, + Delegated: true, + CreatedAt: time.Date(2023, 7, 23, 8, 12, 41, 0, time.UTC), + UpdatedAt: time.Date(2023, 7, 24, 5, 50, 28, 0, time.UTC), + }, + } + assert.Equal(t, expected, zones) +} + +func TestClient_GetRecords(t *testing.T) { + client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodGet, "records.json")) + + ctx := mockContext() + + records, err := client.GetRecords(ctx, "zzz") + require.NoError(t, err) + + expected := []Record{ + { + ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", + Name: "example.com.", + Type: "SOA", + Values: []string{ + "cdns-ns01.sbercloud.ru. mail.sbercloud.ru 1 120 3600 604800 3600", + }, + TTL: "3600", + Enables: true, + }, + { + ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", + Name: "example.com.", + Type: "NS", + Values: []string{ + "cdns-ns01.sbercloud.ru.", + "cdns-ns02.sbercloud.ru.", + }, + TTL: "3600", + Enables: true, + }, + { + ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", + Name: "www.example.com.", + Type: "A", + Values: []string{ + "8.8.8.8", + }, + TTL: "3600", + Enables: true, + }, + } + assert.Equal(t, expected, records) +} + +func TestClient_CreateRecord(t *testing.T) { + client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodPost, "record.json")) + + ctx := mockContext() + + recordReq := Record{ + Name: "www.example.com.", + Type: "TXT", + Values: []string{"text"}, + TTL: "3600", + } + + record, err := client.CreateRecord(ctx, "zzz", recordReq) + require.NoError(t, err) + + expected := &Record{ + ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", + Name: "www.example.com.", + Type: "TXT", + Values: []string{ + "txt", + }, + TTL: "3600", + Enables: true, + } + assert.Equal(t, expected, record) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, "/zones/zzz/records/example.com/TXT", writeFixtureHandler(http.MethodDelete, "record.json")) + + ctx := mockContext() + + err := client.DeleteRecord(ctx, "zzz", "example.com", "TXT") + require.NoError(t, err) +} diff --git a/providers/cloudru/internal/fixtures/auth-error.json b/providers/cloudru/internal/fixtures/auth-error.json new file mode 100644 index 0000000..31cf629 --- /dev/null +++ b/providers/cloudru/internal/fixtures/auth-error.json @@ -0,0 +1,4 @@ +{ + "error": "invalid_client", + "error_description": "client not found" +} diff --git a/providers/cloudru/internal/fixtures/auth.json b/providers/cloudru/internal/fixtures/auth.json new file mode 100644 index 0000000..e803665 --- /dev/null +++ b/providers/cloudru/internal/fixtures/auth.json @@ -0,0 +1,8 @@ +{ + "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImEyMjM3ZDhhLWQ0ZDQtNDA5Yi04ZTMxLWM3NGJhYTZhM2NjYiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaWFtIl0sImF1dGhfdGltZSI6MTY5MDA1Mzg1MiwiYXpwIjoiOWE0M2I0OTM1ZDRhMDc5NmRkYjE0Mjk0NjUxYjk2NzciLCJlbWFpbCI6ImxlZ29AOTlmN2I5NzItZmZlYS00OTkyLTgyN2EtY2M4MDYzOTg1MmNhLmlhbS5zYmVyY2xvdWQucnUiLCJleHAiOjE2OTAwNTc0NTIsImlhdCI6MTY5MDA1Mzg1MiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmlhbS5zYmVyY2xvdWQucnUvYXV0aC9zeXN0ZW0iLCJqdGkiOiJlYzk0ZWJhNC03NzU2LTRjNjQtYmNmMC0zMzYxODIwNWM5ODkiLCJuYmYiOjE2OTAwNTM4NTIsIm5vbmNlIjoiIiwicmVhbG1fYWNjZXNzIjpudWxsLCJyZXNvdXJjZV9hY2Nlc3MiOnt9LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJvbGVzIiwic3ViIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX2lkIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX3R5cGUiOiJzZXJ2aWNlX2FjY291bnQiLCJ0eXAiOiJCZWFyZXIifQ.hhPr-Xr_NbyRwrqGoqeepthWfpfmD47RjzHUwo2lVPkeMiL8AMWzDPRxs-8gns9eTSHZCoAH0RjyrBnTaOrztInM72h8_rIIFr0MMPIIqrUkp2id_alya9eoiSWg_69PzNZ2CKWJDylL8o4Vi9_cSBYp-6H1xNcOAvO4a9xkNCoGGiogjHWNFq64qnS_P6fYY-pl9leuprCeq1GAKPODevHwzmc4gkEZIj_15SUh_ofJRJICgyLmkELQ8a0wDGYmZcdNKiGQDpd7rHaGrOvO1k8IJHfgs5aCMyuHXybTg6AMlodpYs8MBdk6K_VFY-cxSRB8ocq_Q7Hgt9qaRADg2Q", + "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImEyMjM3ZDhhLWQ0ZDQtNDA5Yi04ZTMxLWM3NGJhYTZhM2NjYiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaWFtIl0sImF1dGhfdGltZSI6MTY5MDA1Mzg1MiwiYXpwIjoiOWE0M2I0OTM1ZDRhMDc5NmRkYjE0Mjk0NjUxYjk2NzciLCJlbWFpbCI6ImxlZ29AOTlmN2I5NzItZmZlYS00OTkyLTgyN2EtY2M4MDYzOTg1MmNhLmlhbS5zYmVyY2xvdWQucnUiLCJleHAiOjE2OTAwNTc0NTIsImlhdCI6MTY5MDA1Mzg1MiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmlhbS5zYmVyY2xvdWQucnUvYXV0aC9zeXN0ZW0iLCJqdGkiOiIxNDRmMDRlNS1jYjZkLTQ2NTktODJhMi0yMmE5MDQwNGZlZjAiLCJuYmYiOjE2OTAwNTM4NTIsIm5vbmNlIjoiIiwicmVhbG1fYWNjZXNzIjpudWxsLCJyZXNvdXJjZV9hY2Nlc3MiOnt9LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJvbGVzIiwic3ViIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX2lkIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX3R5cGUiOiJzZXJ2aWNlX2FjY291bnQiLCJ0eXAiOiJJRCJ9.oW9w9X2EBozdY7JTnL6WBPE114BM52ZOaWLkXamJvUOks_F4fRxw5lJIN-LkTwMZ9jE3PsBV2_SueCL5Ry2ISiEXaZeoQ_FPnSkz-CMFDP6Ph2erOvEWQInTIPA6h-ToIhYMZR8lc_kPOmar2mTT8b043FZ6zFDf28PJCCo8snCgA_tIO7R0fNJYT7Hr-UR7LSrE-Sjz7lsgttyDEPH1P4yPm4ZzRLYLcR240p1iGKG9yxtl8IL6uxseS4pUddimaH6jFPhMFLH44PV4O_-uYs74erjoPiroCHiaWQIdDR5GZDoPCbYXQa0knh9hnK1pX6fO-krHeT3RtfuFf5609A", + "expires_in": 3600, + "not-before-policy": 0, + "scope": "openid profile email roles", + "token_type": "Bearer" +} diff --git a/providers/cloudru/internal/fixtures/record.json b/providers/cloudru/internal/fixtures/record.json new file mode 100644 index 0000000..05dd340 --- /dev/null +++ b/providers/cloudru/internal/fixtures/record.json @@ -0,0 +1,11 @@ +{ + "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", + "name": "www.example.com.", + "type": "TXT", + "values": [ + "txt" + ], + "ttl": "3600", + "enables": true, + "readonly": false +} diff --git a/providers/cloudru/internal/fixtures/records.json b/providers/cloudru/internal/fixtures/records.json new file mode 100644 index 0000000..93cdd06 --- /dev/null +++ b/providers/cloudru/internal/fixtures/records.json @@ -0,0 +1,38 @@ +{ + "items": [ + { + "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", + "name": "example.com.", + "type": "SOA", + "values": [ + "cdns-ns01.sbercloud.ru. mail.sbercloud.ru 1 120 3600 604800 3600" + ], + "ttl": "3600", + "enables": true, + "readonly": true + }, + { + "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", + "name": "example.com.", + "type": "NS", + "values": [ + "cdns-ns01.sbercloud.ru.", + "cdns-ns02.sbercloud.ru." + ], + "ttl": "3600", + "enables": true, + "readonly": true + }, + { + "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", + "name": "www.example.com.", + "type": "A", + "values": [ + "8.8.8.8" + ], + "ttl": "3600", + "enables": true, + "readonly": false + } + ] +} diff --git a/providers/cloudru/internal/fixtures/zones.json b/providers/cloudru/internal/fixtures/zones.json new file mode 100644 index 0000000..762db9f --- /dev/null +++ b/providers/cloudru/internal/fixtures/zones.json @@ -0,0 +1,14 @@ +{ + "items": [ + { + "id": "59556fcd-95ff-451f-b49b-9732f21f944a", + "parent_id": "2d7b6194-2b83-4f71-86fd-a1e727e347b2", + "name": "example.com", + "valid": true, + "validation_text": "sbc-verification: 5c86c962-7ee2-4983-b39b-1d9461959d8b", + "delegated": true, + "created_at": "2023-07-23T08:12:41.000000Z", + "updated_at": "2023-07-24T05:50:28.000000Z" + } + ] +} diff --git a/providers/cloudru/internal/identity.go b/providers/cloudru/internal/identity.go new file mode 100644 index 0000000..49f3b44 --- /dev/null +++ b/providers/cloudru/internal/identity.go @@ -0,0 +1,106 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" +) + +type token string + +const tokenKey token = "token" + +// obtainToken Logs into cloud.ru and acquires a bearer token for use in future API calls. +// https://cloud.ru/ru/docs/clouddns/ug/topics/api-ref_authentication.html +func (c *Client) obtainToken(ctx context.Context) (*Token, error) { + data := make(url.Values) + data.Set("grant_type", "access_key") + data.Set("client_id", c.keyID) + data.Set("client_secret", c.secret) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.AuthEndpoint.String(), strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + 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() }() + + if resp.StatusCode != http.StatusOK { + return nil, parseError(req, resp) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + tok := Token{} + err = json.Unmarshal(raw, &tok) + if err != nil { + return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + if !strings.EqualFold(tok.TokenType, "Bearer") { + return nil, fmt.Errorf("received unexpected token type: %s", tok.TokenType) + } + + tok.Deadline = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second) + + return &tok, nil +} + +func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { + c.muToken.Lock() + defer c.muToken.Unlock() + + if c.token != nil && time.Now().Before(c.token.Deadline) { + // Already authenticated, stop now + return context.WithValue(ctx, tokenKey, c.token), nil + } + + tok, err := c.obtainToken(ctx) + if err != nil { + return nil, err + } + + return context.WithValue(ctx, tokenKey, tok), nil +} + +func parseError(req *http.Request, resp *http.Response) error { + if resp.StatusCode < 400 || resp.StatusCode > 499 { + return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + raw, _ := io.ReadAll(resp.Body) + + errResp := &authResponseError{} + err := json.Unmarshal(raw, errResp) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("%d: %w", resp.StatusCode, errResp) +} + +func getToken(ctx context.Context) *Token { + tok, ok := ctx.Value(tokenKey).(*Token) + if !ok { + return nil + } + + return tok +} diff --git a/providers/cloudru/internal/identity_test.go b/providers/cloudru/internal/identity_test.go new file mode 100644 index 0000000..7329e7f --- /dev/null +++ b/providers/cloudru/internal/identity_test.go @@ -0,0 +1,92 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockContext() context.Context { + return context.WithValue(context.Background(), tokenKey, &Token{AccessToken: "xxx"}) +} + +func tokenHandler(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, http.MethodPost), http.StatusMethodNotAllowed) + return + } + + err := req.ParseForm() + if err != nil { + http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + grantType := req.Form.Get("grant_type") + clientID := req.Form.Get("client_id") + clientSecret := req.Form.Get("client_secret") + + if clientID != "user" || clientSecret != "secret" || grantType != "access_key" { + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + _ = json.NewEncoder(rw).Encode(Token{ + AccessToken: "xxx", + TokenID: "yyy", + ExpiresIn: 666, + TokenType: "Bearer", + Scope: "openid profile email roles", + }) +} + +func TestClient_obtainToken(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/", tokenHandler) + + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.AuthEndpoint, _ = url.Parse(server.URL) + + assert.Nil(t, client.token) + + tok, err := client.obtainToken(context.Background()) + require.NoError(t, err) + + assert.NotNil(t, tok) + assert.NotZero(t, tok.Deadline) + assert.Equal(t, "xxx", tok.AccessToken) +} + +func TestClient_CreateAuthenticatedContext(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/", tokenHandler) + + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.AuthEndpoint, _ = url.Parse(server.URL) + + assert.Nil(t, client.token) + + ctx, err := client.CreateAuthenticatedContext(context.Background()) + require.NoError(t, err) + + tok := getToken(ctx) + + assert.NotNil(t, tok) + assert.NotZero(t, tok.Deadline) + assert.Equal(t, "xxx", tok.AccessToken) +} diff --git a/providers/cloudru/internal/types.go b/providers/cloudru/internal/types.go new file mode 100644 index 0000000..d233c73 --- /dev/null +++ b/providers/cloudru/internal/types.go @@ -0,0 +1,53 @@ +package internal + +import ( + "fmt" + "time" +) + +type Token struct { + // The bearer token for use in API requests + AccessToken string `json:"access_token"` + TokenID string `json:"id_token"` + TokenType string `json:"token_type"` + // Number in seconds before the expiration + ExpiresIn int `json:"expires_in"` + NotBeforePolicy int `json:"not-before-policy"` + Scope string `json:"scope"` + + Deadline time.Time `json:"-"` +} + +type authResponseError struct { + ErrorMsg string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +func (a authResponseError) Error() string { + return fmt.Sprintf("%s: %s", a.ErrorMsg, a.ErrorDescription) +} + +type APIResponse[T any] struct { + Items []T `json:"items"` +} + +type Zone struct { + ID string `json:"id,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Name string `json:"name,omitempty"` + Valid bool `json:"valid,omitempty"` + ValidationText string `json:"validationText,omitempty"` + Delegated bool `json:"delegated,omitempty"` + LastCheck time.Time `json:"lastCheck,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +type Record struct { + ZoneID string `json:"zone_id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Values []string `json:"values,omitempty"` + TTL string `json:"ttl,omitempty"` + Enables bool `json:"enables,omitempty"` +} diff --git a/providers/cloudxns/internal/client.go b/providers/cloudxns/internal/client.go index 61ae0ef..7497c0a 100644 --- a/providers/cloudxns/internal/client.go +++ b/providers/cloudxns/internal/client.go @@ -60,7 +60,7 @@ func (c *Client) GetDomainInformation(ctx context.Context, fqdn string) (*Data, authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return nil, fmt.Errorf("cloudflare: could not find zone for FQDN %q: %w", fqdn, err) + return nil, fmt.Errorf("could not find zone: %w", err) } var domains []Data diff --git a/providers/cloudxns/internal/client_test.go b/providers/cloudxns/internal/client_test.go index e497217..ac4e36d 100644 --- a/providers/cloudxns/internal/client_test.go +++ b/providers/cloudxns/internal/client_test.go @@ -81,23 +81,23 @@ func TestClient_GetDomainInformation(t *testing.T) { }{ { desc: "domain found", - fqdn: "_acme-challenge.foo.com.", + fqdn: "_acme-challenge.example.org.", response: &apiResponse{ Code: 1, }, data: []Data{ { ID: "1", - Domain: "bar.com.", + Domain: "example.com.", }, { ID: "2", - Domain: "foo.com.", + Domain: "example.org.", }, }, expected: result{domain: &Data{ ID: "2", - Domain: "foo.com.", + Domain: "example.org.", }}, }, { @@ -109,11 +109,11 @@ func TestClient_GetDomainInformation(t *testing.T) { data: []Data{ { ID: "5", - Domain: "bar.com.", + Domain: "example.com.", }, { ID: "6", - Domain: "foo.com.", + Domain: "example.org.", }, }, expected: result{error: true}, @@ -152,13 +152,13 @@ func TestClient_FindTxtRecord(t *testing.T) { }{ { desc: "record found", - fqdn: "_acme-challenge.foo.com.", + fqdn: "_acme-challenge.example.org.", zoneID: "test-zone", txtRecords: []TXTRecord{ { ID: 1, RecordID: "Record-A", - Host: "_acme-challenge.foo.com", + Host: "_acme-challenge.example.org", Value: "txtTXTtxtTXTtxtTXTtxtTXT", Type: "TXT", LineID: 6, @@ -167,7 +167,7 @@ func TestClient_FindTxtRecord(t *testing.T) { { ID: 2, RecordID: "Record-B", - Host: "_acme-challenge.bar.com", + Host: "_acme-challenge.example.com", Value: "TXTtxtTXTtxtTXTtxtTXTtxt", Type: "TXT", LineID: 6, @@ -181,7 +181,7 @@ func TestClient_FindTxtRecord(t *testing.T) { txtRecord: &TXTRecord{ ID: 1, RecordID: "Record-A", - Host: "_acme-challenge.foo.com", + Host: "_acme-challenge.example.org", Value: "txtTXTtxtTXTtxtTXTtxtTXT", Type: "TXT", LineID: 6, @@ -197,7 +197,7 @@ func TestClient_FindTxtRecord(t *testing.T) { { ID: 1, RecordID: "Record-A", - Host: "_acme-challenge.foo.com", + Host: "_acme-challenge.example.org", Value: "txtTXTtxtTXTtxtTXTtxtTXT", Type: "TXT", LineID: 6, @@ -206,7 +206,7 @@ func TestClient_FindTxtRecord(t *testing.T) { { ID: 2, RecordID: "Record-B", - Host: "_acme-challenge.bar.com", + Host: "_acme-challenge.example.com", Value: "TXTtxtTXTtxtTXTtxtTXTtxt", Type: "TXT", LineID: 6, @@ -249,9 +249,9 @@ func TestClient_AddTxtRecord(t *testing.T) { desc: "sub-domain", domain: &Data{ ID: "1", - Domain: "bar.com.", + Domain: "example.com.", }, - fqdn: "_acme-challenge.foo.bar.com.", + fqdn: "_acme-challenge.foo.example.com.", value: "txtTXTtxtTXTtxtTXTtxtTXT", ttl: 30, expected: `{"domain_id":1,"host":"_acme-challenge.foo","value":"txtTXTtxtTXTtxtTXTtxtTXT","type":"TXT","line_id":"1","ttl":"30"}`, @@ -260,9 +260,9 @@ func TestClient_AddTxtRecord(t *testing.T) { desc: "main domain", domain: &Data{ ID: "2", - Domain: "bar.com.", + Domain: "example.com.", }, - fqdn: "_acme-challenge.bar.com.", + fqdn: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 30, expected: `{"domain_id":2,"host":"_acme-challenge","value":"TXTtxtTXTtxtTXTtxtTXTtxt","type":"TXT","line_id":"1","ttl":"30"}`, diff --git a/providers/conoha/conoha.go b/providers/conoha/conoha.go index 5725f8c..461a34b 100644 --- a/providers/conoha/conoha.go +++ b/providers/conoha/conoha.go @@ -105,7 +105,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { tokens, err := identifier.GetToken(context.TODO(), auth) if err != nil { - return nil, fmt.Errorf("conoha: failed to login: %w", err) + return nil, fmt.Errorf("conoha: failed to log in: %w", err) } client, err := internal.NewClient(config.Region, tokens.Access.Token.ID) @@ -126,7 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("conoha: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("conoha: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -157,7 +157,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("conoha: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("conoha: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() diff --git a/providers/conoha/conoha_test.go b/providers/conoha/conoha_test.go index 8d8197f..9db5ba7 100644 --- a/providers/conoha/conoha_test.go +++ b/providers/conoha/conoha_test.go @@ -29,7 +29,7 @@ func TestNewDNSProvider(t *testing.T) { EnvAPIUsername: "api_username", EnvAPIPassword: "api_password", }, - expected: `conoha: failed to login: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, + expected: `conoha: failed to log in: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, }, { desc: "missing credentials", @@ -99,7 +99,7 @@ func TestNewDNSProviderConfig(t *testing.T) { }{ { desc: "complete credentials, but login failed", - expected: `conoha: failed to login: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, + expected: `conoha: failed to log in: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, tenant: "tenant_id", username: "api_username", password: "api_password", diff --git a/providers/constellix/constellix.go b/providers/constellix/constellix.go index ae717e2..0b86ec8 100644 --- a/providers/constellix/constellix.go +++ b/providers/constellix/constellix.go @@ -6,11 +6,14 @@ import ( "errors" "fmt" "net/http" + "slices" + "strconv" "time" "github.com/entrustcorporation/dv/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/entrustcorporation/dv/providers/constellix/internal" + "github.com/hashicorp/go-retryablehttp" ) // Environment variables names. @@ -85,7 +88,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("constellix: %w", err) } - client := internal.NewClient(tr.Wrap(config.HTTPClient)) + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 5 + retryClient.HTTPClient = tr.Wrap(config.HTTPClient) + retryClient.Backoff = backoff + + client := internal.NewClient(retryClient.StandardClient()) return &DNSProvider{config: config, client: client}, nil } @@ -102,7 +110,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("constellix: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("constellix: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -145,7 +153,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("constellix: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("constellix: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -265,11 +273,24 @@ func containsValue(record *internal.Record, value string) bool { return false } - for _, val := range record.Value { - if val.Value == fmt.Sprintf(`%q`, value) { - return true + qValue := fmt.Sprintf(`%q`, value) + + return slices.ContainsFunc(record.Value, func(val internal.RecordValue) bool { + return val.Value == qValue + }) +} + +func backoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration { + if resp != nil { + // https://api.dns.constellix.com/v4/docs#section/Using-the-API/Rate-Limiting + if resp.StatusCode == http.StatusTooManyRequests { + if s, ok := resp.Header["X-Ratelimit-Reset"]; ok { + if sleep, err := strconv.ParseInt(s[0], 10, 64); err == nil { + return time.Second * time.Duration(sleep) + } + } } } - return false + return retryablehttp.DefaultBackoff(min, max, attemptNum, resp) } diff --git a/providers/constellix/internal/auth.go b/providers/constellix/internal/auth.go index 8be4f84..1a13601 100644 --- a/providers/constellix/internal/auth.go +++ b/providers/constellix/internal/auth.go @@ -23,7 +23,7 @@ type TokenTransport struct { Transport http.RoundTripper } -// NewTokenTransport Creates a HTTP transport for API authentication. +// NewTokenTransport Creates an HTTP transport for API authentication. func NewTokenTransport(apiKey, secretKey string) (*TokenTransport, error) { if apiKey == "" { return nil, errors.New("credentials missing: API key") diff --git a/providers/cpanel/cpanel.go b/providers/cpanel/cpanel.go new file mode 100644 index 0000000..ca3f594 --- /dev/null +++ b/providers/cpanel/cpanel.go @@ -0,0 +1,331 @@ +// Package cpanel implements a DNS provider for solving the DNS-01 challenge using CPanel. +package cpanel + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/cpanel/internal/cpanel" + "github.com/entrustcorporation/dv/providers/cpanel/internal/shared" + "github.com/entrustcorporation/dv/providers/cpanel/internal/whm" +) + +// Environment variables names. +const ( + envNamespace = "CPANEL_" + + EnvMode = envNamespace + "MODE" + EnvUsername = envNamespace + "USERNAME" + EnvToken = envNamespace + "TOKEN" + EnvBaseURL = envNamespace + "BASE_URL" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +type apiClient interface { + FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) + AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) + EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) + DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) +} + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Mode string + Username string + Token string + BaseURL string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + Mode: env.GetOrDefaultString(EnvMode, "cpanel"), + TTL: env.GetOrDefaultInt(EnvTTL, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*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 apiClient +} + +// NewDNSProvider returns a DNSProvider instance configured for CPanel. +// Credentials must be passed in the environment variables: +// CPANEL_USERNAME, CPANEL_TOKEN, CPANEL_BASE_URL, CPANEL_NAMESERVER. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvToken, EnvBaseURL) + if err != nil { + return nil, fmt.Errorf("cpanel: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Token = values[EnvToken] + config.BaseURL = values[EnvBaseURL] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for CPanel. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("cpanel: the configuration of the DNS provider is nil") + } + + if config.Username == "" || config.Token == "" { + return nil, errors.New("cpanel: some credentials information are missing") + } + + if config.BaseURL == "" { + return nil, errors.New("cpanel: server information are missing") + } + + client, err := createClient(config) + if err != nil { + return nil, fmt.Errorf("cpanel: create client error: %w", err) + } + + return &DNSProvider{ + config: config, + client: client, + }, 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 +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) + } + + zone := dns01.UnFqdn(authZone) + + zoneInfo, err := d.client.FetchZoneInformation(ctx, zone) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err) + } + + serial, err := getZoneSerial(authZone, zoneInfo) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err) + } + + valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) + + var found bool + var existingRecord shared.ZoneRecord + for _, record := range zoneInfo { + if slices.Contains(record.DataB64, valueB64) { + existingRecord = record + found = true + break + } + } + + record := shared.Record{ + DName: info.EffectiveFQDN, + TTL: d.config.TTL, + RecordType: "TXT", + } + + // New record. + if !found { + record.Data = []string{info.Value} + + _, err = d.client.AddRecord(ctx, serial, zone, record) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: add record: %w", d.config.Mode, err) + } + + return nil + } + + // Update existing record. + record.LineIndex = existingRecord.LineIndex + + for _, dataB64 := range existingRecord.DataB64 { + data, errD := base64.StdEncoding.DecodeString(dataB64) + if errD != nil { + return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD) + } + + record.Data = append(record.Data, string(data)) + } + + record.Data = append(record.Data, info.Value) + + _, err = d.client.EditRecord(ctx, serial, zone, record) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) + } + + zone := dns01.UnFqdn(authZone) + + zoneInfo, err := d.client.FetchZoneInformation(ctx, zone) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err) + } + + serial, err := getZoneSerial(authZone, zoneInfo) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err) + } + + valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) + + var found bool + var existingRecord shared.ZoneRecord + for _, record := range zoneInfo { + if slices.Contains(record.DataB64, valueB64) { + existingRecord = record + found = true + break + } + } + + if !found { + return nil + } + + var newData []string + for _, dataB64 := range existingRecord.DataB64 { + if dataB64 == valueB64 { + continue + } + + data, errD := base64.StdEncoding.DecodeString(dataB64) + if errD != nil { + return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD) + } + + newData = append(newData, string(data)) + } + + // Delete record. + if len(newData) == 0 { + _, err = d.client.DeleteRecord(ctx, serial, zone, existingRecord.LineIndex) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: delete record: %w", d.config.Mode, err) + } + + return nil + } + + // Remove one value. + record := shared.Record{ + DName: info.EffectiveFQDN, + TTL: d.config.TTL, + RecordType: "TXT", + Data: newData, + LineIndex: existingRecord.LineIndex, + } + + _, err = d.client.EditRecord(ctx, serial, zone, record) + if err != nil { + return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err) + } + + return nil +} + +func getZoneSerial(zoneFqdn string, zoneInfo []shared.ZoneRecord) (uint32, error) { + nameB64 := base64.StdEncoding.EncodeToString([]byte(zoneFqdn)) + + for _, record := range zoneInfo { + if record.Type != "record" || record.RecordType != "SOA" || record.DNameB64 != nameB64 { + continue + } + + // https://github.com/go-acme/lego/issues/1060#issuecomment-1925572386 + // https://github.com/go-acme/lego/issues/1060#issuecomment-1925581832 + data, err := base64.StdEncoding.DecodeString(record.DataB64[2]) + if err != nil { + return 0, fmt.Errorf("decode serial DNameB64: %w", err) + } + + var newSerial uint32 + _, err = fmt.Sscan(string(data), &newSerial) + if err != nil { + return 0, fmt.Errorf("decode serial DNameB64, invalid serial value %q: %w", string(data), err) + } + + return newSerial, nil + } + + return 0, errors.New("zone serial not found") +} + +func createClient(config *Config) (apiClient, error) { + switch strings.ToLower(config.Mode) { + case "cpanel": + client, err := cpanel.NewClient(config.BaseURL, config.Username, config.Token) + if err != nil { + return nil, fmt.Errorf("failed to create cPanel API client: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return client, nil + + case "whm": + client, err := whm.NewClient(config.BaseURL, config.Username, config.Token) + if err != nil { + return nil, fmt.Errorf("failed to create WHM API client: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return client, nil + + default: + return nil, fmt.Errorf("unsupported mode: %q", config.Mode) + } +} diff --git a/providers/cpanel/cpanel.toml b/providers/cpanel/cpanel.toml new file mode 100644 index 0000000..eac811e --- /dev/null +++ b/providers/cpanel/cpanel.toml @@ -0,0 +1,39 @@ +Name = "CPanel/WHM" +Description = '''''' +URL = "https://cpanel.net/" +Code = "cpanel" +Since = "v4.16.0" + +Example = ''' +### CPANEL (default) + +CPANEL_USERNAME = "yyyy" +CPANEL_TOKEN = "xxxx" +CPANEL_BASE_URL = "https://example.com:2083" \ +lego --email you@example.com --dns cpanel --domains my.example.org run + +## WHM + +CPANEL_MODE = whm +CPANEL_USERNAME = "yyyy" +CPANEL_TOKEN = "xxxx" +CPANEL_BASE_URL = "https://example.com:2087" \ +lego --email you@example.com --dns cpanel --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + CPANEL_USERNAME = "username" + CPANEL_TOKEN = "API token" + CPANEL_BASE_URL = "API server URL" + [Configuration.Additional] + CPANEL_MODE = "use cpanel API or WHM API (Default: cpanel)" + CPANEL_POLLING_INTERVAL = "Time between DNS propagation check" + CPANEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + CPANEL_TTL = "The TTL of the TXT record used for the DNS challenge" + CPANEL_HTTP_TIMEOUT = "API request timeout" + CPANEL_REGION = "The region" + +[Links] + API_CPANEL = "https://api.docs.cpanel.net/cpanel/introduction/" + API_WHM = "https://api.docs.cpanel.net/whm/introduction/" diff --git a/providers/cpanel/cpanel_test.go b/providers/cpanel/cpanel_test.go new file mode 100644 index 0000000..282e52b --- /dev/null +++ b/providers/cpanel/cpanel_test.go @@ -0,0 +1,305 @@ +package cpanel + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/entrustcorporation/dv/providers/cpanel/internal/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvMode, + EnvUsername, + EnvToken, + EnvBaseURL). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + expectedMode string + }{ + { + desc: "success cpanel mode (default)", + envVars: map[string]string{ + EnvUsername: "user", + EnvToken: "secret", + EnvBaseURL: "https://example.com", + }, + expectedMode: "cpanel", + }, + { + desc: "success whm mode", + envVars: map[string]string{ + EnvMode: "whm", + EnvUsername: "user", + EnvToken: "secret", + EnvBaseURL: "https://example.com", + }, + expectedMode: "whm", + }, + { + desc: "missing user", + envVars: map[string]string{ + EnvToken: "secret", + EnvBaseURL: "https://example.com", + }, + expected: "cpanel: some credentials information are missing: CPANEL_USERNAME", + }, + { + desc: "missing token", + envVars: map[string]string{ + EnvUsername: "user", + EnvBaseURL: "https://example.com", + }, + expected: "cpanel: some credentials information are missing: CPANEL_TOKEN", + }, + { + desc: "missing base URL", + envVars: map[string]string{ + EnvUsername: "user", + EnvToken: "secret", + EnvBaseURL: "", + }, + expected: "cpanel: some credentials information are missing: CPANEL_BASE_URL", + }, + } + + 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 == "" { + assert.Equal(t, test.expectedMode, p.config.Mode) + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + mode string + username string + token string + baseURL string + expected string + }{ + { + desc: "success", + mode: "whm", + username: "user", + token: "secret", + baseURL: "https://example.com", + }, + { + desc: "missing mode", + username: "user", + token: "secret", + baseURL: "https://example.com", + expected: `cpanel: create client error: unsupported mode: ""`, + }, + { + desc: "invalid mode", + mode: "test", + username: "user", + token: "secret", + baseURL: "https://example.com", + expected: `cpanel: create client error: unsupported mode: "test"`, + }, + { + desc: "missing username", + mode: "whm", + username: "", + token: "secret", + baseURL: "https://example.com", + expected: "cpanel: some credentials information are missing", + }, + { + desc: "missing token", + mode: "whm", + username: "user", + token: "", + baseURL: "https://example.com", + expected: "cpanel: some credentials information are missing", + }, + { + desc: "missing base URL", + mode: "whm", + username: "user", + token: "secret", + baseURL: "", + expected: "cpanel: server information are missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Mode = test.mode + config.Username = test.username + config.Token = test.token + config.BaseURL = test.baseURL + + 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 Test_getZoneSerial(t *testing.T) { + zones := []shared.ZoneRecord{ + { + Type: "comment", + LineIndex: 1, + TextB64: "OyBab25lIGZpbGUgZm9yIGV4YW1wbGUuY29t", + }, + { + Type: "control", + LineIndex: 2, + TextB64: "JFRUTCAxNDQwMA==", + }, + { + DNameB64: "ZXhhbXBsZS5jb20u", + LineIndex: 4, + RecordType: "NS", + Type: "record", + TTL: 86400, + DataB64: []string{"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4="}, + }, + { + DataB64: []string{ + "YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4=", + "ZW1haWwuaXB4Y29yZS5jb20u", + "MjAyNDAyMDQwOQ==", + "MzYwMA==", + "MTgwMA==", + "MTIwOTYwMA==", + "ODY0MDA=", + }, + RecordType: "SOA", + Type: "record", + TTL: 86400, + LineIndex: 3, + DNameB64: "ZXhhbXBsZS5jb20u", + }, + { + RecordType: "A", + Type: "record", + TTL: 3600, + DataB64: []string{"MTAuMTAuMTAuMTA="}, + LineIndex: 9, + DNameB64: "ZXhhbXBsZS5jb20u", + }, + } + + serial, err := getZoneSerial("example.com.", zones) + require.NoError(t, err) + + assert.EqualValues(t, 2024020409, serial) +} + +func Test_getZoneSerial_error(t *testing.T) { + zones := []shared.ZoneRecord{ + { + Type: "comment", + LineIndex: 1, + TextB64: "OyBab25lIGZpbGUgZm9yIGV4YW1wbGUuY29t", + }, + { + Type: "control", + LineIndex: 2, + TextB64: "JFRUTCAxNDQwMA==", + }, + { + DNameB64: "ZXhhbXBsZS5jb20u", + LineIndex: 4, + RecordType: "NS", + Type: "record", + TTL: 86400, + DataB64: []string{"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4="}, + }, + { + DataB64: []string{ + "YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4=", + "ZW1haWwuaXB4Y29yZS5jb20u", + "MjAyNDAyMDQwOQ==", + "MzYwMA==", + "MTgwMA==", + "MTIwOTYwMA==", + "ODY0MDA=", + }, + RecordType: "SOA", + Type: "record", + TTL: 86400, + LineIndex: 3, + DNameB64: "ZXhhbXBsZS5vcmcu", + }, + { + RecordType: "A", + Type: "record", + TTL: 3600, + DataB64: []string{"MTAuMTAuMTAuMTA="}, + LineIndex: 9, + DNameB64: "ZXhhbXBsZS5jb20u", + }, + } + + serial, err := getZoneSerial("example.com.", zones) + require.Error(t, err) + + assert.EqualValues(t, 0, serial) +} + +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(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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/cpanel/internal/cpanel/client.go b/providers/cpanel/internal/cpanel/client.go new file mode 100644 index 0000000..874e73b --- /dev/null +++ b/providers/cpanel/internal/cpanel/client.go @@ -0,0 +1,155 @@ +package cpanel + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/entrustcorporation/dv/providers/cpanel/internal/shared" + "github.com/entrustcorporation/dv/providers/internal/errutils" +) + +const statusFailed = 0 + +type Client struct { + username string + token string + + baseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(baseURL string, username string, token string) (*Client, error) { + apiEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &Client{ + username: username, + token: token, + baseURL: apiEndpoint.JoinPath("execute"), + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// FetchZoneInformation fetches zone information. +// https://api.docs.cpanel.net/openapi/cpanel/operation/dns-parse_zone/ +func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { + endpoint := c.baseURL.JoinPath("DNS", "parse_zone") + + query := endpoint.Query() + query.Set("zone", domain) + endpoint.RawQuery = query.Encode() + + var result APIResponse[[]shared.ZoneRecord] + + err := c.doRequest(ctx, endpoint, &result) + if err != nil { + return nil, err + } + + if result.Status == statusFailed { + return nil, toError(result) + } + + return result.Data, nil +} + +// AddRecord adds a new record. +// +// add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' +func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { + data, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON data: %w", err) + } + + return c.updateZone(ctx, serial, domain, "add", string(data)) +} + +// EditRecord edits an existing record. +// +// edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' +func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { + data, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON data: %w", err) + } + + return c.updateZone(ctx, serial, domain, "edit", string(data)) +} + +// DeleteRecord deletes an existing record. +// +// remove=22 +func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { + return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex)) +} + +// https://api.docs.cpanel.net/openapi/cpanel/operation/dns-mass_edit_zone/ +func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { + endpoint := c.baseURL.JoinPath("DNS", "mass_edit_zone") + + query := endpoint.Query() + query.Set("serial", strconv.FormatUint(uint64(serial), 10)) + query.Set(action, data) + query.Set("zone", domain) + endpoint.RawQuery = query.Encode() + + var result APIResponse[shared.ZoneSerial] + + err := c.doRequest(ctx, endpoint, &result) + if err != nil { + return nil, err + } + + if result.Status == statusFailed { + return nil, toError(result) + } + + return &result.Data, nil +} + +func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + // https://api.docs.cpanel.net/cpanel/tokens/#using-an-api-token + req.Header.Set("Authorization", fmt.Sprintf("cpanel %s:%s", c.username, c.token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return errutils.NewUnexpectedResponseStatusCodeError(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 +} diff --git a/providers/cpanel/internal/cpanel/client_test.go b/providers/cpanel/internal/cpanel/client_test.go new file mode 100644 index 0000000..f36bca3 --- /dev/null +++ b/providers/cpanel/internal/cpanel/client_test.go @@ -0,0 +1,170 @@ +package cpanel + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/entrustcorporation/dv/providers/cpanel/internal/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, pattern string, filename string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) + return + } + + open, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = open.Close() }() + + rw.WriteHeader(http.StatusOK) + _, err = io.Copy(rw, open) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client, err := NewClient(server.URL, "user", "secret") + require.NoError(t, err) + + client.HTTPClient = server.Client() + + return client +} + +func TestClient_FetchZoneInformation(t *testing.T) { + client := setupTest(t, "/execute/DNS/parse_zone", "zone-info.json") + + zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") + require.NoError(t, err) + + expected := []shared.ZoneRecord{{ + LineIndex: 22, + Type: "record", + DataB64: []string{"dGV4YXMuY29tLg=="}, + DNameB64: "dGV4YXMuY29tLg==", + RecordType: "MX", + TTL: 14400, + }} + + assert.Equal(t, expected, zoneInfo) +} + +func TestClient_FetchZoneInformation_error(t *testing.T) { + client := setupTest(t, "/execute/DNS/parse_zone", "zone-info_error.json") + + zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") + require.Error(t, err) + + assert.Nil(t, zoneInfo) +} + +func TestClient_AddRecord(t *testing.T) { + client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + + record := shared.Record{ + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + require.NoError(t, err) + + expected := &shared.ZoneSerial{NewSerial: "2021031903"} + + assert.Equal(t, expected, zoneSerial) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + + record := shared.Record{ + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + require.Error(t, err) + + assert.Nil(t, zoneSerial) +} + +func TestClient_EditRecord(t *testing.T) { + client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + + record := shared.Record{ + LineIndex: 9, + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + require.NoError(t, err) + + expected := &shared.ZoneSerial{NewSerial: "2021031903"} + + assert.Equal(t, expected, zoneSerial) +} + +func TestClient_EditRecord_error(t *testing.T) { + client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + + record := shared.Record{ + LineIndex: 9, + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + require.Error(t, err) + + assert.Nil(t, zoneSerial) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + + zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + require.NoError(t, err) + + expected := &shared.ZoneSerial{NewSerial: "2021031903"} + + assert.Equal(t, expected, zoneSerial) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + + zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + require.Error(t, err) + + assert.Nil(t, zoneSerial) +} diff --git a/providers/cpanel/internal/cpanel/fixtures/update-zone.json b/providers/cpanel/internal/cpanel/fixtures/update-zone.json new file mode 100644 index 0000000..842d94a --- /dev/null +++ b/providers/cpanel/internal/cpanel/fixtures/update-zone.json @@ -0,0 +1,12 @@ +{ + "metadata": { + "transformed": 1 + }, + "messages": null, + "status": 1, + "warnings": null, + "errors": null, + "data": { + "new_serial": "2021031903" + } +} diff --git a/providers/cpanel/internal/cpanel/fixtures/update-zone_error.json b/providers/cpanel/internal/cpanel/fixtures/update-zone_error.json new file mode 100644 index 0000000..ed316f1 --- /dev/null +++ b/providers/cpanel/internal/cpanel/fixtures/update-zone_error.json @@ -0,0 +1,14 @@ +{ + "warnings": null, + "messages": [ + "a", + "b", + "c" + ], + "data": null, + "errors": [ + "You do not control a DNS zone named example.com." + ], + "metadata": {}, + "status": 0 +} diff --git a/providers/cpanel/internal/cpanel/fixtures/zone-info.json b/providers/cpanel/internal/cpanel/fixtures/zone-info.json new file mode 100644 index 0000000..48833ba --- /dev/null +++ b/providers/cpanel/internal/cpanel/fixtures/zone-info.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "transformed": 1 + }, + "messages": null, + "status": 1, + "warnings": null, + "errors": null, + "data": [ + { + "line_index": 22, + "dname_b64": "dGV4YXMuY29tLg==", + "data_b64": [ + "dGV4YXMuY29tLg==" + ], + "type": "record", + "ttl": 14400, + "record_type": "MX" + } + ] +} diff --git a/providers/cpanel/internal/cpanel/fixtures/zone-info_error.json b/providers/cpanel/internal/cpanel/fixtures/zone-info_error.json new file mode 100644 index 0000000..ed316f1 --- /dev/null +++ b/providers/cpanel/internal/cpanel/fixtures/zone-info_error.json @@ -0,0 +1,14 @@ +{ + "warnings": null, + "messages": [ + "a", + "b", + "c" + ], + "data": null, + "errors": [ + "You do not control a DNS zone named example.com." + ], + "metadata": {}, + "status": 0 +} diff --git a/providers/cpanel/internal/cpanel/types.go b/providers/cpanel/internal/cpanel/types.go new file mode 100644 index 0000000..cb4dbd5 --- /dev/null +++ b/providers/cpanel/internal/cpanel/types.go @@ -0,0 +1,24 @@ +package cpanel + +import ( + "fmt" + "strings" +) + +type APIResponse[T any] struct { + Metadata Metadata `json:"metadata,omitempty"` + Data T `json:"data,omitempty"` + + Status int `json:"status,omitempty"` + Messages []string `json:"messages,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +type Metadata struct { + Transformed int `json:"transformed,omitempty"` +} + +func toError[T any](r APIResponse[T]) error { + return fmt.Errorf("error(%d): %s: %s", r.Status, strings.Join(r.Errors, ", "), strings.Join(r.Messages, ", ")) +} diff --git a/providers/cpanel/internal/shared/types.go b/providers/cpanel/internal/shared/types.go new file mode 100644 index 0000000..a12c554 --- /dev/null +++ b/providers/cpanel/internal/shared/types.go @@ -0,0 +1,23 @@ +package shared + +type Record struct { + DName string `json:"dname,omitempty"` + TTL int `json:"ttl,omitempty"` + RecordType string `json:"record_type,omitempty"` + Data []string `json:"data,omitempty"` + LineIndex int `json:"line_index,omitempty"` +} + +type ZoneRecord struct { + LineIndex int `json:"line_index,omitempty"` + Type string `json:"type,omitempty"` + DataB64 []string `json:"data_b64,omitempty"` + DNameB64 string `json:"dname_b64,omitempty"` + TextB64 string `json:"text_b64,omitempty"` + RecordType string `json:"record_type,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type ZoneSerial struct { + NewSerial string `json:"new_serial,omitempty"` +} diff --git a/providers/cpanel/internal/whm/client.go b/providers/cpanel/internal/whm/client.go new file mode 100644 index 0000000..0d38686 --- /dev/null +++ b/providers/cpanel/internal/whm/client.go @@ -0,0 +1,159 @@ +package whm + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/entrustcorporation/dv/providers/cpanel/internal/shared" + "github.com/entrustcorporation/dv/providers/internal/errutils" +) + +const statusFailed = 0 + +type Client struct { + username string + token string + + baseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(baseURL string, username string, token string) (*Client, error) { + apiEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &Client{ + username: username, + token: token, + baseURL: apiEndpoint.JoinPath("json-api"), + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// FetchZoneInformation fetches zone information. +// https://api.docs.cpanel.net/openapi/whm/operation/parse_dns_zone/ +func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { + endpoint := c.baseURL.JoinPath("parse_dns_zone") + + query := endpoint.Query() + query.Set("zone", domain) + endpoint.RawQuery = query.Encode() + + var result APIResponse[ZoneData] + + err := c.doRequest(ctx, endpoint, &result) + if err != nil { + return nil, err + } + + if result.Metadata.Result == statusFailed { + return nil, toError(result.Metadata) + } + + return result.Data.Payload, nil +} + +// AddRecord adds a new record. +// +// add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' +func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { + data, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON data: %w", err) + } + + return c.updateZone(ctx, serial, domain, "add", string(data)) +} + +// EditRecord edits an existing record. +// +// edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' +func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { + data, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON data: %w", err) + } + + return c.updateZone(ctx, serial, domain, "edit", string(data)) +} + +// DeleteRecord deletes an existing record. +// +// remove=22 +func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { + return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex)) +} + +// https://api.docs.cpanel.net/openapi/whm/operation/mass_edit_dns_zone/ +func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { + endpoint := c.baseURL.JoinPath("mass_edit_dns_zone") + + query := endpoint.Query() + query.Set("serial", strconv.FormatUint(uint64(serial), 10)) + query.Set(action, data) + query.Set("zone", domain) + endpoint.RawQuery = query.Encode() + + var result APIResponse[shared.ZoneSerial] + + err := c.doRequest(ctx, endpoint, &result) + if err != nil { + return nil, err + } + + if result.Metadata.Result == statusFailed { + return nil, toError(result.Metadata) + } + + return &result.Data, nil +} + +func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { + query := endpoint.Query() + query.Set("api.version", "1") + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + // https://api.docs.cpanel.net/whm/tokens/ + req.Header.Set("Authorization", fmt.Sprintf("whm %s:%s", c.username, c.token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return errutils.NewUnexpectedResponseStatusCodeError(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 +} diff --git a/providers/cpanel/internal/whm/client_test.go b/providers/cpanel/internal/whm/client_test.go new file mode 100644 index 0000000..503afca --- /dev/null +++ b/providers/cpanel/internal/whm/client_test.go @@ -0,0 +1,170 @@ +package whm + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/entrustcorporation/dv/providers/cpanel/internal/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, pattern string, filename string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) + return + } + + open, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = open.Close() }() + + rw.WriteHeader(http.StatusOK) + _, err = io.Copy(rw, open) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client, err := NewClient(server.URL, "user", "secret") + require.NoError(t, err) + + client.HTTPClient = server.Client() + + return client +} + +func TestClient_FetchZoneInformation(t *testing.T) { + client := setupTest(t, "/json-api/parse_dns_zone", "zone-info.json") + + zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") + require.NoError(t, err) + + expected := []shared.ZoneRecord{{ + LineIndex: 22, + Type: "record", + DataB64: []string{"dGV4YXMuY29tLg=="}, + DNameB64: "dGV4YXMuY29tLg==", + RecordType: "MX", + TTL: 14400, + }} + + assert.Equal(t, expected, zoneInfo) +} + +func TestClient_FetchZoneInformation_error(t *testing.T) { + client := setupTest(t, "/json-api/parse_dns_zone", "zone-info_error.json") + + zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com") + require.Error(t, err) + + assert.Nil(t, zoneInfo) +} + +func TestClient_AddRecord(t *testing.T) { + client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + + record := shared.Record{ + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + require.NoError(t, err) + + expected := &shared.ZoneSerial{NewSerial: "2021031903"} + + assert.Equal(t, expected, zoneSerial) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + + record := shared.Record{ + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record) + require.Error(t, err) + + assert.Nil(t, zoneSerial) +} + +func TestClient_EditRecord(t *testing.T) { + client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + + record := shared.Record{ + LineIndex: 9, + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + require.NoError(t, err) + + expected := &shared.ZoneSerial{NewSerial: "2021031903"} + + assert.Equal(t, expected, zoneSerial) +} + +func TestClient_EditRecord_error(t *testing.T) { + client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + + record := shared.Record{ + LineIndex: 9, + DName: "example", + TTL: 14400, + RecordType: "TXT", + Data: []string{"string1", "string2"}, + } + + zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record) + require.Error(t, err) + + assert.Nil(t, zoneSerial) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + + zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + require.NoError(t, err) + + expected := &shared.ZoneSerial{NewSerial: "2021031903"} + + assert.Equal(t, expected, zoneSerial) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + + zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0) + require.Error(t, err) + + assert.Nil(t, zoneSerial) +} diff --git a/providers/cpanel/internal/whm/fixtures/update-zone.json b/providers/cpanel/internal/whm/fixtures/update-zone.json new file mode 100644 index 0000000..fcbd058 --- /dev/null +++ b/providers/cpanel/internal/whm/fixtures/update-zone.json @@ -0,0 +1,11 @@ +{ + "data": { + "new_serial": "2021031903" + }, + "metadata": { + "command": "mass_edit_dns_zone", + "reason": "OK", + "result": 1, + "version": 1 + } +} diff --git a/providers/cpanel/internal/whm/fixtures/update-zone_error.json b/providers/cpanel/internal/whm/fixtures/update-zone_error.json new file mode 100644 index 0000000..eff5736 --- /dev/null +++ b/providers/cpanel/internal/whm/fixtures/update-zone_error.json @@ -0,0 +1,9 @@ +{ + "data": null, + "metadata": { + "command": "mass_edit_dns_zone", + "reason": "There is a problem", + "result": 0, + "version": 1 + } +} diff --git a/providers/cpanel/internal/whm/fixtures/zone-info.json b/providers/cpanel/internal/whm/fixtures/zone-info.json new file mode 100644 index 0000000..bbcfdb3 --- /dev/null +++ b/providers/cpanel/internal/whm/fixtures/zone-info.json @@ -0,0 +1,22 @@ +{ + "data": { + "payload": [ + { + "line_index": 22, + "type": "record", + "data_b64": [ + "dGV4YXMuY29tLg==" + ], + "dname_b64": "dGV4YXMuY29tLg==", + "record_type": "MX", + "ttl": 14400 + } + ] + }, + "metadata": { + "command": "parse_dns_zone", + "reason": "OK", + "result": 1, + "version": 1 + } +} diff --git a/providers/cpanel/internal/whm/fixtures/zone-info_error.json b/providers/cpanel/internal/whm/fixtures/zone-info_error.json new file mode 100644 index 0000000..3364856 --- /dev/null +++ b/providers/cpanel/internal/whm/fixtures/zone-info_error.json @@ -0,0 +1,9 @@ +{ + "data": null, + "metadata": { + "command": "parse_dns_zone", + "reason": "There is a problem", + "result": 0, + "version": 1 + } +} diff --git a/providers/cpanel/internal/whm/types.go b/providers/cpanel/internal/whm/types.go new file mode 100644 index 0000000..d8791b7 --- /dev/null +++ b/providers/cpanel/internal/whm/types.go @@ -0,0 +1,27 @@ +package whm + +import ( + "fmt" + + "github.com/entrustcorporation/dv/providers/cpanel/internal/shared" +) + +type APIResponse[T any] struct { + Metadata Metadata `json:"metadata,omitempty"` + Data T `json:"data,omitempty"` +} + +type Metadata struct { + Command string `json:"command,omitempty"` + Reason string `json:"reason,omitempty"` + Result int `json:"result,omitempty"` + Version int `json:"version,omitempty"` +} + +type ZoneData struct { + Payload []shared.ZoneRecord `json:"payload,omitempty"` +} + +func toError(m Metadata) error { + return fmt.Errorf("%s error(%d): %s", m.Command, m.Result, m.Reason) +} diff --git a/providers/derak/derak.go b/providers/derak/derak.go index 080398d..036ae39 100644 --- a/providers/derak/derak.go +++ b/providers/derak/derak.go @@ -111,7 +111,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("derak: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("derak: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/desec/desec.go b/providers/desec/desec.go index 7b995a8..a994f9c 100644 --- a/providers/desec/desec.go +++ b/providers/desec/desec.go @@ -43,8 +43,8 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -106,7 +106,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("desec: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -156,7 +156,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("desec: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/designate/designate.go b/providers/designate/designate.go index 3e18661..a02fab3 100644 --- a/providers/designate/designate.go +++ b/providers/designate/designate.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "slices" "sync" "time" @@ -128,7 +129,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("designate: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.getZoneID(authZone) @@ -146,7 +147,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } if existingRecord != nil { - if contains(existingRecord.Records, info.Value) { + if slices.Contains(existingRecord.Records, info.Value) { log.Printf("designate: the record already exists: %s", info.Value) return nil } @@ -168,7 +169,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("designate: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.getZoneID(authZone) @@ -197,15 +198,6 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } -func contains(values []string, value string) bool { - for _, v := range values { - if v == value { - return true - } - } - return false -} - func (d *DNSProvider) createRecord(zoneID, fqdn, value string) error { createOpts := recordsets.CreateOpts{ Name: fqdn, @@ -228,7 +220,7 @@ func (d *DNSProvider) createRecord(zoneID, fqdn, value string) error { } func (d *DNSProvider) updateRecord(record *recordsets.RecordSet, value string) error { - if contains(record.Records, value) { + if slices.Contains(record.Records, value) { log.Printf("skip: the record already exists: %s", value) return nil } diff --git a/providers/designate/designate.toml b/providers/designate/designate.toml index b885999..55a1cd3 100644 --- a/providers/designate/designate.toml +++ b/providers/designate/designate.toml @@ -43,6 +43,10 @@ For more information, you can read about the different methods of authentication - [Keystone username/password](https://docs.openstack.org/keystone/latest/user/supported_clients.html) - [Keystone application credentials](https://docs.openstack.org/keystone/latest/user/application_credentials.html) + +Public cloud providers with support for Designate: + +- [Fuga Cloud](https://fuga.cloud/) ''' [Configuration] @@ -65,4 +69,4 @@ For more information, you can read about the different methods of authentication [Links] API = "https://docs.openstack.org/designate/latest/" - GoClient = "https://godoc.org/github.com/gophercloud/gophercloud/openstack/dns/v2" + GoClient = "https://pkg.go.dev/github.com/gophercloud/gophercloud/openstack/dns/v2" diff --git a/providers/digitalocean/digitalocean.go b/providers/digitalocean/digitalocean.go index 8d3a2ae..47d3910 100644 --- a/providers/digitalocean/digitalocean.go +++ b/providers/digitalocean/digitalocean.go @@ -114,7 +114,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) if err != nil { - return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("digitalocean: could not find zone for domain %q: %w", domain, err) } record := internal.Record{Type: "TXT", Name: info.EffectiveFQDN, Data: info.Value, TTL: d.config.TTL} @@ -137,7 +137,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("digitalocean: could not find zone for domain %q: %w", domain, err) } // get the record's unique ID from when we created it diff --git a/providers/dns_providers.go b/providers/dns_providers.go index 0e43065..962c30c 100644 --- a/providers/dns_providers.go +++ b/providers/dns_providers.go @@ -12,6 +12,7 @@ import ( "github.com/entrustcorporation/dv/providers/auroradns" "github.com/entrustcorporation/dv/providers/autodns" "github.com/entrustcorporation/dv/providers/azure" + "github.com/entrustcorporation/dv/providers/azuredns" "github.com/entrustcorporation/dv/providers/bindman" "github.com/entrustcorporation/dv/providers/bluecat" "github.com/entrustcorporation/dv/providers/brandit" @@ -21,9 +22,11 @@ import ( "github.com/entrustcorporation/dv/providers/clouddns" "github.com/entrustcorporation/dv/providers/cloudflare" "github.com/entrustcorporation/dv/providers/cloudns" + "github.com/entrustcorporation/dv/providers/cloudru" "github.com/entrustcorporation/dv/providers/cloudxns" "github.com/entrustcorporation/dv/providers/conoha" "github.com/entrustcorporation/dv/providers/constellix" + "github.com/entrustcorporation/dv/providers/cpanel" "github.com/entrustcorporation/dv/providers/derak" "github.com/entrustcorporation/dv/providers/desec" "github.com/entrustcorporation/dv/providers/designate" @@ -40,6 +43,7 @@ import ( "github.com/entrustcorporation/dv/providers/dynu" "github.com/entrustcorporation/dv/providers/easydns" "github.com/entrustcorporation/dv/providers/edgedns" + "github.com/entrustcorporation/dv/providers/efficientip" "github.com/entrustcorporation/dv/providers/epik" "github.com/entrustcorporation/dv/providers/exec" "github.com/entrustcorporation/dv/providers/exoscale" @@ -65,6 +69,7 @@ import ( "github.com/entrustcorporation/dv/providers/internetbs" "github.com/entrustcorporation/dv/providers/inwx" "github.com/entrustcorporation/dv/providers/ionos" + "github.com/entrustcorporation/dv/providers/ipv64" "github.com/entrustcorporation/dv/providers/iwantmyname" "github.com/entrustcorporation/dv/providers/joker" "github.com/entrustcorporation/dv/providers/liara" @@ -73,6 +78,8 @@ import ( "github.com/entrustcorporation/dv/providers/liquidweb" "github.com/entrustcorporation/dv/providers/loopia" "github.com/entrustcorporation/dv/providers/luadns" + "github.com/entrustcorporation/dv/providers/mailinabox" + "github.com/entrustcorporation/dv/providers/metaname" "github.com/entrustcorporation/dv/providers/mydnsjp" "github.com/entrustcorporation/dv/providers/mythicbeasts" "github.com/entrustcorporation/dv/providers/namecheap" @@ -93,6 +100,7 @@ import ( "github.com/entrustcorporation/dv/providers/plesk" "github.com/entrustcorporation/dv/providers/porkbun" "github.com/entrustcorporation/dv/providers/rackspace" + "github.com/entrustcorporation/dv/providers/rcodezero" "github.com/entrustcorporation/dv/providers/regru" "github.com/entrustcorporation/dv/providers/rfc2136" "github.com/entrustcorporation/dv/providers/rimuhosting" @@ -102,6 +110,7 @@ import ( "github.com/entrustcorporation/dv/providers/scaleway" "github.com/entrustcorporation/dv/providers/selectel" "github.com/entrustcorporation/dv/providers/servercow" + "github.com/entrustcorporation/dv/providers/shellrent" "github.com/entrustcorporation/dv/providers/simply" "github.com/entrustcorporation/dv/providers/sonic" "github.com/entrustcorporation/dv/providers/stackpath" @@ -116,9 +125,11 @@ import ( "github.com/entrustcorporation/dv/providers/vkcloud" "github.com/entrustcorporation/dv/providers/vscale" "github.com/entrustcorporation/dv/providers/vultr" + "github.com/entrustcorporation/dv/providers/webnames" "github.com/entrustcorporation/dv/providers/websupport" "github.com/entrustcorporation/dv/providers/wedos" "github.com/entrustcorporation/dv/providers/yandex" + "github.com/entrustcorporation/dv/providers/yandex360" "github.com/entrustcorporation/dv/providers/yandexcloud" "github.com/entrustcorporation/dv/providers/zoneee" "github.com/entrustcorporation/dv/providers/zonomi" @@ -137,6 +148,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return arvancloud.NewDNSProvider() case "azure": return azure.NewDNSProvider() + case "azuredns": + return azuredns.NewDNSProvider() case "auroradns": return auroradns.NewDNSProvider() case "autodns": @@ -159,12 +172,16 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return cloudflare.NewDNSProvider() case "cloudns": return cloudns.NewDNSProvider() + case "cloudru": + return cloudru.NewDNSProvider() case "cloudxns": return cloudxns.NewDNSProvider() case "conoha": return conoha.NewDNSProvider() case "constellix": return constellix.NewDNSProvider() + case "cpanel": + return cpanel.NewDNSProvider() case "derak": return derak.NewDNSProvider() case "desec": @@ -197,6 +214,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return easydns.NewDNSProvider() case "edgedns", "fastdns": // "fastdns" is for compatibility with v3, must be dropped in v5 return edgedns.NewDNSProvider() + case "efficientip": + return efficientip.NewDNSProvider() case "epik": return epik.NewDNSProvider() case "exec": @@ -247,6 +266,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return inwx.NewDNSProvider() case "ionos": return ionos.NewDNSProvider() + case "ipv64": + return ipv64.NewDNSProvider() case "iwantmyname": return iwantmyname.NewDNSProvider() case "joker": @@ -259,12 +280,16 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return linode.NewDNSProvider() case "liquidweb": return liquidweb.NewDNSProvider() - case "luadns": - return luadns.NewDNSProvider() case "loopia": return loopia.NewDNSProvider() + case "luadns": + return luadns.NewDNSProvider() + case "mailinabox": + return mailinabox.NewDNSProvider() case "manual": return dns01.NewDNSProviderManual() + case "metaname": + return metaname.NewDNSProvider() case "mydnsjp": return mydnsjp.NewDNSProvider() case "mythicbeasts": @@ -305,6 +330,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return porkbun.NewDNSProvider() case "rackspace": return rackspace.NewDNSProvider() + case "rcodezero": + return rcodezero.NewDNSProvider() case "regru": return regru.NewDNSProvider() case "rfc2136": @@ -323,6 +350,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return selectel.NewDNSProvider() case "servercow": return servercow.NewDNSProvider() + case "shellrent": + return shellrent.NewDNSProvider() case "simply": return simply.NewDNSProvider() case "sonic": @@ -351,12 +380,16 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return vscale.NewDNSProvider() case "vultr": return vultr.NewDNSProvider() + case "webnames": + return webnames.NewDNSProvider() case "websupport": return websupport.NewDNSProvider() case "wedos": return wedos.NewDNSProvider() case "yandex": return yandex.NewDNSProvider() + case "yandex360": + return yandex360.NewDNSProvider() case "yandexcloud": return yandexcloud.NewDNSProvider() case "zoneee": diff --git a/providers/dns_providers_test.go b/providers/dns_providers_test.go index 004bd37..37071d6 100644 --- a/providers/dns_providers_test.go +++ b/providers/dns_providers_test.go @@ -29,12 +29,12 @@ func TestKnownDNSProviderError(t *testing.T) { envTest.ClearEnv() provider, err := NewDNSChallengeProviderByName("exec") - assert.Error(t, err) + require.Error(t, err) assert.Nil(t, provider) } func TestUnknownDNSProvider(t *testing.T) { provider, err := NewDNSChallengeProviderByName("foobar") - assert.Error(t, err) + require.Error(t, err) assert.Nil(t, provider) } diff --git a/providers/dnsimple/dnsimple.toml b/providers/dnsimple/dnsimple.toml index e3f6f50..0dd8f06 100644 --- a/providers/dnsimple/dnsimple.toml +++ b/providers/dnsimple/dnsimple.toml @@ -16,7 +16,7 @@ Additional = ''' if `DNSIMPLE_BASE_URL` is not defined or empty, the production URL is used by default. While you can manage DNS records in the [DNSimple Sandbox environment](https://developer.dnsimple.com/sandbox/), -DNS records will not resolve and you will not be able to satisfy the ACME DNS challenge. +DNS records will not resolve, and you will not be able to satisfy the ACME DNS challenge. To authenticate you need to provide a valid API token. HTTP Basic Authentication is intentionally not supported. @@ -24,7 +24,7 @@ HTTP Basic Authentication is intentionally not supported. ### API tokens You can [generate a new API token](https://support.dnsimple.com/articles/api-access-token/) from your account page. -Only Account API tokens are supported, if you try to use an User API token you will receive an error message. +Only Account API tokens are supported, if you try to use a User API token you will receive an error message. ''' [Configuration] diff --git a/providers/dnsmadeeasy/dnsmadeeasy.go b/providers/dnsmadeeasy/dnsmadeeasy.go index 42c87e3..9a352d2 100644 --- a/providers/dnsmadeeasy/dnsmadeeasy.go +++ b/providers/dnsmadeeasy/dnsmadeeasy.go @@ -120,7 +120,7 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q (%s): %w", domainName, info.EffectiveFQDN, err) + return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q: %w", domainName, err) } ctx := context.Background() @@ -148,7 +148,7 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q (%s): %w", domainName, info.EffectiveFQDN, err) + return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q: %w", domainName, err) } ctx := context.Background() diff --git a/providers/dnsmadeeasy/internal/client.go b/providers/dnsmadeeasy/internal/client.go index fe3d364..42fa2da 100644 --- a/providers/dnsmadeeasy/internal/client.go +++ b/providers/dnsmadeeasy/internal/client.go @@ -84,6 +84,7 @@ func (c *Client) GetRecords(ctx context.Context, domain *Domain, recordName, rec query := endpoint.Query() query.Set("recordName", recordName) query.Set("type", recordType) + endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -113,7 +114,7 @@ func (c *Client) CreateRecord(ctx context.Context, domain *Domain, record *Recor // DeleteRecord deletes a TXT records. func (c *Client) DeleteRecord(ctx context.Context, record Record) error { - endpoint := c.BaseURL.JoinPath("/dns/managed", strconv.Itoa(record.SourceID), "records", strconv.Itoa(record.ID)) + endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(record.SourceID), "records", strconv.Itoa(record.ID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { diff --git a/providers/dnspod/dnspod.go b/providers/dnspod/dnspod.go index f403d51..99b5fe5 100644 --- a/providers/dnspod/dnspod.go +++ b/providers/dnspod/dnspod.go @@ -143,7 +143,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { - return "", "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) + return "", "", fmt.Errorf("could not find zone: %w", err) } var hostedZone dnspod.Domain diff --git a/providers/domeneshop/domeneshop.go b/providers/domeneshop/domeneshop.go index b6ad889..f92e7e2 100644 --- a/providers/domeneshop/domeneshop.go +++ b/providers/domeneshop/domeneshop.go @@ -143,7 +143,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) + return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, zone) diff --git a/providers/domeneshop/internal/client.go b/providers/domeneshop/internal/client.go index ac85bd9..721352a 100644 --- a/providers/domeneshop/internal/client.go +++ b/providers/domeneshop/internal/client.go @@ -17,7 +17,7 @@ import ( const defaultBaseURL string = "https://api.domeneshop.no/v0" // Client implements a very simple wrapper around the Domeneshop API. -// For now it will only deal with adding and removing TXT records, as required by ACME providers. +// For now, it will only deal with adding and removing TXT records, as required by ACME providers. // https://api.domeneshop.no/docs/ type Client struct { apiToken string diff --git a/providers/dreamhost/dreamhost_test.go b/providers/dreamhost/dreamhost_test.go index 796d4fe..0f91ffa 100644 --- a/providers/dreamhost/dreamhost_test.go +++ b/providers/dreamhost/dreamhost_test.go @@ -121,12 +121,12 @@ func TestDNSProvider_Present(t *testing.T) { assert.Equal(t, http.MethodGet, r.Method, "method") q := r.URL.Query() - assert.Equal(t, q.Get("key"), fakeAPIKey) - assert.Equal(t, q.Get("cmd"), "dns-add_record") - assert.Equal(t, q.Get("format"), "json") - assert.Equal(t, q.Get("record"), "_acme-challenge.example.com") - assert.Equal(t, q.Get("value"), fakeKeyAuth) - assert.Equal(t, q.Get("comment"), "Managed+By+lego") + assert.Equal(t, fakeAPIKey, q.Get("key")) + assert.Equal(t, "dns-add_record", q.Get("cmd")) + assert.Equal(t, "json", q.Get("format")) + assert.Equal(t, "_acme-challenge.example.com", q.Get("record")) + assert.Equal(t, fakeKeyAuth, q.Get("value")) + assert.Equal(t, "Managed+By+lego", q.Get("comment")) _, err := fmt.Fprintf(w, `{"data":"record_added","result":"success"}`) if err != nil { @@ -163,12 +163,12 @@ func TestDNSProvider_Cleanup(t *testing.T) { assert.Equal(t, http.MethodGet, r.Method, "method") q := r.URL.Query() - assert.Equal(t, q.Get("key"), fakeAPIKey, "key mismatch") - assert.Equal(t, q.Get("cmd"), "dns-remove_record", "cmd mismatch") - assert.Equal(t, q.Get("format"), "json") - assert.Equal(t, q.Get("record"), "_acme-challenge.example.com") - assert.Equal(t, q.Get("value"), fakeKeyAuth, "value mismatch") - assert.Equal(t, q.Get("comment"), "Managed+By+lego") + assert.Equal(t, fakeAPIKey, q.Get("key"), "key mismatch") + assert.Equal(t, "dns-remove_record", q.Get("cmd"), "cmd mismatch") + assert.Equal(t, "json", q.Get("format")) + assert.Equal(t, "_acme-challenge.example.com", q.Get("record")) + assert.Equal(t, fakeKeyAuth, q.Get("value"), "value mismatch") + assert.Equal(t, "Managed+By+lego", q.Get("comment")) _, err := fmt.Fprintf(w, `{"data":"record_removed","result":"success"}`) if err != nil { diff --git a/providers/dyn/dyn.go b/providers/dyn/dyn.go index 00a5dc8..0a865ce 100644 --- a/providers/dyn/dyn.go +++ b/providers/dyn/dyn.go @@ -98,7 +98,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("dyn: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("dyn: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) @@ -125,7 +125,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("dyn: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("dyn: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) diff --git a/providers/dynu/internal/auth.go b/providers/dynu/internal/auth.go index 72d15a8..7a21a10 100644 --- a/providers/dynu/internal/auth.go +++ b/providers/dynu/internal/auth.go @@ -16,7 +16,7 @@ type TokenTransport struct { Transport http.RoundTripper } -// NewTokenTransport Creates a HTTP transport for API authentication. +// NewTokenTransport Creates an HTTP transport for API authentication. func NewTokenTransport(apiKey string) (*TokenTransport, error) { if apiKey == "" { return nil, errors.New("credentials missing: API key") diff --git a/providers/dynu/internal/client.go b/providers/dynu/internal/client.go index 77ec705..7543bfb 100644 --- a/providers/dynu/internal/client.go +++ b/providers/dynu/internal/client.go @@ -110,7 +110,7 @@ func (c Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostnam return &apiResp, nil } -// doRetry the API is really unstable so we need to retry on EOF. +// doRetry the API is really unstable, so we need to retry on EOF. func (c Client) doRetry(ctx context.Context, method, uri string, body []byte, result any) error { operation := func() error { return c.do(ctx, method, uri, body, result) diff --git a/providers/easydns/easydns.go b/providers/easydns/easydns.go index 193c70b..77025b7 100644 --- a/providers/easydns/easydns.go +++ b/providers/easydns/easydns.go @@ -15,7 +15,6 @@ import ( "github.com/entrustcorporation/dv/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/entrustcorporation/dv/providers/easydns/internal" - "github.com/miekg/dns" ) // Environment variables names. @@ -117,20 +116,34 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - apiHost, apiDomain := splitFqdn(info.EffectiveFQDN) + authZone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("easydns: %w", err) + } + + if authZone == "" { + return fmt.Errorf("easydns: could not find zone for domain %q", domain) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("easydns: %w", err) + } record := internal.ZoneRecord{ - Domain: apiDomain, - Host: apiHost, + Domain: authZone, + Host: subDomain, Type: "TXT", Rdata: info.Value, TTL: strconv.Itoa(d.config.TTL), Priority: "0", } - recordID, err := d.client.AddRecord(context.Background(), apiDomain, record) + recordID, err := d.client.AddRecord(ctx, dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("easydns: error adding zone record: %w", err) } @@ -146,6 +159,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) key := getMapKey(info.EffectiveFQDN, info.Value) @@ -158,9 +173,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - _, apiDomain := splitFqdn(info.EffectiveFQDN) + authZone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("easydns: %w", err) + } - err := d.client.DeleteRecord(context.Background(), apiDomain, recordID) + if authZone == "" { + return fmt.Errorf("easydns: could not find zone for domain %q", domain) + } + + err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID) d.recordIDsMu.Lock() defer delete(d.recordIDs, key) @@ -185,15 +207,28 @@ func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } -func splitFqdn(fqdn string) (host, domain string) { - parts := dns.SplitDomainName(fqdn) - length := len(parts) - - host = strings.Join(parts[0:length-2], ".") - domain = strings.Join(parts[length-2:length], ".") - return -} - func getMapKey(fqdn, value string) string { return fqdn + "|" + value } + +func (d *DNSProvider) findZone(ctx context.Context, domain string) (string, error) { + var errAll error + + for { + i := strings.Index(domain, ".") + if i == -1 { + break + } + + _, err := d.client.ListZones(ctx, domain) + if err == nil { + return domain, nil + } + + errAll = errors.Join(errAll, err) + + domain = domain[i+1:] + } + + return "", errAll +} diff --git a/providers/easydns/easydns_test.go b/providers/easydns/easydns_test.go index ea1f854..972ff8c 100644 --- a/providers/easydns/easydns_test.go +++ b/providers/easydns/easydns_test.go @@ -147,6 +147,39 @@ func TestNewDNSProviderConfig(t *testing.T) { func TestDNSProvider_Present(t *testing.T) { provider, mux := setupTest(t) + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + mux.HandleFunc("/zones/records/add/example.com/TXT", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPut, r.Method, "method") assert.Equal(t, "format=json", r.URL.RawQuery, "query") @@ -191,7 +224,40 @@ func TestDNSProvider_Present(t *testing.T) { } func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) { - provider, _ := setupTest(t) + provider, mux := setupTest(t) + + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) @@ -200,6 +266,39 @@ func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) { func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { provider, mux := setupTest(t) + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodDelete, r.Method, "method") assert.Equal(t, "format=json", r.URL.RawQuery, "query") @@ -228,6 +327,39 @@ func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) { provider, mux := setupTest(t) + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + errorMessage := `{ "error": { "code": 406, @@ -253,43 +385,6 @@ func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) { require.EqualError(t, err, expectedError) } -func TestSplitFqdn(t *testing.T) { - testCases := []struct { - desc string - fqdn string - expectedHost string - expectedDomain string - }{ - { - desc: "domain only", - fqdn: "domain.com.", - expectedHost: "", - expectedDomain: "domain.com", - }, - { - desc: "single-part host", - fqdn: "_acme-challenge.domain.com.", - expectedHost: "_acme-challenge", - expectedDomain: "domain.com", - }, - { - desc: "multi-part host", - fqdn: "_acme-challenge.sub.domain.com.", - expectedHost: "_acme-challenge.sub", - expectedDomain: "domain.com", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - actualHost, actualDomain := splitFqdn(test.fqdn) - - require.Equal(t, test.expectedHost, actualHost) - require.Equal(t, test.expectedDomain, actualDomain) - }) - } -} - func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") diff --git a/providers/easydns/internal/client.go b/providers/easydns/internal/client.go index c3a4735..99a2e25 100644 --- a/providers/easydns/internal/client.go +++ b/providers/easydns/internal/client.go @@ -37,6 +37,27 @@ func NewClient(token string, key string) *Client { } } +func (c *Client) ListZones(ctx context.Context, domain string) ([]ZoneRecord, error) { + endpoint := c.BaseURL.JoinPath("zones", "records", "all", domain) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + response := &apiResponse[[]ZoneRecord]{} + err = c.do(req, response) + if err != nil { + return nil, err + } + + if response.Error != nil { + return nil, response.Error + } + + return response.Data, nil +} + func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord) (string, error) { endpoint := c.BaseURL.JoinPath("zones", "records", "add", domain, "TXT") @@ -45,12 +66,16 @@ func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord return "", err } - response := &addRecordResponse{} + response := &apiResponse[*ZoneRecord]{} err = c.do(req, response) if err != nil { return "", err } + if response.Error != nil { + return "", response.Error + } + recordID := response.Data.ID return recordID, nil @@ -64,7 +89,9 @@ func (c *Client) DeleteRecord(ctx context.Context, domain, recordID string) erro return err } - return c.do(req, nil) + err = c.do(req, nil) + + return err } func (c *Client) do(req *http.Request, result any) error { diff --git a/providers/easydns/internal/client_test.go b/providers/easydns/internal/client_test.go index 7ea61d3..030b28f 100644 --- a/providers/easydns/internal/client_test.go +++ b/providers/easydns/internal/client_test.go @@ -67,6 +67,33 @@ func setupTest(t *testing.T, method, pattern string, status int, file string) *C return client } +func TestClient_ListZones(t *testing.T) { + client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "list-zone.json") + + zones, err := client.ListZones(context.Background(), "example.com") + require.NoError(t, err) + + expected := []ZoneRecord{{ + ID: "60898922", + Domain: "example.com", + Host: "hosta", + TTL: "300", + Priority: "0", + Type: "A", + Rdata: "1.2.3.4", + LastMod: "2019-08-28 19:09:50", + }} + + assert.Equal(t, expected, zones) +} + +func TestClient_ListZones_error(t *testing.T) { + client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "error1.json") + + _, err := client.ListZones(context.Background(), "example.com") + require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") +} + func TestClient_AddRecord(t *testing.T) { client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "add-record.json") @@ -85,6 +112,22 @@ func TestClient_AddRecord(t *testing.T) { assert.Equal(t, "xxx", recordID) } +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "error1.json") + + record := ZoneRecord{ + Domain: "example.com", + Host: "test631", + Type: "TXT", + Rdata: "txt", + TTL: "300", + Priority: "0", + } + + _, err := client.AddRecord(context.Background(), "example.com", record) + require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") +} + func TestClient_DeleteRecord(t *testing.T) { client := setupTest(t, http.MethodDelete, "/zones/records/example.com/xxx", http.StatusOK, "") diff --git a/providers/easydns/internal/fixtures/error.json b/providers/easydns/internal/fixtures/error.json new file mode 100644 index 0000000..3ea1674 --- /dev/null +++ b/providers/easydns/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "msg": "Enhance your calm", + "status": 403 +} diff --git a/providers/easydns/internal/fixtures/error1.json b/providers/easydns/internal/fixtures/error1.json new file mode 100644 index 0000000..02982c4 --- /dev/null +++ b/providers/easydns/internal/fixtures/error1.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": 420, + "message": "Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!" + } +} diff --git a/providers/easydns/internal/fixtures/list-zone.json b/providers/easydns/internal/fixtures/list-zone.json new file mode 100644 index 0000000..561a45d --- /dev/null +++ b/providers/easydns/internal/fixtures/list-zone.json @@ -0,0 +1,22 @@ +{ + "msg": "message", + "status": 200, + "tm": 0, + "data": [ + { + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + } + ], + "count": 43, + "total": 43, + "start": 0, + "max": 1000 +} diff --git a/providers/easydns/internal/readme.md b/providers/easydns/internal/readme.md new file mode 100644 index 0000000..aa3a54b --- /dev/null +++ b/providers/easydns/internal/readme.md @@ -0,0 +1,57 @@ +The API doc is mainly wrong on the response schema: + +ex: + +- the doc for `/zones/records/all/{domain}` + +```json +{ + "msg": "string", + "status": 200, + "tm": 1709190001, + "data": { + "id": 60898922, + "domain": "example.com", + "host": "hosta", + "ttl": 300, + "prio": 0, + "geozone_id": 0, + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }, + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +``` + +- The reality: + +```json +{ + "tm": 1709190001, + "data": [ + { + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + } + ], + "count": 0, + "total": 0, + "start": 0, + "max": 0, + "status": 200 +} +``` + +`data` is an array. +`id`, `ttl`, `geozone_id` are strings. diff --git a/providers/easydns/internal/types.go b/providers/easydns/internal/types.go index 5235c4d..035e992 100644 --- a/providers/easydns/internal/types.go +++ b/providers/easydns/internal/types.go @@ -1,5 +1,19 @@ package internal +import "fmt" + +type apiResponse[T any] struct { + Msg string `json:"msg"` + Status int `json:"status"` + Tm int `json:"tm"` + Data T `json:"data"` + Count int `json:"count"` + Total int `json:"total"` + Start int `json:"start"` + Max int `json:"max"` + Error *Error `json:"error,omitempty"` +} + type ZoneRecord struct { ID string `json:"id,omitempty"` Domain string `json:"domain"` @@ -13,9 +27,11 @@ type ZoneRecord struct { NewHost string `json:"new_host,omitempty"` } -type addRecordResponse struct { - Msg string `json:"msg"` - Tm int `json:"tm"` - Data ZoneRecord `json:"data"` - Status int `json:"status"` +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("code %d: %s", e.Code, e.Message) } diff --git a/providers/edgedns/edgedns.go b/providers/edgedns/edgedns.go index cb89517..6b2da7f 100644 --- a/providers/edgedns/edgedns.go +++ b/providers/edgedns/edgedns.go @@ -4,6 +4,7 @@ package edgedns import ( "errors" "fmt" + "slices" "strings" "time" @@ -62,7 +63,7 @@ type DNSProvider struct { } // NewDNSProvider returns a DNSProvider instance configured for Akamai EdgeDNS: -// Akamai credentials are automatically detected in the following locations and prioritized in the following order: +// Akamai's credentials are automatically detected in the following locations and prioritized in the following order: // // 1. Section-specific environment variables `AKAMAI_{SECTION}_HOST`, `AKAMAI_{SECTION}_ACCESS_TOKEN`, `AKAMAI_{SECTION}_CLIENT_TOKEN`, `AKAMAI_{SECTION}_CLIENT_SECRET` where `{SECTION}` is specified using `AKAMAI_EDGERC_SECTION` // 2. If `AKAMAI_EDGERC_SECTION` is not defined or is set to `default`: Environment variables `AKAMAI_HOST`, `AKAMAI_ACCESS_TOKEN`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_CLIENT_SECRET` @@ -120,7 +121,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } if err == nil && record == nil { - return fmt.Errorf("edgedns: unknown error") + return errors.New("edgedns: unknown error") } if record != nil { @@ -175,11 +176,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } if existingRec == nil { - return fmt.Errorf("edgedns: unknown failure") + return errors.New("edgedns: unknown failure") } if len(existingRec.Target) == 0 { - return fmt.Errorf("edgedns: TXT record is invalid") + return errors.New("edgedns: TXT record is invalid") } if !containsValue(existingRec.Target, info.Value) { @@ -224,13 +225,9 @@ func getZone(domain string) (string, error) { } func containsValue(values []string, value string) bool { - for _, val := range values { - if strings.Trim(val, `"`) == value { - return true - } - } - - return false + return slices.ContainsFunc(values, func(val string) bool { + return strings.Trim(val, `"`) == value + }) } func isNotFound(err error) bool { diff --git a/providers/edgedns/edgedns.toml b/providers/edgedns/edgedns.toml index 7929e03..dffa6e4 100644 --- a/providers/edgedns/edgedns.toml +++ b/providers/edgedns/edgedns.toml @@ -15,7 +15,7 @@ lego --email you@example.com --dns edgedns --domains my.example.org run ''' Additional = ''' -Akamai credentials are automatically detected in the following locations and prioritized in the following order: +Akamai's credentials are automatically detected in the following locations and prioritized in the following order: 1. Section-specific environment variables (where `{SECTION}` is specified using `AKAMAI_EDGERC_SECTION`): - `AKAMAI_{SECTION}_HOST` @@ -40,7 +40,7 @@ See also: - [Setting up Akamai credentials](https://developer.akamai.com/api/getting-started) - [.edgerc Format](https://developer.akamai.com/legacy/introduction/Conf_Client.html#edgercformat) - [API Client Authentication](https://developer.akamai.com/legacy/introduction/Client_Auth.html) -- [Config from Env](https://github.com/akamai/AkamaiOPEN-edgegrid-golang/blob/master/edgegrid/config.go#L118) +- [Config from Env](https://github.com/akamai/AkamaiOPEN-edgegrid-golang/blob/master/pkg/edgegrid/config.go#L118) ''' [Configuration] diff --git a/providers/edgedns/edgedns_integration_test.go b/providers/edgedns/edgedns_integration_test.go index dbedc6d..1c297a6 100644 --- a/providers/edgedns/edgedns_integration_test.go +++ b/providers/edgedns/edgedns_integration_test.go @@ -78,9 +78,9 @@ func TestLiveTTL(t *testing.T) { } t.Run(fmt.Sprintf("testing record set %d", i), func(t *testing.T) { - assert.Equal(t, rrset.Name, fqdn) - assert.Equal(t, rrset.Type, "TXT") - assert.Equal(t, rrset.TTL, dns01.DefaultTTL) + assert.Equal(t, fqdn, rrset.Name) + assert.Equal(t, "TXT", rrset.Type) + assert.Equal(t, dns01.DefaultTTL, rrset.TTL) }) } } diff --git a/providers/edgedns/edgedns_test.go b/providers/edgedns/edgedns_test.go index 5c2bf3f..61436f6 100644 --- a/providers/edgedns/edgedns_test.go +++ b/providers/edgedns/edgedns_test.go @@ -158,13 +158,13 @@ func TestDNSProvider_findZone(t *testing.T) { }{ { desc: "Extract root record name", - domain: "bar.com.", - expected: "bar.com", + domain: "example.com.", + expected: "example.com", }, { desc: "Extract sub record name", - domain: "foo.bar.com.", - expected: "bar.com", + domain: "foo.example.com.", + expected: "example.com", }, } diff --git a/providers/efficientip/efficientip.go b/providers/efficientip/efficientip.go new file mode 100644 index 0000000..cfe7b4a --- /dev/null +++ b/providers/efficientip/efficientip.go @@ -0,0 +1,162 @@ +// Package efficientip implements a DNS provider for solving the DNS-01 challenge using Efficient IP. +package efficientip + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/efficientip/internal" +) + +// Environment variables names. +const ( + envNamespace = "EFFICIENTIP_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + EnvHostname = envNamespace + "HOSTNAME" + EnvDNSName = envNamespace + "DNS_NAME" + EnvViewName = envNamespace + "VIEW_NAME" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password string + Hostname string + DNSName string + ViewName string + InsecureSkipVerify bool + 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, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a new DNS provider +// using environment variable EFFICIENTIP_API_KEY for adding and removing the DNS record. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword, EnvHostname, EnvDNSName) + if err != nil { + return nil, fmt.Errorf("efficientip: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.Hostname = values[EnvHostname] + config.DNSName = values[EnvDNSName] + config.ViewName = env.GetOrDefaultString(EnvViewName, "") + config.InsecureSkipVerify = env.GetOrDefaultBool(EnvInsecureSkipVerify, false) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Efficient IP. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("efficientip: the configuration of the DNS provider is nil") + } + + if config.Username == "" { + return nil, errors.New("efficientip: missing username") + } + if config.Password == "" { + return nil, errors.New("efficientip: missing password") + } + if config.Hostname == "" { + return nil, errors.New("efficientip: missing hostname") + } + if config.DNSName == "" { + return nil, errors.New("efficientip: missing dnsname") + } + + client := internal.NewClient(config.Hostname, config.Username, config.Password) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + if config.InsecureSkipVerify { + client.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + return &DNSProvider{config: config, client: client}, nil +} + +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + r := internal.ResourceRecord{ + RRName: dns01.UnFqdn(info.EffectiveFQDN), + RRType: "TXT", + Value1: info.Value, + DNSName: d.config.DNSName, + DNSViewName: d.config.ViewName, + } + + _, err := d.client.AddRecord(ctx, r) + if err != nil { + return fmt.Errorf("efficientip: add record: %w", err) + } + + return nil +} + +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + params := internal.DeleteInputParameters{ + RRName: dns01.UnFqdn(info.EffectiveFQDN), + RRType: "TXT", + RRValue1: info.Value, + DNSName: d.config.DNSName, + DNSViewName: d.config.ViewName, + } + + _, err := d.client.DeleteRecord(ctx, params) + if err != nil { + return fmt.Errorf("efficientip: delete record: %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 +} diff --git a/providers/efficientip/efficientip.toml b/providers/efficientip/efficientip.toml new file mode 100644 index 0000000..cd20228 --- /dev/null +++ b/providers/efficientip/efficientip.toml @@ -0,0 +1,27 @@ +Name = "Efficient IP" +Description = '''''' +URL = "https://efficientip.com/" +Code = "efficientip" +Since = "v4.13.0" + +Example = ''' +EFFICIENTIP_USERNAME="user" \ +EFFICIENTIP_PASSWORD="secret" \ +EFFICIENTIP_HOSTNAME="ipam.example.org" \ +EFFICIENTIP_DNS_NAME="dns.smart" \ +lego --email you@example.com --dns efficientip --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + EFFICIENTIP_USERNAME = "Username" + EFFICIENTIP_PASSWORD = "Password" + EFFICIENTIP_HOSTNAME = "Hostname (ex: foo.example.com)" + EFFICIENTIP_DNS_NAME = "DNS name (ex: dns.smart)" + [Configuration.Additional] + EFFICIENTIP_INSECURE_SKIP_VERIFY = "Whether or not to verify EfficientIP API certificate" + EFFICIENTIP_VIEW_NAME = "View name (ex: external)" + EFFICIENTIP_POLLING_INTERVAL = "Time between DNS propagation check" + EFFICIENTIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + EFFICIENTIP_TTL = "The TTL of the TXT record used for the DNS challenge" + EFFICIENTIP_HTTP_TIMEOUT = "API request timeout" diff --git a/providers/efficientip/efficientip_test.go b/providers/efficientip/efficientip_test.go new file mode 100644 index 0000000..3ee2da7 --- /dev/null +++ b/providers/efficientip/efficientip_test.go @@ -0,0 +1,201 @@ +package efficientip + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvUsername, + EnvPassword, + EnvHostname, + EnvDNSName, + EnvViewName, +).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", + EnvHostname: "example.com", + EnvDNSName: "dns.smart", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + EnvHostname: "example.com", + EnvDNSName: "dns.smart", + }, + expected: "efficientip: some credentials information are missing: EFFICIENTIP_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "", + EnvHostname: "example.com", + EnvDNSName: "dns.smart", + }, + expected: "efficientip: some credentials information are missing: EFFICIENTIP_PASSWORD", + }, + { + desc: "missing hostname", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + EnvHostname: "", + EnvDNSName: "dns.smart", + }, + expected: "efficientip: some credentials information are missing: EFFICIENTIP_HOSTNAME", + }, + { + desc: "missing DNS name", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + EnvHostname: "example.com", + EnvDNSName: "", + }, + expected: "efficientip: some credentials information are missing: EFFICIENTIP_DNS_NAME", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "efficientip: some credentials information are missing: EFFICIENTIP_USERNAME,EFFICIENTIP_PASSWORD,EFFICIENTIP_HOSTNAME,EFFICIENTIP_DNS_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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + hostname string + dnsName string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + hostname: "example.com", + dnsName: "dns.smart", + }, + { + desc: "missing username", + password: "secret", + hostname: "example.com", + dnsName: "dns.smart", + expected: "efficientip: missing username", + }, + { + desc: "missing password", + username: "user", + hostname: "example.com", + dnsName: "dns.smart", + expected: "efficientip: missing password", + }, + { + desc: "missing hostname", + username: "user", + password: "secret", + dnsName: "dns.smart", + expected: "efficientip: missing hostname", + }, + { + desc: "missing dnsName", + username: "user", + password: "secret", + hostname: "example.com", + expected: "efficientip: missing dnsname", + }, + { + desc: "missing all", + expected: "efficientip: missing username", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + + config.Username = test.username + config.Password = test.password + config.Hostname = test.hostname + config.DNSName = test.dnsName + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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(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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/efficientip/internal/client.go b/providers/efficientip/internal/client.go new file mode 100644 index 0000000..38e8151 --- /dev/null +++ b/providers/efficientip/internal/client.go @@ -0,0 +1,209 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" + querystring "github.com/google/go-querystring/query" +) + +type Client struct { + baseURL *url.URL + HTTPClient *http.Client + + username string + password string +} + +func NewClient(hostname string, username string, password string) *Client { + baseURL, _ := url.Parse(fmt.Sprintf("https://%s/rest/", hostname)) + + return &Client{ + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + baseURL: baseURL, + username: username, + password: password, + } +} + +func (c Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) { + endpoint := c.baseURL.JoinPath("dns_rr_list") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []ResourceRecord + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, error) { + endpoint := c.baseURL.JoinPath("dns_rr_info") + + query := endpoint.Query() + query.Set("rr_id", id) + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result []ResourceRecord + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } + + return &result[0], nil +} + +func (c Client) AddRecord(ctx context.Context, record ResourceRecord) (*BaseOutput, error) { + endpoint := c.baseURL.JoinPath("dns_rr_add") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var result []BaseOutput + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } + + return &result[0], nil +} + +func (c Client) DeleteRecord(ctx context.Context, params DeleteInputParameters) (*BaseOutput, error) { + endpoint := c.baseURL.JoinPath("dns_rr_delete") + + // (rr_id || (rr_name && (dns_id || dns_name || hostaddr))) + + v, err := querystring.Values(params) + if err != nil { + return nil, fmt.Errorf("query parameters: %w", err) + } + endpoint.RawQuery = v.Encode() + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return nil, err + } + + var result []BaseOutput + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } + + return &result[0], nil +} + +func (c Client) do(req *http.Request, result any) error { + req.SetBasicAuth(c.username, c.password) + req.Header.Set("cache-control", "no-cache") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + switch req.Method { + case http.MethodPost: + if resp.StatusCode != http.StatusCreated { + return parseError(req, resp) + } + default: + if resp.StatusCode == http.StatusNoContent { + return nil + } + + if resp.StatusCode != http.StatusOK { + 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 response APIError + err := json.Unmarshal(raw, &response) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code %d] %w", resp.StatusCode, response) +} diff --git a/providers/efficientip/internal/client_test.go b/providers/efficientip/internal/client_test.go new file mode 100644 index 0000000..a766c90 --- /dev/null +++ b/providers/efficientip/internal/client_test.go @@ -0,0 +1,427 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) + return + } + + username, password, ok := req.BasicAuth() + if !ok { + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + if username != "user" { + http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "user"), http.StatusUnauthorized) + return + } + + if password != "secret" { + http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) + return + } + + open, err := os.Open(filepath.Join("fixtures", file)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = open.Close() }() + + rw.WriteHeader(status) + _, err = io.Copy(rw, open) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + srvURL, _ := url.Parse(server.URL) + + client := NewClient(srvURL.Host, "user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func TestListRecords(t *testing.T) { + client := setupTest(t, http.MethodGet, "/dns_rr_list", http.StatusOK, "dns_rr_list.json") + + ctx := context.Background() + + records, err := client.ListRecords(ctx) + require.NoError(t, err) + + expected := []ResourceRecord{ + { + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "test1", + RRAuthGsstsig: "0", + RRFullName: "test.lego.example.com", + RRFullNameUTF: "test.lego.example.com", + RRGlue: "test", + RRGlueID: "21", + RRID: "239", + RRNameID: "26", + RRType: "TXT", + RRTypeID: "6", + RRValueID: "274", + TTL: "3600", + Value1: "test1", + VDNSParentID: "0", + VDNSParentName: "#", + }, + { + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "test2", + RRAuthGsstsig: "0", + RRFullName: "test.lego.example.com", + RRFullNameUTF: "test.lego.example.com", + RRGlue: "test", + RRGlueID: "21", + RRID: "241", + RRNameID: "26", + RRType: "TXT", + RRTypeID: "6", + RRValueID: "275", + TTL: "3600", + Value1: "test2", + VDNSParentID: "0", + VDNSParentName: "#", + }, + { + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "test1", + RRAuthGsstsig: "0", + RRFullName: "lego.example.com", + RRFullNameUTF: "lego.example.com", + RRGlue: ".", + RRGlueID: "3", + RRID: "245", + RRNameID: "21", + RRType: "TXT", + RRTypeID: "6", + RRValueID: "274", + TTL: "3600", + Value1: "test1", + VDNSParentID: "0", + VDNSParentName: "#", + }, + { + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "test2", + RRAuthGsstsig: "0", + RRFullName: "lego.example.com", + RRFullNameUTF: "lego.example.com", + RRGlue: ".", + RRGlueID: "3", + RRID: "247", + RRNameID: "21", + RRType: "TXT", + RRTypeID: "6", + RRValueID: "275", + TTL: "3600", + Value1: "test2", + VDNSParentID: "0", + VDNSParentName: "#", + }, + { + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "dns.smart, root@lego.example.com, 2023062719, 1200, 600, 1209600, 3600", + RRAuthGsstsig: "0", + RRFullName: "lego.example.com", + RRFullNameUTF: "lego.example.com", + RRGlue: ".", + RRGlueID: "3", + RRID: "201", + RRNameID: "21", + RRType: "SOA", + RRTypeID: "2", + RRValueID: "282", + TTL: "3600", + Value1: "dns.smart", + Value2: "root@lego.example.com", + Value3: "2023062719", + Value4: "1200", + Value5: "600", + Value6: "1209600", + Value7: "3600", + VDNSParentID: "0", + VDNSParentName: "#", + }, + { + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "dns.smart", + RRAuthGsstsig: "0", + RRFullName: "lego.example.com", + RRFullNameUTF: "lego.example.com", + RRGlue: ".", + RRGlueID: "3", + RRID: "200", + RRNameID: "21", + RRType: "NS", + RRTypeID: "1", + RRValueID: "10", + TTL: "3600", + Value1: "dns.smart", + VDNSParentID: "0", + VDNSParentName: "#", + }, + { + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "127.0.0.1", + RRAuthGsstsig: "0", + RRFullName: "loopback.lego.example.com", + RRFullNameUTF: "loopback.lego.example.com", + RRGlue: "loopback", + RRGlueID: "17", + RRID: "208", + RRNameID: "22", + RRType: "A", + RRTypeID: "3", + RRValueID: "237", + RRValueIP4Addr: "7f000001", + RRValueIPAddr: "7f000001", + TTL: "3600", + Value1: "127.0.0.1", + VDNSParentID: "0", + VDNSParentName: "#", + }, + } + + assert.Equal(t, expected, records) +} + +func TestGetRecord(t *testing.T) { + client := setupTest(t, http.MethodGet, "/dns_rr_info", http.StatusOK, "dns_rr_info.json") + + ctx := context.Background() + + record, err := client.GetRecord(ctx, "239") + require.NoError(t, err) + + expected := &ResourceRecord{ + ErrorCode: "0", + DelayedCreateTime: "0", + DelayedDeleteTime: "0", + DelayedTime: "0", + DNSCloud: "0", + DNSID: "3", + DNSName: "dns.smart", + DNSType: "vdns", + DNSViewID: "0", + DNSViewName: "#", + DNSZoneID: "9", + DNSZoneIsReverse: "0", + DNSZoneIsRpz: "0", + DNSZoneName: "lego.example.com", + DNSZoneNameUTF: "lego.example.com", + DNSZoneSiteName: "#", + DNSZoneSortZone: "lego.example.com", + DNSZoneType: "master", + RRAllValue: "test1", + RRAuthGsstsig: "0", + RRFullName: "test.lego.example.com", + RRFullNameUTF: "test.lego.example.com", + RRGlue: "test", + RRGlueID: "21", + RRID: "239", + RRNameID: "26", + RRType: "TXT", + RRTypeID: "6", + RRValueID: "274", + TTL: "3600", + Value1: "test1", + VDNSParentID: "0", + VDNSParentName: "#", + } + + assert.Equal(t, expected, record) +} + +func TestAddRecord(t *testing.T) { + client := setupTest(t, http.MethodPost, "/dns_rr_add", http.StatusCreated, "dns_rr_add.json") + + ctx := context.Background() + + r := ResourceRecord{ + RRName: "test.example.com", + RRType: "TXT", + Value1: "test", + DNSName: "dns.smart", + DNSViewName: "external", + } + + resp, err := client.AddRecord(ctx, r) + require.NoError(t, err) + + expected := &BaseOutput{RetOID: "239"} + + assert.Equal(t, expected, resp) +} + +func TestDeleteRecord(t *testing.T) { + client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusOK, "dns_rr_delete.json") + + ctx := context.Background() + + resp, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"}) + require.NoError(t, err) + + expected := &BaseOutput{RetOID: "251"} + + assert.Equal(t, expected, resp) +} + +func TestDeleteRecord_error(t *testing.T) { + client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusBadRequest, "dns_rr_delete-error.json") + + ctx := context.Background() + + _, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"}) + require.ErrorAs(t, err, &APIError{}) +} diff --git a/providers/efficientip/internal/fixtures/dns_rr_add.json b/providers/efficientip/internal/fixtures/dns_rr_add.json new file mode 100644 index 0000000..49932c2 --- /dev/null +++ b/providers/efficientip/internal/fixtures/dns_rr_add.json @@ -0,0 +1,5 @@ +[ + { + "ret_oid": "239" + } +] diff --git a/providers/efficientip/internal/fixtures/dns_rr_delete-error.json b/providers/efficientip/internal/fixtures/dns_rr_delete-error.json new file mode 100644 index 0000000..7eb0db6 --- /dev/null +++ b/providers/efficientip/internal/fixtures/dns_rr_delete-error.json @@ -0,0 +1,6 @@ +{ + "errno": "20117", + "errmsg": "This RR does not exist", + "severity": "error", + "category": "dns_rr_delete" +} diff --git a/providers/efficientip/internal/fixtures/dns_rr_delete.json b/providers/efficientip/internal/fixtures/dns_rr_delete.json new file mode 100644 index 0000000..d794fba --- /dev/null +++ b/providers/efficientip/internal/fixtures/dns_rr_delete.json @@ -0,0 +1,5 @@ +[ + { + "ret_oid": "251" + } +] diff --git a/providers/efficientip/internal/fixtures/dns_rr_info.json b/providers/efficientip/internal/fixtures/dns_rr_info.json new file mode 100644 index 0000000..8a2f6d0 --- /dev/null +++ b/providers/efficientip/internal/fixtures/dns_rr_info.json @@ -0,0 +1,64 @@ +[ + { + "errno": "0", + "rr_all_value": "test1", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "test.lego.example.com", + "rr_full_name_utf": "test.lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "", + "rr_value_ip4_addr": "", + "rr_glue": "test", + "rr_type": "TXT", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "test1", + "value2": "", + "value3": "", + "value4": "", + "value5": "", + "value6": "", + "value7": "", + "dnszone_id": "9", + "rr_id": "239", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "26", + "rr_value_id": "274", + "rr_type_id": "6", + "rr_glue_id": "21", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + } +] diff --git a/providers/efficientip/internal/fixtures/dns_rr_list.json b/providers/efficientip/internal/fixtures/dns_rr_list.json new file mode 100644 index 0000000..2371eaf --- /dev/null +++ b/providers/efficientip/internal/fixtures/dns_rr_list.json @@ -0,0 +1,436 @@ +[ + { + "errno": "0", + "rr_all_value": "test1", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "test.lego.example.com", + "rr_full_name_utf": "test.lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "", + "rr_value_ip4_addr": "", + "rr_glue": "test", + "rr_type": "TXT", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "test1", + "value2": "", + "value3": "", + "value4": "", + "value5": "", + "value6": "", + "value7": "", + "dnszone_id": "9", + "rr_id": "239", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "26", + "rr_value_id": "274", + "rr_type_id": "6", + "rr_glue_id": "21", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + }, + { + "errno": "0", + "rr_all_value": "test2", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "test.lego.example.com", + "rr_full_name_utf": "test.lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "", + "rr_value_ip4_addr": "", + "rr_glue": "test", + "rr_type": "TXT", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "test2", + "value2": "", + "value3": "", + "value4": "", + "value5": "", + "value6": "", + "value7": "", + "dnszone_id": "9", + "rr_id": "241", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "26", + "rr_value_id": "275", + "rr_type_id": "6", + "rr_glue_id": "21", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + }, + { + "errno": "0", + "rr_all_value": "test1", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "lego.example.com", + "rr_full_name_utf": "lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "", + "rr_value_ip4_addr": "", + "rr_glue": ".", + "rr_type": "TXT", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "test1", + "value2": "", + "value3": "", + "value4": "", + "value5": "", + "value6": "", + "value7": "", + "dnszone_id": "9", + "rr_id": "245", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "21", + "rr_value_id": "274", + "rr_type_id": "6", + "rr_glue_id": "3", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + }, + { + "errno": "0", + "rr_all_value": "test2", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "lego.example.com", + "rr_full_name_utf": "lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "", + "rr_value_ip4_addr": "", + "rr_glue": ".", + "rr_type": "TXT", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "test2", + "value2": "", + "value3": "", + "value4": "", + "value5": "", + "value6": "", + "value7": "", + "dnszone_id": "9", + "rr_id": "247", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "21", + "rr_value_id": "275", + "rr_type_id": "6", + "rr_glue_id": "3", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + }, + { + "errno": "0", + "rr_all_value": "dns.smart, root@lego.example.com, 2023062719, 1200, 600, 1209600, 3600", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "lego.example.com", + "rr_full_name_utf": "lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "", + "rr_value_ip4_addr": "", + "rr_glue": ".", + "rr_type": "SOA", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "dns.smart", + "value2": "root@lego.example.com", + "value3": "2023062719", + "value4": "1200", + "value5": "600", + "value6": "1209600", + "value7": "3600", + "dnszone_id": "9", + "rr_id": "201", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "21", + "rr_value_id": "282", + "rr_type_id": "2", + "rr_glue_id": "3", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + }, + { + "errno": "0", + "rr_all_value": "dns.smart", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "lego.example.com", + "rr_full_name_utf": "lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "", + "rr_value_ip4_addr": "", + "rr_glue": ".", + "rr_type": "NS", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "dns.smart", + "value2": "", + "value3": "", + "value4": "", + "value5": "", + "value6": "", + "value7": "", + "dnszone_id": "9", + "rr_id": "200", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "21", + "rr_value_id": "10", + "rr_type_id": "1", + "rr_glue_id": "3", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + }, + { + "errno": "0", + "rr_all_value": "127.0.0.1", + "dnszone_sort_zone": "lego.example.com", + "dnszone_is_rpz": "0", + "dnszone_type": "master", + "rr_full_name": "loopback.lego.example.com", + "rr_full_name_utf": "loopback.lego.example.com", + "rr_name_ip_addr": "", + "rr_name_ip4_addr": "", + "rr_value_ip_addr": "7f000001", + "rr_value_ip4_addr": "7f000001", + "rr_glue": "loopback", + "rr_type": "A", + "ttl": "3600", + "delayed_time": "0", + "rr_class_name": "", + "value1": "127.0.0.1", + "value2": "", + "value3": "", + "value4": "", + "value5": "", + "value6": "", + "value7": "", + "dnszone_id": "9", + "rr_id": "208", + "dns_id": "3", + "dnszone_name_utf": "lego.example.com", + "dnszone_name": "lego.example.com", + "dns_name": "dns.smart", + "dns_type": "vdns", + "dns_cloud": "0", + "vdns_parent_id": "0", + "dnsview_name": "#", + "dnsview_class_name": "", + "dnsview_id": "0", + "dnszone_site_name": "#", + "dnszone_is_reverse": "0", + "dnszone_masters": "", + "vdns_parent_name": "#", + "dnszone_forwarders": "", + "dns_class_name": "", + "dnszone_class_name": "", + "dns_version": "", + "dns_comment": "", + "delayed_create_time": "0", + "delayed_delete_time": "0", + "multistatus": "", + "rr_auth_gsstsig": "0", + "rr_last_update_time": "", + "rr_last_update_days": "", + "rr_name_id": "22", + "rr_value_id": "237", + "rr_type_id": "3", + "rr_glue_id": "17", + "dnsview_class_parameters": "", + "dnsview_class_parameters_properties": "", + "dnsview_class_parameters_inheritance_source": "", + "rr_class_parameters": "", + "rr_class_parameters_properties": "", + "rr_class_parameters_inheritance_source": "" + } +] diff --git a/providers/efficientip/internal/types.go b/providers/efficientip/internal/types.go new file mode 100644 index 0000000..017089a --- /dev/null +++ b/providers/efficientip/internal/types.go @@ -0,0 +1,109 @@ +package internal + +import "fmt" + +type ResourceRecord struct { + ErrorCode string `json:"errno,omitempty"` + + DelayedCreateTime string `json:"delayed_create_time,omitempty"` + DelayedDeleteTime string `json:"delayed_delete_time,omitempty"` + DelayedTime string `json:"delayed_time,omitempty"` + DNSClassName string `json:"dns_class_name,omitempty"` + DNSCloud string `json:"dns_cloud,omitempty"` + DNSComment string `json:"dns_comment,omitempty"` + DNSID string `json:"dns_id,omitempty"` + DNSName string `json:"dns_name,omitempty"` + DNSType string `json:"dns_type,omitempty"` + DNSVersion string `json:"dns_version,omitempty"` + DNSViewClassName string `json:"dnsview_class_name,omitempty"` + DNSViewClassParameters string `json:"dnsview_class_parameters,omitempty"` + DNSViewClassParametersInheritanceSource string `json:"dnsview_class_parameters_inheritance_source,omitempty"` + DNSViewClassParametersProperties string `json:"dnsview_class_parameters_properties,omitempty"` + DNSViewID string `json:"dnsview_id,omitempty"` + DNSViewName string `json:"dnsview_name,omitempty"` + DNSZoneClassName string `json:"dnszone_class_name,omitempty"` + DNSZoneForwarders string `json:"dnszone_forwarders,omitempty"` + DNSZoneID string `json:"dnszone_id,omitempty"` + DNSZoneIsReverse string `json:"dnszone_is_reverse,omitempty"` + DNSZoneIsRpz string `json:"dnszone_is_rpz,omitempty"` + DNSZoneMasters string `json:"dnszone_masters,omitempty"` + DNSZoneName string `json:"dnszone_name,omitempty"` + DNSZoneNameUTF string `json:"dnszone_name_utf,omitempty"` + DNSZoneSiteName string `json:"dnszone_site_name,omitempty"` + DNSZoneSortZone string `json:"dnszone_sort_zone,omitempty"` + DNSZoneType string `json:"dnszone_type,omitempty"` + MultiStatus string `json:"multistatus,omitempty"` + RRAllValue string `json:"rr_all_value,omitempty"` + RRAuthGsstsig string `json:"rr_auth_gsstsig,omitempty"` + RRClassName string `json:"rr_class_name,omitempty"` + RRClassParameters string `json:"rr_class_parameters,omitempty"` + RRClassParametersInheritanceSource string `json:"rr_class_parameters_inheritance_source,omitempty"` + RRClassParametersProperties string `json:"rr_class_parameters_properties,omitempty"` + RRFullName string `json:"rr_full_name,omitempty"` + RRFullNameUTF string `json:"rr_full_name_utf,omitempty"` + RRGlue string `json:"rr_glue,omitempty"` + RRGlueID string `json:"rr_glue_id,omitempty"` + RRID string `json:"rr_id,omitempty"` + RRLastUpdateDays string `json:"rr_last_update_days,omitempty"` + RRLastUpdateTime string `json:"rr_last_update_time,omitempty"` + RRName string `json:"rr_name,omitempty"` + RRNameID string `json:"rr_name_id,omitempty"` + RRNameIP4Addr string `json:"rr_name_ip4_addr,omitempty"` + RRNameIPAddr string `json:"rr_name_ip_addr,omitempty"` + RRType string `json:"rr_type,omitempty"` + RRTypeID string `json:"rr_type_id,omitempty"` + RRValueID string `json:"rr_value_id,omitempty"` + RRValueIP4Addr string `json:"rr_value_ip4_addr,omitempty"` + RRValueIPAddr string `json:"rr_value_ip_addr,omitempty"` + TTL string `json:"ttl,omitempty"` + Value1 string `json:"value1,omitempty"` + Value2 string `json:"value2,omitempty"` + Value3 string `json:"value3,omitempty"` + Value4 string `json:"value4,omitempty"` + Value5 string `json:"value5,omitempty"` + Value6 string `json:"value6,omitempty"` + Value7 string `json:"value7,omitempty"` + VDNSParentID string `json:"vdns_parent_id,omitempty"` + VDNSParentName string `json:"vdns_parent_name,omitempty"` +} + +type DeleteInputParameters struct { + RRID string `url:"rr_id,omitempty"` + DNSName string `url:"dns_name,omitempty"` + DNSViewName string `url:"dnsview_name,omitempty"` + RRName string `url:"rr_name,omitempty"` + RRType string `url:"rr_type,omitempty"` + RRValue1 string `url:"rr_value1,omitempty"` +} + +type BaseOutput struct { + RetOID string `json:"ret_oid,omitempty"` +} + +type APIError struct { + ErrorCode string `json:"errno,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Severity string `json:"severity,omitempty"` + Category string `json:"category,omitempty"` + Parameters string `json:"parameters,omitempty"` + ParamFormat string `json:"param_format,omitempty"` + ParamValue string `json:"param_value,omitempty"` +} + +func (a APIError) Error() string { + msg := fmt.Sprintf("%s: %s %s %s", a.Category, a.Severity, a.ErrorCode, a.ErrMsg) + + if a.Parameters != "" { + msg += fmt.Sprintf(" parameters: %s", a.Parameters) + } + + if a.ParamFormat != "" { + msg += fmt.Sprintf(" param_format: %s", a.ParamFormat) + } + + if a.ParamValue != "" { + msg += fmt.Sprintf(" param_value: %s", a.ParamValue) + } + + return msg +} diff --git a/providers/epik/epik.go b/providers/epik/epik.go index 7566a54..cd0989a 100644 --- a/providers/epik/epik.go +++ b/providers/epik/epik.go @@ -99,7 +99,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // find authZone authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("epik: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("epik: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -129,7 +129,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // find authZone authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("epik: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("epik: could not find zone for domain %q: %w", domain, err) } dom := dns01.UnFqdn(authZone) diff --git a/providers/epik/internal/client_test.go b/providers/epik/internal/client_test.go index a1d0186..78c4452 100644 --- a/providers/epik/internal/client_test.go +++ b/providers/epik/internal/client_test.go @@ -94,7 +94,7 @@ func TestClient_GetDNSRecords_error(t *testing.T) { mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) _, err := client.GetDNSRecords(context.Background(), "example.com") - assert.Error(t, err) + require.Error(t, err) } func TestClient_CreateHostRecord(t *testing.T) { @@ -135,7 +135,7 @@ func TestClient_CreateHostRecord_error(t *testing.T) { } _, err := client.CreateHostRecord(context.Background(), "example.com", record) - assert.Error(t, err) + require.Error(t, err) } func TestClient_RemoveHostRecord(t *testing.T) { @@ -160,7 +160,7 @@ func TestClient_RemoveHostRecord_error(t *testing.T) { mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json")) _, err := client.RemoveHostRecord(context.Background(), "example.com", "abc123") - assert.Error(t, err) + require.Error(t, err) } func testHandler(method string, statusCode int, filename string) http.HandlerFunc { diff --git a/providers/exec/exec.toml b/providers/exec/exec.toml index a274f07..e5868d6 100644 --- a/providers/exec/exec.toml +++ b/providers/exec/exec.toml @@ -69,7 +69,7 @@ EXEC_PATH=./update-dns.sh \ It will then call the program `./update-dns.sh` like this: ```bash -./update-dns.sh "present" "my.example.org." "--" "some-token" "KxAy-J3NwUmg9ZQuM-gP_Mq1nStaYSaP9tYQs5_-YsE.ksT-qywTd8058G-SHHWA3RAN72Pr0yWtPYmmY5UBpQ8" +./update-dns.sh "present" "--" "my.example.org." "some-token" "KxAy-J3NwUmg9ZQuM-gP_Mq1nStaYSaP9tYQs5_-YsE.ksT-qywTd8058G-SHHWA3RAN72Pr0yWtPYmmY5UBpQ8" ``` ## Commands @@ -84,29 +84,14 @@ you can use the `--` delimiter to specify the start of positional arguments, and | Mode | Command | |---------|----------------------------------------------------| -| default | `myprogram present -- ` | +| default | `myprogram present ` | | `RAW` | `myprogram present -- ` | ### Cleanup | Mode | Command | |---------|----------------------------------------------------| -| default | `myprogram cleanup -- ` | +| default | `myprogram cleanup ` | | `RAW` | `myprogram cleanup -- ` | -### Timeout - -The command have to display propagation timeout and polling interval into Stdout. - -The values must be formatted as JSON, and times are in seconds. -Example: `{"timeout": 30, "interval": 5}` - -If an error occurs or if the command is not provided: -the default display propagation timeout and polling interval are used. - -| Mode | Command | -|---------|----------------------------------------------------| -| default | `myprogram timeout` | -| `RAW` | `myprogram timeout` | - ''' diff --git a/providers/exoscale/exoscale.go b/providers/exoscale/exoscale.go index dc34b94..fa865cd 100644 --- a/providers/exoscale/exoscale.go +++ b/providers/exoscale/exoscale.go @@ -116,7 +116,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN) if err != nil { - return err + return fmt.Errorf("exoscale: %w", err) } zone, err := d.findExistingZone(zoneName) @@ -171,7 +171,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN) if err != nil { - return err + return fmt.Errorf("exoscale: %w", err) } zone, err := d.findExistingZone(zoneName) @@ -246,7 +246,7 @@ func (d *DNSProvider) findExistingRecordID(zoneID, recordName string) (string, e func (d *DNSProvider) findZoneAndRecordName(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", "", fmt.Errorf("designate: could not find zone for FQDN %q: %w", fqdn, err) + return "", "", fmt.Errorf("could not find zone: %w", err) } zone = dns01.UnFqdn(zone) diff --git a/providers/exoscale/exoscale_test.go b/providers/exoscale/exoscale_test.go index 2aad3fc..059698c 100644 --- a/providers/exoscale/exoscale_test.go +++ b/providers/exoscale/exoscale_test.go @@ -144,17 +144,17 @@ func TestDNSProvider_FindZoneAndRecordName(t *testing.T) { }{ { desc: "Extract root record name", - fqdn: "_acme-challenge.bar.com.", + fqdn: "_acme-challenge.example.com.", expected: expected{ - zone: "bar.com", + zone: "example.com", recordName: "_acme-challenge", }, }, { desc: "Extract sub record name", - fqdn: "_acme-challenge.foo.bar.com.", + fqdn: "_acme-challenge.foo.example.com.", expected: expected{ - zone: "bar.com", + zone: "example.com", recordName: "_acme-challenge.foo", }, }, diff --git a/providers/gandi/gandi.go b/providers/gandi/gandi.go index cd34b7c..618f7d8 100644 --- a/providers/gandi/gandi.go +++ b/providers/gandi/gandi.go @@ -131,7 +131,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // find authZone and Gandi zone_id for fqdn authZone, err := d.findZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("gandi: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("gandi: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() diff --git a/providers/gandiv5/gandiv5.go b/providers/gandiv5/gandiv5.go index 54ea57a..78ef657 100644 --- a/providers/gandiv5/gandiv5.go +++ b/providers/gandiv5/gandiv5.go @@ -11,6 +11,7 @@ import ( "time" "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/entrustcorporation/dv/providers/gandiv5/internal" ) @@ -23,7 +24,8 @@ const minTTL = 300 const ( envNamespace = "GANDIV5_" - EnvAPIKey = envNamespace + "API_KEY" + EnvAPIKey = envNamespace + "API_KEY" + EnvPersonalAccessToken = envNamespace + "PERSONAL_ACCESS_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -39,12 +41,13 @@ type inProgressInfo struct { // Config is used to configure the creation of the DNSProvider. type Config struct { - BaseURL string - APIKey string - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPClient *http.Client + BaseURL string + APIKey string // Deprecated use PersonalAccessToken + PersonalAccessToken string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -76,13 +79,10 @@ type DNSProvider struct { // NewDNSProvider returns a DNSProvider instance configured for Gandi. // Credentials must be passed in the environment variable: GANDIV5_API_KEY. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAPIKey) - if err != nil { - return nil, fmt.Errorf("gandi: %w", err) - } - + // TODO(ldez): rewrite this when APIKey will be removed. config := NewDefaultConfig() - config.APIKey = values[EnvAPIKey] + config.APIKey = env.GetOrFile(EnvAPIKey) + config.PersonalAccessToken = env.GetOrFile(EnvPersonalAccessToken) return NewDNSProviderConfig(config) } @@ -93,15 +93,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("gandiv5: the configuration of the DNS provider is nil") } - if config.APIKey == "" { - return nil, errors.New("gandiv5: no API Key given") + if config.APIKey != "" { + log.Print("gandiv5: API Key is deprecated, use Personal Access Token instead") + } + + if config.APIKey == "" && config.PersonalAccessToken == "" { + return nil, errors.New("gandiv5: credentials information are missing") } if config.TTL < minTTL { return nil, fmt.Errorf("gandiv5: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } - client := internal.NewClient(config.APIKey) + client := internal.NewClient(config.APIKey, config.PersonalAccessToken) if config.BaseURL != "" { baseURL, err := url.Parse(config.BaseURL) @@ -130,7 +134,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // find authZone authZone, err := d.findZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("gandiv5: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("gandiv5: could not find zone for domain %q: %w", domain, err) } // determine name of TXT record diff --git a/providers/gandiv5/gandiv5.toml b/providers/gandiv5/gandiv5.toml index 19ed906..4d952b2 100644 --- a/providers/gandiv5/gandiv5.toml +++ b/providers/gandiv5/gandiv5.toml @@ -5,13 +5,14 @@ Code = "gandiv5" Since = "v0.5.0" Example = ''' -GANDIV5_API_KEY=abcdefghijklmnopqrstuvwx \ +GANDIV5_PERSONAL_ACCESS_TOKEN=abcdefghijklmnopqrstuvwx \ lego --email you@example.com --dns gandiv5 --domains my.example.org run ''' [Configuration] [Configuration.Credentials] - GANDIV5_API_KEY = "API key" + GANDIV5_PERSONAL_ACCESS_TOKEN = "Personal Access Token" + GANDIV5_API_KEY = "API key (Deprecated)" [Configuration.Additional] GANDIV5_POLLING_INTERVAL = "Time between DNS propagation check" GANDIV5_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" diff --git a/providers/gandiv5/gandiv5_test.go b/providers/gandiv5/gandiv5_test.go index 36288d7..57fed03 100644 --- a/providers/gandiv5/gandiv5_test.go +++ b/providers/gandiv5/gandiv5_test.go @@ -10,11 +10,10 @@ import ( "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/tester" - "github.com/entrustcorporation/dv/providers/gandiv5/internal" "github.com/stretchr/testify/require" ) -var envTest = tester.NewEnvTest(EnvAPIKey) +var envTest = tester.NewEnvTest(EnvAPIKey, EnvPersonalAccessToken) func TestNewDNSProvider(t *testing.T) { testCases := []struct { @@ -33,7 +32,7 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvAPIKey: "", }, - expected: "gandi: some credentials information are missing: GANDIV5_API_KEY", + expected: "gandiv5: credentials information are missing", }, } @@ -70,7 +69,7 @@ func TestNewDNSProviderConfig(t *testing.T) { }, { desc: "missing credentials", - expected: "gandiv5: no API Key given", + expected: "gandiv5: credentials information are missing", }, } @@ -122,8 +121,8 @@ func TestDNSProvider(t *testing.T) { mux.HandleFunc("/domains/example.com/records/_acme-challenge.abc.def/TXT", func(rw http.ResponseWriter, req *http.Request) { log.Infof("request: %s %s", req.Method, req.URL) - if req.Header.Get(internal.APIKeyHeader) == "" { - http.Error(rw, `{"message": "missing API key"}`, http.StatusUnauthorized) + if req.Header.Get("Authorization") != "Bearer 123412341234123412341234" { + http.Error(rw, `{"message": "missing or malformed Authorization"}`, http.StatusUnauthorized) return } @@ -165,7 +164,7 @@ func TestDNSProvider(t *testing.T) { } config := NewDefaultConfig() - config.APIKey = "123412341234123412341234" + config.PersonalAccessToken = "123412341234123412341234" config.BaseURL = server.URL provider, err := NewDNSProviderConfig(config) diff --git a/providers/gandiv5/internal/client.go b/providers/gandiv5/internal/client.go index 96bf9bf..4d7235a 100644 --- a/providers/gandiv5/internal/client.go +++ b/providers/gandiv5/internal/client.go @@ -20,20 +20,25 @@ const defaultBaseURL = "https://dns.api.gandi.net/api/v5" // APIKeyHeader API key header. const APIKeyHeader = "X-Api-Key" +// Related to Personal Access Token. +const authorizationHeader = "Authorization" + // Client the Gandi API v5 client. type Client struct { apiKey string + pat string BaseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. -func NewClient(apiKey string) *Client { +func NewClient(apiKey, pat string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, + pat: pat, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } @@ -128,6 +133,10 @@ func (c *Client) do(req *http.Request, result any) error { req.Header.Set(APIKeyHeader, c.apiKey) } + if c.pat != "" { + req.Header.Set(authorizationHeader, "Bearer "+c.pat) + } + resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) diff --git a/providers/gcloud/gcloud.toml b/providers/gcloud/gcloud.toml index c08824b..261e35b 100644 --- a/providers/gcloud/gcloud.toml +++ b/providers/gcloud/gcloud.toml @@ -21,6 +21,7 @@ GCE_PROJECT="gc-project-id" GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file. GCE_SERVICE_ACCOUNT = "Account" [Configuration.Additional] GCE_ALLOW_PRIVATE_ZONE = "Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false)" + GCE_ZONE_ID = "Allows to skip the automatic detection of the zone" GCE_POLLING_INTERVAL = "Time between DNS propagation check" GCE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" GCE_TTL = "The TTL of the TXT record used for the DNS challenge" diff --git a/providers/gcloud/googlecloud.go b/providers/gcloud/googlecloud.go index 50e01ef..9091a48 100644 --- a/providers/gcloud/googlecloud.go +++ b/providers/gcloud/googlecloud.go @@ -32,6 +32,7 @@ const ( EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT" EnvProject = envNamespace + "PROJECT" + EnvZoneID = envNamespace + "ZONE_ID" EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE" EnvDebug = envNamespace + "DEBUG" @@ -44,6 +45,7 @@ const ( type Config struct { Debug bool Project string + ZoneID string AllowPrivateZone bool PropagationTimeout time.Duration PollingInterval time.Duration @@ -55,6 +57,7 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ Debug: env.GetOrDefaultBool(EnvDebug, false), + ZoneID: env.GetOrDefaultString(EnvZoneID, ""), AllowPrivateZone: env.GetOrDefaultBool(EnvAllowPrivateZone, false), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second), @@ -310,24 +313,16 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // getHostedZone returns the managed-zone. func (d *DNSProvider) getHostedZone(domain string) (string, error) { - authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + authZone, zones, err := d.lookupHostedZoneID(domain) if err != nil { - return "", fmt.Errorf("designate: could not find zone for FQDN %q: %w", domain, err) + return "", err } - zones, err := d.client.ManagedZones. - List(d.config.Project). - DnsName(authZone). - Do() - if err != nil { - return "", fmt.Errorf("API call failed: %w", err) - } - - if len(zones.ManagedZones) == 0 { + if len(zones) == 0 { return "", fmt.Errorf("no matching domain found for domain %s", authZone) } - for _, z := range zones.ManagedZones { + for _, z := range zones { if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) { return z.Name, nil } @@ -340,6 +335,45 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { return "", fmt.Errorf("no public zone found for domain %s", authZone) } +// lookupHostedZoneID finds the managed zone ID in Google. +// +// Be careful here. +// An automated system might run in a GCloud Service Account, with access to edit the zone +// +// (gcloud dns managed-zones get-iam-policy $zone_id) (role roles/dns.admin) +// +// but not with project-wide access to list all zones +// +// (gcloud projects get-iam-policy $project_id) (a role with permission dns.managedZones.list) +// +// If we force a zone list to succeed, we demand more permissions than needed. +func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZone, error) { + // GCE_ZONE_ID override for service accounts to avoid needing zones-list permission + if d.config.ZoneID != "" { + zone, err := d.client.ManagedZones.Get(d.config.Project, d.config.ZoneID).Do() + if err != nil { + return "", nil, fmt.Errorf("API call ManagedZones.Get for explicit zone ID %q in project %q failed: %w", d.config.ZoneID, d.config.Project, err) + } + + return zone.DnsName, []*dns.ManagedZone{zone}, nil + } + + authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + if err != nil { + return "", nil, fmt.Errorf("could not find zone: %w", err) + } + + zones, err := d.client.ManagedZones. + List(d.config.Project). + DnsName(authZone). + Do() + if err != nil { + return "", nil, fmt.Errorf("API call ManagedZones.List failed: %w", err) + } + + return authZone, zones.ManagedZones, nil +} + func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) { recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do() if err != nil { diff --git a/providers/gcloud/googlecloud_test.go b/providers/gcloud/googlecloud_test.go index 02071b1..453fdd5 100644 --- a/providers/gcloud/googlecloud_test.go +++ b/providers/gcloud/googlecloud_test.go @@ -391,7 +391,6 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() - provider, err := NewDNSProviderCredentials(envTest.GetValue(EnvProject)) require.NoError(t, err) diff --git a/providers/gcore/gcore.go b/providers/gcore/gcore.go index e23b4de..8ad76a7 100644 --- a/providers/gcore/gcore.go +++ b/providers/gcore/gcore.go @@ -57,7 +57,7 @@ type DNSProvider struct { client *internal.Client } -// NewDNSProvider returns an instance of DNSProvider configured for G-Core Labs DNS API. +// NewDNSProvider returns an instance of DNSProvider configured for G-Core DNS API. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPermanentAPIToken) if err != nil { @@ -70,7 +70,7 @@ func NewDNSProvider() (*DNSProvider, error) { return NewDNSProviderConfig(config) } -// NewDNSProviderConfig return a DNSProvider instance configured for G-Core Labs DNS API. +// NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("gcore: the configuration of the DNS provider is nil") @@ -130,8 +130,8 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { return nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// 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 } diff --git a/providers/gcore/gcore.toml b/providers/gcore/gcore.toml index d08caff..121a6d8 100644 --- a/providers/gcore/gcore.toml +++ b/providers/gcore/gcore.toml @@ -1,6 +1,6 @@ -Name = "G-Core Labs" +Name = "G-Core" Description = '''''' -URL = "https://gcorelabs.com/dns/" +URL = "https://gcore.com/dns/" Code = "gcore" Since = "v4.5.0" @@ -11,7 +11,7 @@ lego --email you@example.com --dns gcore --domains my.example.org run [Configuration] [Configuration.Credentials] - GCORE_PERMANENT_API_TOKEN = "Permanent API tokene (https://gcorelabs.com/blog/permanent-api-token-explained/)" + GCORE_PERMANENT_API_TOKEN = "Permanent API token (https://gcore.com/blog/permanent-api-token-explained/)" [Configuration.Additional] GCORE_POLLING_INTERVAL = "Time between DNS propagation check" GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" @@ -19,4 +19,4 @@ lego --email you@example.com --dns gcore --domains my.example.org run GCORE_HTTP_TIMEOUT = "API request timeout" [Links] - API = "https://dnsapi.gcorelabs.com/docs#tag/zonesV2" + API = "https://api.gcore.com/docs/dns#tag/zones" diff --git a/providers/gcore/internal/client.go b/providers/gcore/internal/client.go index b562dbc..654b61e 100644 --- a/providers/gcore/internal/client.go +++ b/providers/gcore/internal/client.go @@ -14,7 +14,7 @@ import ( "github.com/entrustcorporation/dv/providers/internal/errutils" ) -const defaultBaseURL = "https://api.gcorelabs.com/dns" +const defaultBaseURL = "https://api.gcore.com/dns" const ( authorizationHeader = "Authorization" @@ -43,7 +43,7 @@ func NewClient(token string) *Client { } // GetZone gets zone information. -// https://dnsapi.gcorelabs.com/docs#operation/Zone +// https://api.gcore.com/docs/dns#tag/zones/operation/Zone func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) { endpoint := c.baseURL.JoinPath("v2", "zones", name) @@ -57,7 +57,7 @@ func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) { } // GetRRSet gets RRSet item. -// https://dnsapi.gcorelabs.com/docs#operation/RRSet +// https://api.gcore.com/docs/dns#tag/rrsets/operation/RRSet func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error) { endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) @@ -71,7 +71,7 @@ func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error) } // DeleteRRSet removes RRSet record. -// https://dnsapi.gcorelabs.com/docs#operation/DeleteRRSet +// https://api.gcore.com/docs/dns#tag/rrsets/operation/DeleteRRSet func (c *Client) DeleteRRSet(ctx context.Context, zone, name string) error { endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) @@ -102,14 +102,14 @@ func (c *Client) AddRRSet(ctx context.Context, zone, recordName, value string, t return c.createRRSet(ctx, zone, recordName, record) } -// https://dnsapi.gcorelabs.com/docs#operation/CreateRRSet +// https://api.gcore.com/docs/dns#tag/rrsets/operation/CreateRRSet func (c *Client) createRRSet(ctx context.Context, zone, name string, record RRSet) error { endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) return c.doRequest(ctx, http.MethodPost, endpoint, record, nil) } -// https://dnsapi.gcorelabs.com/docs#operation/UpdateRRSet +// https://api.gcore.com/docs/dns#tag/rrsets/operation/UpdateRRSet func (c *Client) updateRRSet(ctx context.Context, zone, name string, record RRSet) error { endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType) diff --git a/providers/glesys/glesys.go b/providers/glesys/glesys.go index 56432aa..37a1c5c 100644 --- a/providers/glesys/glesys.go +++ b/providers/glesys/glesys.go @@ -14,11 +14,7 @@ import ( "github.com/entrustcorporation/dv/providers/glesys/internal" ) -const ( - // defaultBaseURL is the GleSYS API endpoint used by Present and CleanUp. - defaultBaseURL = "https://api.glesys.com/domain" - minTTL = 60 -) +const minTTL = 60 // Environment variables names. const ( @@ -114,7 +110,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // find authZone authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("glesys: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("glesys: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/godaddy/godaddy.go b/providers/godaddy/godaddy.go index d96dab5..6c50bd1 100644 --- a/providers/godaddy/godaddy.go +++ b/providers/godaddy/godaddy.go @@ -95,8 +95,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return &DNSProvider{config: config, client: client}, nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// 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 } @@ -107,7 +107,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("godaddy: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("godaddy: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) @@ -153,7 +153,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("godaddy: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("godaddy: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) diff --git a/providers/googledomains/googledomains.go b/providers/googledomains/googledomains.go index 9f61e7c..78a6661 100644 --- a/providers/googledomains/googledomains.go +++ b/providers/googledomains/googledomains.go @@ -3,6 +3,7 @@ package googledomains import ( "context" + "errors" "fmt" "net/http" "time" @@ -62,11 +63,11 @@ func NewDNSProvider() (*DNSProvider, error) { // NewDNSProviderConfig returns the Google Domains DNS provider with the provided config. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { - return nil, fmt.Errorf("googledomains: the configuration of the DNS provider is nil") + return nil, errors.New("googledomains: the configuration of the DNS provider is nil") } if config.AccessToken == "" { - return nil, fmt.Errorf("googledomains: access token is missing") + return nil, errors.New("googledomains: access token is missing") } service, err := acmedns.NewService(context.Background(), option.WithHTTPClient(config.HTTPClient)) @@ -88,7 +89,7 @@ type DNSProvider struct { func (d *DNSProvider) Present(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) if err != nil { - return fmt.Errorf("googledomains: error finding zone for domain %s: %w", domain, err) + return fmt.Errorf("googledomains: could not find zone for domain %q: %w", domain, err) } rotateReq := acmedns.RotateChallengesRequest{ @@ -108,7 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) if err != nil { - return fmt.Errorf("googledomains: error finding zone for domain %s: %w", domain, err) + return fmt.Errorf("googledomains: could not find zone for domain %q: %w", domain, err) } rotateReq := acmedns.RotateChallengesRequest{ diff --git a/providers/hetzner/hetzner.go b/providers/hetzner/hetzner.go index d6b977e..ee71e4f 100644 --- a/providers/hetzner/hetzner.go +++ b/providers/hetzner/hetzner.go @@ -91,8 +91,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return &DNSProvider{config: config, client: client}, nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// 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 } @@ -103,7 +103,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("hetzner: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) @@ -141,7 +141,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("hetzner: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) diff --git a/providers/hostingde/hostingde.go b/providers/hostingde/hostingde.go index c23d5bc..33a5e84 100644 --- a/providers/hostingde/hostingde.go +++ b/providers/hostingde/hostingde.go @@ -11,7 +11,7 @@ import ( "github.com/entrustcorporation/dv/dns01" "github.com/go-acme/lego/v4/platform/config/env" - "github.com/entrustcorporation/dv/providers/hostingde/internal" + "github.com/entrustcorporation/dv/providers/internal/hostingde" ) // Environment variables names. @@ -52,7 +52,7 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config - client *internal.Client + client *hostingde.Client recordIDs map[string]string recordIDsMu sync.Mutex @@ -86,7 +86,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return &DNSProvider{ config: config, - client: internal.NewClient(config.APIKey), + client: hostingde.NewClient(config.APIKey), recordIDs: make(map[string]string), }, nil } @@ -109,8 +109,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() // get the ZoneConfig for that domain - zonesFind := internal.ZoneConfigsFindRequest{ - Filter: internal.Filter{Field: "zoneName", Value: zoneName}, + zonesFind := hostingde.ZoneConfigsFindRequest{ + Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, Limit: 1, Page: 1, } @@ -122,14 +122,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zoneConfig.Name = zoneName - rec := []internal.DNSRecord{{ + rec := []hostingde.DNSRecord{{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), Content: info.Value, TTL: d.config.TTL, }} - req := internal.ZoneUpdateRequest{ + req := hostingde.ZoneUpdateRequest{ ZoneConfig: *zoneConfig, RecordsToAdd: rec, } @@ -166,8 +166,8 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() // get the ZoneConfig for that domain - zonesFind := internal.ZoneConfigsFindRequest{ - Filter: internal.Filter{Field: "zoneName", Value: zoneName}, + zonesFind := hostingde.ZoneConfigsFindRequest{ + Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, Limit: 1, Page: 1, } @@ -178,13 +178,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } zoneConfig.Name = zoneName - rec := []internal.DNSRecord{{ + rec := []hostingde.DNSRecord{{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), Content: `"` + info.Value + `"`, }} - req := internal.ZoneUpdateRequest{ + req := hostingde.ZoneUpdateRequest{ ZoneConfig: *zoneConfig, RecordsToDelete: rec, } @@ -208,7 +208,7 @@ func (d *DNSProvider) getZoneName(fqdn string) (string, error) { zoneName, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) + return "", fmt.Errorf("could not find zone: %w", err) } if zoneName == "" { diff --git a/providers/hosttech/hosttech.go b/providers/hosttech/hosttech.go index 37c82dd..33c66c7 100644 --- a/providers/hosttech/hosttech.go +++ b/providers/hosttech/hosttech.go @@ -102,7 +102,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("hosttech: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -142,7 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("hosttech: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() diff --git a/providers/httpnet/httpnet.go b/providers/httpnet/httpnet.go new file mode 100644 index 0000000..11b8714 --- /dev/null +++ b/providers/httpnet/httpnet.go @@ -0,0 +1,223 @@ +// Package httpnet implements a DNS provider for solving the DNS-01 challenge using http.net. +package httpnet + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/internal/hostingde" +) + +// Environment variables names. +const ( + envNamespace = "HTTPNET_" + + EnvAPIKey = envNamespace + "API_KEY" + EnvZoneName = envNamespace + "ZONE_NAME" + + 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 + ZoneName 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, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *hostingde.Client + + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for http.net. +// Credentials must be passed in the environment variables: +// HTTPNET_ZONE_NAME and HTTPNET_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("httpnet: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + config.ZoneName = env.GetOrFile(EnvZoneName) + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for http.net. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("httpnet: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("httpnet: API key missing") + } + + client := hostingde.NewClient(config.APIKey) + client.BaseURL, _ = url.Parse(hostingde.DefaultHTTPNetBaseURL) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, 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 +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + zoneName, err := d.getZoneName(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("httpnet: could not find zone for domain %q: %w", domain, err) + } + + ctx := context.Background() + + // get the ZoneConfig for that domain + zonesFind := hostingde.ZoneConfigsFindRequest{ + Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, + Limit: 1, + Page: 1, + } + + zoneConfig, err := d.client.GetZone(ctx, zonesFind) + if err != nil { + return fmt.Errorf("httpnet: %w", err) + } + + zoneConfig.Name = zoneName + + rec := []hostingde.DNSRecord{{ + Type: "TXT", + Name: dns01.UnFqdn(info.EffectiveFQDN), + Content: info.Value, + TTL: d.config.TTL, + }} + + req := hostingde.ZoneUpdateRequest{ + ZoneConfig: *zoneConfig, + RecordsToAdd: rec, + } + + response, err := d.client.UpdateZone(ctx, req) + if err != nil { + return fmt.Errorf("httpnet: %w", err) + } + + for _, record := range response.Records { + if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == fmt.Sprintf(`%q`, info.Value) { + d.recordIDsMu.Lock() + d.recordIDs[info.EffectiveFQDN] = record.ID + d.recordIDsMu.Unlock() + } + } + + if d.recordIDs[info.EffectiveFQDN] == "" { + return fmt.Errorf("httpnet: error getting ID of just created record, for domain %s", domain) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + zoneName, err := d.getZoneName(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("httpnet: could not find zone for domain %q: %w", domain, err) + } + + ctx := context.Background() + + // get the ZoneConfig for that domain + zonesFind := hostingde.ZoneConfigsFindRequest{ + Filter: hostingde.Filter{Field: "zoneName", Value: zoneName}, + Limit: 1, + Page: 1, + } + + zoneConfig, err := d.client.GetZone(ctx, zonesFind) + if err != nil { + return fmt.Errorf("httpnet: %w", err) + } + zoneConfig.Name = zoneName + + rec := []hostingde.DNSRecord{{ + Type: "TXT", + Name: dns01.UnFqdn(info.EffectiveFQDN), + Content: `"` + info.Value + `"`, + }} + + req := hostingde.ZoneUpdateRequest{ + ZoneConfig: *zoneConfig, + RecordsToDelete: rec, + } + + // Delete record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, info.EffectiveFQDN) + d.recordIDsMu.Unlock() + + _, err = d.client.UpdateZone(ctx, req) + if err != nil { + return fmt.Errorf("httpnet: %w", err) + } + return nil +} + +func (d *DNSProvider) getZoneName(fqdn string) (string, error) { + if d.config.ZoneName != "" { + return d.config.ZoneName, nil + } + + zoneName, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", fmt.Errorf("could not find zone: %w", err) + } + + if zoneName == "" { + return "", errors.New("empty zone name") + } + + return dns01.UnFqdn(zoneName), nil +} diff --git a/providers/httpnet/httpnet.toml b/providers/httpnet/httpnet.toml new file mode 100644 index 0000000..a465d06 --- /dev/null +++ b/providers/httpnet/httpnet.toml @@ -0,0 +1,25 @@ +Name = "http.net" +Description = '''''' +URL = "https://www.http.net/" +Code = "httpnet" +Since = "v4.15.0" + +Example = ''' +HTTPNET_API_KEY=xxxxxxxx \ +lego --email you@example.com --dns httpnet --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + HTTPNET_API_KEY = "API key" + [Configuration.Additional] + HTTPNET_ZONE_NAME = "Zone name in ACE format" + HTTPNET_POLLING_INTERVAL = "Time between DNS propagation check" + HTTPNET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + HTTPNET_TTL = "The TTL of the TXT record used for the DNS challenge" + HTTPNET_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://www.http.net/docs/api/#dns" + + diff --git a/providers/httpnet/httpnet_test.go b/providers/httpnet/httpnet_test.go new file mode 100644 index 0000000..a9bc527 --- /dev/null +++ b/providers/httpnet/httpnet_test.go @@ -0,0 +1,139 @@ +package httpnet + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAPIKey, + EnvZoneName). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "123", + EnvZoneName: "example.org", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAPIKey: "", + EnvZoneName: "", + }, + expected: "httpnet: some credentials information are missing: HTTPNET_API_KEY", + }, + { + desc: "missing access key", + envVars: map[string]string{ + EnvAPIKey: "", + EnvZoneName: "456", + }, + expected: "httpnet: some credentials information are missing: HTTPNET_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.recordIDs) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + zoneName string + expected string + }{ + { + desc: "success", + apiKey: "123", + zoneName: "example.org", + }, + { + desc: "missing credentials", + expected: "httpnet: API key missing", + }, + { + desc: "missing api key", + zoneName: "456", + expected: "httpnet: API key missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.ZoneName = test.zoneName + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.recordIDs) + } 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(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) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/httpreq/httpreq.go b/providers/httpreq/httpreq.go index 9e11d65..98856f8 100644 --- a/providers/httpreq/httpreq.go +++ b/providers/httpreq/httpreq.go @@ -1,4 +1,4 @@ -// Package httpreq implements a DNS provider for solving the DNS-01 challenge through a HTTP server. +// Package httpreq implements a DNS provider for solving the DNS-01 challenge through an HTTP server. package httpreq import ( diff --git a/providers/httpreq/httpreq.toml b/providers/httpreq/httpreq.toml index a3bfd67..cd6c823 100644 --- a/providers/httpreq/httpreq.toml +++ b/providers/httpreq/httpreq.toml @@ -17,7 +17,7 @@ The server must provide: - `POST` `/present` - `POST` `/cleanup` -The URL of the server must be define by `HTTPREQ_ENDPOINT`. +The URL of the server must be defined by `HTTPREQ_ENDPOINT`. ### Mode diff --git a/providers/hurricane/internal/client.go b/providers/hurricane/internal/client.go index 359f285..30ef1c2 100644 --- a/providers/hurricane/internal/client.go +++ b/providers/hurricane/internal/client.go @@ -113,7 +113,7 @@ func evaluateBody(body string, hostname string) error { case codeAbuse: return fmt.Errorf("%s: blocked hostname for abuse: %s", body, hostname) case codeBadAgent: - return fmt.Errorf("%s: user agent not sent or HTTP method not recognized; open an issue on go-acme/lego on Github", body) + return fmt.Errorf("%s: user agent not sent or HTTP method not recognized; open an issue on go-acme/lego on GitHub", body) case codeBadAuth: return fmt.Errorf("%s: wrong authentication token provided for TXT record %s", body, hostname) case codeInterval: diff --git a/providers/hyperone/hyperone.go b/providers/hyperone/hyperone.go index 9c176d0..ddad527 100644 --- a/providers/hyperone/hyperone.go +++ b/providers/hyperone/hyperone.go @@ -191,7 +191,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { func (d *DNSProvider) getHostedZone(ctx context.Context, fqdn string) (*internal.Zone, error) { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return nil, fmt.Errorf("hetzner: could not find zone for FQDN %q: %w", fqdn, err) + return nil, fmt.Errorf("could not find zone: %w", err) } return d.client.FindZone(ctx, authZone) diff --git a/providers/hyperone/internal/token.go b/providers/hyperone/internal/token.go index 89447c6..69f2f47 100644 --- a/providers/hyperone/internal/token.go +++ b/providers/hyperone/internal/token.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - "github.com/go-jose/go-jose/v3" - "github.com/go-jose/go-jose/v3/jwt" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" ) type TokenSigner struct { @@ -65,7 +65,7 @@ type Payload struct { func (payload *Payload) buildToken(signer *jose.Signer) (string, error) { builder := jwt.Signed(*signer).Claims(payload) - token, err := builder.CompactSerialize() + token, err := builder.Serialize() if err != nil { return "", fmt.Errorf("failed to build JWT: %w", err) } diff --git a/providers/ibmcloud/ibmcloud.toml b/providers/ibmcloud/ibmcloud.toml index 1b562ce..2a87c58 100644 --- a/providers/ibmcloud/ibmcloud.toml +++ b/providers/ibmcloud/ibmcloud.toml @@ -12,7 +12,7 @@ lego --email you@example.com --dns ibmcloud --domains my.example.org run [Configuration] [Configuration.Credentials] - SOFTLAYER_USERNAME = "User name (IBM Cloud is _)" + SOFTLAYER_USERNAME = "Username (IBM Cloud is _)" SOFTLAYER_API_KEY = "Classic Infrastructure API key" [Configuration.Additional] SOFTLAYER_POLLING_INTERVAL = "Time between DNS propagation check" diff --git a/providers/iij/iij.go b/providers/iij/iij.go index 61f25cc..11925d8 100644 --- a/providers/iij/iij.go +++ b/providers/iij/iij.go @@ -4,6 +4,7 @@ package iij import ( "errors" "fmt" + "slices" "strconv" "strings" "time" @@ -229,7 +230,7 @@ func splitDomain(domain string, zones []string) (string, string, error) { for i := 0; i < len(parts)-1; i++ { zone = strings.Join(parts[i:], ".") - if zoneContains(zone, zones) { + if slices.Contains(zones, zone) { baseOwner := strings.Join(parts[0:i], ".") if baseOwner != "" { baseOwner = "." + baseOwner @@ -245,12 +246,3 @@ func splitDomain(domain string, zones []string) (string, string, error) { return owner, zone, nil } - -func zoneContains(zone string, zones []string) bool { - for _, z := range zones { - if zone == z { - return true - } - } - return false -} diff --git a/providers/hostingde/internal/client.go b/providers/internal/hostingde/client.go similarity index 90% rename from providers/hostingde/internal/client.go rename to providers/internal/hostingde/client.go index 418c3aa..9c03b22 100644 --- a/providers/hostingde/internal/client.go +++ b/providers/internal/hostingde/client.go @@ -1,4 +1,4 @@ -package internal +package hostingde import ( "bytes" @@ -14,23 +14,26 @@ import ( "github.com/entrustcorporation/dv/providers/internal/errutils" ) -const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json" +const ( + DefaultHostingdeBaseURL = "https://secure.hosting.de/api/dns/v1/json" + DefaultHTTPNetBaseURL = "https://partner.http.net/api/dns/v1/json" +) // Client the API client for Hosting.de. type Client struct { apiKey string - baseURL *url.URL + BaseURL *url.URL HTTPClient *http.Client } // NewClient creates new Client. func NewClient(apiKey string) *Client { - baseURL, _ := url.Parse(defaultBaseURL) + baseURL, _ := url.Parse(DefaultHostingdeBaseURL) return &Client{ apiKey: apiKey, - baseURL: baseURL, + BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } @@ -71,7 +74,7 @@ func (c Client) GetZone(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneC // ListZoneConfigs lists zone configuration. // https://www.hosting.de/api/?json#list-zoneconfigs func (c Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneResponse, error) { - endpoint := c.baseURL.JoinPath("zoneConfigsFind") + endpoint := c.BaseURL.JoinPath("zoneConfigsFind") req.AuthToken = c.apiKey @@ -96,7 +99,7 @@ func (c Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) // UpdateZone updates a zone. // https://www.hosting.de/api/?json#updating-zones func (c Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, error) { - endpoint := c.baseURL.JoinPath("zoneUpdate") + endpoint := c.BaseURL.JoinPath("zoneUpdate") req.AuthToken = c.apiKey diff --git a/providers/hostingde/internal/client_test.go b/providers/internal/hostingde/client_test.go similarity index 99% rename from providers/hostingde/internal/client_test.go rename to providers/internal/hostingde/client_test.go index af76d0d..d538c8b 100644 --- a/providers/hostingde/internal/client_test.go +++ b/providers/internal/hostingde/client_test.go @@ -1,4 +1,4 @@ -package internal +package hostingde import ( "bytes" @@ -26,7 +26,7 @@ func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { client := NewClient("secret") client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) + client.BaseURL, _ = url.Parse(server.URL) mux.HandleFunc(pattern, handler) diff --git a/providers/hostingde/internal/fixtures/zoneConfigsFind.json b/providers/internal/hostingde/fixtures/zoneConfigsFind.json similarity index 100% rename from providers/hostingde/internal/fixtures/zoneConfigsFind.json rename to providers/internal/hostingde/fixtures/zoneConfigsFind.json diff --git a/providers/hostingde/internal/fixtures/zoneConfigsFind_error.json b/providers/internal/hostingde/fixtures/zoneConfigsFind_error.json similarity index 100% rename from providers/hostingde/internal/fixtures/zoneConfigsFind_error.json rename to providers/internal/hostingde/fixtures/zoneConfigsFind_error.json diff --git a/providers/hostingde/internal/fixtures/zoneUpdate.json b/providers/internal/hostingde/fixtures/zoneUpdate.json similarity index 100% rename from providers/hostingde/internal/fixtures/zoneUpdate.json rename to providers/internal/hostingde/fixtures/zoneUpdate.json diff --git a/providers/hostingde/internal/fixtures/zoneUpdate_error.json b/providers/internal/hostingde/fixtures/zoneUpdate_error.json similarity index 100% rename from providers/hostingde/internal/fixtures/zoneUpdate_error.json rename to providers/internal/hostingde/fixtures/zoneUpdate_error.json diff --git a/providers/hostingde/internal/types.go b/providers/internal/hostingde/types.go similarity index 99% rename from providers/hostingde/internal/types.go rename to providers/internal/hostingde/types.go index a706008..4f33471 100644 --- a/providers/hostingde/internal/types.go +++ b/providers/internal/hostingde/types.go @@ -1,4 +1,4 @@ -package internal +package hostingde import "encoding/json" diff --git a/providers/internal/selectel/client.go b/providers/internal/selectel/client.go index f8addd5..8a53091 100644 --- a/providers/internal/selectel/client.go +++ b/providers/internal/selectel/client.go @@ -55,7 +55,7 @@ func (c *Client) GetDomainByName(ctx context.Context, domainName string) (*Domai statusCode, err := c.do(req, domain) if err != nil { if statusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1 { - // Look up for the next sub domain + // Look up for the next subdomain subIndex := strings.Index(domainName, ".") return c.GetDomainByName(ctx, domainName[subIndex+1:]) } diff --git a/providers/internal/selectel/client_test.go b/providers/internal/selectel/client_test.go index fd658ae..703fd7b 100644 --- a/providers/internal/selectel/client_test.go +++ b/providers/internal/selectel/client_test.go @@ -78,7 +78,7 @@ func TestClient_ListRecords_error(t *testing.T) { records, err := client.ListRecords(context.Background(), 123) - assert.EqualError(t, err, "request failed with status code 401: API error: 400 - error description - field that the error occurred in") + require.EqualError(t, err, "request failed with status code 401: API error: 400 - error description - field that the error occurred in") assert.Nil(t, records) } diff --git a/providers/inwx/inwx.go b/providers/inwx/inwx.go index f08df71..b6dd29a 100644 --- a/providers/inwx/inwx.go +++ b/providers/inwx/inwx.go @@ -51,8 +51,9 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - config *Config - client *goinwx.Client + config *Config + client *goinwx.Client + previousUnlock time.Time } // NewDNSProvider returns a DNSProvider instance configured for Dyn DNS. @@ -108,7 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { defer func() { errL := d.client.Account.Logout() if errL != nil { - log.Infof("inwx: failed to logout: %v", errL) + log.Infof("inwx: failed to log out: %v", errL) } }() @@ -158,7 +159,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { defer func() { errL := d.client.Account.Logout() if errL != nil { - log.Infof("inwx: failed to logout: %v", errL) + log.Infof("inwx: failed to log out: %v", errL) } }() @@ -199,13 +200,38 @@ func (d *DNSProvider) twoFactorAuth(info *goinwx.LoginResponse) error { } if d.config.SharedSecret == "" { - return errors.New("two factor authentication but no shared secret is given") + return errors.New("two-factor authentication but no shared secret is given") } - tan, err := totp.GenerateCode(d.config.SharedSecret, time.Now()) + // INWX forbids re-authentication with a previously used TAN. + // To avoid using the same TAN twice, we wait until the next TOTP period. + sleep := d.computeSleep(time.Now()) + if sleep != 0 { + log.Infof("inwx: waiting %s for next TOTP token", sleep) + time.Sleep(sleep) + } + + now := time.Now() + + tan, err := totp.GenerateCode(d.config.SharedSecret, now) if err != nil { return err } + d.previousUnlock = now.Truncate(30 * time.Second) + return d.client.Account.Unlock(tan) } + +func (d *DNSProvider) computeSleep(now time.Time) time.Duration { + if d.previousUnlock.IsZero() { + return 0 + } + + endPeriod := d.previousUnlock.Add(30 * time.Second) + if endPeriod.After(now) { + return endPeriod.Sub(now) + } + + return 0 +} diff --git a/providers/inwx/inwx_test.go b/providers/inwx/inwx_test.go index 14dbd43..f0c8f36 100644 --- a/providers/inwx/inwx_test.go +++ b/providers/inwx/inwx_test.go @@ -2,8 +2,10 @@ package inwx import ( "testing" + "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -141,3 +143,45 @@ func TestLivePresentAndCleanup(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func Test_computeSleep(t *testing.T) { + testCases := []struct { + desc string + now string + expected time.Duration + }{ + { + desc: "after 30s", + now: "2024-01-01T06:30:30Z", + expected: 0 * time.Second, + }, + { + desc: "0s", + now: "2024-01-01T06:30:00Z", + expected: 0 * time.Second, + }, + { + desc: "before 30s", + now: "2024-01-01T06:29:40Z", // 10 s + expected: 20 * time.Second, + }, + } + + previous, err := time.Parse(time.RFC3339, "2024-01-01T06:29:30Z") + require.NoError(t, err) + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + now, err := time.Parse(time.RFC3339, test.now) + require.NoError(t, err) + + d := &DNSProvider{previousUnlock: previous} + + sleep := d.computeSleep(now) + assert.Equal(t, test.expected, sleep) + }) + } +} diff --git a/providers/ionos/ionos.go b/providers/ionos/ionos.go index d12e34a..5ad017d 100644 --- a/providers/ionos/ionos.go +++ b/providers/ionos/ionos.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "time" @@ -171,8 +172,8 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { } for _, record := range records { - if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == info.Value { - err := d.client.RemoveRecord(ctx, zone.ID, record.ID) + if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == strconv.Quote(info.Value) { + err = d.client.RemoveRecord(ctx, zone.ID, record.ID) if err != nil { return fmt.Errorf("ionos: failed to remove record (zone=%s, record=%s): %w", zone.ID, record.ID, err) } @@ -180,7 +181,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { } } - return nil + return fmt.Errorf("ionos: failed to remove record, record not found (zone=%s, domain=%s, fqdn=%s, value=%s)", zone.ID, domain, info.EffectiveFQDN, info.Value) } func findZone(zones []internal.Zone, domain string) *internal.Zone { diff --git a/providers/ipv64/internal/client.go b/providers/ipv64/internal/client.go new file mode 100644 index 0000000..277c3b8 --- /dev/null +++ b/providers/ipv64/internal/client.go @@ -0,0 +1,153 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" + "golang.org/x/oauth2" +) + +const defaultBaseURL = "https://ipv64.net" + +type Client struct { + baseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(hc *http.Client) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + if hc == nil { + hc = &http.Client{Timeout: 15 * time.Second} + } + + return &Client{ + baseURL: baseURL, + HTTPClient: hc, + } +} + +func (c Client) GetDomains(ctx context.Context) (*Domains, error) { + endpoint := c.baseURL.JoinPath("api") + + query := endpoint.Query() + query.Set("get_domains", "") + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + results := &Domains{} + + err = c.do(req, results) + if err != nil { + return nil, err + } + + return results, nil +} + +func (c Client) AddRecord(ctx context.Context, domain, prefix, recordType, content string) error { + endpoint := c.baseURL.JoinPath("api") + + data := make(url.Values) + data.Set("add_record", domain) + data.Set("praefix", prefix) + data.Set("type", recordType) + data.Set("content", content) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + return c.do(req, nil) +} + +func (c Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, content string) error { + endpoint := c.baseURL.JoinPath("api") + + data := make(url.Values) + data.Set("del_record", domain) + data.Set("praefix", prefix) + data.Set("type", recordType) + data.Set("content", content) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint.String(), strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + return c.do(req, nil) +} + +func (c Client) do(req *http.Request, result any) error { + if req.Method != http.MethodGet { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + 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) + } + + if string(raw) == "null" { + return fmt.Errorf("unexpected response: %s", string(raw)) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + errAPI := &APIError{} + err := json.Unmarshal(raw, errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return errAPI +} + +func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + + client.Transport = &oauth2.Transport{ + Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), + Base: client.Transport, + } + + return client +} diff --git a/providers/ipv64/internal/client_test.go b/providers/ipv64/internal/client_test.go new file mode 100644 index 0000000..1966f9f --- /dev/null +++ b/providers/ipv64/internal/client_test.go @@ -0,0 +1,149 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAPIKey = "secret" + +func setupTest(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + + server := httptest.NewServer(handler) + + client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func testHandler(method, filename string, statusCode int) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + auth := req.Header.Get("Authorization") + if auth != "Bearer "+testAPIKey { + http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) + return + } + + file, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + rw.WriteHeader(statusCode) + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func TestClient_GetDomains(t *testing.T) { + client := setupTest(t, testHandler(http.MethodGet, "get_domains.json", http.StatusOK)) + + domains, err := client.GetDomains(context.Background()) + require.NoError(t, err) + + expected := &Domains{ + APIResponse: APIResponse{ + Status: "200 OK", + Info: "success", + }, + APICall: "get_domains", + Subdomains: map[string]Subdomain{ + "lego.home64.net": { + Updates: 0, + Wildcard: 1, + DomainUpdateHash: "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", + Records: []Record{ + { + RecordID: 50665, + Content: "2606:2800:220:1:248:1893:25c8:1946", + TTL: 60, + Type: "AAAA", + Prefix: "", + LastUpdate: "2023-07-19 13:18:59", + RecordKey: "MTA0YzdmMWVjYTFiNDBmZjYwMTU0OGUy", + }, + }, + }, + "lego.ipv64.net": { + Updates: 0, + Wildcard: 1, + DomainUpdateHash: "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", + Records: []Record{ + { + RecordID: 50664, + Content: "2606:2800:220:1:248:1893:25c8:1946", + TTL: 60, + Type: "AAAA", + Prefix: "", + LastUpdate: "2023-07-19 13:18:59", + RecordKey: "ZDMxOWUxMjZjOTk5MmQ3N2M3ODc4NjJj", + }, + }, + }, + }, + } + + assert.Equal(t, expected, domains) +} + +func TestClient_GetDomains_error(t *testing.T) { + client := setupTest(t, testHandler(http.MethodGet, "error.json", http.StatusUnauthorized)) + + domains, err := client.GetDomains(context.Background()) + require.Error(t, err) + + require.Nil(t, domains) +} + +func TestClient_AddRecord(t *testing.T) { + client := setupTest(t, testHandler(http.MethodPost, "add_record.json", http.StatusCreated)) + + err := client.AddRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, testHandler(http.MethodPost, "add_record-error.json", http.StatusBadRequest)) + + err := client.AddRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + require.Error(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, testHandler(http.MethodDelete, "del_record.json", http.StatusAccepted)) + + err := client.DeleteRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := setupTest(t, testHandler(http.MethodDelete, "del_record-error.json", http.StatusBadRequest)) + + err := client.DeleteRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") + require.Error(t, err) +} diff --git a/providers/ipv64/internal/fixtures/add_record-error.json b/providers/ipv64/internal/fixtures/add_record-error.json new file mode 100644 index 0000000..73f3098 --- /dev/null +++ b/providers/ipv64/internal/fixtures/add_record-error.json @@ -0,0 +1,5 @@ +{ + "info": "error", + "status": "400 Bad Request", + "add_record": "dns record already there" +} diff --git a/providers/ipv64/internal/fixtures/add_record.json b/providers/ipv64/internal/fixtures/add_record.json new file mode 100644 index 0000000..2e5a64e --- /dev/null +++ b/providers/ipv64/internal/fixtures/add_record.json @@ -0,0 +1,5 @@ +{ + "info": "success", + "status": "201 Created", + "add_record": "lego.ipv64.net" +} diff --git a/providers/ipv64/internal/fixtures/del_record-error.json b/providers/ipv64/internal/fixtures/del_record-error.json new file mode 100644 index 0000000..a3d108d --- /dev/null +++ b/providers/ipv64/internal/fixtures/del_record-error.json @@ -0,0 +1,5 @@ +{ + "info": "error", + "status": "403 Forbidden", + "del_record": "del_record" +} diff --git a/providers/ipv64/internal/fixtures/del_record.json b/providers/ipv64/internal/fixtures/del_record.json new file mode 100644 index 0000000..4e27626 --- /dev/null +++ b/providers/ipv64/internal/fixtures/del_record.json @@ -0,0 +1,5 @@ +{ + "info": "success", + "status": "202 Accepted", + "del_record": "del_record" +} diff --git a/providers/ipv64/internal/fixtures/error.json b/providers/ipv64/internal/fixtures/error.json new file mode 100644 index 0000000..ba0fe85 --- /dev/null +++ b/providers/ipv64/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "status": "401 Unauthorized", + "info": "Unauthorized" +} diff --git a/providers/ipv64/internal/fixtures/get_domains.json b/providers/ipv64/internal/fixtures/get_domains.json new file mode 100644 index 0000000..3cb1de8 --- /dev/null +++ b/providers/ipv64/internal/fixtures/get_domains.json @@ -0,0 +1,39 @@ +{ + "subdomains": { + "lego.ipv64.net": { + "updates": 0, + "wildcard": 1, + "domain_update_hash": "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", + "records": [ + { + "record_id": 50664, + "content": "2606:2800:220:1:248:1893:25c8:1946", + "ttl": 60, + "type": "AAAA", + "praefix": "", + "last_update": "2023-07-19 13:18:59", + "record_key": "ZDMxOWUxMjZjOTk5MmQ3N2M3ODc4NjJj" + } + ] + }, + "lego.home64.net": { + "updates": 0, + "wildcard": 1, + "domain_update_hash": "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", + "records": [ + { + "record_id": 50665, + "content": "2606:2800:220:1:248:1893:25c8:1946", + "ttl": 60, + "type": "AAAA", + "praefix": "", + "last_update": "2023-07-19 13:18:59", + "record_key": "MTA0YzdmMWVjYTFiNDBmZjYwMTU0OGUy" + } + ] + } + }, + "info": "success", + "status": "200 OK", + "add_domain": "get_domains" +} diff --git a/providers/ipv64/internal/types.go b/providers/ipv64/internal/types.go new file mode 100644 index 0000000..e9e357e --- /dev/null +++ b/providers/ipv64/internal/types.go @@ -0,0 +1,63 @@ +package internal + +import "fmt" + +type APIResponse struct { + Status string `json:"status"` + Info string `json:"info"` +} + +// error + +type APIError struct { + APIResponse + AddRecordMessage string `json:"add_record"` + DelRecordMessage string `json:"del_record"` + AddDomainMessage string `json:"add_domain"` + DelDomainMessage string `json:"del_domain"` +} + +func (a APIError) Error() string { + msg := a.Info + switch { + case a.AddRecordMessage != "": + msg = a.AddRecordMessage + case a.DelRecordMessage != "": + msg = a.DelRecordMessage + case a.AddDomainMessage != "": + msg = a.AddDomainMessage + case a.DelDomainMessage != "": + msg = a.DelDomainMessage + } + + if msg == "" { + return fmt.Sprintf("%s: %s", a.Status, a.Info) + } + + return fmt.Sprintf("%s (%s): %s", a.Info, a.Status, msg) +} + +// get_domains + +type Domains struct { + APIResponse + APICall string `json:"add_domain"` + Subdomains map[string]Subdomain `json:"subdomains"` +} + +type Subdomain struct { + Updates int `json:"updates"` + Wildcard int `json:"wildcard"` + DomainUpdateHash string `json:"domain_update_hash"` + Records []Record `json:"records"` +} + +type Record struct { + RecordID int `json:"record_id"` + Content string `json:"content"` + TTL int `json:"ttl"` + Type string `json:"type"` + Prefix string `json:"praefix"` + LastUpdate string `json:"last_update"` + RecordKey string `json:"record_key"` +} diff --git a/providers/ipv64/ipv64.go b/providers/ipv64/ipv64.go new file mode 100644 index 0000000..c272095 --- /dev/null +++ b/providers/ipv64/ipv64.go @@ -0,0 +1,143 @@ +// Package ipv64 implements a DNS provider for solving the DNS-01 challenge using IPv64. +// See https://ipv64.net/healthcheck_updater_api for more info on updating TXT records. +package ipv64 + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/ipv64/internal" + "github.com/miekg/dns" +) + +// Environment variables names. +const ( + envNamespace = "IPV64_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" // Deprecated: unused, will be removed in v5. +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client + SequenceInterval time.Duration // Deprecated: unused, will be removed in v5. +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + 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 new DNS provider using +// environment variable IPV64_TOKEN for adding and removing the DNS record. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("ipv64: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for IPv64. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ipv64: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("ipv64: credentials missing") + } + + client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey)) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{config: config, client: client}, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + sub, root, err := splitDomain(dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("ipv64: %w", err) + } + + err = d.client.AddRecord(context.Background(), root, sub, "TXT", info.Value) + if err != nil { + return fmt.Errorf("ipv64: %w", err) + } + + return nil +} + +// CleanUp clears IPv64 TXT record. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + sub, root, err := splitDomain(dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("ipv64: %w", err) + } + + err = d.client.DeleteRecord(context.Background(), root, sub, "TXT", info.Value) + if err != nil { + return fmt.Errorf("ipv64: %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 splitDomain(full string) (string, string, error) { + split := dns.Split(full) + if len(split) < 3 { + return "", "", fmt.Errorf("unsupported domain: %s", full) + } + + if len(split) == 3 { + return "", full, nil + } + + domain := full[split[len(split)-3]:] + subDomain := full[:split[len(split)-3]-1] + + return subDomain, domain, nil +} diff --git a/providers/ipv64/ipv64.toml b/providers/ipv64/ipv64.toml new file mode 100644 index 0000000..6bcf841 --- /dev/null +++ b/providers/ipv64/ipv64.toml @@ -0,0 +1,22 @@ +Name = "IPv64" +Description = '''''' +URL = "https://ipv64.net/" +Code = "ipv64" +Since = "v4.13.0" + +Example = ''' +IPV64_API_KEY=xxxxxx \ +lego --email you@example.com --dns ipv64 --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + IPV64_API_KEY = "Account API Key" + [Configuration.Additional] + IPV64_POLLING_INTERVAL = "Time between DNS propagation check" + IPV64_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + IPV64_TTL = "The TTL of the TXT record used for the DNS challenge" + IPV64_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://ipv64.net/dyndns_updater_api" diff --git a/providers/ipv64/ipv64_test.go b/providers/ipv64/ipv64_test.go new file mode 100644 index 0000000..114919e --- /dev/null +++ b/providers/ipv64/ipv64_test.go @@ -0,0 +1,195 @@ +package ipv64 + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func Test_splitDomain(t *testing.T) { + type expected struct { + root string + sub string + requireErr require.ErrorAssertionFunc + } + + testCases := []struct { + desc string + domain string + expected expected + }{ + { + desc: "empty", + domain: "", + expected: expected{ + requireErr: require.Error, + }, + }, + { + desc: "2 levels", + domain: "example.com", + expected: expected{ + requireErr: require.Error, + }, + }, + { + desc: "3 levels", + domain: "_acme-challenge.example.com", + expected: expected{ + root: "_acme-challenge.example.com", + sub: "", + requireErr: require.NoError, + }, + }, + { + desc: "4 levels", + domain: "_acme-challenge.sub.example.com", + expected: expected{ + root: "sub.example.com", + sub: "_acme-challenge", + requireErr: require.NoError, + }, + }, + { + desc: "5 levels", + domain: "_acme-challenge.my.sub.example.com", + expected: expected{ + root: "sub.example.com", + sub: "_acme-challenge.my", + requireErr: require.NoError, + }, + }, + { + desc: "6 levels", + domain: "_acme-challenge.my.sub.sub.example.com", + expected: expected{ + root: "sub.example.com", + sub: "_acme-challenge.my.sub", + requireErr: require.NoError, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + sub, root, err := splitDomain(test.domain) + test.expected.requireErr(t, err) + + assert.Equal(t, test.expected.root, root) + assert.Equal(t, test.expected.sub, sub) + }) + } +} + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "123", + }, + }, + { + desc: "missing api key", + envVars: map[string]string{ + EnvAPIKey: "", + }, + expected: "ipv64: some credentials information are missing: IPV64_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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + expected string + }{ + { + desc: "success", + apiKey: "123", + }, + { + desc: "missing credentials", + expected: "ipv64: 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) + } 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(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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/joker/internal/dmapi/client_test.go b/providers/joker/internal/dmapi/client_test.go index 7bdb07e..a1240b7 100644 --- a/providers/joker/internal/dmapi/client_test.go +++ b/providers/joker/internal/dmapi/client_test.go @@ -226,8 +226,8 @@ func Test_RemoveTxtEntryFromZone(t *testing.T) { t.Parallel() zone, modified := RemoveTxtEntryFromZone(test.input, "_acme-challenge") - assert.Equal(t, zone, test.expected) - assert.Equal(t, modified, test.modified) + assert.Equal(t, test.expected, zone) + assert.Equal(t, test.modified, modified) }) } } @@ -258,7 +258,7 @@ func Test_AddTxtEntryToZone(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { zone := AddTxtEntryToZone(test.input, "_acme-challenge", "test", 120) - assert.Equal(t, zone, test.expected) + assert.Equal(t, test.expected, zone) }) } } @@ -299,7 +299,7 @@ func Test_fixTxtLines(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { line := fixTxtLines(test.input) - assert.Equal(t, line, test.expected) + assert.Equal(t, test.expected, line) }) } } diff --git a/providers/joker/joker.toml b/providers/joker/joker.toml index 52a17c7..786097a 100644 --- a/providers/joker/joker.toml +++ b/providers/joker/joker.toml @@ -29,7 +29,7 @@ In the SVC mode, username and passsword are not your email and account passwords As per [Joker.com documentation](https://joker.com/faq/content/6/496/en/let_s-encrypt-support.html): -> 1. please login at Joker.com, visit 'My Domains', +> 1. please log in at Joker.com, visit 'My Domains', > find the domain you want to add Let's Encrypt certificate for, and chose "DNS" in the menu > > 2. on the top right, you will find the setting for 'Dynamic DNS'. diff --git a/providers/joker/provider_dmapi.go b/providers/joker/provider_dmapi.go index 4e68630..be192fc 100644 --- a/providers/joker/provider_dmapi.go +++ b/providers/joker/provider_dmapi.go @@ -78,7 +78,7 @@ func (d *dmapiProvider) Present(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) @@ -116,7 +116,7 @@ func (d *dmapiProvider) CleanUp(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) diff --git a/providers/joker/provider_svc.go b/providers/joker/provider_svc.go index 6c881c7..5884b75 100644 --- a/providers/joker/provider_svc.go +++ b/providers/joker/provider_svc.go @@ -59,7 +59,7 @@ func (d *svcProvider) Present(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) @@ -76,7 +76,7 @@ func (d *svcProvider) CleanUp(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) diff --git a/providers/liara/liara.go b/providers/liara/liara.go index bf619a4..da0b3db 100644 --- a/providers/liara/liara.go +++ b/providers/liara/liara.go @@ -123,7 +123,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("liara: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("liara: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -155,7 +155,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("liara: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("liara: could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID diff --git a/providers/lightsail/lightsail.go b/providers/lightsail/lightsail.go index 98220fc..aaf26d8 100644 --- a/providers/lightsail/lightsail.go +++ b/providers/lightsail/lightsail.go @@ -2,17 +2,18 @@ package lightsail import ( + "context" "errors" "fmt" "math/rand" "strconv" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/client" - "github.com/aws/aws-sdk-go/aws/request" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/lightsail" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/retry" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/lightsail" + awstypes "github.com/aws/aws-sdk-go-v2/service/lightsail/types" "github.com/entrustcorporation/dv/dns01" "github.com/go-acme/lego/v4/platform/config/env" ) @@ -32,27 +33,6 @@ const ( EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) -// customRetryer implements the client.Retryer interface by composing the DefaultRetryer. -// It controls the logic for retrying recoverable request errors (e.g. when rate limits are exceeded). -type customRetryer struct { - client.DefaultRetryer -} - -// RetryRules overwrites the DefaultRetryer's method. -// It uses a basic exponential backoff algorithm that returns an initial -// delay of ~400ms with an upper limit of ~30 seconds which should prevent -// causing a high number of consecutive throttling errors. -// For reference: Route 53 enforces an account-wide(!) 5req/s query limit. -func (c customRetryer) RetryRules(r *request.Request) time.Duration { - retryCount := r.RetryCount - if retryCount > 7 { - retryCount = 7 - } - - delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) - return time.Duration(delay) * time.Millisecond -} - // Config is used to configure the creation of the DNSProvider. type Config struct { DNSZone string @@ -71,7 +51,7 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - client *lightsail.Lightsail + client *lightsail.Client config *Config } @@ -102,35 +82,55 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("lightsail: the configuration of the DNS provider is nil") } - retryer := customRetryer{} - retryer.NumMaxRetries = maxRetries - - conf := aws.NewConfig().WithRegion(config.Region) - sess, err := session.NewSession(request.WithRetryer(conf, retryer)) + ctx := context.Background() + + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(config.Region), + awsconfig.WithRetryer(func() aws.Retryer { + return retry.NewStandard(func(options *retry.StandardOptions) { + options.MaxAttempts = maxRetries + + // It uses a basic exponential backoff algorithm that returns an initial + // delay of ~400ms with an upper limit of ~30 seconds which should prevent + // causing a high number of consecutive throttling errors. + // For reference: Route 53 enforces an account-wide(!) 5req/s query limit. + options.Backoff = retry.BackoffDelayerFunc(func(attempt int, err error) (time.Duration, error) { + retryCount := attempt + if retryCount > 7 { + retryCount = 7 + } + + delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) + return time.Duration(delay) * time.Millisecond, nil + }) + }) + }), + ) if err != nil { return nil, err } return &DNSProvider{ config: config, - client: lightsail.New(sess), + client: lightsail.NewFromConfig(cfg), }, nil } // Present creates a TXT record using the specified parameters. -func (d *DNSProvider) Present(domain, token, keyAuth string) error { +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) params := &lightsail.CreateDomainEntryInput{ DomainName: aws.String(d.config.DNSZone), - DomainEntry: &lightsail.DomainEntry{ + DomainEntry: &awstypes.DomainEntry{ Name: aws.String(info.EffectiveFQDN), Target: aws.String(strconv.Quote(info.Value)), Type: aws.String("TXT"), }, } - _, err := d.client.CreateDomainEntry(params) + _, err := d.client.CreateDomainEntry(ctx, params) if err != nil { return fmt.Errorf("lightsail: %w", err) } @@ -139,19 +139,20 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) params := &lightsail.DeleteDomainEntryInput{ DomainName: aws.String(d.config.DNSZone), - DomainEntry: &lightsail.DomainEntry{ + DomainEntry: &awstypes.DomainEntry{ Name: aws.String(info.EffectiveFQDN), Type: aws.String("TXT"), Target: aws.String(strconv.Quote(info.Value)), }, } - _, err := d.client.DeleteDomainEntry(params) + _, err := d.client.DeleteDomainEntry(ctx, params) if err != nil { return fmt.Errorf("lightsail: %w", err) } diff --git a/providers/lightsail/lightsail.toml b/providers/lightsail/lightsail.toml index fab06ef..4ade894 100644 --- a/providers/lightsail/lightsail.toml +++ b/providers/lightsail/lightsail.toml @@ -56,4 +56,4 @@ Alternatively, you can also set the `Resource` to `*` (wildcard), which allow to LIGHTSAIL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" [Links] - GoClient = "https://github.com/aws/aws-sdk-go/" + GoClient = "https://github.com/aws/aws-sdk-go-v2" diff --git a/providers/lightsail/lightsail_integration_test.go b/providers/lightsail/lightsail_integration_test.go index 4eb7997..20e45ee 100644 --- a/providers/lightsail/lightsail_integration_test.go +++ b/providers/lightsail/lightsail_integration_test.go @@ -1,11 +1,12 @@ package lightsail import ( + "context" "testing" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/lightsail" + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/stretchr/testify/require" ) @@ -24,13 +25,15 @@ func TestLiveTTL(t *testing.T) { err = provider.Present(domain, "foo", "bar") require.NoError(t, err) - // we need a separate Lightsail client here as the one in the DNS provider is - // unexported. + // we need a separate Lightsail client here as the one in the DNS provider is unexported. fqdn := "_acme-challenge." + domain - sess, err := session.NewSession() + + ctx := context.Background() + + cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) - svc := lightsail.New(sess) + svc := lightsail.NewFromConfig(cfg) require.NoError(t, err) defer func() { @@ -44,15 +47,24 @@ func TestLiveTTL(t *testing.T) { DomainName: aws.String(domain), } - resp, err := svc.GetDomain(params) + resp, err := svc.GetDomain(ctx, params) require.NoError(t, err) entries := resp.Domain.DomainEntries for _, entry := range entries { - if aws.StringValue(entry.Type) == "TXT" && aws.StringValue(entry.Name) == fqdn { + if deref(entry.Type) == "TXT" && deref(entry.Name) == fqdn { return } } t.Fatalf("Could not find a TXT record for _acme-challenge.%s", domain) } + +func deref[T string | int | int32 | int64 | bool](v *T) T { + if v == nil { + var zero T + return zero + } + + return *v +} diff --git a/providers/lightsail/lightsail_test.go b/providers/lightsail/lightsail_test.go index f385745..8ff60c1 100644 --- a/providers/lightsail/lightsail_test.go +++ b/providers/lightsail/lightsail_test.go @@ -1,14 +1,16 @@ package lightsail import ( + "context" "os" "testing" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/lightsail" + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -29,23 +31,26 @@ var envTest = tester.NewEnvTest( WithDomain(EnvDNSZone). WithLiveTestRequirements(envAwsAccessKeyID, envAwsSecretAccessKey, EnvDNSZone) -func makeProvider(serverURL string) (*DNSProvider, error) { - config := &aws.Config{ - Credentials: credentials.NewStaticCredentials("abc", "123", " "), - Endpoint: aws.String(serverURL), - Region: aws.String("mock-region"), - MaxRetries: aws.Int(1), - } +type endpointResolverMock struct { + endpoint string +} - sess, err := session.NewSession(config) - if err != nil { - return nil, err - } +func (e endpointResolverMock) ResolveEndpoint(_, _ string, _ ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{URL: e.endpoint}, nil +} - conf := NewDefaultConfig() +func makeProvider(serverURL string) *DNSProvider { + config := aws.Config{ + Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), + Region: "mock-region", + EndpointResolverWithOptions: endpointResolverMock{endpoint: serverURL}, + RetryMaxAttempts: 1, + } - client := lightsail.New(sess) - return &DNSProvider{client: client, config: conf}, nil + return &DNSProvider{ + client: lightsail.NewFromConfig(config), + config: NewDefaultConfig(), + } } func TestCredentialsFromEnv(t *testing.T) { @@ -56,15 +61,19 @@ func TestCredentialsFromEnv(t *testing.T) { _ = os.Setenv(envAwsSecretAccessKey, "123") _ = os.Setenv(envAwsRegion, "us-east-1") - config := &aws.Config{ - CredentialsChainVerboseErrors: aws.Bool(true), - } - - sess, err := session.NewSession(config) + ctx := context.Background() + cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) - _, err = sess.Config.Credentials.Get() + cs, err := cfg.Credentials.Retrieve(ctx) require.NoError(t, err, "Expected credentials to be set from environment") + + expected := aws.Credentials{ + AccessKeyID: "123", + SecretAccessKey: "123", + Source: "EnvConfigCredentials", + } + assert.Equal(t, expected, cs) } func TestDNSProvider_Present(t *testing.T) { @@ -74,12 +83,11 @@ func TestDNSProvider_Present(t *testing.T) { serverURL := newMockServer(t, mockResponses) - provider, err := makeProvider(serverURL) - require.NoError(t, err) + provider := makeProvider(serverURL) domain := "example.com" keyAuth := "123456d==" - err = provider.Present(domain, "", keyAuth) + err := provider.Present(domain, "", keyAuth) require.NoError(t, err, "Expected Present to return no error") } diff --git a/providers/linode/linode.go b/providers/linode/linode.go index f9fc3dd..f2d1ae2 100644 --- a/providers/linode/linode.go +++ b/providers/linode/linode.go @@ -104,8 +104,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return &DNSProvider{config: config, client: &client}, nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// 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() (time.Duration, time.Duration) { timeout := d.config.PropagationTimeout if d.config.PropagationTimeout <= 0 { @@ -177,7 +177,7 @@ func (d *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) { // Lookup the zone that handles the specified FQDN. authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return nil, fmt.Errorf("inwx: could not find zone for FQDN %q: %w", fqdn, err) + return nil, fmt.Errorf("could not find zone: %w", err) } // Query the authority zone. diff --git a/providers/linode/linode_test.go b/providers/linode/linode_test.go index 7b12cbd..70b33ed 100644 --- a/providers/linode/linode_test.go +++ b/providers/linode/linode_test.go @@ -197,7 +197,7 @@ func TestDNSProvider_Present(t *testing.T) { Page: 1, }, Data: []linodego.Domain{{ - Domain: "foobar.com", + Domain: "example.com", ID: 1234, }}, }, diff --git a/providers/liquidweb/liquidweb.go b/providers/liquidweb/liquidweb.go index c9b0c14..7d4407c 100644 --- a/providers/liquidweb/liquidweb.go +++ b/providers/liquidweb/liquidweb.go @@ -4,7 +4,9 @@ package liquidweb import ( "errors" "fmt" + "sort" "strconv" + "strings" "sync" "time" @@ -14,11 +16,12 @@ import ( "github.com/liquidweb/liquidweb-go/network" ) -const defaultBaseURL = "https://api.stormondemand.com" +const defaultBaseURL = "https://api.liquidweb.com" // Environment variables names. const ( - envNamespace = "LIQUID_WEB_" + envNamespace = "LIQUID_WEB_" + altEnvNamespace = "LWAPI_" EnvURL = envNamespace + "URL" EnvUsername = envNamespace + "USERNAME" @@ -45,15 +48,13 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { - config := &Config{ + return &Config{ BaseURL: defaultBaseURL, - TTL: env.GetOrDefaultInt(EnvTTL, 300), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), - HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 1*time.Minute), + TTL: env.GetOneWithFallback(EnvTTL, 300, strconv.Atoi, altEnvName(EnvTTL)), + PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)), + PollingInterval: env.GetOneWithFallback(EnvPollingInterval, 2*time.Second, env.ParseSecond, altEnvName(EnvPollingInterval)), + HTTPTimeout: env.GetOneWithFallback(EnvHTTPTimeout, 1*time.Minute, env.ParseSecond, altEnvName(EnvHTTPTimeout)), } - - return config } // DNSProvider implements the challenge.Provider interface. @@ -66,16 +67,19 @@ type DNSProvider struct { // NewDNSProvider returns a DNSProvider instance configured for Liquid Web. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvUsername, EnvPassword, EnvZone) + values, err := env.GetWithFallback( + []string{EnvUsername, altEnvName(EnvUsername)}, + []string{EnvPassword, altEnvName(EnvPassword)}, + ) if err != nil { return nil, fmt.Errorf("liquidweb: %w", err) } config := NewDefaultConfig() - config.BaseURL = env.GetOrFile(EnvURL) + config.BaseURL = env.GetOneWithFallback(EnvURL, defaultBaseURL, env.ParseString, altEnvName(EnvURL)) config.Username = values[EnvUsername] config.Password = values[EnvPassword] - config.Zone = values[EnvZone] + config.Zone = env.GetOneWithFallback(EnvZone, "", env.ParseString, altEnvName(EnvZone)) return NewDNSProviderConfig(config) } @@ -90,19 +94,6 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { config.BaseURL = defaultBaseURL } - if config.Zone == "" { - return nil, errors.New("liquidweb: zone is missing") - } - - if config.Username == "" { - return nil, errors.New("liquidweb: username is missing") - } - - if config.Password == "" { - return nil, errors.New("liquidweb: password is missing") - } - - // Initialize LW client. client, err := lw.NewAPI(config.Username, config.Password, config.BaseURL, int(config.HTTPTimeout.Seconds())) if err != nil { return nil, fmt.Errorf("liquidweb: could not create Liquid Web API client: %w", err) @@ -133,6 +124,15 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { TTL: d.config.TTL, } + if params.Zone == "" { + bestZone, err := d.findZone(params.Name) + if err != nil { + return fmt.Errorf("liquidweb: %w", err) + } + + params.Zone = bestZone + } + dnsEntry, err := d.client.NetworkDNS.Create(params) if err != nil { return fmt.Errorf("liquidweb: could not create TXT record: %w", err) @@ -167,3 +167,35 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } + +func (d *DNSProvider) findZone(domain string) (string, error) { + zones, err := d.client.NetworkDNSZone.ListAll() + if err != nil { + return "", fmt.Errorf("failed to retrieve zones for account: %w", err) + } + + // filter the zones on the account to only ones that match + var zs []network.DNSZone + for _, item := range zones.Items { + if strings.HasSuffix(domain, item.Name) { + zs = append(zs, item) + } + } + + if len(zs) < 1 { + return "", fmt.Errorf("no valid zone in account for certificate '%s'", domain) + } + + // powerdns _only_ looks for records on the longest matching subdomain zone aka, + // for test.sub.example.com if sub.example.com exists, + // it will look there it will not look atexample.com even if it also exists + sort.Slice(zs, func(i, j int) bool { + return len(zs[i].Name) > len(zs[j].Name) + }) + + return zs[0].Name, nil +} + +func altEnvName(v string) string { + return strings.ReplaceAll(v, envNamespace, altEnvNamespace) +} diff --git a/providers/liquidweb/liquidweb.toml b/providers/liquidweb/liquidweb.toml index 1da0944..c911691 100644 --- a/providers/liquidweb/liquidweb.toml +++ b/providers/liquidweb/liquidweb.toml @@ -5,24 +5,23 @@ Code = "liquidweb" Since = "v3.1.0" Example = ''' -LIQUID_WEB_USERNAME=someuser \ -LIQUID_WEB_PASSWORD=somepass \ -LIQUID_WEB_ZONE=tacoman.com.net \ +LWAPI_USERNAME=someuser \ +LWAPI_PASSWORD=somepass \ lego --email you@example.com --dns liquidweb --domains my.example.org run ''' [Configuration] [Configuration.Credentials] - LIQUID_WEB_USERNAME = "Storm API Username" - LIQUID_WEB_PASSWORD = "Storm API Password" - LIQUID_WEB_ZONE = "DNS Zone" + LWAPI_USERNAME = "Liquid Web API Username" + LWAPI_PASSWORD = "Liquid Web API Password" [Configuration.Additional] - LIQUID_WEB_URL = "Storm API endpoint" - LIQUID_WEB_TTL = "The TTL of the TXT record used for the DNS challenge" - LIQUID_WEB_POLLING_INTERVAL = "Time between DNS propagation check" - LIQUID_WEB_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - LIQUID_WEB_HTTP_TIMEOUT = "Maximum waiting time for the DNS records to be created (not verified)" + LWAPI_ZONE = "DNS Zone" + LWAPI_URL = "Liquid Web API endpoint" + LWAPI_TTL = "The TTL of the TXT record used for the DNS challenge" + LWAPI_POLLING_INTERVAL = "Time between DNS propagation check" + LWAPI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + LWAPI_HTTP_TIMEOUT = "Maximum waiting time for the DNS records to be created (not verified)" [Links] - API = "https://cart.liquidweb.com/storm/api/docs/v1/" + API = "https://api.liquidweb.com/docs/" GoClient = "https://github.com/liquidweb/liquidweb-go" diff --git a/providers/liquidweb/liquidweb_test.go b/providers/liquidweb/liquidweb_test.go index 17c0225..c62ff65 100644 --- a/providers/liquidweb/liquidweb_test.go +++ b/providers/liquidweb/liquidweb_test.go @@ -1,15 +1,11 @@ package liquidweb import ( - "fmt" - "io" - "net/http" - "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" + "github.com/liquidweb/liquidweb-go/network" "github.com/stretchr/testify/require" ) @@ -22,23 +18,20 @@ var envTest = tester.NewEnvTest( EnvZone). WithDomain(envDomain) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { +func setupTest(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider { t.Helper() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + serverURL := mockAPIServer(t, initRecs) config := NewDefaultConfig() config.Username = "blars" config.Password = "tacoman" - config.BaseURL = server.URL - config.Zone = "tacoman.com" + config.BaseURL = serverURL provider, err := NewDNSProviderConfig(config) require.NoError(t, err) - return provider, mux + return provider } func TestNewDNSProvider(t *testing.T) { @@ -48,7 +41,14 @@ func TestNewDNSProvider(t *testing.T) { expected string }{ { - desc: "success", + desc: "minimum-success", + envVars: map[string]string{ + EnvUsername: "blars", + EnvPassword: "tacoman", + }, + }, + { + desc: "set-everything", envVars: map[string]string{ EnvURL: "https://storm.com", EnvUsername: "blars", @@ -59,7 +59,7 @@ func TestNewDNSProvider(t *testing.T) { { desc: "missing credentials", envVars: map[string]string{}, - expected: "liquidweb: some credentials information are missing: LIQUID_WEB_USERNAME,LIQUID_WEB_PASSWORD,LIQUID_WEB_ZONE", + expected: "liquidweb: some credentials information are missing: LIQUID_WEB_USERNAME,LIQUID_WEB_PASSWORD", }, { desc: "missing username", @@ -74,14 +74,8 @@ func TestNewDNSProvider(t *testing.T) { envVars: map[string]string{ EnvUsername: "blars", EnvZone: "blars.com", - }, expected: "liquidweb: some credentials information are missing: LIQUID_WEB_PASSWORD", - }, - { - desc: "missing zone", - envVars: map[string]string{ - EnvUsername: "blars", - EnvPassword: "tacoman", - }, expected: "liquidweb: some credentials information are missing: LIQUID_WEB_ZONE", + }, + expected: "liquidweb: some credentials information are missing: LIQUID_WEB_PASSWORD", }, } @@ -126,28 +120,21 @@ func TestNewDNSProviderConfig(t *testing.T) { username: "", password: "", zone: "", - expected: "liquidweb: zone is missing", + expected: "liquidweb: could not create Liquid Web API client: provided username is empty", }, { desc: "missing username", username: "", password: "secret", zone: "example.com", - expected: "liquidweb: username is missing", + expected: "liquidweb: could not create Liquid Web API client: provided username is empty", }, { desc: "missing password", username: "acme", password: "", zone: "example.com", - expected: "liquidweb: password is missing", - }, - { - desc: "missing zone", - username: "acme", - password: "secret", - zone: "", - expected: "liquidweb: zone is missing", + expected: "liquidweb: could not create Liquid Web API client: provided password is empty", }, } @@ -174,75 +161,102 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/v1/Network/DNS/Record/create", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - - username, password, ok := r.BasicAuth() - assert.Equal(t, "blars", username) - assert.Equal(t, "tacoman", password) - assert.True(t, ok) - - reqBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := ` - { - "params": { - "name": "_acme-challenge.tacoman.com", - "rdata": "\"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU\"", - "ttl": 300, - "type": "TXT", - "zone": "tacoman.com" - } - }` - assert.JSONEq(t, expectedReqBody, string(reqBody)) - - w.WriteHeader(http.StatusOK) - _, err = fmt.Fprintf(w, `{ - "type": "TXT", - "name": "_acme-challenge.tacoman.com", - "rdata": "\"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU\"", - "ttl": 300, - "id": 1234567, - "prio": null - }`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := setupTest(t) err := provider.Present("tacoman.com", "", "") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - provider, mux := setupTest(t) + provider := setupTest(t, network.DNSRecord{ + Name: "_acme-challenge.tacoman.com", + RData: "123d==", + Type: "TXT", + TTL: 300, + ID: 1234567, + ZoneID: 42, + }) - mux.HandleFunc("/v1/Network/DNS/Record/delete", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) + provider.recordIDs["123d=="] = 1234567 - username, password, ok := r.BasicAuth() - assert.Equal(t, "blars", username) - assert.Equal(t, "tacoman", password) - assert.True(t, ok) + err := provider.CleanUp("tacoman.com.", "123d==", "") + require.NoError(t, err) +} - _, err := fmt.Fprintf(w, `{"deleted": "123"}`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) +func TestDNSProvider(t *testing.T) { + testCases := []struct { + desc string + initRecs []network.DNSRecord + domain string + token string + keyAuth string + present bool + expPresentErr string + cleanup bool + }{ + { + desc: "expected successful", + domain: "tacoman.com", + token: "123", + keyAuth: "456", + present: true, + cleanup: true, + }, + { + desc: "other successful", + domain: "banana.com", + token: "123", + keyAuth: "456", + present: true, + cleanup: true, + }, + { + desc: "zone not on account", + domain: "huckleberry.com", + token: "123", + keyAuth: "456", + present: true, + expPresentErr: "no valid zone in account for certificate '_acme-challenge.huckleberry.com'", + cleanup: false, + }, + { + desc: "ssl for domain", + domain: "sundae.cherry.com", + token: "5847953", + keyAuth: "34872934", + present: true, + cleanup: true, + }, + { + desc: "complicated domain", + domain: "always.money.stand.banana.com", + token: "5847953", + keyAuth: "there is always money in the banana stand", + present: true, + cleanup: true, + }, + } - provider.recordIDs["123"] = 1234567 + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + provider := setupTest(t, test.initRecs...) + + if test.present { + err := provider.Present(test.domain, test.token, test.keyAuth) + if test.expPresentErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, test.expPresentErr) + } + } - err := provider.CleanUp("tacoman.com.", "123", "") - require.NoError(t, err, "fail to remove TXT record") + if test.cleanup { + err := provider.CleanUp(test.domain, test.token, test.keyAuth) + require.NoError(t, err) + } + }) + } } func TestLivePresent(t *testing.T) { @@ -251,7 +265,6 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() - provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/liquidweb/servermock_test.go b/providers/liquidweb/servermock_test.go new file mode 100644 index 0000000..8c22595 --- /dev/null +++ b/providers/liquidweb/servermock_test.go @@ -0,0 +1,305 @@ +package liquidweb + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/http/httptest" + "testing" + + "github.com/liquidweb/liquidweb-go/network" + "github.com/liquidweb/liquidweb-go/types" +) + +func mockAPIServer(t *testing.T, initRecs []network.DNSRecord) string { + t.Helper() + + recs := make(map[int]network.DNSRecord) + + for _, rec := range initRecs { + recs[int(rec.ID)] = rec + } + + mux := http.NewServeMux() + mux.Handle("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)) + mux.Handle("/v1/Network/DNS/Record/create", mockAPICreate(recs)) + mux.Handle("/v1/Network/DNS/Zone/list", mockAPIListZones()) + mux.Handle("/bleed/Network/DNS/Record/delete", mockAPIDelete(recs)) + mux.Handle("/bleed/Network/DNS/Record/create", mockAPICreate(recs)) + mux.Handle("/bleed/Network/DNS/Zone/list", mockAPIListZones()) + + server := httptest.NewServer(requireBasicAuth(requireJSON(mux))) + t.Cleanup(server.Close) + + return server.URL +} + +func requireBasicAuth(next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if ok && username == "blars" && password == "tacoman" { + next.ServeHTTP(w, r) + return + } + + http.Error(w, "invalid auth", http.StatusForbidden) + } +} + +func requireJSON(next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + buf := &bytes.Buffer{} + + _, err := buf.ReadFrom(r.Body) + if err != nil { + http.Error(w, "malformed request - json required", http.StatusBadRequest) + return + } + + r.Body = io.NopCloser(buf) + next.ServeHTTP(w, r) + } +} + +func mockAPICreate(recs map[int]network.DNSRecord) http.HandlerFunc { + _, mockAPIServerZones := makeMockZones() + + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "invalid request", http.StatusInternalServerError) + return + } + + req := struct { + Params network.DNSRecord `json:"params"` + }{} + + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, makeEncodingError(body), http.StatusBadRequest) + return + } + req.Params.ID = types.FlexInt(rand.Intn(10000000)) + req.Params.ZoneID = types.FlexInt(mockAPIServerZones[req.Params.Name]) + + if _, exists := recs[int(req.Params.ID)]; exists { + http.Error(w, "dns record already exists", http.StatusTeapot) + return + } + recs[int(req.Params.ID)] = req.Params + + resp, err := json.Marshal(req.Params) + if err != nil { + http.Error(w, "", http.StatusInternalServerError) + return + } + http.Error(w, string(resp), http.StatusOK) + } +} + +func mockAPIDelete(recs map[int]network.DNSRecord) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "invalid request", http.StatusInternalServerError) + return + } + + req := struct { + Params struct { + Name string `json:"name"` + ID int `json:"id"` + } `json:"params"` + }{} + + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, makeEncodingError(body), http.StatusBadRequest) + return + } + + if req.Params.ID == 0 { + http.Error(w, `{"error":"","error_class":"LW::Exception::Input::Multiple","errors":[{"error":"","error_class":"LW::Exception::Input::Required","field":"id","full_message":"The required field 'id' was missing a value.","position":null}],"field":["id"],"full_message":"The following input errors occurred:\nThe required field 'id' was missing a value.","type":null}`, http.StatusOK) + return + } + + if _, ok := recs[req.Params.ID]; !ok { + http.Error(w, fmt.Sprintf(`{"error":"","error_class":"LW::Exception::RecordNotFound","field":"network_dns_rr","full_message":"Record 'network_dns_rr: %d' not found","input":"%d","public_message":null}`, req.Params.ID, req.Params.ID), http.StatusOK) + return + } + delete(recs, req.Params.ID) + http.Error(w, fmt.Sprintf("{\"deleted\":%d}", req.Params.ID), http.StatusOK) + } +} + +func mockAPIListZones() http.HandlerFunc { + mockZones, mockAPIServerZones := makeMockZones() + + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "invalid request", http.StatusInternalServerError) + return + } + + req := struct { + Params struct { + PageNum int `json:"page_num"` + } `json:"params"` + }{} + + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, makeEncodingError(body), http.StatusBadRequest) + return + } + + switch { + case req.Params.PageNum < 1: + req.Params.PageNum = 1 + case req.Params.PageNum > len(mockZones): + req.Params.PageNum = len(mockZones) + } + resp := mockZones[req.Params.PageNum] + resp.ItemTotal = types.FlexInt(len(mockAPIServerZones)) + resp.PageNum = types.FlexInt(req.Params.PageNum) + resp.PageSize = 5 + resp.PageTotal = types.FlexInt(len(mockZones)) + + var respBody []byte + if respBody, err = json.Marshal(resp); err == nil { + http.Error(w, string(respBody), http.StatusOK) + return + } + + http.Error(w, "", http.StatusInternalServerError) + } +} + +func makeEncodingError(buf []byte) string { + return fmt.Sprintf(`{"data":"%q","encoding":"JSON","error":"unexpected end of string while parsing JSON string, at character offset 32 (before \"(end of string)\") at /usr/local/lp/libs/perl/LW/Base/Role/Serializer.pm line 16.\n","error_class":"LW::Exception::Deserialize","full_message":"Could not deserialize \"%q\" from JSON: unexpected end of string while parsing JSON string, at character offset 32 (before \"(end of string)\") at /usr/local/lp/libs/perl/LW/Base/Role/Serializer.pm line 16.\n"}⏎`, string(buf), string(buf)) +} + +func makeMockZones() (map[int]network.DNSZoneList, map[string]int) { + mockZones := map[int]network.DNSZoneList{ + 1: { + Items: []network.DNSZone{ + { + ID: 1, + Name: "blars.com", + Active: 1, + DelegationStatus: "CORRECT", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 2, + Name: "tacoman.com", + Active: 1, + DelegationStatus: "CORRECT", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 3, + Name: "storm.com", + Active: 1, + DelegationStatus: "CORRECT", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 4, + Name: "not-apple.com", + Active: 1, + DelegationStatus: "BAD_NAMESERVERS", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 5, + Name: "example.com", + Active: 1, + DelegationStatus: "BAD_NAMESERVERS", + PrimaryNameserver: "ns.liquidweb.com", + }, + }, + }, + 2: { + Items: []network.DNSZone{ + { + ID: 6, + Name: "banana.com", + Active: 1, + DelegationStatus: "NXDOMAIN", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 7, + Name: "cherry.com", + Active: 1, + DelegationStatus: "SERVFAIL", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 8, + Name: "dates.com", + Active: 1, + DelegationStatus: "SERVFAIL", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 9, + Name: "eggplant.com", + Active: 1, + DelegationStatus: "SERVFAIL", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 10, + Name: "fig.com", + Active: 1, + DelegationStatus: "UNKNOWN", + PrimaryNameserver: "ns.liquidweb.com", + }, + }, + }, + 3: { + Items: []network.DNSZone{ + { + ID: 11, + Name: "grapes.com", + Active: 1, + DelegationStatus: "UNKNOWN", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 12, + Name: "money.banana.com", + Active: 1, + DelegationStatus: "UNKNOWN", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 13, + Name: "money.stand.banana.com", + Active: 1, + DelegationStatus: "UNKNOWN", + PrimaryNameserver: "ns.liquidweb.com", + }, + { + ID: 14, + Name: "stand.banana.com", + Active: 1, + DelegationStatus: "UNKNOWN", + PrimaryNameserver: "ns.liquidweb.com", + }, + }, + }, + } + + mockAPIServerZones := make(map[string]int) + for _, page := range mockZones { + for _, zone := range page.Items { + mockAPIServerZones[zone.Name] = int(zone.ID) + } + } + return mockZones, mockAPIServerZones +} diff --git a/providers/loopia/internal/client_test.go b/providers/loopia/internal/client_test.go index e62fc2b..4fe2e1f 100644 --- a/providers/loopia/internal/client_test.go +++ b/providers/loopia/internal/client_test.go @@ -239,7 +239,7 @@ func TestClient_rpcCall_404(t *testing.T) { client.BaseURL = server.URL + "/" err := client.rpcCall(context.Background(), call, &responseString{}) - assert.EqualError(t, err, "unexpected status code: [status code: 404] body: ") + require.EqualError(t, err, "unexpected status code: [status code: 404] body: ") } func TestClient_rpcCall_RPCError(t *testing.T) { @@ -270,7 +270,7 @@ func TestClient_rpcCall_RPCError(t *testing.T) { client.BaseURL = server.URL + "/" err := client.rpcCall(context.Background(), call, &responseString{}) - assert.EqualError(t, err, "RPC Error: (201) Method signature error: 42") + require.EqualError(t, err, "RPC Error: (201) Method signature error: 42") } func TestUnmarshallFaultyRecordObject(t *testing.T) { diff --git a/providers/loopia/loopia.go b/providers/loopia/loopia.go index 9ff1b4f..3e7ac55 100644 --- a/providers/loopia/loopia.go +++ b/providers/loopia/loopia.go @@ -192,7 +192,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err = d.client.RemoveSubdomain(ctx, authZone, subDomain) if err != nil { - return fmt.Errorf("loopia: failed to remove sub-domain: %w", err) + return fmt.Errorf("loopia: failed to remove subdomain: %w", err) } return nil @@ -201,7 +201,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) { authZone, err := d.findZoneByFqdn(fqdn) if err != nil { - return "", "", fmt.Errorf("desec: could not find zone for FQDN %q: %w", fqdn, err) + return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, authZone) diff --git a/providers/loopia/loopia_mock_test.go b/providers/loopia/loopia_mock_test.go index f786b3b..bf4af59 100644 --- a/providers/loopia/loopia_mock_test.go +++ b/providers/loopia/loopia_mock_test.go @@ -3,7 +3,6 @@ package loopia import ( "context" "errors" - "fmt" "testing" "github.com/entrustcorporation/dv/providers/loopia/internal" @@ -47,7 +46,7 @@ func TestDNSProvider_Present(t *testing.T) { { desc: "AddTXTRecord fails", - addTXTRecordError: fmt.Errorf("unknown error: 'ADDTXT'"), + addTXTRecordError: errors.New("unknown error: 'ADDTXT'"), callAddTXTRecord: true, expectedError: "loopia: failed to add TXT record: unknown error: 'ADDTXT'", @@ -55,7 +54,7 @@ func TestDNSProvider_Present(t *testing.T) { { desc: "GetTXTRecords fails", - getTXTRecordsError: fmt.Errorf("unknown error: 'GETTXT'"), + getTXTRecordsError: errors.New("unknown error: 'GETTXT'"), callAddTXTRecord: true, callGetTXTRecords: true, @@ -149,10 +148,10 @@ func TestDNSProvider_Cleanup(t *testing.T) { callGetTXTRecords: true, callRemoveSubdomain: true, - expectedError: `loopia: failed to remove sub-domain: unknown error: "UNKNOWN_ERROR"`, + expectedError: `loopia: failed to remove subdomain: unknown error: "UNKNOWN_ERROR"`, }, { - desc: "Dont call removeSubdomain when records", + desc: "Don't call removeSubdomain when records", getTXTRecordsReturn: []internal.RecordObj{{Type: "TXT", Rdata: "LEFTOVER"}}, callAddTXTRecord: true, diff --git a/providers/luadns/luadns.go b/providers/luadns/luadns.go index fb4fc8e..e2d2720 100644 --- a/providers/luadns/luadns.go +++ b/providers/luadns/luadns.go @@ -124,7 +124,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("luadns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("luadns: could not find zone for domain %q: %w", domain, err) } zone := findZone(zones, dns01.UnFqdn(authZone)) diff --git a/providers/mailinabox/mailinabox.go b/providers/mailinabox/mailinabox.go new file mode 100644 index 0000000..07687b7 --- /dev/null +++ b/providers/mailinabox/mailinabox.go @@ -0,0 +1,131 @@ +// Package mailinabox implements a DNS provider for solving the DNS-01 challenge using Mail-in-a-Box. +package mailinabox + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/nrdcg/mailinabox" +) + +// Environment variables names. +const ( + envNamespace = "MAILINABOX_" + + EnvEmail = envNamespace + "EMAIL" + EnvPassword = envNamespace + "PASSWORD" + EnvBaseURL = envNamespace + "BASE_URL" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Email string + Password string + BaseURL string + PropagationTimeout time.Duration + PollingInterval time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *mailinabox.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Mail-in-a-Box. +// Credentials must be passed in the environment variables: +// MAILINABOX_EMAIL, MAILINABOX_PASSWORD, and MAILINABOX_BASE_URL. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvBaseURL, EnvEmail, EnvPassword) + if err != nil { + return nil, fmt.Errorf("mailinabox: %w", err) + } + + config := NewDefaultConfig() + config.BaseURL = values[EnvBaseURL] + config.Email = values[EnvEmail] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for deSEC. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("mailinabox: the configuration of the DNS provider is nil") + } + + if config.Email == "" || config.Password == "" { + return nil, errors.New("mailinabox: incomplete credentials, missing email or password") + } + + if config.BaseURL == "" { + return nil, errors.New("mailinabox: missing base URL") + } + + client, err := mailinabox.New(config.BaseURL, config.Email, config.Password) + if err != nil { + return nil, fmt.Errorf("mailinabox: %w", err) + } + + return &DNSProvider{config: config, client: client}, 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 +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + record := mailinabox.Record{ + Name: dns01.UnFqdn(info.EffectiveFQDN), + Type: "TXT", + Value: info.Value, + } + + _, err := d.client.DNS.AddRecord(ctx, record) + if err != nil { + return fmt.Errorf("mailinabox: add record: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + record := mailinabox.Record{ + Name: dns01.UnFqdn(info.EffectiveFQDN), + Type: "TXT", + Value: info.Value, + } + + _, err := d.client.DNS.RemoveRecord(ctx, record) + if err != nil { + return fmt.Errorf("mailinabox: remove record: %w", err) + } + + return nil +} diff --git a/providers/mailinabox/mailinabox.toml b/providers/mailinabox/mailinabox.toml new file mode 100644 index 0000000..fdfef08 --- /dev/null +++ b/providers/mailinabox/mailinabox.toml @@ -0,0 +1,24 @@ +Name = "Mail-in-a-Box" +Description = '''''' +URL = "https://mailinabox.email" +Code = "mailinabox" +Since = "v4.16.0" + +Example = ''' +MAILINABOX_EMAIL=user@example.com \ +MAILINABOX_PASSWORD=yyyy \ +MAILINABOX_BASE_URL=https://box.example.com \ +lego --email you@example.com --dns mailinabox --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + MAILINABOX_EMAIL = "User email" + MAILINABOX_PASSWORD = "User password" + MAILINABOX_BASE_URL = "Base API URL (ex: https://box.example.com)" + [Configuration.Additional] + MAILINABOX_POLLING_INTERVAL = "Time between DNS propagation check" + MAILINABOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + +[Links] + API = "https://mailinabox.email/api-docs.html" diff --git a/providers/mailinabox/mailinabox_test.go b/providers/mailinabox/mailinabox_test.go new file mode 100644 index 0000000..1b95c22 --- /dev/null +++ b/providers/mailinabox/mailinabox_test.go @@ -0,0 +1,159 @@ +package mailinabox + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvBaseURL, EnvEmail, EnvPassword).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvEmail: "user@example.com", + EnvPassword: "secret", + }, + }, + { + desc: "missing base URL", + envVars: map[string]string{ + EnvEmail: "user@example.com", + EnvPassword: "secret", + }, + expected: "mailinabox: some credentials information are missing: MAILINABOX_BASE_URL", + }, + { + desc: "missing email", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvPassword: "secret", + }, + expected: "mailinabox: some credentials information are missing: MAILINABOX_EMAIL", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvEmail: "user@example.com", + }, + expected: "mailinabox: some credentials information are missing: MAILINABOX_PASSWORD", + }, + { + desc: "missing all options", + expected: "mailinabox: some credentials information are missing: MAILINABOX_BASE_URL,MAILINABOX_EMAIL,MAILINABOX_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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + baseURL string + email string + password string + expected string + }{ + { + desc: "success", + baseURL: "https://example.com", + email: "user@example.com", + password: "secret", + }, + { + desc: "missing base URL", + email: "user@example.com", + password: "secret", + expected: "mailinabox: missing base URL", + }, + { + desc: "missing email", + baseURL: "https://example.com", + password: "secret", + expected: "mailinabox: incomplete credentials, missing email or password", + }, + { + desc: "missing password", + baseURL: "https://example.com", + email: "user@example.com", + expected: "mailinabox: incomplete credentials, missing email or password", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.BaseURL = test.baseURL + config.Email = test.email + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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(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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/metaname/metaname.go b/providers/metaname/metaname.go new file mode 100644 index 0000000..1c26baa --- /dev/null +++ b/providers/metaname/metaname.go @@ -0,0 +1,159 @@ +// Package metaname implements a DNS provider for solving the DNS-01 challenge using Metaname. +package metaname + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/nzdjb/go-metaname" +) + +// Environment variables names. +const ( + envNamespace = "METANAME_" + + EnvAccountReference = envNamespace + "ACCOUNT_REFERENCE" + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + AccountReference string + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *metaname.MetanameClient + + records map[string]string + recordsMu sync.Mutex +} + +// NewDNSProvider returns a new DNS provider +// using environment variable METANAME_API_KEY for adding and removing the DNS record. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAccountReference, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("metaname: %w", err) + } + + config := NewDefaultConfig() + config.AccountReference = values[EnvAccountReference] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Metaname. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("metaname: the configuration of the DNS provider is nil") + } + + if config.AccountReference == "" { + return nil, errors.New("metaname: missing account reference") + } + if config.APIKey == "" { + return nil, errors.New("metaname: missing api key") + } + + return &DNSProvider{ + config: config, + client: metaname.NewMetanameClient(config.AccountReference, config.APIKey), + records: make(map[string]string), + }, nil +} + +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("metaname: could not find zone for domain %q: %w", domain, err) + } + + authZone = dns01.UnFqdn(authZone) + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("metaname: could not extract subDomain: %w", err) + } + + ctx := context.Background() + + r := metaname.ResourceRecord{ + Name: subDomain, + Type: "TXT", + Aux: nil, + Ttl: d.config.TTL, + Data: info.Value, + } + + ref, err := d.client.CreateDnsRecord(ctx, authZone, r) + if err != nil { + return fmt.Errorf("metaname: add record: %w", err) + } + + d.recordsMu.Lock() + d.records[token] = ref + d.recordsMu.Unlock() + + return nil +} + +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("metaname: could not find zone for domain %q: %w", domain, err) + } + + authZone = dns01.UnFqdn(authZone) + + ctx := context.Background() + + d.recordsMu.Lock() + ref, ok := d.records[token] + d.recordsMu.Unlock() + + if !ok { + return fmt.Errorf("metaname: unknown ref for %s", info.EffectiveFQDN) + } + + err = d.client.DeleteDnsRecord(ctx, authZone, ref) + if err != nil { + return fmt.Errorf("metaname: delete record: %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 +} diff --git a/providers/metaname/metaname.toml b/providers/metaname/metaname.toml new file mode 100644 index 0000000..bacdf9b --- /dev/null +++ b/providers/metaname/metaname.toml @@ -0,0 +1,24 @@ +Name = "Metaname" +Description = '''''' +URL = "https://metaname.net" +Code = "metaname" +Since = "v4.13.0" + +Example = ''' +METANAME_ACCOUNT_REFERENCE=xxxx \ +METANAME_API_KEY=yyyyyyy \ +lego --email you@example.com --dns metaname --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + METANAME_ACCOUNT_REFERENCE = "The four-digit reference of a Metaname account" + METANAME_API_KEY = "API Key" + [Configuration.Additional] + METANAME_POLLING_INTERVAL = "Time between DNS propagation check" + METANAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + METANAME_TTL = "The TTL of the TXT record used for the DNS challenge" + +[Links] + API = "https://metaname.net/api/1.1/doc" + GoClient = "https://github.com/nzdjb/go-metaname" diff --git a/providers/metaname/metaname_test.go b/providers/metaname/metaname_test.go new file mode 100644 index 0000000..174af40 --- /dev/null +++ b/providers/metaname/metaname_test.go @@ -0,0 +1,145 @@ +package metaname + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAccountReference, EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAccountReference: "user", + EnvAPIKey: "secret", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvAccountReference: "", + EnvAPIKey: "secret", + }, + expected: "metaname: some credentials information are missing: METANAME_ACCOUNT_REFERENCE", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvAccountReference: "user", + EnvAPIKey: "", + }, + expected: "metaname: some credentials information are missing: METANAME_API_KEY", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "metaname: some credentials information are missing: METANAME_ACCOUNT_REFERENCE,METANAME_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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + accountReference string + apiKey string + expected string + }{ + { + desc: "success", + accountReference: "user", + apiKey: "secret", + }, + { + desc: "missing username", + apiKey: "secret", + expected: "metaname: missing account reference", + }, + { + desc: "missing password", + accountReference: "user", + expected: "metaname: missing api key", + }, + { + desc: "missing all", + expected: "metaname: missing account reference", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + + config.AccountReference = test.accountReference + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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(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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/mydnsjp/mydnsjp_test.go b/providers/mydnsjp/mydnsjp_test.go index 71a28fe..96eb958 100644 --- a/providers/mydnsjp/mydnsjp_test.go +++ b/providers/mydnsjp/mydnsjp_test.go @@ -128,7 +128,7 @@ func TestLivePresent(t *testing.T) { require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { @@ -143,5 +143,5 @@ func TestLiveCleanUp(t *testing.T) { time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } diff --git a/providers/mythicbeasts/internal/client.go b/providers/mythicbeasts/internal/client.go index 48e5efb..01b788f 100644 --- a/providers/mythicbeasts/internal/client.go +++ b/providers/mythicbeasts/internal/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -20,7 +21,7 @@ const ( AuthBaseURL = "https://auth.mythic-beasts.com/login" ) -// Client the EasyDNS API client. +// Client the Mythic Beasts API client. type Client struct { username string password string @@ -134,7 +135,7 @@ func (c *Client) do(req *http.Request, result any) error { if tok != nil { req.Header.Set("Authorization", "Bearer "+tok.Token) } else { - return fmt.Errorf("not logged in") + return errors.New("not logged in") } resp, err := c.HTTPClient.Do(req) diff --git a/providers/mythicbeasts/mythicbeasts.go b/providers/mythicbeasts/mythicbeasts.go index 59eaf8e..3abb05f 100644 --- a/providers/mythicbeasts/mythicbeasts.go +++ b/providers/mythicbeasts/mythicbeasts.go @@ -123,7 +123,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("mythicbeasts: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("mythicbeasts: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -152,7 +152,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("mythicbeasts: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("mythicbeasts: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/namecheap/internal/client.go b/providers/namecheap/internal/client.go index 74c6fb4..ab6dd39 100644 --- a/providers/namecheap/internal/client.go +++ b/providers/namecheap/internal/client.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -73,7 +74,7 @@ func (c *Client) SetHosts(ctx context.Context, sld, tld string, hosts []Record) addParam("TLD", tld), func(values url.Values) { for i, h := range hosts { - ind := fmt.Sprintf("%d", i+1) + ind := strconv.Itoa(i + 1) values.Add("HostName"+ind, h.Name) values.Add("RecordType"+ind, h.Type) values.Add("Address"+ind, h.Address) diff --git a/providers/namecheap/namecheap.go b/providers/namecheap/namecheap.go index fd2f3e9..d494e05 100644 --- a/providers/namecheap/namecheap.go +++ b/providers/namecheap/namecheap.go @@ -18,17 +18,16 @@ import ( ) // Notes about namecheap's tool API: -// 1. Using the API requires registration. Once registered, use your account -// name and API key to access the API. -// 2. There is no API to add or modify a single DNS record. Instead you must -// read the entire list of records, make modifications, and then write the -// entire updated list of records. (Yuck.) -// 3. Namecheap's DNS updates can be slow to propagate. I've seen them take -// as long as an hour. -// 4. Namecheap requires you to whitelist the IP address from which you call -// its APIs. It also requires all API calls to include the whitelisted IP -// address as a form or query string value. This code uses a namecheap -// service to query the client's IP address. +// 1. Using the API requires registration. +// Once registered, use your account name and API key to access the API. +// 2. There is no API to add or modify a single DNS record. +// Instead, you must read the entire list of records, make modifications, +// and then write the entire updated list of records. (Yuck.) +// 3. Namecheap's DNS updates can be slow to propagate. +// I've seen them take as long as an hour. +// 4. Namecheap requires you to whitelist the IP address from which you call its APIs. +// It also requires all API calls to include the whitelisted IP address as a form or query string value. +// This code uses a namecheap service to query the client's IP address. // Environment variables names. const ( diff --git a/providers/namesilo/namesilo.go b/providers/namesilo/namesilo.go index 14ecb75..e6bf634 100644 --- a/providers/namesilo/namesilo.go +++ b/providers/namesilo/namesilo.go @@ -90,7 +90,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("namesilo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("namesilo: could not find zone for domain %q: %w", domain, err) } zoneName := dns01.UnFqdn(zone) @@ -124,7 +124,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("namesilo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("namesilo: could not find zone for domain %q: %w", domain, err) } zoneName := dns01.UnFqdn(zone) diff --git a/providers/namesilo/namesilo_test.go b/providers/namesilo/namesilo_test.go index 0724447..4b01d73 100644 --- a/providers/namesilo/namesilo_test.go +++ b/providers/namesilo/namesilo_test.go @@ -112,7 +112,6 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() - provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/nearlyfreespeech/internal/client.go b/providers/nearlyfreespeech/internal/client.go index 0cecb01..8d3d356 100644 --- a/providers/nearlyfreespeech/internal/client.go +++ b/providers/nearlyfreespeech/internal/client.go @@ -28,6 +28,8 @@ type Client struct { login string apiKey string + signer *Signer + baseURL *url.URL HTTPClient *http.Client } @@ -38,6 +40,7 @@ func NewClient(login string, apiKey string) *Client { return &Client{ login: login, apiKey: apiKey, + signer: NewSigner(), baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } @@ -74,7 +77,7 @@ func (c Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Val } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(authenticationHeader, c.createSignature(endpoint.Path, payload)) + req.Header.Set(authenticationHeader, c.signer.Sign(endpoint.Path, payload, c.login, c.apiKey)) resp, err := c.HTTPClient.Do(req) if err != nil { @@ -90,33 +93,51 @@ func (c Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Val return nil } -func (c Client) createSignature(uri string, body string) string { - // This is the only part of this that needs to be serialized. - salt := make([]byte, 16) - for i := 0; i < 16; i++ { - salt[i] = saltBytes[rand.Intn(len(saltBytes))] +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + errAPI := &APIError{} + err := json.Unmarshal(raw, errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } + return errAPI +} + +type Signer struct { + saltShaker func() []byte + clock func() time.Time +} + +func NewSigner() *Signer { + return &Signer{saltShaker: getRandomSalt, clock: time.Now} +} + +func (c Signer) Sign(uri string, body, login, apiKey string) string { // Header is "login;timestamp;salt;hash". // hash is SHA1("login;timestamp;salt;api-key;request-uri;body-hash") // and body-hash is SHA1(body). bodyHash := sha1.Sum([]byte(body)) - timestamp := strconv.FormatInt(time.Now().Unix(), 10) + timestamp := strconv.FormatInt(c.clock().Unix(), 10) - hashInput := fmt.Sprintf("%s;%s;%s;%s;%s;%02x", c.login, timestamp, salt, c.apiKey, uri, bodyHash) + // Workaround for https://golang.org/issue/58605 + uri = "/" + strings.TrimLeft(uri, "/") - return fmt.Sprintf("%s;%s;%s;%02x", c.login, timestamp, salt, sha1.Sum([]byte(hashInput))) -} + salt := c.saltShaker() -func parseError(req *http.Request, resp *http.Response) error { - raw, _ := io.ReadAll(resp.Body) + hashInput := fmt.Sprintf("%s;%s;%s;%s;%s;%02x", login, timestamp, salt, apiKey, uri, bodyHash) - errAPI := &APIError{} - err := json.Unmarshal(raw, errAPI) - if err != nil { - return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + return fmt.Sprintf("%s;%s;%s;%02x", login, timestamp, salt, sha1.Sum([]byte(hashInput))) +} + +func getRandomSalt() []byte { + // This is the only part of this that needs to be serialized. + salt := make([]byte, 16) + for i := 0; i < 16; i++ { + salt[i] = saltBytes[rand.Intn(len(saltBytes))] } - return errAPI + return salt } diff --git a/providers/nearlyfreespeech/internal/client_test.go b/providers/nearlyfreespeech/internal/client_test.go index 05d7d67..16fa82e 100644 --- a/providers/nearlyfreespeech/internal/client_test.go +++ b/providers/nearlyfreespeech/internal/client_test.go @@ -9,7 +9,9 @@ import ( "net/url" "os" "testing" + "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,6 +26,9 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) { client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) + client.signer.saltShaker = func() []byte { return []byte("0123456789ABCDEF") } + client.signer.clock = func() time.Time { return time.Unix(1692475113, 0) } + return client, mux } @@ -147,3 +152,63 @@ func TestClient_RemoveRecord_error(t *testing.T) { err := client.RemoveRecord(context.Background(), "example.com", record) require.Error(t, err) } + +func TestSigner_Sign(t *testing.T) { + testCases := []struct { + desc string + path string + now int64 + salt string + expected string + }{ + { + desc: "basic", + path: "/path", + now: 1692475113, + salt: "0123456789ABCDEF", + expected: "user;1692475113;0123456789ABCDEF;417a9988c7ad7919b297884dd120b5808d8a1e6f", + }, + { + desc: "another date", + path: "/path", + now: 1692567766, + salt: "0123456789ABCDEF", + expected: "user;1692567766;0123456789ABCDEF;b5c28286fd2e1a45a7c576dc2a6430116f721502", + }, + { + desc: "another salt", + path: "/path", + now: 1692475113, + salt: "FEDCBA9876543210", + expected: "user;1692475113;FEDCBA9876543210;0f766822bda4fdc09829be4e1ea5e27ae3ae334e", + }, + { + desc: "empty path", + path: "", + now: 1692475113, + salt: "0123456789ABCDEF", + expected: "user;1692475113;0123456789ABCDEF;c7c241a4d15d04d92805631d58d4d72ac1c339a1", + }, + { + desc: "root path", + path: "/", + now: 1692475113, + salt: "0123456789ABCDEF", + expected: "user;1692475113;0123456789ABCDEF;c7c241a4d15d04d92805631d58d4d72ac1c339a1", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + signer := NewSigner() + signer.saltShaker = func() []byte { return []byte(test.salt) } + signer.clock = func() time.Time { return time.Unix(test.now, 0) } + + sign := signer.Sign(test.path, "data", "user", "secret") + + assert.Equal(t, test.expected, sign) + }) + } +} diff --git a/providers/nearlyfreespeech/nearlyfreespeech.go b/providers/nearlyfreespeech/nearlyfreespeech.go index 32b6562..584a9ad 100644 --- a/providers/nearlyfreespeech/nearlyfreespeech.go +++ b/providers/nearlyfreespeech/nearlyfreespeech.go @@ -113,7 +113,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -142,7 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/netcup/internal/client_test.go b/providers/netcup/internal/client_test.go index 0b85263..6eb45de 100644 --- a/providers/netcup/internal/client_test.go +++ b/providers/netcup/internal/client_test.go @@ -356,7 +356,7 @@ func TestClient_UpdateDNSRecord_Live(t *testing.T) { info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - require.NoError(t, err, fmt.Errorf("error finding DNSZone, %w", err)) + require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err)) hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) diff --git a/providers/netcup/netcup.go b/providers/netcup/netcup.go index 676671a..7548692 100644 --- a/providers/netcup/netcup.go +++ b/providers/netcup/netcup.go @@ -97,7 +97,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("netcup: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("netcup: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateSessionContext(context.Background()) @@ -144,7 +144,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("netcup: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("netcup: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateSessionContext(context.Background()) diff --git a/providers/netlify/netlify.go b/providers/netlify/netlify.go index 17bd291..56b3e06 100644 --- a/providers/netlify/netlify.go +++ b/providers/netlify/netlify.go @@ -102,7 +102,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("netlify: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("netlify: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) @@ -132,7 +132,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("netlify: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("netlify: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) diff --git a/providers/nicmanager/nicmanager.go b/providers/nicmanager/nicmanager.go index 935363c..f40e7c8 100644 --- a/providers/nicmanager/nicmanager.go +++ b/providers/nicmanager/nicmanager.go @@ -140,7 +140,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("nicmanager: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("nicmanager: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -173,7 +173,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("nicmanager: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("nicmanager: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -201,5 +201,5 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } } - return fmt.Errorf("nicmanager: no record found to cleanup") + return errors.New("nicmanager: no record found to clean up") } diff --git a/providers/nicmanager/nicmanager.toml b/providers/nicmanager/nicmanager.toml index 7d902ad..913f685 100644 --- a/providers/nicmanager/nicmanager.toml +++ b/providers/nicmanager/nicmanager.toml @@ -30,7 +30,7 @@ lego --email you@example.com --dns nicmanager --domains my.example.org run Additional = ''' ## Description -You can login using your account name + username or using your email address. +You can log in using your account name + username or using your email address. Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`. ''' diff --git a/providers/nifcloud/internal/client.go b/providers/nifcloud/internal/client.go index 2997a96..5fc8690 100644 --- a/providers/nifcloud/internal/client.go +++ b/providers/nifcloud/internal/client.go @@ -149,10 +149,9 @@ func (c *Client) sign(req *http.Request) error { } func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { - buf := new(bytes.Buffer) + body := new(bytes.Buffer) if payload != nil { - body := new(bytes.Buffer) body.WriteString(xml.Header) err := xml.NewEncoder(body).Encode(payload) if err != nil { @@ -160,7 +159,7 @@ func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payloa } } - req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } diff --git a/providers/nifcloud/nifcloud.go b/providers/nifcloud/nifcloud.go index 2e36b20..891c971 100644 --- a/providers/nifcloud/nifcloud.go +++ b/providers/nifcloud/nifcloud.go @@ -131,7 +131,15 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { } func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("could not find zone: %w", err) + } + name := dns01.UnFqdn(fqdn) + if authZone == fqdn { + name = "@" + } reqParams := internal.ChangeResourceRecordSetsRequest{ XMLNs: internal.XMLNs, @@ -159,11 +167,6 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { }, } - authZone, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) - } - ctx := context.Background() resp, err := d.client.ChangeResourceRecordSets(ctx, dns01.UnFqdn(authZone), reqParams) diff --git a/providers/nodion/nodion.go b/providers/nodion/nodion.go index d4e4b1b..32f95cd 100644 --- a/providers/nodion/nodion.go +++ b/providers/nodion/nodion.go @@ -109,7 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("nodion: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("nodion: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -160,7 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("nodion: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("nodion: could not find zone for domain %q: %w", domain, err) } d.zoneIDsMu.Lock() diff --git a/providers/ns1/ns1.go b/providers/ns1/ns1.go index 15fb95e..8cf79a8 100644 --- a/providers/ns1/ns1.go +++ b/providers/ns1/ns1.go @@ -97,7 +97,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if errors.Is(err, rest.ErrRecordMissing) || record == nil { log.Infof("Create a new record for [zone: %s, fqdn: %s, domain: %s]", zone.Zone, info.EffectiveFQDN, domain) - record = dns.NewRecord(zone.Zone, dns01.UnFqdn(info.EffectiveFQDN), "TXT") + // Work through a bug in the NS1 API library that causes 400 Input validation failed (Value None for field '.filters' is not of type ...) + // So the `tags` and `blockedTags` parameters should be initialized to empty. + record = dns.NewRecord(zone.Zone, dns01.UnFqdn(info.EffectiveFQDN), "TXT", make(map[string]string), make([]string, 0)) record.TTL = d.config.TTL record.Answers = []*dns.Answer{{Rdata: []string{info.Value}}} @@ -152,12 +154,12 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) getHostedZone(fqdn string) (*dns.Zone, error) { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return nil, fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) + return nil, fmt.Errorf("could not find zone: %w", err) } authZone = dns01.UnFqdn(authZone) - zone, _, err := d.client.Zones.Get(authZone) + zone, _, err := d.client.Zones.Get(authZone, false) if err != nil { return nil, fmt.Errorf("failed to get zone [authZone: %q, fqdn: %q]: %w", authZone, fqdn, err) } diff --git a/providers/oraclecloud/configprovider.go b/providers/oraclecloud/configprovider.go index e5fa66d..838d78a 100644 --- a/providers/oraclecloud/configprovider.go +++ b/providers/oraclecloud/configprovider.go @@ -28,7 +28,7 @@ func (p *configProvider) PrivateRSAKey() (*rsa.PrivateKey, error) { return nil, err } - return common.PrivateKeyFromBytes(privateKey, common.String(p.privateKeyPassphrase)) + return common.PrivateKeyFromBytesWithPassword(privateKey, []byte(p.privateKeyPassphrase)) } func (p *configProvider) KeyID() (string, error) { diff --git a/providers/oraclecloud/oraclecloud.go b/providers/oraclecloud/oraclecloud.go index 347816f..af93d84 100644 --- a/providers/oraclecloud/oraclecloud.go +++ b/providers/oraclecloud/oraclecloud.go @@ -107,7 +107,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zoneNameOrID, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("oraclecloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("oraclecloud: could not find zone for domain %q: %w", domain, err) } // generate request to dns.PatchDomainRecordsRequest @@ -142,7 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zoneNameOrID, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("oraclecloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("oraclecloud: could not find zone for domain %q: %w", domain, err) } // search to TXT record's hash to delete @@ -161,7 +161,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } if *domainRecords.OpcTotalItems == 0 { - return errors.New("oraclecloud: no record to CleanUp") + return errors.New("oraclecloud: no record to clean up") } var deleteHash *string @@ -173,7 +173,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } if deleteHash == nil { - return errors.New("oraclecloud: no record to CleanUp") + return errors.New("oraclecloud: no record to clean up") } recordOperation := dns.RecordOperation{ diff --git a/providers/otc/internal/mock.go b/providers/otc/internal/mock.go index 33cb072..2ed7f84 100644 --- a/providers/otc/internal/mock.go +++ b/providers/otc/internal/mock.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const fakeOTCToken = "62244bc21da68d03ebac94e6636ff01f" @@ -143,7 +144,7 @@ func (m *DNSServerMock) HandleListRecordsetsSuccessfully() { assert.Equal(m.t, "application/json", r.Header.Get("Content-Type")) raw, err := io.ReadAll(r.Body) - assert.Nil(m.t, err) + require.NoError(m.t, err) exceptedString := `{ "name": "_acme-challenge.example.com.", "description": "Added TXT record for ACME dns-01 challenge using lego client", diff --git a/providers/otc/otc.go b/providers/otc/otc.go index a8cd2a1..90ac3e8 100644 --- a/providers/otc/otc.go +++ b/providers/otc/otc.go @@ -33,6 +33,7 @@ const ( EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) // Config is used to configure the creation of the DNSProvider. @@ -44,6 +45,7 @@ type Config struct { Password string PropagationTimeout time.Duration PollingInterval time.Duration + SequenceInterval time.Duration TTL int HTTPClient *http.Client } @@ -55,6 +57,7 @@ func NewDefaultConfig() *Config { PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), IdentityEndpoint: env.GetOrDefaultString(EnvIdentityEndpoint, defaultIdentityEndpoint), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), Transport: &http.Transport{ @@ -132,7 +135,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("otc: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("otc: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -169,7 +172,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("otc: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("otc: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -202,3 +205,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } + +// Sequential All DNS challenges for this provider will be resolved sequentially. +// Returns the interval between each iteration. +func (d *DNSProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} diff --git a/providers/otc/otc.toml b/providers/otc/otc.toml index 7f9703b..e3c6015 100644 --- a/providers/otc/otc.toml +++ b/providers/otc/otc.toml @@ -16,6 +16,7 @@ Example = '''''' [Configuration.Additional] OTC_POLLING_INTERVAL = "Time between DNS propagation check" OTC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + OTC_SEQUENCE_INTERVAL = "Time between sequential requests" OTC_TTL = "The TTL of the TXT record used for the DNS challenge" OTC_HTTP_TIMEOUT = "API request timeout" diff --git a/providers/otc/otc_test.go b/providers/otc/otc_test.go index 2a96b6c..73bbdfb 100644 --- a/providers/otc/otc_test.go +++ b/providers/otc/otc_test.go @@ -63,18 +63,18 @@ func (s *OTCSuite) TestLoginEnv() { provider, err := NewDNSProvider() s.Require().NoError(err) - s.Equal(provider.config.DomainName, "unittest1") - s.Equal(provider.config.UserName, "unittest2") - s.Equal(provider.config.Password, "unittest3") - s.Equal(provider.config.ProjectName, "unittest4") - s.Equal(provider.config.IdentityEndpoint, "unittest5") + s.Equal("unittest1", provider.config.DomainName) + s.Equal("unittest2", provider.config.UserName) + s.Equal("unittest3", provider.config.Password) + s.Equal("unittest4", provider.config.ProjectName) + s.Equal("unittest5", provider.config.IdentityEndpoint) os.Setenv(EnvIdentityEndpoint, "") provider, err = NewDNSProvider() s.Require().NoError(err) - s.Equal(provider.config.IdentityEndpoint, "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens") + s.Equal("https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens", provider.config.IdentityEndpoint) } func (s *OTCSuite) TestLoginEnvEmpty() { @@ -103,7 +103,7 @@ func (s *OTCSuite) TestDNSProvider_Present_EmptyZone() { s.Require().NoError(err) err = provider.Present("example.com", "", "foobar") - s.NotNil(err) + s.Error(err) } func (s *OTCSuite) TestDNSProvider_CleanUp() { diff --git a/providers/ovh/ovh.go b/providers/ovh/ovh.go index 5a49b70..d389b53 100644 --- a/providers/ovh/ovh.go +++ b/providers/ovh/ovh.go @@ -127,7 +127,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // Parse domain name authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("ovh: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("ovh: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) @@ -175,7 +175,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("ovh: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("ovh: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) diff --git a/providers/ovh/ovh.toml b/providers/ovh/ovh.toml index ab8ae49..ddd51d2 100644 --- a/providers/ovh/ovh.toml +++ b/providers/ovh/ovh.toml @@ -17,7 +17,7 @@ Additional = ''' Application key and secret can be created by following the [OVH guide](https://docs.ovh.com/gb/en/customer/first-steps-with-ovh-api/). -When requesting the consumer key, the following configuration can be use to define access rights: +When requesting the consumer key, the following configuration can be used to define access rights: ```json { diff --git a/providers/pdns/internal/client.go b/providers/pdns/internal/client.go index f261cc3..5bc4c82 100644 --- a/providers/pdns/internal/client.go +++ b/providers/pdns/internal/client.go @@ -30,10 +30,11 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(host *url.URL, serverName string, apiKey string) *Client { +func NewClient(host *url.URL, serverName string, apiVersion int, apiKey string) *Client { return &Client{ serverName: serverName, apiKey: apiKey, + apiVersion: apiVersion, Host: host, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } @@ -218,7 +219,8 @@ func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, paylo req.Header.Set("Accept", "application/json") - if payload != nil { + // PowerDNS doesn't follow HTTP convention about the "Content-Type" header. + if method != http.MethodGet && method != http.MethodDelete { req.Header.Set("Content-Type", "application/json") } diff --git a/providers/pdns/internal/client_test.go b/providers/pdns/internal/client_test.go index d102a5e..4e17a4f 100644 --- a/providers/pdns/internal/client_test.go +++ b/providers/pdns/internal/client_test.go @@ -57,7 +57,7 @@ func setupTest(t *testing.T, method, pattern string, status int, file string) *C serverURL, _ := url.Parse(server.URL) - client := NewClient(serverURL, "server", "secret") + client := NewClient(serverURL, "server", 0, "secret") client.HTTPClient = server.Client() return client @@ -151,8 +151,7 @@ func TestClient_joinPath(t *testing.T) { host, err := url.Parse(test.baseURL) require.NoError(t, err) - client := NewClient(host, "test", "secret") - client.apiVersion = test.apiVersion + client := NewClient(host, "test", test.apiVersion, "secret") endpoint := client.joinPath(test.uri) diff --git a/providers/pdns/pdns.go b/providers/pdns/pdns.go index de3f2d5..92847c3 100644 --- a/providers/pdns/pdns.go +++ b/providers/pdns/pdns.go @@ -23,6 +23,7 @@ const ( EnvAPIURL = envNamespace + "API_URL" EnvTTL = envNamespace + "TTL" + EnvAPIVersion = envNamespace + "API_VERSION" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" @@ -34,6 +35,7 @@ type Config struct { APIKey string Host *url.URL ServerName string + APIVersion int PropagationTimeout time.Duration PollingInterval time.Duration TTL int @@ -43,10 +45,11 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ + ServerName: env.GetOrDefaultString(EnvServerName, "localhost"), + APIVersion: env.GetOrDefaultInt(EnvAPIVersion, 0), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), - ServerName: env.GetOrDefaultString(EnvServerName, "localhost"), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -94,18 +97,20 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("pdns: API URL missing") } - client := internal.NewClient(config.Host, config.ServerName, config.APIKey) + client := internal.NewClient(config.Host, config.ServerName, config.APIVersion, config.APIKey) - err := client.SetAPIVersion(context.Background()) - if err != nil { - log.Warnf("pdns: failed to get API version %v", err) + if config.APIVersion <= 0 { + err := client.SetAPIVersion(context.Background()) + if err != nil { + log.Warnf("pdns: failed to get API version %v", err) + } } return &DNSProvider{config: config, client: client}, nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// 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 } @@ -116,7 +121,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("pdns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -142,7 +147,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } rec := internal.Record{ - Content: "\"" + info.EffectiveFQDN + "\"", + Content: "\"" + info.Value + "\"", Disabled: false, // pre-v1 API @@ -178,7 +183,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("pdns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() diff --git a/providers/pdns/pdns.toml b/providers/pdns/pdns.toml index f1209e4..a59c02c 100644 --- a/providers/pdns/pdns.toml +++ b/providers/pdns/pdns.toml @@ -18,6 +18,7 @@ Tested and confirmed to work with PowerDNS authoritative server 3.4.8 and 4.0.1. PowerDNS Notes: - PowerDNS API does not currently support SSL, therefore you should take care to ensure that traffic between lego and the PowerDNS API is over a trusted network, VPN etc. - In order to have the SOA serial automatically increment each time the `_acme-challenge` record is added/modified via the API, set `SOA-EDIT-API` to `INCEPTION-INCREMENT` for the zone in the `domainmetadata` table +- Some PowerDNS servers doesn't have root API endpoints enabled and API version autodetection will not work. In that case version number can be defined using `PDNS_API_VERSION`. ''' [Configuration] @@ -25,11 +26,12 @@ PowerDNS Notes: PDNS_API_KEY = "API key" PDNS_API_URL = "API URL" [Configuration.Additional] + PDNS_SERVER_NAME = "Name of the server in the URL, 'localhost' by default" + PDNS_API_VERSION = "Skip API version autodetection and use the provided version number." PDNS_POLLING_INTERVAL = "Time between DNS propagation check" PDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" PDNS_TTL = "The TTL of the TXT record used for the DNS challenge" PDNS_HTTP_TIMEOUT = "API request timeout" - PDNS_SERVER_NAME = "Name of the server in the URL, 'localhost' by default" [Links] API = "https://doc.powerdns.com/md/httpapi/README/" diff --git a/providers/pdns/pdns_test.go b/providers/pdns/pdns_test.go index 854b159..70b386b 100644 --- a/providers/pdns/pdns_test.go +++ b/providers/pdns/pdns_test.go @@ -76,30 +76,31 @@ func TestNewDNSProvider(t *testing.T) { func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { - desc string - apiKey string - host *url.URL - expected string + desc string + apiKey string + customAPIVersion int + host *url.URL + expected string }{ { desc: "success", apiKey: "123", - host: func() *url.URL { - u, _ := url.Parse("http://example.com") - return u - }(), + host: mustParse("http://example.com"), + }, + { + desc: "success custom API version", + apiKey: "123", + customAPIVersion: 1, + host: mustParse("http://example.com"), }, { desc: "missing credentials", expected: "pdns: API key missing", }, { - desc: "missing API key", - apiKey: "", - host: func() *url.URL { - u, _ := url.Parse("http://example.com") - return u - }(), + desc: "missing API key", + apiKey: "", + host: mustParse("http://example.com"), expected: "pdns: API key missing", }, { @@ -114,6 +115,7 @@ func TestNewDNSProviderConfig(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Host = test.host + config.APIVersion = test.customAPIVersion p, err := NewDNSProviderConfig(config) @@ -143,3 +145,11 @@ func TestLivePresentAndCleanup(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mustParse(rawURL string) *url.URL { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return u +} diff --git a/providers/plesk/plesk.go b/providers/plesk/plesk.go index 20445cc..b4cd74e 100644 --- a/providers/plesk/plesk.go +++ b/providers/plesk/plesk.go @@ -123,7 +123,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("plesk: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("plesk: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() diff --git a/providers/porkbun/porkbun.go b/providers/porkbun/porkbun.go index cb5f7d8..941a12a 100644 --- a/providers/porkbun/porkbun.go +++ b/providers/porkbun/porkbun.go @@ -171,7 +171,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func splitDomain(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) + return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, zone) diff --git a/providers/rackspace/internal/client.go b/providers/rackspace/internal/client.go index ca29114..3bff4be 100644 --- a/providers/rackspace/internal/client.go +++ b/providers/rackspace/internal/client.go @@ -80,7 +80,7 @@ func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) erro func (c *Client) GetHostedZoneID(ctx context.Context, fqdn string) (string, error) { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) + return "", fmt.Errorf("could not find zone: %w", err) } zoneSearchResponse, err := c.listDomainsByName(ctx, dns01.UnFqdn(authZone)) diff --git a/providers/rackspace/rackspace_test.go b/providers/rackspace/rackspace_test.go index 1e120e0..cbc57b4 100644 --- a/providers/rackspace/rackspace_test.go +++ b/providers/rackspace/rackspace_test.go @@ -29,12 +29,12 @@ func TestNewDNSProviderConfig(t *testing.T) { require.NoError(t, err) assert.NotNil(t, provider.config) - assert.Equal(t, provider.token, "testToken", "The token should match") + assert.Equal(t, "testToken", provider.token, "The token should match") } func TestNewDNSProviderConfig_MissingCredErr(t *testing.T) { _, err := NewDNSProviderConfig(NewDefaultConfig()) - assert.EqualError(t, err, "rackspace: credentials missing") + require.EqualError(t, err, "rackspace: credentials missing") } func TestDNSProvider_Present(t *testing.T) { diff --git a/providers/rcodezero/internal/client.go b/providers/rcodezero/internal/client.go new file mode 100644 index 0000000..4893f4d --- /dev/null +++ b/providers/rcodezero/internal/client.go @@ -0,0 +1,114 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" + "github.com/miekg/dns" +) + +const defaultBaseURL = "https://my.rcodezero.at/api" + +const authorizationHeader = "Authorization" + +// Client for the RcodeZero API. +type Client struct { + apiToken string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiToken string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiToken: apiToken, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +func (c *Client) UpdateRecords(ctx context.Context, authZone string, sets []UpdateRRSet) (*APIResponse, error) { + endpoint := c.baseURL.JoinPath("v1", "acme", "zones", strings.TrimSuffix(dns.Fqdn(authZone), "."), "rrsets") + + req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, sets) + if err != nil { + return nil, err + } + + return c.do(req) +} + +func (c *Client) do(req *http.Request) (*APIResponse, error) { + req.Header.Set(authorizationHeader, "Bearer "+c.apiToken) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return nil, parseError(req, resp) + } + + result := &APIResponse{} + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, 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) + + errAPI := &APIResponse{} + err := json.Unmarshal(raw, errAPI) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code: %d] %w", resp.StatusCode, errAPI) +} diff --git a/providers/rcodezero/internal/client_test.go b/providers/rcodezero/internal/client_test.go new file mode 100644 index 0000000..c19e6e5 --- /dev/null +++ b/providers/rcodezero/internal/client_test.go @@ -0,0 +1,96 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) + return + } + + apiToken := req.Header.Get(authorizationHeader) + if apiToken != "Bearer secret" { + http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiToken), http.StatusBadRequest) + return + } + + if file == "" { + rw.WriteHeader(status) + return + } + + open, err := os.Open(filepath.Join("fixtures", file)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = open.Close() }() + + rw.WriteHeader(status) + _, err = io.Copy(rw, open) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient("secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func TestClient_UpdateRecords_error(t *testing.T) { + client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusUnprocessableEntity, "error.json") + + rrSet := []UpdateRRSet{{ + Name: "acme.example.org.", + ChangeType: "add", + Type: "TXT", + Records: []Record{{Content: `"my-acme-challenge"`}}, + }} + + resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet) + require.ErrorAs(t, err, new(*APIResponse)) + assert.Nil(t, resp) +} + +func TestClient_UpdateRecords(t *testing.T) { + client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusOK, "rrsets-response.json") + + rrSet := []UpdateRRSet{{ + Name: "acme.example.org.", + ChangeType: "add", + Type: "TXT", + Records: []Record{{Content: `"my-acme-challenge"`}}, + }} + + resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet) + require.NoError(t, err) + + expected := &APIResponse{Status: "ok", Message: "RRsets updated"} + + assert.Equal(t, expected, resp) +} diff --git a/providers/rcodezero/internal/fixtures/error.json b/providers/rcodezero/internal/fixtures/error.json new file mode 100644 index 0000000..6257f75 --- /dev/null +++ b/providers/rcodezero/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "message": "A human readable error message" +} diff --git a/providers/rcodezero/internal/fixtures/rrsets-response.json b/providers/rcodezero/internal/fixtures/rrsets-response.json new file mode 100644 index 0000000..83bdfa1 --- /dev/null +++ b/providers/rcodezero/internal/fixtures/rrsets-response.json @@ -0,0 +1,4 @@ +{ + "status": "ok", + "message": "RRsets updated" +} diff --git a/providers/rcodezero/internal/types.go b/providers/rcodezero/internal/types.go new file mode 100644 index 0000000..6fdffb8 --- /dev/null +++ b/providers/rcodezero/internal/types.go @@ -0,0 +1,25 @@ +package internal + +import "fmt" + +type UpdateRRSet struct { + Name string `json:"name"` + Type string `json:"type"` + ChangeType string `json:"changetype"` + Records []Record `json:"records"` + TTL int `json:"ttl"` +} + +type Record struct { + Content string `json:"content"` + Disabled bool `json:"disabled"` +} + +type APIResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +func (a APIResponse) Error() string { + return fmt.Sprintf("%s: %s", a.Status, a.Message) +} diff --git a/providers/rcodezero/rcodezero.go b/providers/rcodezero/rcodezero.go new file mode 100644 index 0000000..9be9cb5 --- /dev/null +++ b/providers/rcodezero/rcodezero.go @@ -0,0 +1,145 @@ +// Package rcodezero implements a DNS provider for solving the DNS-01 challenge using RcodeZero Anycast network. +package rcodezero + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/rcodezero/internal" +) + +// Environment variables names. +const ( + envNamespace = "RCODEZERO_" + + EnvAPIToken = envNamespace + "API_TOKEN" + + 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 { + APIToken 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, 240*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + 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 RcodeZero. +// Credentials must be passed in the environment variable: +// RCODEZERO_API_URL and RCODEZERO_API_TOKEN. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIToken) + if err != nil { + return nil, fmt.Errorf("rcodezero: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for RcodeZero. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("rcodezero: the configuration of the DNS provider is nil") + } + + if config.APIToken == "" { + return nil, errors.New("rcodezero: API token missing") + } + + client := internal.NewClient(config.APIToken) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{config: config, client: client}, 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 +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("rcodezero: could not find zone for domain %q: %w", domain, err) + } + + rrSet := []internal.UpdateRRSet{{ + Name: info.EffectiveFQDN, + ChangeType: "update", + Type: "TXT", + TTL: d.config.TTL, + Records: []internal.Record{{Content: `"` + info.Value + `"`}}, + }} + + _, err = d.client.UpdateRecords(ctx, authZone, rrSet) + if err != nil { + return fmt.Errorf("rcodezero: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx := context.Background() + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("rcodezero: could not find zone for domain %q: %w", domain, err) + } + + rrSet := []internal.UpdateRRSet{{ + Name: info.EffectiveFQDN, + Type: "TXT", + ChangeType: "delete", + }} + + _, err = d.client.UpdateRecords(ctx, authZone, rrSet) + if err != nil { + return fmt.Errorf("rcodezero: %w", err) + } + + return nil +} diff --git a/providers/rcodezero/rcodezero.toml b/providers/rcodezero/rcodezero.toml new file mode 100644 index 0000000..a012736 --- /dev/null +++ b/providers/rcodezero/rcodezero.toml @@ -0,0 +1,33 @@ +Name = "RcodeZero" +Description = '''''' +URL = "https://www.rcodezero.at/" +Code = "rcodezero" +Since = "v4.13" + +Example = ''' +RCODEZERO_API_TOKEN= \ +lego --email you@example.com --dns rcodezero --domains my.example.org run +''' + +Additional = ''' +## Description + +Generate your API Token via https://my.rcodezero.at with the `ACME` permissions. +These are special tokens with limited access for ACME requests only. + +RcodeZero is an Anycast Network so the distribution of the DNS01-Challenge can take up to 2 minutes. + +''' + +[Configuration] + [Configuration.Credentials] + RCODEZERO_API_TOKEN = "API token" + [Configuration.Additional] + RCODEZERO_POLLING_INTERVAL = "Time between DNS propagation check" + RCODEZERO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + RCODEZERO_TTL = "The TTL of the TXT record used for the DNS challenge" + RCODEZERO_HTTP_TIMEOUT = "API request timeout" + +[Links] + # Note: the API endpoint used inside the client is not documented. + API = "https://my.rcodezero.at/openapi" diff --git a/providers/rcodezero/rcodezero_test.go b/providers/rcodezero/rcodezero_test.go new file mode 100644 index 0000000..1f09460 --- /dev/null +++ b/providers/rcodezero/rcodezero_test.go @@ -0,0 +1,105 @@ +package rcodezero + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAPIToken). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIToken: "123", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAPIToken: "", + }, + expected: "rcodezero: some credentials information are missing: RCODEZERO_API_TOKEN", + }, + } + + 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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "123", + }, + { + desc: "missing credentials", + expected: "rcodezero: API token missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresentAndCleanup(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/regru/internal/client.go b/providers/regru/internal/client.go index ef5eb37..6718462 100644 --- a/providers/regru/internal/client.go +++ b/providers/regru/internal/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" "github.com/entrustcorporation/dv/providers/internal/errutils" @@ -39,8 +40,6 @@ func NewClient(username, password string) *Client { // https://www.reg.ru/support/help/api2#zone_remove_record func (c Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content string) error { request := RemoveRecordRequest{ - Username: c.username, - Password: c.password, Domains: []Domain{{DName: domain}}, SubDomain: subDomain, Content: content, @@ -60,8 +59,6 @@ func (c Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content // https://www.reg.ru/support/help/api2#zone_add_txt func (c Client) AddTXTRecord(ctx context.Context, domain, subDomain, content string) error { request := AddTxtRequest{ - Username: c.username, - Password: c.password, Domains: []Domain{{DName: domain}}, SubDomain: subDomain, Text: content, @@ -79,21 +76,27 @@ func (c Client) AddTXTRecord(ctx context.Context, domain, subDomain, content str func (c Client) doRequest(ctx context.Context, request any, fragments ...string) (*APIResponse, error) { endpoint := c.baseURL.JoinPath(fragments...) + query := endpoint.Query() + query.Set("username", c.username) + query.Set("password", c.password) + endpoint.RawQuery = query.Encode() + inputData, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to create input data: %w", err) } - query := endpoint.Query() - query.Add("input_data", string(inputData)) - query.Add("input_format", "json") - endpoint.RawQuery = query.Encode() + data := url.Values{} + data.Set("input_data", string(inputData)) + data.Set("input_format", "json") - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) diff --git a/providers/regru/internal/types.go b/providers/regru/internal/types.go index 785b922..6d4d557 100644 --- a/providers/regru/internal/types.go +++ b/providers/regru/internal/types.go @@ -56,9 +56,6 @@ func (d DomainResponse) Error() string { // AddTxtRequest is the representation of the payload of a request to add a TXT record. type AddTxtRequest struct { - Username string `json:"username"` - Password string `json:"password"` - Domains []Domain `json:"domains,omitempty"` SubDomain string `json:"subdomain,omitempty"` Text string `json:"text,omitempty"` @@ -67,9 +64,6 @@ type AddTxtRequest struct { // RemoveRecordRequest is the representation of the payload of a request to remove a record. type RemoveRecordRequest struct { - Username string `json:"username"` - Password string `json:"password"` - Domains []Domain `json:"domains,omitempty"` SubDomain string `json:"subdomain,omitempty"` Content string `json:"content,omitempty"` diff --git a/providers/regru/regru.go b/providers/regru/regru.go index ae4e7dd..18f4f4a 100644 --- a/providers/regru/regru.go +++ b/providers/regru/regru.go @@ -3,6 +3,7 @@ package regru import ( "context" + "crypto/tls" "errors" "fmt" "net/http" @@ -19,6 +20,8 @@ const ( EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" + EnvTLSCert = envNamespace + "TLS_CERT" + EnvTLSKey = envNamespace + "TLS_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -30,6 +33,8 @@ const ( type Config struct { Username string Password string + TLSCert string + TLSKey string PropagationTimeout time.Duration PollingInterval time.Duration @@ -67,6 +72,8 @@ func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] + config.TLSCert = env.GetOrDefaultString(EnvTLSCert, "") + config.TLSKey = env.GetOrDefaultString(EnvTLSKey, "") return NewDNSProviderConfig(config) } @@ -87,6 +94,27 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { client.HTTPClient = config.HTTPClient } + if config.TLSCert != "" || config.TLSKey != "" { + if config.TLSCert == "" { + return nil, errors.New("regru: TLS certificate is missing") + } + + if config.TLSKey == "" { + return nil, errors.New("regru: TLS key is missing") + } + + tlsCert, err := tls.X509KeyPair([]byte(config.TLSCert), []byte(config.TLSKey)) + if err != nil { + return nil, fmt.Errorf("regru: %w", err) + } + + client.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }, + } + } + return &DNSProvider{config: config, client: client}, nil } @@ -102,7 +130,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("regru: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("regru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -125,7 +153,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("regru: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("regru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/regru/regru.toml b/providers/regru/regru.toml index 27168d7..5bdb2c9 100644 --- a/providers/regru/regru.toml +++ b/providers/regru/regru.toml @@ -15,6 +15,8 @@ lego --email you@example.com --dns regru --domains my.example.org run REGRU_USERNAME = "API username" REGRU_PASSWORD = "API password" [Configuration.Additional] + REGRU_TLS_CERT = "authentication certificate" + REGRU_TLS_KEY = "authentication private key" REGRU_POLLING_INTERVAL = "Time between DNS propagation check" REGRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" REGRU_TTL = "The TTL of the TXT record used for the DNS challenge" diff --git a/providers/rfc2136/rfc2136.go b/providers/rfc2136/rfc2136.go index 656e944..b7615fa 100644 --- a/providers/rfc2136/rfc2136.go +++ b/providers/rfc2136/rfc2136.go @@ -176,7 +176,6 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { // Setup client c := &dns.Client{Timeout: d.config.DNSTimeout} - c.SingleInflight = true // TSIG authentication / msg signing if d.config.TSIGKey != "" && d.config.TSIGSecret != "" { diff --git a/providers/rfc2136/rfc2136_test.go b/providers/rfc2136/rfc2136_test.go index 18c0cf9..44649ae 100644 --- a/providers/rfc2136/rfc2136_test.go +++ b/providers/rfc2136/rfc2136_test.go @@ -85,7 +85,7 @@ func TestServerError(t *testing.T) { err = provider.Present(fakeDomain, "", fakeKeyAuth) require.Error(t, err) if !strings.Contains(err.Error(), "NOTZONE") { - t.Errorf("Expected Present() to return an error with the 'NOTZONE' rcode string but it did not: %v", err) + t.Errorf("Expected Present() to return an error with the 'NOTZONE' rcode string, but it did not: %v", err) } } diff --git a/providers/route53/route53.go b/providers/route53/route53.go index 75c900f..a4e1262 100644 --- a/providers/route53/route53.go +++ b/providers/route53/route53.go @@ -2,19 +2,21 @@ package route53 import ( + "context" "errors" "fmt" "math/rand" "strings" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/client" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/credentials/stscreds" - "github.com/aws/aws-sdk-go/aws/request" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/route53" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/retry" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/route53" + awstypes "github.com/aws/aws-sdk-go-v2/service/route53/types" + "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/entrustcorporation/dv/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" @@ -30,6 +32,7 @@ const ( EnvHostedZoneID = envNamespace + "HOSTED_ZONE_ID" EnvMaxRetries = envNamespace + "MAX_RETRIES" EnvAssumeRoleArn = envNamespace + "ASSUME_ROLE_ARN" + EnvExternalID = envNamespace + "EXTERNAL_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -48,12 +51,13 @@ type Config struct { HostedZoneID string MaxRetries int AssumeRoleArn string + ExternalID string TTL int PropagationTimeout time.Duration PollingInterval time.Duration - Client *route53.Route53 + Client *route53.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. @@ -62,6 +66,7 @@ func NewDefaultConfig() *Config { HostedZoneID: env.GetOrFile(EnvHostedZoneID), MaxRetries: env.GetOrDefaultInt(EnvMaxRetries, 5), AssumeRoleArn: env.GetOrDefaultString(EnvAssumeRoleArn, ""), + ExternalID: env.GetOrDefaultString(EnvExternalID, ""), TTL: env.GetOrDefaultInt(EnvTTL, 10), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), @@ -71,31 +76,10 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { - client *route53.Route53 + client *route53.Client config *Config } -// customRetryer implements the client.Retryer interface by composing the DefaultRetryer. -// It controls the logic for retrying recoverable request errors (e.g. when rate limits are exceeded). -type customRetryer struct { - client.DefaultRetryer -} - -// RetryRules overwrites the DefaultRetryer's method. -// It uses a basic exponential backoff algorithm: -// that returns an initial delay of ~400ms with an upper limit of ~30 seconds, -// which should prevent causing a high number of consecutive throttling errors. -// For reference: Route 53 enforces an account-wide(!) 5req/s query limit. -func (d customRetryer) RetryRules(r *request.Request) time.Duration { - retryCount := r.RetryCount - if retryCount > 7 { - retryCount = 7 - } - - delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) - return time.Duration(delay) * time.Millisecond -} - // NewDNSProvider returns a DNSProvider instance configured for the AWS Route 53 service. // // AWS Credentials are automatically detected in the following locations and prioritized in the following order: @@ -111,7 +95,7 @@ func NewDNSProvider() (*DNSProvider, error) { return NewDNSProviderConfig(NewDefaultConfig()) } -// NewDNSProviderConfig takes a given config ans returns a custom configured DNSProvider instance. +// NewDNSProviderConfig takes a given config and returns a custom configured DNSProvider instance. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("route53: the configuration of the Route53 DNS provider is nil") @@ -121,13 +105,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return &DNSProvider{client: config.Client, config: config}, nil } - sess, err := createSession(config) + ctx := context.Background() + + cfg, err := createAWSConfig(ctx, config) if err != nil { return nil, err } return &DNSProvider{ - client: route53.New(sess), + client: route53.NewFromConfig(cfg), config: config, }, nil } @@ -139,14 +125,15 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) - hostedZoneID, err := d.getHostedZoneID(info.EffectiveFQDN) + hostedZoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("route53: failed to determine hosted zone ID: %w", err) } - records, err := d.getExistingRecordSets(hostedZoneID, info.EffectiveFQDN) + records, err := d.getExistingRecordSets(ctx, hostedZoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("route53: %w", err) } @@ -155,74 +142,93 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { var found bool for _, record := range records { - if aws.StringValue(record.Value) == realValue { + if deref(record.Value) == realValue { found = true } } if !found { - records = append(records, &route53.ResourceRecord{Value: aws.String(realValue)}) + records = append(records, awstypes.ResourceRecord{Value: aws.String(realValue)}) } - recordSet := &route53.ResourceRecordSet{ + recordSet := &awstypes.ResourceRecordSet{ Name: aws.String(info.EffectiveFQDN), - Type: aws.String("TXT"), + Type: "TXT", TTL: aws.Int64(int64(d.config.TTL)), ResourceRecords: records, } - err = d.changeRecord(route53.ChangeActionUpsert, hostedZoneID, recordSet) + err = d.changeRecord(ctx, awstypes.ChangeActionUpsert, hostedZoneID, recordSet) if err != nil { return fmt.Errorf("route53: %w", err) } + return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) - hostedZoneID, err := d.getHostedZoneID(info.EffectiveFQDN) + hostedZoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("failed to determine Route 53 hosted zone ID: %w", err) } - records, err := d.getExistingRecordSets(hostedZoneID, info.EffectiveFQDN) + existingRecords, err := d.getExistingRecordSets(ctx, hostedZoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("route53: %w", err) } - if len(records) == 0 { + if len(existingRecords) == 0 { return nil } - recordSet := &route53.ResourceRecordSet{ + var nonLegoRecords []awstypes.ResourceRecord + for _, record := range existingRecords { + if deref(record.Value) != `"`+info.Value+`"` { + nonLegoRecords = append(nonLegoRecords, record) + } + } + + action := awstypes.ChangeActionUpsert + + recordSet := &awstypes.ResourceRecordSet{ Name: aws.String(info.EffectiveFQDN), - Type: aws.String("TXT"), + Type: "TXT", TTL: aws.Int64(int64(d.config.TTL)), - ResourceRecords: records, + ResourceRecords: nonLegoRecords, + } + + // If the records are only records created by lego. + if len(nonLegoRecords) == 0 { + action = awstypes.ChangeActionDelete + + recordSet.ResourceRecords = existingRecords } - err = d.changeRecord(route53.ChangeActionDelete, hostedZoneID, recordSet) + err = d.changeRecord(ctx, action, hostedZoneID, recordSet) if err != nil { return fmt.Errorf("route53: %w", err) } + return nil } -func (d *DNSProvider) changeRecord(action, hostedZoneID string, recordSet *route53.ResourceRecordSet) error { +func (d *DNSProvider) changeRecord(ctx context.Context, action awstypes.ChangeAction, hostedZoneID string, recordSet *awstypes.ResourceRecordSet) error { recordSetInput := &route53.ChangeResourceRecordSetsInput{ HostedZoneId: aws.String(hostedZoneID), - ChangeBatch: &route53.ChangeBatch{ + ChangeBatch: &awstypes.ChangeBatch{ Comment: aws.String("Managed by Lego"), - Changes: []*route53.Change{{ - Action: aws.String(action), + Changes: []awstypes.Change{{ + Action: action, ResourceRecordSet: recordSet, }}, }, } - resp, err := d.client.ChangeResourceRecordSets(recordSetInput) + resp, err := d.client.ChangeResourceRecordSets(ctx, recordSetInput) if err != nil { return fmt.Errorf("failed to change record set: %w", err) } @@ -232,26 +238,26 @@ func (d *DNSProvider) changeRecord(action, hostedZoneID string, recordSet *route return wait.For("route53", d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { reqParams := &route53.GetChangeInput{Id: changeID} - resp, err := d.client.GetChange(reqParams) + resp, err := d.client.GetChange(ctx, reqParams) if err != nil { return false, fmt.Errorf("failed to query change status: %w", err) } - if aws.StringValue(resp.ChangeInfo.Status) == route53.ChangeStatusInsync { + if resp.ChangeInfo.Status == awstypes.ChangeStatusInsync { return true, nil } - return false, fmt.Errorf("unable to retrieve change: ID=%s", aws.StringValue(changeID)) + return false, fmt.Errorf("unable to retrieve change: ID=%s", deref(changeID)) }) } -func (d *DNSProvider) getExistingRecordSets(hostedZoneID, fqdn string) ([]*route53.ResourceRecord, error) { +func (d *DNSProvider) getExistingRecordSets(ctx context.Context, hostedZoneID, fqdn string) ([]awstypes.ResourceRecord, error) { listInput := &route53.ListResourceRecordSetsInput{ HostedZoneId: aws.String(hostedZoneID), StartRecordName: aws.String(fqdn), - StartRecordType: aws.String("TXT"), + StartRecordType: "TXT", } - recordSetsOutput, err := d.client.ListResourceRecordSets(listInput) + recordSetsOutput, err := d.client.ListResourceRecordSets(ctx, listInput) if err != nil { return nil, err } @@ -260,10 +266,10 @@ func (d *DNSProvider) getExistingRecordSets(hostedZoneID, fqdn string) ([]*route return nil, nil } - var records []*route53.ResourceRecord + var records []awstypes.ResourceRecord for _, recordSet := range recordSetsOutput.ResourceRecordSets { - if aws.StringValue(recordSet.Name) == fqdn { + if deref(recordSet.Name) == fqdn { records = append(records, recordSet.ResourceRecords...) } } @@ -271,7 +277,7 @@ func (d *DNSProvider) getExistingRecordSets(hostedZoneID, fqdn string) ([]*route return records, nil } -func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) { +func (d *DNSProvider) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { if d.config.HostedZoneID != "" { return d.config.HostedZoneID, nil } @@ -285,7 +291,7 @@ func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) { reqParams := &route53.ListHostedZonesByNameInput{ DNSName: aws.String(dns01.UnFqdn(authZone)), } - resp, err := d.client.ListHostedZonesByName(reqParams) + resp, err := d.client.ListHostedZonesByName(ctx, reqParams) if err != nil { return "", err } @@ -293,8 +299,8 @@ func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) { var hostedZoneID string for _, hostedZone := range resp.HostedZones { // .Name has a trailing dot - if !aws.BoolValue(hostedZone.Config.PrivateZone) && aws.StringValue(hostedZone.Name) == authZone { - hostedZoneID = aws.StringValue(hostedZone.Id) + if !hostedZone.Config.PrivateZone && deref(hostedZone.Name) == authZone { + hostedZoneID = deref(hostedZone.Id) break } } @@ -308,41 +314,60 @@ func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) { return hostedZoneID, nil } -func createSession(config *Config) (*session.Session, error) { - if err := createSessionCheckParams(config); err != nil { - return nil, err +func createAWSConfig(ctx context.Context, config *Config) (aws.Config, error) { + if err := createAWSConfigCheckParams(config); err != nil { + return aws.Config{}, err } - retry := customRetryer{} - retry.NumMaxRetries = config.MaxRetries + optFns := []func(options *awsconfig.LoadOptions) error{ + awsconfig.WithRetryer(func() aws.Retryer { + return retry.NewStandard(func(options *retry.StandardOptions) { + options.MaxAttempts = config.MaxRetries + + // It uses a basic exponential backoff algorithm that returns an initial + // delay of ~400ms with an upper limit of ~30 seconds which should prevent + // causing a high number of consecutive throttling errors. + // For reference: Route 53 enforces an account-wide(!) 5req/s query limit. + options.Backoff = retry.BackoffDelayerFunc(func(attempt int, err error) (time.Duration, error) { + retryCount := attempt + if retryCount > 7 { + retryCount = 7 + } + + delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) + return time.Duration(delay) * time.Millisecond, nil + }) + }) + }), + } - awsConfig := aws.NewConfig() if config.AccessKeyID != "" && config.SecretAccessKey != "" { - awsConfig = awsConfig.WithCredentials(credentials.NewStaticCredentials(config.AccessKeyID, config.SecretAccessKey, config.SessionToken)) + optFns = append(optFns, + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.SecretAccessKey, config.SessionToken)), + ) } if config.Region != "" { - awsConfig = awsConfig.WithRegion(config.Region) + optFns = append(optFns, awsconfig.WithRegion(config.Region)) } - sessionCfg := request.WithRetryer(awsConfig, retry) - - sess, err := session.NewSessionWithOptions(session.Options{Config: *sessionCfg}) + cfg, err := awsconfig.LoadDefaultConfig(ctx, optFns...) if err != nil { - return nil, err + return aws.Config{}, err } - if config.AssumeRoleArn == "" { - return sess, nil + if config.AssumeRoleArn != "" { + cfg.Credentials = stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), config.AssumeRoleArn, func(options *stscreds.AssumeRoleOptions) { + if config.ExternalID != "" { + options.ExternalID = &config.ExternalID + } + }) } - return session.NewSession(&aws.Config{ - Region: sess.Config.Region, - Credentials: stscreds.NewCredentials(sess, config.AssumeRoleArn), - }) + return cfg, nil } -func createSessionCheckParams(config *Config) error { +func createAWSConfigCheckParams(config *Config) error { if config == nil { return errors.New("config is nil") } @@ -357,3 +382,12 @@ func createSessionCheckParams(config *Config) error { return nil } + +func deref[T string | int | int32 | int64 | bool](v *T) T { + if v == nil { + var zero T + return zero + } + + return *v +} diff --git a/providers/route53/route53.toml b/providers/route53/route53.toml index 3b5f2a5..f16541e 100644 --- a/providers/route53/route53.toml +++ b/providers/route53/route53.toml @@ -9,7 +9,7 @@ AWS_ACCESS_KEY_ID=your_key_id \ AWS_SECRET_ACCESS_KEY=your_secret_access_key \ AWS_REGION=aws-region \ AWS_HOSTED_ZONE_ID=your_hosted_zone_id \ - --domains example.com --email your_example@email.com --dns route53 --accept-tos=true run +lego --domains example.com --email your_example@email.com --dns route53 --accept-tos=true run ''' Additional = ''' @@ -70,7 +70,7 @@ so it is recommended to narrow them down as much as possible if you are using th ### Least privilege policy for production purposes -The following AWS IAM policy document describes least privilege permissions required for lego to complete the DNS challenge. +The following AWS IAM policy document describes the least privilege permissions required for lego to complete the DNS challenge. Write access is limited to a specified hosted zone's DNS TXT records with a key of `_acme-challenge.example.com`. Replace `Z11111112222222333333` with your hosted zone ID and `example.com` with your domain name to use this policy. @@ -129,7 +129,8 @@ Replace `Z11111112222222333333` with your hosted zone ID and `example.com` with AWS_HOSTED_ZONE_ID = "Override the hosted zone ID." AWS_PROFILE = "Managed by the AWS client (`AWS_PROFILE_FILE` is not supported)" AWS_SDK_LOAD_CONFIG = "Managed by the AWS client. Retrieve the region from the CLI config file (`AWS_SDK_LOAD_CONFIG_FILE` is not supported)" - AWS_ASSUME_ROLE_ARN = "Managed by the AWS Role ARN (`AWS_ASSUME_ROLE_ARN` is not supported)" + AWS_ASSUME_ROLE_ARN = "Managed by the AWS Role ARN (`AWS_ASSUME_ROLE_ARN_FILE` is not supported)" + AWS_EXTERNAL_ID = "Managed by STS AssumeRole API operation (`AWS_EXTERNAL_ID_FILE` is not supported)" [Configuration.Additional] AWS_SHARED_CREDENTIALS_FILE = "Managed by the AWS client. Shared credentials file." AWS_MAX_RETRIES = "The number of maximum returns the service will use to make an individual API request" @@ -139,4 +140,4 @@ Replace `Z11111112222222333333` with your hosted zone ID and `example.com` with [Links] API = "https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html" - GoClient = "https://github.com/aws/aws-sdk-go/aws" + GoClient = "https://github.com/aws/aws-sdk-go-v2" diff --git a/providers/route53/route53_integration_test.go b/providers/route53/route53_integration_test.go index acc301b..2fbcf52 100644 --- a/providers/route53/route53_integration_test.go +++ b/providers/route53/route53_integration_test.go @@ -1,11 +1,12 @@ package route53 import ( + "context" "testing" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/route53" + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/stretchr/testify/require" ) @@ -26,9 +27,13 @@ func TestLiveTTL(t *testing.T) { // we need a separate R53 client here as the one in the DNS provider is unexported. fqdn := "_acme-challenge." + domain + "." - sess, err := session.NewSession() + + ctx := context.Background() + + cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) - svc := route53.New(sess) + + svc := route53.NewFromConfig(cfg) defer func() { errC := provider.CleanUp(domain, "foo", "bar") @@ -37,17 +42,17 @@ func TestLiveTTL(t *testing.T) { } }() - zoneID, err := provider.getHostedZoneID(fqdn) + zoneID, err := provider.getHostedZoneID(context.Background(), fqdn) require.NoError(t, err) params := &route53.ListResourceRecordSetsInput{ HostedZoneId: aws.String(zoneID), } - resp, err := svc.ListResourceRecordSets(params) + resp, err := svc.ListResourceRecordSets(ctx, params) require.NoError(t, err) for _, v := range resp.ResourceRecordSets { - if aws.StringValue(v.Name) == fqdn && aws.StringValue(v.Type) == "TXT" && aws.Int64Value(v.TTL) == 10 { + if deref(v.Name) == fqdn && v.Type == "TXT" && deref(v.TTL) == 10 { return } } diff --git a/providers/route53/route53_test.go b/providers/route53/route53_test.go index f1dd1a2..1c8e5f5 100644 --- a/providers/route53/route53_test.go +++ b/providers/route53/route53_test.go @@ -1,14 +1,15 @@ package route53 import ( + "context" "os" "testing" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/route53" + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,21 +29,26 @@ var envTest = tester.NewEnvTest( WithDomain(envDomain). WithLiveTestRequirements(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion, envDomain) +type endpointResolverMock struct { + endpoint string +} + +func (e endpointResolverMock) ResolveEndpoint(_, _ string, _ ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{URL: e.endpoint}, nil +} + func makeTestProvider(t *testing.T, serverURL string) *DNSProvider { t.Helper() - config := &aws.Config{ - Credentials: credentials.NewStaticCredentials("abc", "123", " "), - Endpoint: aws.String(serverURL), - Region: aws.String("mock-region"), - MaxRetries: aws.Int(1), + cfg := aws.Config{ + Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), + Region: "mock-region", + EndpointResolverWithOptions: endpointResolverMock{endpoint: serverURL}, + RetryMaxAttempts: 1, } - sess, err := session.NewSession(config) - require.NoError(t, err) - return &DNSProvider{ - client: route53.New(sess), + client: route53.NewFromConfig(cfg), config: NewDefaultConfig(), } } @@ -55,22 +61,21 @@ func Test_loadCredentials_FromEnv(t *testing.T) { _ = os.Setenv(EnvSecretAccessKey, "456") _ = os.Setenv(EnvRegion, "us-east-1") - config := &aws.Config{ - CredentialsChainVerboseErrors: aws.Bool(true), - } + ctx := context.Background() - sess, err := session.NewSession(config) + cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) - value, err := sess.Config.Credentials.Get() + value, err := cfg.Credentials.Retrieve(ctx) require.NoError(t, err, "Expected credentials to be set from environment") - expected := credentials.Value{ + expected := aws.Credentials{ AccessKeyID: "123", SecretAccessKey: "456", SessionToken: "", - ProviderName: "EnvConfigCredentials", + Source: "EnvConfigCredentials", } + assert.Equal(t, expected, value) } @@ -78,13 +83,12 @@ func Test_loadRegion_FromEnv(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() - os.Setenv(EnvRegion, route53.CloudWatchRegionUsEast1) + _ = os.Setenv(EnvRegion, "foo") - sess, err := session.NewSession(aws.NewConfig()) + cfg, err := awsconfig.LoadDefaultConfig(context.Background()) require.NoError(t, err) - region := aws.StringValue(sess.Config.Region) - assert.Equal(t, route53.CloudWatchRegionUsEast1, region, "Region") + assert.Equal(t, "foo", cfg.Region, "Region") } func Test_getHostedZoneID_FromEnv(t *testing.T) { @@ -93,12 +97,12 @@ func Test_getHostedZoneID_FromEnv(t *testing.T) { expectedZoneID := "zoneID" - os.Setenv(EnvHostedZoneID, expectedZoneID) + _ = os.Setenv(EnvHostedZoneID, expectedZoneID) provider, err := NewDNSProvider() require.NoError(t, err) - hostedZoneID, err := provider.getHostedZoneID("whatever") + hostedZoneID, err := provider.getHostedZoneID(context.Background(), "whatever") require.NoError(t, err, "HostedZoneID") assert.Equal(t, expectedZoneID, hostedZoneID) @@ -144,7 +148,7 @@ func TestNewDefaultConfig(t *testing.T) { t.Run(test.desc, func(t *testing.T) { envTest.ClearEnv() for key, value := range test.envVars { - os.Setenv(key, value) + _ = os.Setenv(key, value) } config := NewDefaultConfig() @@ -156,9 +160,9 @@ func TestNewDefaultConfig(t *testing.T) { func TestDNSProvider_Present(t *testing.T) { mockResponses := MockResponseMap{ - "/2013-04-01/hostedzonesbyname": {StatusCode: 200, Body: ListHostedZonesByNameResponse}, - "/2013-04-01/hostedzone/ABCDEFG/rrset/": {StatusCode: 200, Body: ChangeResourceRecordSetsResponse}, - "/2013-04-01/change/123456": {StatusCode: 200, Body: GetChangeResponse}, + "/2013-04-01/hostedzonesbyname": {StatusCode: 200, Body: ListHostedZonesByNameResponse}, + "/2013-04-01/hostedzone/ABCDEFG/rrset": {StatusCode: 200, Body: ChangeResourceRecordSetsResponse}, + "/2013-04-01/change/123456": {StatusCode: 200, Body: GetChangeResponse}, "/2013-04-01/hostedzone/ABCDEFG/rrset?name=_acme-challenge.example.com.&type=TXT": { StatusCode: 200, Body: "", @@ -178,12 +182,12 @@ func TestDNSProvider_Present(t *testing.T) { require.NoError(t, err, "Expected Present to return no error") } -func TestCreateSession(t *testing.T) { +func Test_createAWSConfig(t *testing.T) { testCases := []struct { desc string env map[string]string config *Config - wantCreds credentials.Value + wantCreds aws.Credentials wantDefaultChain bool wantRegion string wantErr string @@ -218,11 +222,11 @@ func TestCreateSession(t *testing.T) { AccessKeyID: "one", SecretAccessKey: "two", }, - wantCreds: credentials.Value{ + wantCreds: aws.Credentials{ AccessKeyID: "one", SecretAccessKey: "two", SessionToken: "", - ProviderName: credentials.StaticProviderName, + Source: credentials.StaticCredentialsName, }, }, { @@ -232,11 +236,11 @@ func TestCreateSession(t *testing.T) { SecretAccessKey: "two", SessionToken: "three", }, - wantCreds: credentials.Value{ + wantCreds: aws.Credentials{ AccessKeyID: "one", SecretAccessKey: "two", SessionToken: "three", - ProviderName: credentials.StaticProviderName, + Source: credentials.StaticCredentialsName, }, }, { @@ -268,24 +272,26 @@ func TestCreateSession(t *testing.T) { envTest.Apply(test.env) - sess, err := createSession(test.config) + ctx := context.Background() + + cfg, err := createAWSConfig(ctx, test.config) requireErr(t, err, test.wantErr) if err != nil { return } - gotCreds, err := sess.Config.Credentials.Get() + gotCreds, err := cfg.Credentials.Retrieve(ctx) if test.wantDefaultChain { - assert.NotEqual(t, credentials.StaticProviderName, gotCreds.ProviderName) + assert.NotEqual(t, credentials.StaticCredentialsName, gotCreds.Source) } else { require.NoError(t, err) assert.Equal(t, test.wantCreds, gotCreds) } if test.wantRegion != "" { - assert.Equal(t, test.wantRegion, aws.StringValue(sess.Config.Region)) + assert.Equal(t, test.wantRegion, cfg.Region) } }) } diff --git a/providers/safedns/internal/client.go b/providers/safedns/internal/client.go index 709071c..7d33952 100644 --- a/providers/safedns/internal/client.go +++ b/providers/safedns/internal/client.go @@ -50,7 +50,7 @@ func (c *Client) AddRecord(ctx context.Context, zone string, record Record) (*Ad respData := &AddRecordResponse{} err = c.do(req, respData) if err != nil { - return nil, fmt.Errorf("remove record: %w", err) + return nil, fmt.Errorf("add record: %w", err) } return respData, nil diff --git a/providers/safedns/safedns.go b/providers/safedns/safedns.go index b74d0fc..3616817 100644 --- a/providers/safedns/safedns.go +++ b/providers/safedns/safedns.go @@ -105,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) if err != nil { - return fmt.Errorf("safedns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("safedns: could not find zone for domain %q: %w", domain, err) } record := internal.Record{ @@ -133,7 +133,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("safedns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("safedns: could not find zone for domain %q: %w", domain, err) } d.recordIDsMu.Lock() diff --git a/providers/sakuracloud/wrapper.go b/providers/sakuracloud/wrapper.go index e8ce551..53488c8 100644 --- a/providers/sakuracloud/wrapper.go +++ b/providers/sakuracloud/wrapper.go @@ -82,7 +82,7 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error { func (d *DNSProvider) getHostedZone(domain string) (*iaas.DNS, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { - return nil, fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) + return nil, fmt.Errorf("could not find zone: %w", err) } zoneName := dns01.UnFqdn(authZone) diff --git a/providers/sakuracloud/wrapper_test.go b/providers/sakuracloud/wrapper_test.go index e9c7bc6..59f9e24 100644 --- a/providers/sakuracloud/wrapper_test.go +++ b/providers/sakuracloud/wrapper_test.go @@ -83,7 +83,7 @@ func TestDNSProvider_addAndCleanupRecords(t *testing.T) { require.NoError(t, e) require.NotNil(t, updZone) - require.Len(t, updZone.Records, 0) + require.Empty(t, updZone.Records) }) } @@ -143,6 +143,6 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) { require.NoError(t, err) require.NotNil(t, updZone) - require.Len(t, updZone.Records, 0) + require.Empty(t, updZone.Records) }) } diff --git a/providers/scaleway/scaleway.go b/providers/scaleway/scaleway.go index 2fbc27d..2d88981 100644 --- a/providers/scaleway/scaleway.go +++ b/providers/scaleway/scaleway.go @@ -121,13 +121,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Comment: scw.StringPtr("used by lego"), }} - // TODO(ldez) replace domain by FQDN to follow CNAME. req := &scwdomain.UpdateDNSZoneRecordsRequest{ - DNSZone: domain, + DNSZone: info.EffectiveFQDN, Changes: []*scwdomain.RecordChange{{ Add: &scwdomain.RecordChangeAdd{Records: records}, }}, - ReturnAllRecords: scw.BoolPtr(false), + ReturnAllRecords: scw.BoolPtr(false), + DisallowNewZoneCreation: true, } _, err := d.client.UpdateDNSZoneRecords(req) @@ -148,13 +148,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { Data: scw.StringPtr(fmt.Sprintf(`%q`, info.Value)), } - // TODO(ldez) replace domain by FQDN to follow CNAME. req := &scwdomain.UpdateDNSZoneRecordsRequest{ - DNSZone: domain, + DNSZone: info.EffectiveFQDN, Changes: []*scwdomain.RecordChange{{ Delete: &scwdomain.RecordChangeDelete{IDFields: recordIdentifier}, }}, - ReturnAllRecords: scw.BoolPtr(false), + ReturnAllRecords: scw.BoolPtr(false), + DisallowNewZoneCreation: true, } _, err := d.client.UpdateDNSZoneRecords(req) diff --git a/providers/servercow/internal/client.go b/providers/servercow/internal/client.go index 72f4757..14b42c5 100644 --- a/providers/servercow/internal/client.go +++ b/providers/servercow/internal/client.go @@ -149,9 +149,8 @@ func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, paylo req.Header.Set("Accept", "application/json") - if payload != nil { - req.Header.Set("Content-Type", "application/json") - } + // Content-Type should be added even if there is no request body. + req.Header.Set("Content-Type", "application/json") return req, nil } diff --git a/providers/servercow/servercow.go b/providers/servercow/servercow.go index c1da836..d78d8cd 100644 --- a/providers/servercow/servercow.go +++ b/providers/servercow/servercow.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "slices" "time" "github.com/entrustcorporation/dv/dns01" @@ -118,7 +119,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // TXT record entry already existing if record != nil { - if containsValue(record, info.Value) { + if slices.Contains(record.Content, info.Value) { return nil } @@ -177,7 +178,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - if !containsValue(record, info.Value) { + if !slices.Contains(record.Content, info.Value) { return nil } @@ -213,7 +214,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func getAuthZone(domain string) (string, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { - return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) + return "", fmt.Errorf("could not find zone: %w", err) } zoneName := dns01.UnFqdn(authZone) @@ -229,13 +230,3 @@ func findRecords(records []internal.Record, name string) *internal.Record { return nil } - -func containsValue(record *internal.Record, value string) bool { - for _, val := range record.Content { - if val == value { - return true - } - } - - return false -} diff --git a/providers/shellrent/internal/client.go b/providers/shellrent/internal/client.go new file mode 100644 index 0000000..804a7a0 --- /dev/null +++ b/providers/shellrent/internal/client.go @@ -0,0 +1,245 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" +) + +// DefaultBaseURL the default API endpoint. +const defaultBaseURL = "https://manager.shellrent.com/api2" + +const authorizationHeader = "Authorization" + +// Client the Shellrent API client. +type Client struct { + username string + token string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient Creates a new Client. +func NewClient(username string, token string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + token: token, + username: username, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// ListServices lists service IDs. +// https://api.shellrent.com/elenco-dei-servizi-acquistati +func (c Client) ListServices(ctx context.Context) ([]int, error) { + endpoint := c.baseURL.JoinPath("purchase") + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := Response[[]IntOrString]{} + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + if result.Code != 0 { + return nil, result.Base + } + + var ids []int + + for _, datum := range result.Data { + ids = append(ids, datum.Value()) + } + + return ids, nil +} + +// GetServiceDetails gets service details. +// https://api.shellrent.com/dettagli-servizio-acquistato +func (c Client) GetServiceDetails(ctx context.Context, serviceID int) (*ServiceDetails, error) { + endpoint := c.baseURL.JoinPath("purchase", "details", strconv.Itoa(serviceID)) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := Response[*ServiceDetails]{} + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + if result.Code != 0 { + return nil, result.Base + } + + return result.Data, nil +} + +// GetDomainDetails gets domain details. +// https://api.shellrent.com/dettagli-dominio +func (c Client) GetDomainDetails(ctx context.Context, domainID int) (*DomainDetails, error) { + endpoint := c.baseURL.JoinPath("domain", "details", strconv.Itoa(domainID)) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := Response[*DomainDetails]{} + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + if result.Code != 0 { + return nil, result.Base + } + return result.Data, nil +} + +// CreateRecord created a record. +// https://api.shellrent.com/creazione-record-dns-di-un-dominio +func (c Client) CreateRecord(ctx context.Context, domainID int, record Record) (int, error) { + endpoint := c.baseURL.JoinPath("dns_record", "store", strconv.Itoa(domainID)) + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return 0, err + } + + result := Response[*Record]{} + + err = c.do(req, &result) + if err != nil { + return 0, err + } + + if result.Code != 0 { + return 0, result.Base + } + return result.Data.ID.Value(), nil +} + +// DeleteRecord deletes a record. +// https://api.shellrent.com/eliminazione-record-dns-di-un-dominio +func (c Client) DeleteRecord(ctx context.Context, domainID int, recordID int) error { + endpoint := c.baseURL.JoinPath("dns_record", "remove", strconv.Itoa(domainID), strconv.Itoa(recordID)) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + result := Response[any]{} + + err = c.do(req, &result) + if err != nil { + return err + } + + if result.Code != 0 { + return result.Base + } + + return nil +} + +func (c Client) do(req *http.Request, result any) error { + req.Header.Set(authorizationHeader, c.username+"."+c.token) + + 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) + } + + 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 response Base + err := json.Unmarshal(raw, &response) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return response +} + +// TTLRounder rounds the given TTL in seconds to the next accepted value. +// Accepted TTL values are: +// - 3600 +// - 14400 +// - 28800 +// - 57600 +// - 86400 +func TTLRounder(ttl int) int { + for _, validTTL := range []int{3600, 14400, 28800, 57600, 86400} { + if ttl <= validTTL { + return validTTL + } + } + + return 3600 +} diff --git a/providers/shellrent/internal/client_test.go b/providers/shellrent/internal/client_test.go new file mode 100644 index 0000000..cf021ae --- /dev/null +++ b/providers/shellrent/internal/client_test.go @@ -0,0 +1,234 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) + return + } + + auth := req.Header.Get(authorizationHeader) + if auth != "user.secret" { + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + if file == "" { + rw.WriteHeader(status) + return + } + + open, err := os.Open(filepath.Join("fixtures", file)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = open.Close() }() + + rw.WriteHeader(status) + _, err = io.Copy(rw, open) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func TestClient_ListServices(t *testing.T) { + client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "purchase.json") + + services, err := client.ListServices(context.Background()) + require.NoError(t, err) + + expected := []int{2018, 10039, 10128} + + assert.Equal(t, expected, services) +} + +func TestClient_ListServices_error(t *testing.T) { + client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "error.json") + + _, err := client.ListServices(context.Background()) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_ListServices_error_status(t *testing.T) { + client := setupTest(t, http.MethodGet, "/purchase", http.StatusUnauthorized, "error.json") + + _, err := client.ListServices(context.Background()) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_GetServiceDetails(t *testing.T) { + client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusOK, "purchase-details.json") + + services, err := client.GetServiceDetails(context.Background(), 123) + require.NoError(t, err) + + expected := &ServiceDetails{ID: 123, Name: "example", DomainID: 456} + + assert.Equal(t, expected, services) +} + +func TestClient_GetServiceDetails_error(t *testing.T) { + client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusOK, "error.json") + + _, err := client.GetServiceDetails(context.Background(), 123) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_GetServiceDetails_error_status(t *testing.T) { + client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusUnauthorized, "error.json") + + _, err := client.GetServiceDetails(context.Background(), 123) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_GetDomainDetails(t *testing.T) { + client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusOK, "domain-details.json") + + services, err := client.GetDomainDetails(context.Background(), 123) + require.NoError(t, err) + + expected := &DomainDetails{ID: 123, DomainName: "example.com", DomainNameASCII: "example.com"} + + assert.Equal(t, expected, services) +} + +func TestClient_GetDomainDetails_error(t *testing.T) { + client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusOK, "error.json") + + _, err := client.GetDomainDetails(context.Background(), 123) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_GetDomainDetails_error_status(t *testing.T) { + client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusUnauthorized, "error.json") + + _, err := client.GetDomainDetails(context.Background(), 123) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_CreateRecord(t *testing.T) { + client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusOK, "dns_record-store.json") + + services, err := client.CreateRecord(context.Background(), 123, Record{}) + require.NoError(t, err) + + expected := 2255674 + + assert.Equal(t, expected, services) +} + +func TestClient_CreateRecord_error(t *testing.T) { + client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusOK, "error.json") + + _, err := client.CreateRecord(context.Background(), 123, Record{}) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_CreateRecord_error_status(t *testing.T) { + client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusUnauthorized, "error.json") + + _, err := client.CreateRecord(context.Background(), 123, Record{}) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusOK, "dns_record-remove.json") + + err := client.DeleteRecord(context.Background(), 123, 456) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusOK, "error.json") + + err := client.DeleteRecord(context.Background(), 123, 456) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestClient_DeleteRecord_error_status(t *testing.T) { + client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusUnauthorized, "error.json") + + err := client.DeleteRecord(context.Background(), 123, 456) + require.EqualError(t, err, "code 2: Token di autorizzazione non valido") +} + +func TestTTLRounder(t *testing.T) { + testCases := []struct { + desc string + value int + expected int + }{ + { + desc: "lower than 3600", + value: 123, + expected: 3600, + }, + { + desc: "lower than 14400", + value: 12341, + expected: 14400, + }, + { + desc: "lower than 28800", + value: 28341, + expected: 28800, + }, + { + desc: "lower than 57600", + value: 56600, + expected: 57600, + }, + { + desc: "rounded to 86400", + value: 86000, + expected: 86400, + }, + { + desc: "default", + value: 100000, + expected: 3600, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + ttl := TTLRounder(test.value) + + assert.Equal(t, test.expected, ttl) + }) + } +} diff --git a/providers/shellrent/internal/fixtures/dns_record-remove.json b/providers/shellrent/internal/fixtures/dns_record-remove.json new file mode 100644 index 0000000..a145fb7 --- /dev/null +++ b/providers/shellrent/internal/fixtures/dns_record-remove.json @@ -0,0 +1,4 @@ +{ + "error": 0, + "message": "" +} diff --git a/providers/shellrent/internal/fixtures/dns_record-store.json b/providers/shellrent/internal/fixtures/dns_record-store.json new file mode 100644 index 0000000..d2fd5e5 --- /dev/null +++ b/providers/shellrent/internal/fixtures/dns_record-store.json @@ -0,0 +1,8 @@ +{ + "error": 0, + "title": "", + "message": "Record DNS aggiunto con successo", + "data": { + "id": "2255674" + } +} diff --git a/providers/shellrent/internal/fixtures/domain-details.json b/providers/shellrent/internal/fixtures/domain-details.json new file mode 100644 index 0000000..0c26a66 --- /dev/null +++ b/providers/shellrent/internal/fixtures/domain-details.json @@ -0,0 +1,9 @@ +{ + "error": 0, + "message": "", + "data": { + "id": 123, + "domain_name": "example.com", + "domain_name_ascii": "example.com" + } +} diff --git a/providers/shellrent/internal/fixtures/error.json b/providers/shellrent/internal/fixtures/error.json new file mode 100644 index 0000000..85df81b --- /dev/null +++ b/providers/shellrent/internal/fixtures/error.json @@ -0,0 +1,6 @@ +{ + "error": 2, + "title": "", + "message": "Token di autorizzazione non valido", + "data": null +} diff --git a/providers/shellrent/internal/fixtures/purchase-details.json b/providers/shellrent/internal/fixtures/purchase-details.json new file mode 100644 index 0000000..6916ac1 --- /dev/null +++ b/providers/shellrent/internal/fixtures/purchase-details.json @@ -0,0 +1,9 @@ +{ + "error": 0, + "message": "", + "data": { + "ID": 123, + "name": "example", + "domain_id": 456 + } +} diff --git a/providers/shellrent/internal/fixtures/purchase.json b/providers/shellrent/internal/fixtures/purchase.json new file mode 100644 index 0000000..cac3312 --- /dev/null +++ b/providers/shellrent/internal/fixtures/purchase.json @@ -0,0 +1,9 @@ +{ + "error": 0, + "message": "", + "data": [ + 2018, + 10039, + 10128 + ] +} diff --git a/providers/shellrent/internal/types.go b/providers/shellrent/internal/types.go new file mode 100644 index 0000000..a27b063 --- /dev/null +++ b/providers/shellrent/internal/types.go @@ -0,0 +1,74 @@ +package internal + +import ( + "fmt" + "strconv" +) + +type Response[T any] struct { + Base + Data T `json:"data"` +} + +type Base struct { + Code int `json:"error"` + Message string `json:"message"` +} + +func (b Base) Error() string { + return fmt.Sprintf("code %d: %s", b.Code, b.Message) +} + +type ServiceDetails struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DomainID int `json:"domain_id,omitempty"` +} + +type DomainDetails struct { + ID int `json:"id"` + DomainName string `json:"domain_name"` + DomainNameASCII string `json:"domain_name_ascii"` +} + +type Record struct { + ID IntOrString `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Host string `json:"host,omitempty"` + TTL int `json:"ttl,omitempty"` // It can be set to the following values (number of seconds): 3600, 14400, 28800, 57600, 86400 + Destination string `json:"destination,omitempty"` +} + +type IntOrString int + +func (m *IntOrString) Value() int { + if m == nil { + return 0 + } + + return int(*m) +} + +func (m *IntOrString) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + raw := string(data) + if data[0] == '"' { + var err error + raw, err = strconv.Unquote(string(data)) + if err != nil { + return err + } + } + + v, err := strconv.Atoi(raw) + if err != nil { + return err + } + + *m = IntOrString(v) + + return nil +} diff --git a/providers/shellrent/shellrent.go b/providers/shellrent/shellrent.go new file mode 100644 index 0000000..e8c494a --- /dev/null +++ b/providers/shellrent/shellrent.go @@ -0,0 +1,207 @@ +package shellrent + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/shellrent/internal" +) + +// Environment variables names. +const ( + envNamespace = "SHELLRENT_" + + EnvUsername = envNamespace + "USERNAME" + EnvToken = envNamespace + "TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const defaultTTL = 3600 + +type reqKey struct { + domainID int + recordID int +} + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Token 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, defaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]reqKey + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Shellrent. +// Credentials must be passed in the environment variable: SHELLRENT_USERNAME, SHELLRENT_TOKEN. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvToken) + if err != nil { + return nil, fmt.Errorf("shellrent: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Token = values[EnvToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Shellrent. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("shellrent: the configuration of the DNS provider is nil") + } + + if config.Username == "" { + return nil, errors.New("shellrent: missing credentials: username") + } + + if config.Token == "" { + return nil, errors.New("shellrent: missing credentials: token") + } + + client := internal.NewClient(config.Username, config.Token) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]reqKey), + }, 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 +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + if err != nil { + return fmt.Errorf("shellrent: could not find zone for domain %q: %w", domain, err) + } + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.DomainName) + if err != nil { + return fmt.Errorf("shellrent: %w", err) + } + + record := internal.Record{ + Type: "TXT", + Host: subDomain, + TTL: internal.TTLRounder(d.config.TTL), + Destination: info.Value, + } + + recordID, err := d.client.CreateRecord(ctx, zone.ID, record) + if err != nil { + return fmt.Errorf("shellrent: create record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = reqKey{domainID: zone.ID, recordID: recordID} + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) + + // gets the record's unique ID from when we created it + d.recordIDsMu.Lock() + key, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("shellrent: unknown request key for '%s' '%s'", info.EffectiveFQDN, token) + } + + err := d.client.DeleteRecord(ctx, key.domainID, key.recordID) + if err != nil { + return fmt.Errorf("shellrent: delete record: %w", err) + } + + return nil +} + +func (d *DNSProvider) findZone(ctx context.Context, domain string) (*internal.DomainDetails, error) { + services, err := d.client.ListServices(ctx) + if err != nil { + return nil, fmt.Errorf("list services: %w", err) + } + + for _, service := range services { + details, err := d.client.GetServiceDetails(ctx, service) + if err != nil { + return nil, fmt.Errorf("get service details: %w", err) + } + + domainDetails, err := d.client.GetDomainDetails(ctx, details.DomainID) + if err != nil { + return nil, fmt.Errorf("get domain details: %w", err) + } + + domain := domain + + for { + i := strings.Index(domain, ".") + if i == -1 { + break + } + + if strings.EqualFold(domainDetails.DomainName, domain) { + return domainDetails, nil + } + + domain = domain[i+1:] + } + } + + return nil, errors.New("zone not found") +} diff --git a/providers/shellrent/shellrent.toml b/providers/shellrent/shellrent.toml new file mode 100644 index 0000000..5c63db1 --- /dev/null +++ b/providers/shellrent/shellrent.toml @@ -0,0 +1,24 @@ +Name = "Shellrent" +Description = '''''' +URL = "https://www.shellrent.com/" +Code = "shellrent" +Since = "v4.16.0" + +Example = ''' +SHELLRENT_USERNAME=xxxx \ +SHELLRENT_TOKEN=yyyy \ +lego --email you@example.com --dns shellrent --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + SHELLRENT_USERNAME = "Username" + SHELLRENT_TOKEN = "Token" + [Configuration.Additional] + SHELLRENT_POLLING_INTERVAL = "Time between DNS propagation check" + SHELLRENT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + SHELLRENT_TTL = "The TTL of the TXT record used for the DNS challenge" + SHELLRENT_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://api.shellrent.com/section/api2" diff --git a/providers/shellrent/shellrent_test.go b/providers/shellrent/shellrent_test.go new file mode 100644 index 0000000..e5d5299 --- /dev/null +++ b/providers/shellrent/shellrent_test.go @@ -0,0 +1,140 @@ +package shellrent + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvUsername, + EnvToken). + 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", + EnvToken: "secret", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvToken: "secret", + }, + expected: "shellrent: some credentials information are missing: SHELLRENT_USERNAME", + }, + { + desc: "missing token", + envVars: map[string]string{ + EnvUsername: "user", + }, + expected: "shellrent: some credentials information are missing: SHELLRENT_TOKEN", + }, + } + + 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 + token string + expected string + }{ + { + desc: "success", + username: "user", + token: "secret", + }, + { + desc: "missing username", + username: "", + token: "secret", + expected: "shellrent: missing credentials: username", + }, + { + desc: "missing token", + username: "user", + token: "", + expected: "shellrent: missing credentials: token", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Token = test.token + + 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(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) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/simply/internal/client.go b/providers/simply/internal/client.go index d12e72a..1874df7 100644 --- a/providers/simply/internal/client.go +++ b/providers/simply/internal/client.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -87,7 +88,7 @@ func (c *Client) AddRecord(ctx context.Context, zoneName string, record Record) // EditRecord updates a record. func (c *Client) EditRecord(ctx context.Context, zoneName string, id int64, record Record) error { - endpoint := c.createEndpoint(zoneName, fmt.Sprintf("%d", id)) + endpoint := c.createEndpoint(zoneName, strconv.FormatInt(id, 10)) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) if err != nil { @@ -99,7 +100,7 @@ func (c *Client) EditRecord(ctx context.Context, zoneName string, id int64, reco // DeleteRecord deletes a record. func (c *Client) DeleteRecord(ctx context.Context, zoneName string, id int64) error { - endpoint := c.createEndpoint(zoneName, fmt.Sprintf("%d", id)) + endpoint := c.createEndpoint(zoneName, strconv.FormatInt(id, 10)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { diff --git a/providers/simply/simply.go b/providers/simply/simply.go index 6ecec48..bd92a53 100644 --- a/providers/simply/simply.go +++ b/providers/simply/simply.go @@ -115,7 +115,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("simply: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("simply: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) @@ -150,7 +150,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("simply: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("simply: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) diff --git a/providers/sonic/sonic.toml b/providers/sonic/sonic.toml index 286dee0..c4ba74d 100644 --- a/providers/sonic/sonic.toml +++ b/providers/sonic/sonic.toml @@ -26,7 +26,7 @@ See https://public-api.sonic.net/dyndns/#requesting_an_api_key for additional de This `userid` and `apikey` combo allow modifications to any DNS entries connected to the managed domain (hostname). -Hostname should be the toplevel domain managed e.g `example.com` not `www.example.com`. +Hostname should be the toplevel domain managed e.g. `example.com` not `www.example.com`. ''' [Configuration] diff --git a/providers/sonic/sonic_test.go b/providers/sonic/sonic_test.go index 4192327..f9087f8 100644 --- a/providers/sonic/sonic_test.go +++ b/providers/sonic/sonic_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -121,10 +120,10 @@ func TestLivePresent(t *testing.T) { envTest.RestoreEnv() provider, err := NewDNSProvider() - assert.NoError(t, err) + require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { @@ -134,8 +133,8 @@ func TestLiveCleanUp(t *testing.T) { envTest.RestoreEnv() provider, err := NewDNSProvider() - assert.NoError(t, err) + require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") - assert.NoError(t, err) + require.NoError(t, err) } diff --git a/providers/tencentcloud/wrapper.go b/providers/tencentcloud/wrapper.go index 544e732..983832c 100644 --- a/providers/tencentcloud/wrapper.go +++ b/providers/tencentcloud/wrapper.go @@ -33,7 +33,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, erro authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { - return nil, fmt.Errorf("could not find zone for FQDN %q : %w", domain, err) + return nil, fmt.Errorf("could not find zone: %w", err) } var hostedZone *dnspod.DomainListItem diff --git a/providers/transip/fakeclient_test.go b/providers/transip/fakeclient_test.go index 4713274..b1ee043 100644 --- a/providers/transip/fakeclient_test.go +++ b/providers/transip/fakeclient_test.go @@ -2,6 +2,7 @@ package transip import ( "encoding/json" + "errors" "fmt" "time" @@ -24,6 +25,18 @@ type fakeClient struct { domainName string } +func (f *fakeClient) PutWithResponse(_ rest.Request) (rest.Response, error) { + panic("not implemented") +} + +func (f *fakeClient) PostWithResponse(_ rest.Request) (rest.Response, error) { + panic("not implemented") +} + +func (f *fakeClient) PatchWithResponse(_ rest.Request) (rest.Response, error) { + panic("not implemented") +} + func (f *fakeClient) Get(request rest.Request, dest interface{}) error { if f.getInfoLatency != 0 { time.Sleep(f.getInfoLatency) @@ -67,12 +80,12 @@ func (f *fakeClient) Post(request rest.Request) error { body, err := request.GetJSONBody() if err != nil { - return fmt.Errorf("unable get request body") + return errors.New("unable get request body") } var entry dnsEntryWrapper if err := json.Unmarshal(body, &entry); err != nil { - return fmt.Errorf("unable to decode request body") + return errors.New("unable to decode request body") } f.dnsEntries = append(f.dnsEntries, entry.DNSEntry) @@ -91,12 +104,12 @@ func (f *fakeClient) Delete(request rest.Request) error { body, err := request.GetJSONBody() if err != nil { - return fmt.Errorf("unable get request body") + return errors.New("unable get request body") } var entry dnsEntryWrapper if err := json.Unmarshal(body, &entry); err != nil { - return fmt.Errorf("unable to decode request body") + return errors.New("unable to decode request body") } cp := make([]domain.DNSEntry, 0) diff --git a/providers/transip/transip.go b/providers/transip/transip.go index a7f12f4..f16c2e4 100644 --- a/providers/transip/transip.go +++ b/providers/transip/transip.go @@ -95,7 +95,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("transip: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("transip: could not find zone for domain %q: %w", domain, err) } // get the subDomain @@ -127,7 +127,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("transip: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("transip: could not find zone for domain %q: %w", domain, err) } // get the subDomain diff --git a/providers/transip/transip_test.go b/providers/transip/transip_test.go index 119aa63..05d7015 100644 --- a/providers/transip/transip_test.go +++ b/providers/transip/transip_test.go @@ -81,7 +81,7 @@ func TestNewDNSProvider(t *testing.T) { } // The error message for a file not existing is different on Windows and Linux. - // Therefore we test if the error type is the same. + // Therefore, we test if the error type is the same. t.Run("could not open private key path", func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() @@ -92,7 +92,7 @@ func TestNewDNSProvider(t *testing.T) { }) _, err := NewDNSProvider() - assert.ErrorIs(t, err, os.ErrNotExist) + require.ErrorIs(t, err, os.ErrNotExist) }) } @@ -144,14 +144,14 @@ func TestNewDNSProviderConfig(t *testing.T) { } // The error message for a file not existing is different on Windows and Linux. - // Therefore we test if the error type is the same. + // Therefore, we test if the error type is the same. t.Run("could not open private key path", func(t *testing.T) { config := NewDefaultConfig() config.AccountName = "johndoe" config.PrivateKeyPath = "./fixtures/non/existent/private.key" _, err := NewDNSProviderConfig(config) - assert.ErrorIs(t, err, os.ErrNotExist) + require.ErrorIs(t, err, os.ErrNotExist) }) } diff --git a/providers/ultradns/ultradns.go b/providers/ultradns/ultradns.go index 52d0ba7..c163170 100644 --- a/providers/ultradns/ultradns.go +++ b/providers/ultradns/ultradns.go @@ -105,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("ultradns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("ultradns: could not find zone for domain %q: %w", domain, err) } recordService, err := record.Get(d.client) @@ -146,7 +146,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("ultradns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("ultradns: could not find zone for domain %q: %w", domain, err) } recordService, err := record.Get(d.client) diff --git a/providers/ultradns/ultradns_test.go b/providers/ultradns/ultradns_test.go index 08a3b6a..e07b6a5 100644 --- a/providers/ultradns/ultradns_test.go +++ b/providers/ultradns/ultradns_test.go @@ -175,7 +175,6 @@ func TestLivePresent(t *testing.T) { } envTest.RestoreEnv() - provider, err := NewDNSProvider() require.NoError(t, err) diff --git a/providers/variomedia/variomedia.go b/providers/variomedia/variomedia.go index 7617d05..889af24 100644 --- a/providers/variomedia/variomedia.go +++ b/providers/variomedia/variomedia.go @@ -113,7 +113,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("variomedia: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("variomedia: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/vegadns/vegadns_test.go b/providers/vegadns/vegadns_test.go index 4e82da4..60f614c 100644 --- a/providers/vegadns/vegadns_test.go +++ b/providers/vegadns/vegadns_test.go @@ -21,7 +21,7 @@ func TestNewDNSProvider_Fail(t *testing.T) { envTest.ClearEnv() _, err := NewDNSProvider() - assert.Error(t, err, "VEGADNS_URL env missing") + require.Error(t, err, "VEGADNS_URL env missing") } func TestDNSProvider_TimeoutSuccess(t *testing.T) { @@ -34,8 +34,8 @@ func TestDNSProvider_TimeoutSuccess(t *testing.T) { require.NoError(t, err) timeout, interval := provider.Timeout() - assert.Equal(t, timeout, 12*time.Minute) - assert.Equal(t, interval, 1*time.Minute) + assert.Equal(t, 12*time.Minute, timeout) + assert.Equal(t, 1*time.Minute, interval) } func TestDNSProvider_Present(t *testing.T) { diff --git a/providers/vercel/vercel.go b/providers/vercel/vercel.go index e3b9977..a8dd5df 100644 --- a/providers/vercel/vercel.go +++ b/providers/vercel/vercel.go @@ -104,7 +104,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("vercel: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("vercel: could not find zone for domain %q: %w", domain, err) } record := internal.Record{ @@ -132,7 +132,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("vercel: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("vercel: could not find zone for domain %q: %w", domain, err) } // get the record's unique ID from when we created it diff --git a/providers/versio/versio.go b/providers/versio/versio.go index 1137d14..63acf79 100644 --- a/providers/versio/versio.go +++ b/providers/versio/versio.go @@ -120,7 +120,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("versio: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("versio: could not find zone for domain %q: %w", domain, err) } // use mutex to prevent race condition from getDNSRecords until postDNSRecords @@ -161,7 +161,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("versio: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("versio: could not find zone for domain %q: %w", domain, err) } // use mutex to prevent race condition from getDNSRecords until postDNSRecords diff --git a/providers/vinyldns/wrapper.go b/providers/vinyldns/wrapper.go index 258e1b3..d109c0f 100644 --- a/providers/vinyldns/wrapper.go +++ b/providers/vinyldns/wrapper.go @@ -116,7 +116,7 @@ func (d *DNSProvider) waitForChanges(operation string, resp *vinyldns.RecordSetU func splitDomain(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { - return "", "", fmt.Errorf("could not find zone for FDQN %q: %w", fqdn, err) + return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, zone) diff --git a/providers/vkcloud/vkcloud.go b/providers/vkcloud/vkcloud.go index 5cf59c9..93cd3f8 100644 --- a/providers/vkcloud/vkcloud.go +++ b/providers/vkcloud/vkcloud.go @@ -93,7 +93,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } if config.DNSEndpoint == "" { - return nil, fmt.Errorf("vkcloud: DNS endpoint is missing in config") + return nil, errors.New("vkcloud: DNS endpoint is missing in config") } authOpts := gophercloud.AuthOptions{ @@ -121,7 +121,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("vkcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("vkcloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) @@ -161,7 +161,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("vkcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("vkcloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) diff --git a/providers/vkcloud/vkcloud.toml b/providers/vkcloud/vkcloud.toml index 573daa0..20beeef 100644 --- a/providers/vkcloud/vkcloud.toml +++ b/providers/vkcloud/vkcloud.toml @@ -12,7 +12,7 @@ lego --email you@example.com --dns vkcloud --domains "example.org" --domains "*. ''' Additional = ''' -## Credential inforamtion +## Credential information You can find all required and additional information on ["Project/Keys" page](https://mcs.mail.ru/app/en/project/keys) of your cloud. diff --git a/providers/webnames/internal/client.go b/providers/webnames/internal/client.go new file mode 100644 index 0000000..db55784 --- /dev/null +++ b/providers/webnames/internal/client.go @@ -0,0 +1,96 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" +) + +const defaultBaseURL = "https://www.webnames.ru/scripts/json_domain_zone_manager.pl" + +// Client the Webnames API client. +type Client struct { + apiKey string + + baseURL string + HTTPClient *http.Client +} + +// NewClient Creates a new Client. +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + baseURL: defaultBaseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// AddTXTRecord adds a TXT record. +// Inspired by https://github.com/regtime-ltd/certbot-dns-webnames/blob/master/authenticator.sh +func (c *Client) AddTXTRecord(ctx context.Context, domain, subDomain, value string) error { + data := url.Values{} + data.Set("domain", domain) + data.Set("type", "TXT") + data.Set("record", subDomain+":"+value) + data.Set("action", "add") + + return c.doRequest(ctx, data) +} + +// RemoveTXTRecord removes a TXT record. +// Inspired by https://github.com/regtime-ltd/certbot-dns-webnames/blob/master/cleanup.sh +func (c *Client) RemoveTXTRecord(ctx context.Context, domain, subDomain, value string) error { + data := url.Values{} + data.Set("domain", domain) + data.Set("type", "TXT") + data.Set("record", subDomain+":"+value) + data.Set("action", "delete") + + return c.doRequest(ctx, data) +} + +func (c *Client) doRequest(ctx context.Context, data url.Values) error { + data.Set("apikey", c.apiKey) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + 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 errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + var r APIResponse + err = json.Unmarshal(raw, &r) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + if r.Result == "OK" { + return nil + } + + return fmt.Errorf("%s: %s", r.Result, r.Details) +} diff --git a/providers/webnames/internal/client_test.go b/providers/webnames/internal/client_test.go new file mode 100644 index 0000000..0f66eb8 --- /dev/null +++ b/providers/webnames/internal/client_test.go @@ -0,0 +1,155 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, filename string, expectedParams url.Values) *Client { + t.Helper() + + mux := http.NewServeMux() + + mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + err := req.ParseForm() + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + for k, v := range expectedParams { + val := req.PostForm.Get(k) + if len(v) == 0 { + http.Error(rw, fmt.Sprintf("%s: no value", k), http.StatusBadRequest) + return + } + + if val != v[0] { + http.Error(rw, fmt.Sprintf("%s: invalid value: %s != %s", k, val, v[0]), http.StatusBadRequest) + return + } + } + + file, err := os.Open(path.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + server := httptest.NewServer(mux) + + client := NewClient("secret") + client.baseURL = server.URL + client.HTTPClient = server.Client() + + return client +} + +func TestClient_AddTXTRecord(t *testing.T) { + testCases := []struct { + desc string + filename string + require require.ErrorAssertionFunc + }{ + { + desc: "ok", + filename: "ok.json", + require: require.NoError, + }, + { + desc: "error", + filename: "error.json", + require: require.Error, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + data := url.Values{} + data.Set("domain", "example.com") + data.Set("type", "TXT") + data.Set("record", "foo:txtTXTtxt") + data.Set("action", "add") + + client := setupTest(t, test.filename, data) + + domain := "example.com" + subDomain := "foo" + content := "txtTXTtxt" + + err := client.AddTXTRecord(context.Background(), domain, subDomain, content) + test.require(t, err) + }) + } +} + +func TestClient_RemoveTxtRecord(t *testing.T) { + testCases := []struct { + desc string + filename string + require require.ErrorAssertionFunc + }{ + { + desc: "ok", + filename: "ok.json", + require: require.NoError, + }, + { + desc: "error", + filename: "error.json", + require: require.Error, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + data := url.Values{} + data.Set("domain", "example.com") + data.Set("type", "TXT") + data.Set("record", "foo:txtTXTtxt") + data.Set("action", "delete") + + client := setupTest(t, test.filename, data) + + domain := "example.com" + subDomain := "foo" + content := "txtTXTtxt" + + err := client.RemoveTXTRecord(context.Background(), domain, subDomain, content) + test.require(t, err) + }) + } +} diff --git a/providers/webnames/internal/fixtures/error.json b/providers/webnames/internal/fixtures/error.json new file mode 100644 index 0000000..fe0878e --- /dev/null +++ b/providers/webnames/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "result": "ERROR", + "details": "zone_manager_unavailable" +} diff --git a/providers/webnames/internal/fixtures/ok.json b/providers/webnames/internal/fixtures/ok.json new file mode 100644 index 0000000..5641085 --- /dev/null +++ b/providers/webnames/internal/fixtures/ok.json @@ -0,0 +1,4 @@ +{ + "result": "OK", + "details": 1 +} diff --git a/providers/webnames/internal/types.go b/providers/webnames/internal/types.go new file mode 100644 index 0000000..ecdb320 --- /dev/null +++ b/providers/webnames/internal/types.go @@ -0,0 +1,8 @@ +package internal + +import "encoding/json" + +type APIResponse struct { + Result string `json:"result"` + Details json.RawMessage `json:"details"` +} diff --git a/providers/webnames/webnames.go b/providers/webnames/webnames.go new file mode 100644 index 0000000..a6687e0 --- /dev/null +++ b/providers/webnames/webnames.go @@ -0,0 +1,136 @@ +// Package webnames implements a DNS provider for solving the DNS-01 challenge using webnames.ru DNS. +package webnames + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/webnames/internal" +) + +// Environment variables names. +const ( + envNamespace = "WEBNAMES_" + + EnvAPIKey = envNamespace + "API_KEY" + + 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 + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + 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 new DNS provider using +// environment variable WEBNAMES_API_KEY for adding and removing the DNS record. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("webnames: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Webnames. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("webnames: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("webnames: credentials missing") + } + + client := internal.NewClient(config.APIKey) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{config: config, client: client}, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("webnames: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("webnames: %w", err) + } + + err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) + if err != nil { + return fmt.Errorf("webnames: failed to create TXT records [domain: %s, sub domain: %s]: %w", + dns01.UnFqdn(authZone), subDomain, err) + } + + return nil +} + +// CleanUp clears Webnames TXT record. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("webnames: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("webnames: %w", err) + } + + err = d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) + if err != nil { + return fmt.Errorf("webnames: failed to remove TXT records [domain: %s, sub domain: %s]: %w", + dns01.UnFqdn(authZone), subDomain, 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 +} diff --git a/providers/webnames/webnames.toml b/providers/webnames/webnames.toml new file mode 100644 index 0000000..b42ac3e --- /dev/null +++ b/providers/webnames/webnames.toml @@ -0,0 +1,30 @@ +Name = "Webnames" +Description = '''''' +URL = "https://www.webnames.ru/" +Code = "webnames" +Since = "v4.15.0" + +Example = ''' +WEBNAMES_API_KEY=xxxxxx \ +lego --email you@example.com --dns webnames --domains my.example.org run +''' + +Additional = ''' +## API Key + +To obtain the key, you need to change the DNS server to `*.nameself.com`: Personal account / My domains and services / Select the required domain / DNS servers + +The API key can be found: Personal account / My domains and services / Select the required domain / Zone management / acme.sh or certbot settings +''' + +[Configuration] + [Configuration.Credentials] + WEBNAMES_API_KEY = "Domain API key" + [Configuration.Additional] + WEBNAMES_POLLING_INTERVAL = "Time between DNS propagation check" + WEBNAMES_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + WEBNAMES_TTL = "The TTL of the TXT record used for the DNS challenge" + WEBNAMES_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://github.com/regtime-ltd/certbot-dns-webnames" diff --git a/providers/webnames/webnames_test.go b/providers/webnames/webnames_test.go new file mode 100644 index 0000000..3ec6950 --- /dev/null +++ b/providers/webnames/webnames_test.go @@ -0,0 +1,116 @@ +package webnames + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "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: "123", + }, + }, + { + desc: "missing api key", + envVars: map[string]string{ + EnvAPIKey: "", + }, + expected: "webnames: some credentials information are missing: WEBNAMES_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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + expected string + }{ + { + desc: "success", + apiKey: "123", + }, + { + desc: "missing credentials", + expected: "webnames: 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) + } 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(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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/websupport/websupport.go b/providers/websupport/websupport.go index e7622db..84200c9 100644 --- a/providers/websupport/websupport.go +++ b/providers/websupport/websupport.go @@ -105,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("websupport: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("websupport: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -133,12 +133,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return nil } - err = internal.ParseError(resp) - if err != nil { - return fmt.Errorf("websupport: %w", err) - } - - return nil + return fmt.Errorf("websupport: %w", internal.ParseError(resp)) } // CleanUp removes the TXT record matching the specified parameters. @@ -147,7 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("websupport: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("websupport: could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID @@ -172,12 +167,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - err = internal.ParseError(resp) - if err != nil { - return fmt.Errorf("websupport: %w", err) - } - - return nil + return fmt.Errorf("websupport: %w", internal.ParseError(resp)) } // Timeout returns the timeout and interval to use when checking for DNS propagation. diff --git a/providers/wedos/internal/token.go b/providers/wedos/internal/token.go index 7655092..b83b107 100644 --- a/providers/wedos/internal/token.go +++ b/providers/wedos/internal/token.go @@ -2,6 +2,7 @@ package internal import ( "crypto/sha1" + "encoding/hex" "fmt" "io" "time" @@ -14,7 +15,7 @@ func authToken(userName string, wapiPass string) string { func sha1string(txt string) string { h := sha1.New() _, _ = io.WriteString(h, txt) - return fmt.Sprintf("%x", h.Sum(nil)) + return hex.EncodeToString(h.Sum(nil)) } func czechHourString() string { @@ -38,7 +39,7 @@ func czechHour() int { func utcToCet(utc time.Time) time.Time { // https://en.wikipedia.org/wiki/Central_European_Time - // As of 2011, all member states of the European Union observe summer time (daylight saving time), + // As of 2011, all member states of the European Union observe Summer Time (daylight saving time), // from the last Sunday in March to the last Sunday in October. // States within the CET area switch to Central European Summer Time (CEST -- UTC+02:00) for the summer.[1] utcMonth := utc.Month() diff --git a/providers/wedos/wedos.go b/providers/wedos/wedos.go index 195518b..11461eb 100644 --- a/providers/wedos/wedos.go +++ b/providers/wedos/wedos.go @@ -108,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("wedos: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("wedos: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -156,7 +156,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("wedos: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("wedos: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) diff --git a/providers/yandex/yandex.go b/providers/yandex/yandex.go index e354b2e..9f32c2f 100644 --- a/providers/yandex/yandex.go +++ b/providers/yandex/yandex.go @@ -1,4 +1,4 @@ -// Package yandex implements a DNS provider for solving the DNS-01 challenge using Yandex. +// Package yandex implements a DNS provider for solving the DNS-01 challenge using Yandex PDD. package yandex import ( @@ -73,7 +73,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } if config.PddToken == "" { - return nil, fmt.Errorf("yandex: credentials missing") + return nil, errors.New("yandex: credentials missing") } client, err := internal.NewClient(config.PddToken) diff --git a/providers/yandex360/internal/client.go b/providers/yandex360/internal/client.go new file mode 100644 index 0000000..b131971 --- /dev/null +++ b/providers/yandex360/internal/client.go @@ -0,0 +1,147 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/entrustcorporation/dv/providers/internal/errutils" +) + +const defaultBaseURL = "https://api360.yandex.net/" + +type Client struct { + oauthToken string + orgID int64 + + baseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(oauthToken string, orgID int64) (*Client, error) { + if oauthToken == "" { + return nil, errors.New("OAuth token is required") + } + + if orgID == 0 { + return nil, errors.New("orgID is required") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + oauthToken: oauthToken, + orgID: orgID, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// AddRecord Adds a DNS record. +// POST https://api30.yandex.net/directory/v1/org/{orgId}/domains/{domain}/dns +// https://yandex.ru/dev/api360/doc/ref/DomainDNSService/DomainDNSService_Create.html +func (c Client) AddRecord(ctx context.Context, domain string, record Record) (*Record, error) { + endpoint := c.baseURL.JoinPath("directory", "v1", "org", strconv.FormatInt(c.orgID, 10), "domains", domain, "dns") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var newRecord Record + + err = c.do(req, &newRecord) + if err != nil { + return nil, err + } + + return &newRecord, nil +} + +// DeleteRecord Deletes a DNS record. +// DELETE https://api360.yandex.net/directory/v1/org/{orgId}/domains/{domain}/dns/{recordId} +// https://yandex.ru/dev/api360/doc/ref/DomainDNSService/DomainDNSService_Delete.html +func (c Client) DeleteRecord(ctx context.Context, domain string, recordID int64) error { + endpoint := c.baseURL.JoinPath("directory", "v1", "org", strconv.FormatInt(c.orgID, 10), "domains", domain, "dns", strconv.FormatInt(recordID, 10)) + + 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 { + req.Header.Set("Authorization", "OAuth "+c.oauthToken) + + 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 apiErr APIError + err := json.Unmarshal(raw, &apiErr) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr) +} diff --git a/providers/yandex360/internal/client_test.go b/providers/yandex360/internal/client_test.go new file mode 100644 index 0000000..d0ddac0 --- /dev/null +++ b/providers/yandex360/internal/client_test.go @@ -0,0 +1,108 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, pattern, method string, status int, filename string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) + return + } + + open, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = open.Close() }() + + rw.WriteHeader(status) + _, err = io.Copy(rw, open) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client, err := NewClient("secret", 123456) + require.NoError(t, err) + + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func TestClient_AddRecord(t *testing.T) { + client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodPost, http.StatusOK, "add-record.json") + + record := Record{ + Name: "_acme-challenge", + Text: "txtxtxt", + TTL: 60, + Type: "TXT", + } + + newRecord, err := client.AddRecord(context.Background(), "example.com", record) + require.NoError(t, err) + + expected := &Record{ + ID: 789465, + Name: "foo", + Text: "_acme-challenge", + TTL: 60, + Type: "txtxtxt", + } + + assert.Equal(t, expected, newRecord) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodGet, http.StatusUnauthorized, "error.json") + + record := Record{ + Name: "_acme-challenge", + Text: "txtxtxt", + TTL: 60, + Type: "TXT", + } + + newRecord, err := client.AddRecord(context.Background(), "example.com", record) + require.Error(t, err) + + assert.Nil(t, newRecord) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns/789456", http.MethodDelete, http.StatusOK, "delete-record.json") + + err := client.DeleteRecord(context.Background(), "example.com", 789456) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns/789456", http.MethodDelete, http.StatusUnauthorized, "error.json") + + err := client.DeleteRecord(context.Background(), "example.com", 789456) + require.Error(t, err) +} diff --git a/providers/yandex360/internal/fixtures/add-record.json b/providers/yandex360/internal/fixtures/add-record.json new file mode 100644 index 0000000..3472d9a --- /dev/null +++ b/providers/yandex360/internal/fixtures/add-record.json @@ -0,0 +1,7 @@ +{ + "recordID": 789465, + "name": "foo", + "text": "_acme-challenge", + "ttl": 60, + "type": "txtxtxt" +} diff --git a/providers/yandex360/internal/fixtures/delete-record.json b/providers/yandex360/internal/fixtures/delete-record.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/providers/yandex360/internal/fixtures/delete-record.json @@ -0,0 +1 @@ +{} diff --git a/providers/yandex360/internal/fixtures/error.json b/providers/yandex360/internal/fixtures/error.json new file mode 100644 index 0000000..6d6801d --- /dev/null +++ b/providers/yandex360/internal/fixtures/error.json @@ -0,0 +1,9 @@ +{ + "code": 123, + "details": [ + { + "@type": "foo" + } + ], + "message": "bar" +} diff --git a/providers/yandex360/internal/types.go b/providers/yandex360/internal/types.go new file mode 100644 index 0000000..5e08e0a --- /dev/null +++ b/providers/yandex360/internal/types.go @@ -0,0 +1,39 @@ +package internal + +import "fmt" + +type Record struct { + ID int64 `json:"recordId,omitempty"` + Address string `json:"address,omitempty"` + Exchange string `json:"exchange,omitempty"` + Flag int64 `json:"flag,omitempty"` + Name string `json:"name,omitempty"` + Port int64 `json:"port,omitempty"` + Preference int64 `json:"preference,omitempty"` + Priority int64 `json:"priority,omitempty"` + Tag string `json:"tag,omitempty"` + Target string `json:"target,omitempty"` + Text string `json:"text,omitempty"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + Weight int64 `json:"weight,omitempty"` +} + +type APIError struct { + Code int32 `json:"code"` + Details []Detail `json:"details"` + Message string `json:"message"` +} + +func (a APIError) Error() string { + return fmt.Sprintf("%d: %s: %v", a.Code, a.Message, a.Details) +} + +type Detail struct { + Type string `json:"@type"` +} + +func (d Detail) String() string { + return d.Type +} diff --git a/providers/yandex360/yandex360.go b/providers/yandex360/yandex360.go new file mode 100644 index 0000000..3ad9aef --- /dev/null +++ b/providers/yandex360/yandex360.go @@ -0,0 +1,174 @@ +// Package yandex360 implements a DNS provider for solving the DNS-01 challenge using Yandex 360. +package yandex360 + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/entrustcorporation/dv/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/entrustcorporation/dv/providers/yandex360/internal" +) + +// Environment variables names. +const ( + envNamespace = "YANDEX360_" + + EnvOAuthToken = envNamespace + "OAUTH_TOKEN" + EnvOrgID = envNamespace + "ORG_ID" + + 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 { + OAuthToken string + OrgID int64 + 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, 21600), + 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 { + client *internal.Client + config *Config + + recordIDs map[string]int64 + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Yandex 360. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvOAuthToken, EnvOrgID) + if err != nil { + return nil, fmt.Errorf("yandex360: %w", err) + } + + config := NewDefaultConfig() + config.OAuthToken = values[EnvOAuthToken] + + orgID, err := strconv.ParseInt(values[EnvOrgID], 10, 64) + if err != nil { + return nil, fmt.Errorf("yandex360: %w", err) + } + + config.OrgID = orgID + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Yandex 360. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("yandex360: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.OAuthToken, config.OrgID) + if err != nil { + return nil, fmt.Errorf("yandex360: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + client: client, + config: config, + recordIDs: make(map[string]int64), + }, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("yandex360: %w", err) + } + + authZone = dns01.UnFqdn(authZone) + + record := internal.Record{ + Name: subDomain, + TTL: d.config.TTL, + Text: info.Value, + Type: "TXT", + } + + newRecord, err := d.client.AddRecord(context.Background(), authZone, record) + if err != nil { + return fmt.Errorf("yandex360: add DNS record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = newRecord.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err) + } + + authZone = dns01.UnFqdn(authZone) + + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("yandex360: unknown recordID for %q", info.EffectiveFQDN) + } + + err = d.client.DeleteRecord(context.Background(), authZone, recordID) + if err != nil { + return fmt.Errorf("yandex360: delete DNS record: %w", err) + } + + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + 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 +} diff --git a/providers/yandex360/yandex360.toml b/providers/yandex360/yandex360.toml new file mode 100644 index 0000000..ad0ce0d --- /dev/null +++ b/providers/yandex360/yandex360.toml @@ -0,0 +1,25 @@ +Name = "Yandex 360" +Description = ''' +''' +URL = "https://360.yandex.ru" +Code = "yandex360" +Since = "v4.14.0" + +Example = ''' +YANDEX360_OAUTH_TOKEN= \ +YANDEX360_ORG_ID= \ +lego --email you@example.com --dns yandex360 --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + YANDEX360_OAUTH_TOKEN = "The OAuth Token" + YANDEX360_ORG_ID = "The organization ID" + [Configuration.Additional] + YANDEX360_POLLING_INTERVAL = "Time between DNS propagation check" + YANDEX360_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + YANDEX360_HTTP_TIMEOUT = "API request timeout" + YANDEX360_TTL = "The TTL of the TXT record used for the DNS challenge" + +[Links] + API = "https://yandex.ru/dev/api360/doc/ref/DomainDNSService.html" diff --git a/providers/yandex360/yandex360_test.go b/providers/yandex360/yandex360_test.go new file mode 100644 index 0000000..545c909 --- /dev/null +++ b/providers/yandex360/yandex360_test.go @@ -0,0 +1,130 @@ +package yandex360 + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvOAuthToken, EnvOrgID).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvOAuthToken: "secret", + EnvOrgID: "123456", + }, + }, + { + desc: "missing org ID", + envVars: map[string]string{ + EnvOAuthToken: "secret", + }, + expected: "yandex360: some credentials information are missing: YANDEX360_ORG_ID", + }, + { + desc: "missing token", + envVars: map[string]string{ + EnvOrgID: "123456", + }, + expected: "yandex360: some credentials information are missing: YANDEX360_OAUTH_TOKEN", + }, + } + + 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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + oauthToken string + orgID int64 + expected string + }{ + { + desc: "success", + oauthToken: "secret", + orgID: 123456, + }, + { + desc: "missing org ID", + oauthToken: "secret", + expected: "yandex360: orgID is required", + }, + { + desc: "missing token", + orgID: 123456, + expected: "yandex360: OAuth token is required", + }, + } + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.OAuthToken = test.oauthToken + config.OrgID = test.orgID + + 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(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(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/yandexcloud/yandexcloud.go b/providers/yandexcloud/yandexcloud.go index 1c38eaf..8c8e25d 100644 --- a/providers/yandexcloud/yandexcloud.go +++ b/providers/yandexcloud/yandexcloud.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "time" @@ -75,11 +76,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } if config.IamToken == "" { - return nil, fmt.Errorf("yandexcloud: some credentials information are missing IAM token") + return nil, errors.New("yandexcloud: some credentials information are missing IAM token") } if config.FolderID == "" { - return nil, fmt.Errorf("yandexcloud: some credentials information are missing folder id") + return nil, errors.New("yandexcloud: some credentials information are missing folder id") } creds, err := decodeCredentials(config.IamToken) @@ -104,7 +105,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("yandexcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -145,7 +146,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("yandexcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() @@ -197,7 +198,7 @@ func (r *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) { return nil, errors.New("unable to fetch dns zones") } - return response.DnsZones, nil + return response.GetDnsZones(), nil } func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error { @@ -223,7 +224,7 @@ func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, val var deletions []*ycdns.RecordSet if exist != nil { - record.Data = append(record.Data, exist.Data...) + record.SetData(append(record.GetData(), exist.GetData()...)) deletions = append(deletions, exist) } @@ -263,7 +264,7 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val var additions []*ycdns.RecordSet - if len(previousRecord.Data) > 1 { + if len(previousRecord.GetData()) > 1 { // RecordSet is not empty we should update it record := &ycdns.RecordSet{ Name: name, @@ -272,9 +273,9 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val Data: []string{}, } - for _, data := range previousRecord.Data { + for _, data := range previousRecord.GetData() { if data != value { - record.Data = append(record.Data, data) + record.SetData(append(record.GetData(), data)) } } @@ -309,13 +310,11 @@ func decodeCredentials(accountB64 string) (ycsdk.Credentials, error) { } func appendRecordSetData(record *ycdns.RecordSet, value string) bool { - for _, data := range record.Data { - if data == value { - return false - } + if slices.Contains(record.GetData(), value) { + return false } - record.Data = append(record.Data, value) + record.SetData(append(record.GetData(), value)) return true } diff --git a/providers/yandexcloud/yandexcloud.toml b/providers/yandexcloud/yandexcloud.toml index 93dbad4..97677b9 100644 --- a/providers/yandexcloud/yandexcloud.toml +++ b/providers/yandexcloud/yandexcloud.toml @@ -37,7 +37,7 @@ cat key.json | base64 [Configuration] [Configuration.Credentials] - YANDEX_CLOUD_IAM_TOKEN = "The base64 encoded json which contains inforamtion about iam token of serivce account with `dns.admin` permissions" + YANDEX_CLOUD_IAM_TOKEN = "The base64 encoded json which contains information about iam token of service account with `dns.admin` permissions" YANDEX_CLOUD_FOLDER_ID = "The string id of folder (aka project) in Yandex Cloud" [Configuration.Additional] YANDEX_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check" diff --git a/providers/zoneee/zoneee.go b/providers/zoneee/zoneee.go index 02f8812..e13b1dd 100644 --- a/providers/zoneee/zoneee.go +++ b/providers/zoneee/zoneee.go @@ -126,7 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("zoneee: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("zoneee: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) @@ -144,7 +144,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { - return fmt.Errorf("zoneee: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) + return fmt.Errorf("zoneee: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone)