diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..ae17ee40c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+**/zz_gen_*.* linguist-generated
+docs/data/zz_cli_help.toml linguist-generated
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index a4d077e5a..ea3fd9a3a 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -7,9 +7,9 @@ body:
attributes:
label: Welcome
options:
- - label: Yes, I'm using a binary release within 2 latest releases.
+ - label: Yes, I'm using a binary release within the two latest releases.
required: true
- - label: Yes, I've searched similar issues on GitHub and didn't find any.
+ - label: Yes, I've searched for similar issues on GitHub and didn't find any.
required: true
- label: Yes, I've included all information below (version, config, etc).
required: true
@@ -35,6 +35,7 @@ body:
attributes:
label: How do you use lego?
options:
+ - I don't know
- Library
- Binary
- Docker image
@@ -44,6 +45,8 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
+ - Through Certimate
+ - go install
- Other
validations:
required: true
@@ -64,8 +67,9 @@ body:
- type: textarea
id: version
attributes:
- label: Version of lego
+ label: Effective version of lego
description: |-
+ `latest` or `dev` are not effective versions.
```console
$ lego --version
```
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index b4e264177..7f6793167 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -6,7 +6,7 @@ body:
attributes:
label: Welcome
options:
- - label: Yes, I've searched similar issues on GitHub and didn't find any.
+ - label: Yes, I've searched for similar issues on GitHub and didn't find any.
required: true
- type: dropdown
@@ -14,6 +14,7 @@ body:
attributes:
label: How do you use lego?
options:
+ - I don't know
- Library
- Binary
- Docker image
@@ -23,10 +24,20 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
+ - Through Certimate
+ - go install
- Other
validations:
required: true
+ - type: input
+ id: version
+ attributes:
+ label: Effective version of lego
+ description: "`latest` or `dev` are not effective versions."
+ validations:
+ required: true
+
- type: textarea
id: description
attributes:
diff --git a/.github/ISSUE_TEMPLATE/new_dns_provider.yml b/.github/ISSUE_TEMPLATE/new_dns_provider.yml
index 274983636..b319bc287 100644
--- a/.github/ISSUE_TEMPLATE/new_dns_provider.yml
+++ b/.github/ISSUE_TEMPLATE/new_dns_provider.yml
@@ -8,15 +8,21 @@ body:
attributes:
label: Welcome
options:
- - label: Yes, I've searched similar issues on GitHub and didn't find any.
+ - label: Yes, I've searched for similar issues on GitHub and didn't find any.
required: true
- label: Yes, the DNS provider exposes a public API.
required: true
- label: Yes, I know that the lego maintainers don't have an account in all DNS providers in the world.
required: true
+
+ - type: checkboxes
+ id: pr
+ attributes:
+ label: Implementation
+ options:
- label: Yes, I'm able to create a pull request and be able to maintain the implementation.
required: false
- - label: Yes, I'm able to test an implementation if someone creates a pull request to add the support of this DNS provider.
+ - label: Yes, I can test an implementation with the help of the maintainers if someone creates a pull request.
required: false
- type: dropdown
@@ -24,6 +30,7 @@ body:
attributes:
label: How do you use lego?
options:
+ - I don't know
- Library
- Binary
- Docker image
@@ -33,10 +40,23 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
+ - Through Certimate
+ - go install
- Other
validations:
required: true
+ - type: dropdown
+ id: profile
+ attributes:
+ label: Who are you?
+ options:
+ - A customer of this DNS provider
+ - An employee of this DNS provider
+ - Other (please explain)
+ validations:
+ required: true
+
- type: input
id: provider-link
attributes:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 000000000..795320a8d
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,12 @@
+
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 46f7f6730..4f9d444fc 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -12,20 +12,16 @@ jobs:
runs-on: ubuntu-latest
env:
GO_VERSION: stable
- HUGO_VERSION: 0.131.0
+ HUGO_VERSION: 0.148.2
CGO_ENABLED: 0
steps:
- # https://github.com/marketplace/actions/checkout
- - name: Check out code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # https://github.com/marketplace/actions/setup-go-environment
- - name: Set up Go ${{ env.GO_VERSION }}
- uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
diff --git a/.github/workflows/go-cross.yml b/.github/workflows/go-cross.yml
index 30ec652a2..9dee85035 100644
--- a/.github/workflows/go-cross.yml
+++ b/.github/workflows/go-cross.yml
@@ -20,13 +20,8 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- # https://github.com/marketplace/actions/checkout
- - name: Checkout code
- uses: actions/checkout@v4
-
- # https://github.com/marketplace/actions/setup-go-environment
- - name: Set up Go ${{ matrix.go-version }}
- uses: actions/setup-go@v5
+ - uses: actions/checkout@v6
+ - uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index d7404a6b8..33ca106cc 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -13,54 +13,44 @@ jobs:
runs-on: ubuntu-latest
env:
GO_VERSION: stable
- GOLANGCI_LINT_VERSION: v1.62.0
- HUGO_VERSION: 0.131.0
+ GOLANGCI_LINT_VERSION: v2.10
+ HUGO_VERSION: 0.148.2
CGO_ENABLED: 0
LEGO_E2E_TESTS: CI
MEMCACHED_HOSTS: localhost:11211
steps:
- # https://github.com/marketplace/actions/checkout
- - name: Check out code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # https://github.com/marketplace/actions/setup-go-environment
- - name: Set up Go ${{ env.GO_VERSION }}
- uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Check and get dependencies
run: |
- go mod tidy
- git diff --exit-code go.mod
- git diff --exit-code go.sum
+ go mod tidy --diff
- # https://golangci-lint.run/usage/install#other-ci
- - name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }}
+ - name: Generate and Check generated elements
run: |
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
- golangci-lint --version
+ make generate-dns
+ git diff --exit-code
+
+ - uses: golangci/golangci-lint-action@v9
+ with:
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
+ install-only: true
- name: Install Pebble
- run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@3fe019bbc0a41ed16e2fee31592bb91751acaa47
+ run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.9.0
- name: Install challtestsrv
- run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@3fe019bbc0a41ed16e2fee31592bb91751acaa47
+ run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0
- name: Set up a Memcached server
- uses: niden/actions-memcached@v7
-
- - name: Setup /etc/hosts
- run: |
- echo "127.0.0.1 acme.wtf" | sudo tee -a /etc/hosts
- echo "127.0.0.1 lego.wtf" | sudo tee -a /etc/hosts
- echo "127.0.0.1 acme.lego.wtf" | sudo tee -a /etc/hosts
- echo "127.0.0.1 légô.wtf" | sudo tee -a /etc/hosts
- echo "127.0.0.1 xn--lg-bja9b.wtf" | sudo tee -a /etc/hosts
+ run: docker run -d --rm -p 11211:11211 memcached:1.6-alpine
- name: Make
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a102ad796..6a0d3b703 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,6 +5,11 @@ on:
tags:
- v*
+permissions:
+ # Allow the workflow to write attestations.
+ id-token: write
+ attestations: write
+
jobs:
release:
@@ -37,13 +42,11 @@ jobs:
docker-images: true
swap-storage: false
- - name: Check out code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: Set up Go ${{ env.GO_VERSION }}
- uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
@@ -64,11 +67,21 @@ jobs:
# https://goreleaser.com/ci/actions/
- name: Run GoReleaser
+ id: goreleaser
uses: goreleaser/goreleaser-action@v6
with:
- version: latest
+ version: v2.13.0
args: release -p 1 --clean --timeout=90m
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN_REPO }}
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
AUR_KEY: ${{ secrets.AUR_KEY }}
+
+ - uses: actions/attest-build-provenance@v3
+ with:
+ subject-checksums: ./dist/lego_${{ fromJSON(steps.goreleaser.outputs.metadata).version }}_checksums.txt
+ github-token: ${{ secrets.GH_TOKEN_REPO }}
+ - uses: actions/attest-build-provenance@v3
+ with:
+ subject-checksums: ./dist/digests.txt
+ github-token: ${{ secrets.GH_TOKEN_REPO }}
diff --git a/.golangci.yml b/.golangci.yml
index b3383969a..b6ab51ccc 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,265 +1,284 @@
+version: "2"
+
+formatters:
+ enable:
+ - gci
+ - gofmt
+ - gofumpt
+ - goimports
+ settings:
+ gofumpt:
+ extra-rules: true
+ gofmt:
+ rewrite-rules:
+ - pattern: 'interface{}'
+ replacement: 'any'
+
linters:
- enable-all: true
+ default: all
disable:
+ - wsl # Deprecated
+ - bodyclose
+ - canonicalheader
+ - contextcheck
- cyclop # duplicate of gocyclo
- - sqlclosecheck # not relevant (SQL)
- - rowserrcheck # not relevant (SQL)
- - lll
- - gosec
- dupl # not relevant
- - prealloc # too many false-positive
- - bodyclose # too many false-positive
- - mnd
- - testpackage # not relevant
- - tparallel # not relevant
- - paralleltest # not relevant
- - nestif # too many false-positive
- - wrapcheck
- err113 # not relevant
- - nlreturn # not relevant
- - wsl # not relevant
+ - errchkjson
+ - errname
- exhaustive # not relevant
- exhaustruct # not relevant
- - makezero # not relevant
- forbidigo
- - varnamelen # not relevant
- - nilnil # not relevant
- - ireturn # not relevant
- - contextcheck # too many false-positive
- - tenv # we already have a test "framework" to handle env vars
- - noctx
- forcetypeassert
- - tagliatelle
- - errname
- - errchkjson
- - nonamedreturns
+ - gosec
+ - gosmopolitan # not relevant
+ - ireturn # not relevant
+ - lll
+ - makezero # not relevant
+ - mnd
- musttag # false-positive https://github.com/junk1tm/musttag/issues/17
- - gosmopolitan # not relevant
- - exportloopref # Useless with go1.22
- - canonicalheader # Can create side effects in the context of API clients
- - usestdlibvars # false-positive https://github.com/sashamelentyev/usestdlibvars/issues/96
+ - nestif # too many false-positive
+ - nilnil # not relevant
+ - nlreturn # not relevant
+ - noctx
+ - noinlineerr # too strict
+ - nonamedreturns
+ - paralleltest # not relevant
+ - prealloc # too many false-positive
+ - rowserrcheck # not relevant (SQL)
+ - sqlclosecheck # not relevant (SQL)
+ - tagliatelle
+ - testpackage # not relevant
+ - tparallel # not relevant
+ - varnamelen # not relevant
+ - wrapcheck
-linters-settings:
- govet:
- enable:
- - shadow
- gocyclo:
- min-complexity: 12
- goconst:
- min-len: 3
- min-occurrences: 3
- funlen:
- lines: -1
- statements: 50
- misspell:
- locale: US
- ignore-words:
- - internetbs
- depguard:
+ settings:
+ depguard:
+ rules:
+ main:
+ deny:
+ - pkg: github.com/instana/testify
+ desc: not allowed
+ - pkg: github.com/pkg/errors
+ desc: Should be replaced by standard lib errors package
+ funlen:
+ lines: -1
+ statements: 50
+ goconst:
+ min-len: 3
+ min-occurrences: 3
+ gocritic:
+ disabled-checks:
+ - paramTypeCombine # already handle by gofumpt.extra-rules
+ - whyNoLint # already handle by nonolint
+ - unnamedResult
+ - hugeParam
+ - sloppyReassign
+ - rangeValCopy
+ - octalLiteral
+ - ptrToRefParam
+ - appendAssign
+ - ruleguard
+ - httpNoBody
+ - exposedSyncMutex
+ enabled-tags:
+ - diagnostic
+ - style
+ - performance
+ gocyclo:
+ min-complexity: 12
+ godox:
+ keywords:
+ - FIXME
+ govet:
+ disable:
+ - fieldalignment
+ enable-all: true
+ settings:
+ printf:
+ funcs:
+ - Print
+ - Printf
+ - Warn
+ - Warnf
+ - Fatal
+ - Fatalf
+ misspell:
+ locale: US
+ ignore-rules:
+ - internetbs
+ perfsprint:
+ err-error: true
+ errorf: true
+ sprintf1: true
+ strconcat: false
+ revive:
+ rules:
+ - name: struct-tag
+ - name: blank-imports
+ - name: context-as-argument
+ - name: context-keys-type
+ - name: dot-imports
+ - name: error-return
+ - name: error-strings
+ - name: error-naming
+ - name: exported
+ disabled: true
+ - name: if-return
+ - name: increment-decrement
+ - name: var-naming
+ - name: var-declaration
+ - name: package-comments
+ disabled: true
+ - name: range
+ - name: receiver-naming
+ - name: time-naming
+ - name: unexported-return
+ - name: indent-error-flow
+ - name: errorf
+ - name: empty-block
+ - name: superfluous-else
+ - name: unused-parameter
+ disabled: true
+ - name: unreachable-code
+ - name: redefines-builtin-id
+ tagalign:
+ align: false
+ order:
+ - xml
+ - json
+ - yaml
+ - yml
+ - toml
+ - mapstructure
+ - url
+ testifylint:
+ disable:
+ - require-error
+ - go-require
+ usetesting:
+ os-setenv: false # we already have a test "framework" to handle env vars
+ funcorder:
+ struct-method: false
+
+ exclusions:
+ warn-unused: true
+ presets:
+ - comments
+ - std-error-handling
+ paths:
+ # Those elements are related to code borrowed from the official HuaweiCloud API client.
+ - providers/dns/huaweicloud/internal
rules:
- main:
- deny:
- - pkg: "github.com/instana/testify"
- desc: not allowed
- - pkg: "github.com/pkg/errors"
- desc: Should be replaced by standard lib errors package
- tagalign:
- align: false
- order:
- - xml
- - json
- - yaml
- - yml
- - toml
- - mapstructure
- - url
- godox:
- keywords:
- - FIXME
- gocritic:
- enabled-tags:
- - diagnostic
- - style
- - performance
- disabled-checks:
- - paramTypeCombine # already handle by gofumpt.extra-rules
- - whyNoLint # already handle by nonolint
- - unnamedResult
- - hugeParam
- - sloppyReassign
- - rangeValCopy
- - octalLiteral
- - ptrToRefParam
- - appendAssign
- - ruleguard
- - httpNoBody
- - exposedSyncMutex
- revive:
- rules:
- - name: struct-tag
- - name: blank-imports
- - name: context-as-argument
- - name: context-keys-type
- - name: dot-imports
- - name: error-return
- - name: error-strings
- - name: error-naming
- - name: exported
- disabled: true
- - name: if-return
- - name: increment-decrement
- - name: var-naming
- - name: var-declaration
- - name: package-comments
- disabled: true
- - name: range
- - name: receiver-naming
- - name: time-naming
- - name: unexported-return
- - name: indent-error-flow
- - name: errorf
- - name: empty-block
- - name: superfluous-else
- - name: unused-parameter
- disabled: true
- - name: unreachable-code
- - name: redefines-builtin-id
- testifylint:
- disable:
- - require-error
- - go-require
- perfsprint:
- err-error: true
- errorf: true
- sprintf1: true
- strconcat: false
-
-run:
- timeout: 10m
-
-output:
- show-stats: true
- sort-results: true
- sort-order:
- - linter
- - file
+ - path: (.+)_test.go
+ linters:
+ - funlen
+ - goconst
+ - maintidx
+ - path: (.+)_test.go
+ text: Error return value of `fmt.Fprintln` is not checked
+ linters:
+ - errcheck
+ - text: "var-naming: avoid meaningless package names"
+ linters:
+ - revive
+ - text: "var-naming: avoid package names that conflict with Go standard library package names"
+ linters:
+ - revive
+ - path: certcrypto/crypto.go
+ text: (tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable
+ linters:
+ - gochecknoglobals
+ - path: challenge/dns01/nameserver.go
+ text: (defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable
+ linters:
+ - gochecknoglobals
+ - path: challenge/dns01/nameserver_.+.go
+ text: dnsTimeout is a global variable
+ linters:
+ - gochecknoglobals
+ - path: challenge/dns01/precheck.go
+ text: defaultNameserverPort is a global variable
+ linters:
+ - gochecknoglobals
+ - path: challenge/http01/domain_matcher.go
+ text: cyclomatic complexity \d+ of func `parseForwardedHeader` is high
+ linters:
+ - gocyclo
+ - path: challenge/http01/domain_matcher.go
+ text: Function 'parseForwardedHeader' has too many statements
+ linters:
+ - funlen
+ - path: challenge/tlsalpn01/tls_alpn_challenge.go
+ text: idPeAcmeIdentifierV1 is a global variable
+ linters:
+ - gochecknoglobals
+ - path: log/logger.go
+ text: Logger is a global variable
+ linters:
+ - gochecknoglobals
+ - path: e2e/(dnschallenge/)?[\d\w]+_test.go
+ text: load is a global variable
+ linters:
+ - gochecknoglobals
+ - path: providers/(dns|http)/([\d\w]+/)*[\d\w]+_test.go
+ text: envTest is a global variable
+ linters:
+ - gochecknoglobals
+ - path: providers/dns/namecheap/namecheap_test.go
+ text: testCases is a global variable
+ linters:
+ - gochecknoglobals
+ - path: providers/dns/namecheap/transport.go
+ text: (envProxyOnce|envProxyFuncValue) is a global variable
+ linters:
+ - gochecknoglobals
+ - path: providers/dns/acmedns/mock_test.go
+ text: egTestAccount is a global variable
+ linters:
+ - gochecknoglobals
+ - path: providers/http/memcached/memcached_test.go
+ text: memcachedHosts is a global variable
+ linters:
+ - gochecknoglobals
+ - path: providers/dns/checkdomain/internal/types.go
+ text: '`payed` is a misspelling of `paid`'
+ linters:
+ - misspell
+ - path: platform/tester/env_test.go
+ linters:
+ - thelper
+ - path: providers/dns/oraclecloud/oraclecloud_test.go
+ text: 'SA1019: x509.EncryptPEMBlock has been deprecated since Go 1.16'
+ linters:
+ - staticcheck
+ - path: providers/dns/sakuracloud/wrapper.go
+ text: mu is a global variable
+ linters:
+ - gochecknoglobals
+ - path: cmd/cmd_renew.go
+ text: cyclomatic complexity \d+ of func `(renewForDomains|renewForCSR)` is high
+ linters:
+ - gocyclo
+ - path: cmd/cmd_renew.go
+ text: Function 'renewForDomains' has too many statements
+ linters:
+ - funlen
+ - path: providers/dns/cpanel/cpanel.go
+ text: cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high
+ linters:
+ - gocyclo
+ - path: providers/dns/manual/manual.go
+ text: 'SA1019: dns01.DNSProviderManual is deprecated'
+ linters:
+ - staticcheck
+ # Those elements have been replaced by non-exposed structures.
+ - path: providers/dns/linode/linode_test.go
+ text: 'SA1019: linodego\.(DomainsPagedResponse|DomainRecordsPagedResponse) is deprecated'
+ linters:
+ - staticcheck
issues:
- exclude-generated: strict
- exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
- exclude:
- - 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked'
- - 'exported (type|method|function) (.+) should have comment or be unexported'
- - 'ST1000: at least one file in a package should have a package comment'
- exclude-rules:
- - path: (.+)_test.go
- linters:
- - funlen
- - goconst
- - maintidx
- - path: (.+)_test.go
- text: 'Error return value of `fmt.Fprintln` is not checked'
- linters:
- - errcheck
- - path: providers/dns/dns_providers.go
- linters:
- - gocyclo
- - path: certcrypto/crypto.go
- text: '(tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable'
- linters:
- - gochecknoglobals
- - path: challenge/dns01/nameserver.go
- text: '(defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable'
- linters:
- - gochecknoglobals
- - path: challenge/dns01/nameserver_.+.go
- text: 'dnsTimeout is a global variable'
- linters:
- - gochecknoglobals
- - path: challenge/dns01/nameserver_test.go
- text: 'findXByFqdnTestCases is a global variable'
- linters:
- - gochecknoglobals
- - path: challenge/http01/domain_matcher.go
- text: 'string `Host` has \d occurrences, make it a constant'
- linters:
- - goconst
- - path: challenge/http01/domain_matcher.go
- text: 'cyclomatic complexity \d+ of func `parseForwardedHeader` is high'
- linters:
- - gocyclo
- - path: challenge/http01/domain_matcher.go
- text: "Function 'parseForwardedHeader' has too many statements"
- linters:
- - funlen
- - path: challenge/tlsalpn01/tls_alpn_challenge.go
- text: 'idPeAcmeIdentifierV1 is a global variable'
- linters:
- - gochecknoglobals
- - path: log/logger.go
- text: 'Logger is a global variable'
- linters:
- - gochecknoglobals
- - path: 'e2e/(dnschallenge/)?[\d\w]+_test.go'
- text: load is a global variable
- linters:
- - gochecknoglobals
- - path: 'providers/dns/([\d\w]+/)*[\d\w]+_test.go'
- text: 'envTest is a global variable'
- linters:
- - gochecknoglobals
- - path: 'providers/http/([\d\w]+/)*[\d\w]+_test.go'
- text: 'envTest is a global variable'
- linters:
- - gochecknoglobals
- - path: providers/dns/namecheap/namecheap_test.go
- text: 'testCases is a global variable'
- linters:
- - gochecknoglobals
- - path: providers/dns/acmedns/acmedns_test.go
- text: 'egTestAccount is a global variable'
- linters:
- - gochecknoglobals
- - path: providers/http/memcached/memcached_test.go
- text: 'memcachedHosts is a global variable'
- linters:
- - gochecknoglobals
- - path: cmd/zz_gen_cmd_dnshelp.go
- linters:
- - gocyclo
- - funlen
- - path: providers/dns/checkdomain/internal/types.go
- text: '`payed` is a misspelling of `paid`'
- linters:
- - misspell
- - path: platform/tester/env_test.go
- linters:
- - thelper
- - path: providers/dns/oraclecloud/oraclecloud_test.go
- text: 'SA1019: x509.EncryptPEMBlock has been deprecated since Go 1.16'
- linters:
- - staticcheck
- - path: providers/dns/sakuracloud/wrapper.go
- text: 'mu is a global variable'
- linters:
- - gochecknoglobals
- - path: cmd/cmd_renew.go
- text: 'cyclomatic complexity \d+ of func `(renewForDomains|renewForCSR)` is high'
- linters:
- - gocyclo
- - path: providers/dns/cpanel/cpanel.go
- text: 'cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high'
- linters:
- - gocyclo
- - path: providers/dns/servercow/internal/types.go
- text: 'the methods of "Value" use pointer receiver and non-pointer receiver.'
- linters:
- - recvcheck
-
- # Those elements have been replaced by non-exposed structures.
- - path: providers/dns/linode/linode_test.go
- linters:
- - staticcheck
- text: "SA1019: linodego\\.(DomainsPagedResponse|DomainRecordsPagedResponse) is deprecated"
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 32079274e..c358f8a38 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -42,6 +42,10 @@ builds:
goarch: 386
- goos: openbsd
goarch: arm
+ # Deprecated in go1.25, Removed in go1.26
+ # https://go.dev/doc/go1.25#windows
+ - goos: windows
+ goarch: arm
changelog:
sort: asc
@@ -51,98 +55,54 @@ changelog:
- '(?i)^Detach v[\d|.]+'
- '(?i)^Prepare release v[\d|.]+'
+release:
+ skip_upload: false
+ github:
+ owner: 'go-acme'
+ name: 'lego'
+ header: |
+ lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
+
+ Everybody thinks that the others will donate, but in the end, nobody does.
+
+ So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev).
+
+ For key updates, see the [changelog](https://github.com/go-acme/lego/blob/HEAD/CHANGELOG.md#v{{ .Major }}{{ .Minor }}{{ .Patch }}).
+
archives:
- id: lego
name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}'
- format: tar.gz
+ formats: ['tar.gz']
format_overrides:
- goos: windows
- format: zip
+ formats: ['zip']
files:
- LICENSE
- CHANGELOG.md
-docker_manifests:
- - name_template: 'goacme/lego:{{ .Tag }}'
- image_templates:
- - 'goacme/lego:{{ .Tag }}-amd64'
- - 'goacme/lego:{{ .Tag }}-arm64'
- - 'goacme/lego:{{ .Tag }}-armv7'
- - name_template: 'goacme/lego:latest'
- image_templates:
- - 'goacme/lego:{{ .Tag }}-amd64'
- - 'goacme/lego:{{ .Tag }}-arm64'
- - 'goacme/lego:{{ .Tag }}-armv7'
- - name_template: 'goacme/lego:v{{ .Major }}.{{ .Minor }}'
- image_templates:
- - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-amd64'
- - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-arm64'
- - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-armv7'
-
-dockers:
- - use: buildx
- goos: linux
- goarch: amd64
+dockers_v2:
+ - images:
+ - 'goacme/lego'
dockerfile: buildx.Dockerfile
- image_templates:
- - 'goacme/lego:latest-amd64'
- - 'goacme/lego:{{ .Tag }}-amd64'
- - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-amd64'
- build_flag_templates:
- - '--pull'
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/v7
+ tags:
+ - 'latest'
+ - 'v{{ .Major }}'
+ - 'v{{ .Major }}.{{ .Minor }}'
+ - '{{ .Tag }}'
+ labels:
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- - '--label=org.opencontainers.image.title={{.ProjectName}}'
- - '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- - '--label=org.opencontainers.image.source={{.GitURL}}'
- - '--label=org.opencontainers.image.url={{.GitURL}}'
- - '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- - '--label=org.opencontainers.image.created={{.Date}}'
- - '--label=org.opencontainers.image.revision={{.FullCommit}}'
- - '--label=org.opencontainers.image.version={{.Version}}'
- - '--platform=linux/amd64'
-
- - use: buildx
- goos: linux
- goarch: arm64
- dockerfile: buildx.Dockerfile
- image_templates:
- - 'goacme/lego:latest-arm64'
- - 'goacme/lego:{{ .Tag }}-arm64'
- - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-arm64'
- build_flag_templates:
- - '--pull'
- # https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- - '--label=org.opencontainers.image.title={{.ProjectName}}'
- - '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- - '--label=org.opencontainers.image.source={{.GitURL}}'
- - '--label=org.opencontainers.image.url={{.GitURL}}'
- - '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- - '--label=org.opencontainers.image.created={{.Date}}'
- - '--label=org.opencontainers.image.revision={{.FullCommit}}'
- - '--label=org.opencontainers.image.version={{.Version}}'
- - '--platform=linux/arm64'
-
- - use: buildx
- goos: linux
- goarch: arm
- goarm: '7'
- dockerfile: buildx.Dockerfile
- image_templates:
- - 'goacme/lego:latest-armv7'
- - 'goacme/lego:{{ .Tag }}-armv7'
- - 'goacme/lego:v{{ .Major }}.{{ .Minor }}-armv7'
- build_flag_templates:
- - '--pull'
- # https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
- - '--label=org.opencontainers.image.title={{.ProjectName}}'
- - '--label=org.opencontainers.image.description=Lets Encrypt/ACME client and library written in Go'
- - '--label=org.opencontainers.image.source={{.GitURL}}'
- - '--label=org.opencontainers.image.url={{.GitURL}}'
- - '--label=org.opencontainers.image.documentation=https://go-acme.github.io/lego'
- - '--label=org.opencontainers.image.created={{.Date}}'
- - '--label=org.opencontainers.image.revision={{.FullCommit}}'
- - '--label=org.opencontainers.image.version={{.Version}}'
- - '--platform=linux/arm/v7'
+ 'org.opencontainers.image.title': '{{.ProjectName}}'
+ 'org.opencontainers.image.description': 'Lets Encrypt/ACME client and library written in Go'
+ 'org.opencontainers.image.source': '{{.GitURL}}'
+ 'org.opencontainers.image.url': '{{.GitURL}}'
+ 'org.opencontainers.image.documentation': 'https://go-acme.github.io/lego'
+ 'org.opencontainers.image.created': '{{.Date}}'
+ 'org.opencontainers.image.revision': '{{.FullCommit}}'
+ 'org.opencontainers.image.version': '{{.Version}}'
snapcrafts:
- name_template: "{{ .ProjectName }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
@@ -161,8 +121,6 @@ snapcrafts:
Usage:
* `sudo snap install lego`
* `sudo lego --email="you@example.com" --domains="example.com" --server=https://acme-staging-v02.api.letsencrypt.org/directory --http --http.port :8080 run
- channel_templates:
- - edge
apps:
lego:
command: lego
@@ -172,7 +130,7 @@ snapcrafts:
- network-bind
aurs:
- - description: "Let's Encrypt client and ACME library written in Go"
+ - description: "Let s Encrypt client and ACME library written in Go"
skip_upload: false
homepage: https://go-acme.github.io/lego/
name: 'lego-bin'
@@ -188,7 +146,7 @@ aurs:
email: ldez@users.noreply.github.com
package: |-
# Bin
- install -Dm755 "./prm" "${pkgdir}/usr/bin/lego"
+ install -Dm755 "./lego" "${pkgdir}/usr/bin/lego"
# License
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/lego/LICENSE"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f0f0ed27..ae73f70f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,396 @@
# Changelog
-## [v4.20.3](https://github.com/go-acme/lego/releases/tag/v4.20.3) (2024-11-21)
+lego is an independent, free, open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
+
+Everybody thinks that the others will donate, but in the end, nobody does.
+
+So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev).
+
+## v4.32.0
+
+- Release date: 2026-02-19
+- Tag: [v4.32.0](https://github.com/go-acme/lego/releases/tag/v4.32.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for ArtFiles
+- **[dnsprovider]** Add DNS provider for Leaseweb
+- **[dnsprovider]** Add DNS provider for FusionLayer NameSurfer
+- **[dnsprovider]** Add DNS provider for DDNSS
+- **[dnsprovider]** Add DNS provider for Bluecat v2
+- **[dnsprovider]** Add DNS provider for TodayNIC/时代互联
+- **[dnsprovider]** Add DNS provider for DNSExit
+- **[dnsprovider]** alidns: add line record option
+
+### Changed
+
+- **[dnsprovider]** azure: reinforces deprecation
+- **[dnsprovider]** allinkl: detect zone through API
+
+### Fixed
+
+- **[ari]** fix: implement parsing for Retry-After header according to RFC 7231
+- **[dnsprovider]** namesurfer: fix updateDNSHost
+- **[dnsprovider]** timewebcloud: fix subdomain support
+- **[dnsprovider]** fix: deduplicate authz for DNS01 challenge
+- **[lib,cli]** fix: use IPs to define the main domain
+- **[lib]** fix: preserve domain order
+
+## v4.31.0
+
+- Release date: 2026-01-08
+- Tag: [v4.31.0](https://github.com/go-acme/lego/releases/tag/v4.31.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for ISPConfig
+- **[dnsprovider]** Add DNS Provider for ISPConfig (DDNS Module)
+- **[dnsprovider]** Add DNS provider for Alwaysdata
+- **[dnsprovider]** Add DNS provider for JDCloud
+- **[dnsprovider]** Add DNS provider for 35.com/三五互联
+- **[dnsprovider]** f5xc: add an option to configure the domain of the server
+
+### Changed
+
+- **[lib]** feat: improve ACME error types
+- **[dnsprovider,cname]** namedotcom: follow CNAME
+
+### Fixed
+
+- **[dnsprovider]** hetzner: fix compatibility with _FILE suffix
+- **[dnsprovider]** gandiv5: fix API Key header
+
+## v4.30.1
+
+- Release date: 2025-12-16
+- Tag: [v4.30.1](https://github.com/go-acme/lego/releases/tag/v4.30.1)
+
+Due to an error related to `aliyun/credentials-go`, some artifacts of the v4.30.0 release have not been published.
+
+This release contains the same things as v4.30.0.
+
+## v4.30.0
+
+- Release date: 2025-12-16
+- Tag: [v4.30.0](https://github.com/go-acme/lego/releases/tag/v4.30.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for Ionos Cloud
+- **[dnsprovider]** Add DNS provider for Virtualname
+- **[dnsprovider]** Add DNS Provider for Neodigit
+- **[dnsprovider]** Add DNS provider for Syse.no
+- **[dnsprovider]** Add DNS provider for Gravity
+- **[dnsprovider]** Add DNS provider for hosting.nl
+
+### Changed
+
+- **[cli]** feat: remove email requirement
+
+### Fixed
+
+- **[dnsprovider]** autodns: use the right response structure
+
+## v4.29.0
+
+- Release date: 2025-11-29
+- Tag: [v4.29.0](https://github.com/go-acme/lego/releases/tag/v4.29.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for United-Domains
+- **[dnsprovider]** Add DNS provider for Gigahost.no
+- **[dnsprovider]** Add DNS provider for EdgeCenter
+- **[dnsprovider]** Add DNS provider for AlibabaCloud ESA
+- **[dnsprovider]** edgeone: add zones mapping
+- **[dnsprovider]** namecheap: add experimental proxy support
+
+### Changed
+
+- **[dnsprovider]** gandiv5: update base API URL
+
+### Fixed
+
+- **[dnsprovider]** hetzner: use int64 for IDs
+- **[dnsprovider]** baiducloud: pagination and TTL
+- **[dnsprovider]** inwx: fix API breaking changes with record IDs
+
+## v4.28.1
+
+- Release date: 2025-11-06
+- Tag: [v4.28.1](https://github.com/go-acme/lego/releases/tag/v4.28.1)
+
+### Fixed
+
+- **[cli]** fix: skip nil response
+
+## v4.28.0
+
+- Release date: 2025-10-31
+- Tag: [v4.28.0](https://github.com/go-acme/lego/releases/tag/v4.28.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for Anexia
+- **[dnsprovider]** Add DNS provider for webnames.ca
+- **[dnsprovider]** webnames: rename to webnamesru to avoid ambiguity with webnamesca
+
+### Changed
+
+- **[dnsprovider,log]** hetzner: add deprecation logs
+- **[dnsprovider]** iwantmyname: provider deprecation
+- **[cli]** improve retryable HTTP client error handling
+
+### Fixed
+
+- **[dnsprovider]** hostinger: fix record update
+
+## v4.27.0
+
+- Release date: 2025-10-17
+- Tag: [v4.27.0](https://github.com/go-acme/lego/releases/tag/v4.27.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for Octenium
+- **[dnsprovider]** Add DNS provider for Hostinger
+- **[dnsprovider]** Add DNS provider for Beget.com
+
+### Changed
+
+- **[cli]** support `--private-key` with a PKCS#8 keypair
+- **[dnsprovider]** hetzner: update to new API
+- **[dnsprovider]** otc: adds option to use private zone
+
+### Fixed
+
+- **[lib]** fix: deduplicate order identifiers
+
+## v4.26.0
+
+- Release date: 2025-09-13
+- Tag: [v4.26.0](https://github.com/go-acme/lego/releases/tag/v4.26.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for KeyHelp
+- **[dnsprovider]** Add DNS provider for Binary Lane
+- **[dnsprovider]** Add DNS provider for Tencent EdgeOne
+- **[dnsprovider]** azuredns: pipeline credential support
+- **[dnsprovider]** oraclecloud: handle instance_principal authentication
+
+### Changed
+
+- **[dnsprovider]** oraclecloud: add env var aliases
+- **[dnsprovider]** simply: update to API v2
+- **[lib,cli]** EAB: fallback to base64.URLEncoding
+
+### Fixed
+
+- **[dnsprovider]** selectelv2: add missing options
+
+## v4.25.2
+
+- Release date: 2025-08-06
+- Tag: [v4.25.2](https://github.com/go-acme/lego/releases/tag/v4.25.2)
+
+### Changed
+
+- **[cli,log]** log when dynamic renew date not yet reached
+
+### Fixed
+
+- **[cli]** fix: remove wrong env var
+- **[lib,cli]** fix: enforce HTTPS to the ACME server
+
+## v4.25.1
+
+- Release date: 2025-07-21
+- Tag: [v4.25.1](https://github.com/go-acme/lego/releases/tag/v4.25.1)
+
+### Fixed
+
+- **[cli]** fix: wrong CLI flag type
+
+## v4.25.0
+
+- Release date: 2025-07-21
+- Tag: [v4.25.0](https://github.com/go-acme/lego/releases/tag/v4.25.0)
+
+The binary size of this release is about ~50% smaller compared to previous releases.
+
+This will also reduce the module cache usage by 320 MB (this will only affect users of lego as a library or who build lego themselves).
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for ZoneEdit
+- **[cli]** Add an option to define dynamically the renew date
+- **[lib,cli]** Add an option to disable common name in CSR
+
+### Changed
+
+- **[dnsprovider]** vinyldns: add an option to add quotes around the TXT record value
+- **[dnsprovider]** ionos: increase default propagation timeout
+
+### Fixed
+
+- **[cli]** fix: enforce domain into renewal command
+
+## v4.24.0
+
+- Release date: 2025-07-07
+- Tag: [v4.24.0](https://github.com/go-acme/lego/releases/tag/v4.24.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for Azion
+- **[dnsprovider]** Add DNS provider for DynDnsFree.de
+- **[dnsprovider]** Add DNS provider for ConoHa v3
+- **[dnsprovider]** Add DNS provider for RU Center
+- **[dnsprovider]** gcloud: add service account impersonation
+
+### Changed
+
+- **[dnsprovider]** pdns: improve error messages
+- **[dnsprovider]** cloudflare: add quotation marks to TXT record
+- **[dnsprovider]** googledomains: provider deprecation
+- **[dnsprovider]** mijnhost: improve record filter
+
+### Fixed
+
+- **[dnsprovider]** exoscale: fix find record
+- **[dnsprovider]** nicmanager: fix mode env var name and value
+- **[lib,cli]** Check order identifiers difference between client and server
+
+## v4.23.1
+
+- Release date: 2025-04-16
+- Tag: [v4.23.1](https://github.com/go-acme/lego/releases/tag/v4.23.1)
+
+Due to an error related to Snapcraft, some artifacts of the v4.23.0 release have not been published.
+
+This release contains the same things as v4.23.0.
+
+## v4.23.0
+
+- Release date: 2025-04-16
+- Tag: [v4.23.0](https://github.com/go-acme/lego/releases/tag/v4.23.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for Active24
+- **[dnsprovider]** Add DNS provider for BookMyName
+- **[dnsprovider]** Add DNS provider for Axelname
+- **[dnsprovider]** Add DNS provider for Baidu Cloud
+- **[dnsprovider]** Add DNS provider for Metaregistrar
+- **[dnsprovider]** Add DNS provider for F5 XC
+- **[dnsprovider]** Add INFOBLOX_CA_CERTIFICATE option
+- **[dnsprovider]** route53: adds option to use private zone
+- **[dnsprovider]** edgedns: add account switch key option
+- **[dnsprovider]** infoblox: update API client to v2
+- **[lib,cli]** Add delay option for TLSALPN challenge
+
+### Changed
+
+- **[dnsprovider]** designate: speed up API requests by using filters
+- **[dnsprovider]** cloudflare: make base URL configurable
+- **[dnsprovider]** websupport: migrate to API v2
+- **[dnsprovider]** dnssimple: use GetZone
+
+### Fixed
+
+- **[ari]** Fix retry on `alreadyReplaced` error
+- **[cli,log]** Fix malformed log messages
+- **[cli]** Kill hook when the command is stuck
+- **[dnsprovider]** pdns: fix TXT record cleanup for wildcard domains
+- **[dnsprovider]** allinkl: remove `ReturnInfo`
+
+## v4.22.2
+
+- Release date: 2025-02-17
+- Tag: [v4.22.2](https://github.com/go-acme/lego/releases/tag/v4.22.2)
+
+### Fixed
+
+- **[dnsprovider]** acme-dns: use new registred account
+
+## v4.22.1
+
+- Release date: 2025-02-17
+- Tag: [v4.22.1](https://github.com/go-acme/lego/releases/tag/v4.22.1)
+
+### Fixed
+
+- **[dnsprovider]** acme-dns: continue the process when the CNAME is handled by the storage
+
+### Added
+
+## v4.22.0
+
+- Release date: 2025-02-17
+- Tag: [v4.22.0](https://github.com/go-acme/lego/releases/tag/v4.22.0)
+
+### Added
+
+- **[cli]** Add `--private-key` flag to set the private key.
+- **[cli]** Add `LEGO_DEBUG_ACME_HTTP_CLIENT` environment variable to debug the calls to the ACME server.
+- **[cli]** Add `LEGO_EMAIL` environment variable for specifying email.
+- **[cli]** Add `--hook-timeout` flag to run and renew commands.
+- **[dnsprovider]** Add DNS provider for myaddr.{tools,dev,io}
+- **[dnsprovider]** Add DNS provider for Spaceship
+- **[dnsprovider]** acme-dns: add HTTP storage
+- **[lib,cli,httpprovider]** Add `--http.delay` option for HTTP challenge.
+- **[lib,cli,profiles]** Add support for Profiles Extension.
+- **[lib]** Add an option to set CSR email addresses
+
+### Changed
+
+- **[lib]** rewrite status management
+- **[dnsprovider]** docs: improve units and default values
+
+### Removed
+
+- **[dnsprovider]** netcup: remove TTL option
+
+### Fixed
+
+- **[cli,log]** remove extra debug logs
+
+## v4.21.0
+
+- Release date: 2024-12-20
+- Tag: [v4.21.0](https://github.com/go-acme/lego/releases/tag/v4.21.0)
+
+### Added
+
+- **[dnsprovider]** Add DNS provider for Rainyun/雨云
+- **[dnsprovider]** Add DNS provider for West.cn/西部数码
+- **[dnsprovider]** Add DNS provider for ManageEngine CloudDNS
+- **[cli]** feat: add --force-cert-domains flag to renew
+
+### Fixed
+
+- **[cli]** create client only when needed
+- **[cli]** clone the transport with tls-skip-verify
+- **[cli]** use retryable client for ACME server calls
+- **[dnsprovider]** bunny: fix zone detection
+- **[dnsprovider]** inwx: delete only the TXT record related to the DNS challenge
+- **[dnsprovider]** infomaniak: increase default propagation timeout
+- **[dnsprovider]** dnsmadeeasy: use default transport
+- **[dnsprovider]** netcup: increase default propagation values
+- **[dnsprovider]** otc: use default transport
+
+## v4.20.4
+
+- Release date: 2024-11-21
+- Tag: [v4.20.4](https://github.com/go-acme/lego/releases/tag/v4.20.4)
+
+Publish the Snap to the Snapcraft stable channel.
+
+## v4.20.3
+
+- Release date: 2024-11-21
+- Tag: [v4.20.3](https://github.com/go-acme/lego/releases/tag/v4.20.3)
### Fixed
@@ -8,7 +398,10 @@
- **[dnsprovider]** directadmin: fix timeout configuration
- **[httpprovider]** fix: HTTP server IPv6 matching
-## [v4.20.2](https://github.com/go-acme/lego/releases/tag/v4.20.2) (2024-11-11)
+## v4.20.2
+
+- Release date: 2024-11-11
+- Tag: [v4.20.2](https://github.com/go-acme/lego/releases/tag/v4.20.2)
### Added
@@ -36,28 +429,41 @@
- **[dnsprovider]** volcengine: set API information within the default configuration
- **[log]** Parse printf verbs in log line output
-## v4.20.1 (2024-11-11)
+## v4.20.1
+
+- Release date: 2024-11-11
Cancelled due to CI failure.
-## v4.20.0 (2024-11-11)
+## v4.20.0
+
+- Release date: 2024-11-11
Cancelled due to CI failure.
-## [v4.19.2](https://github.com/go-acme/lego/releases/tag/v4.19.2) (2024-10-06)
+## v4.19.2
+
+- Release date: 2024-10-06
+- Tag: [v4.19.2](https://github.com/go-acme/lego/releases/tag/v4.19.2)
### Fixed
- **[lib]** go1.22 compatibility
-## [v4.19.1](https://github.com/go-acme/lego/releases/tag/v4.19.1) (2024-10-06)
+## v4.19.1
+
+- Release date: 2024-10-06
+- Tag: [v4.19.1](https://github.com/go-acme/lego/releases/tag/v4.19.1)
### Fixed
- **[dnsprovider]** selectelv2: use baseURL from configuration
- **[dnsprovider]** epik: add User-Agent
-## [v4.19.0](https://github.com/go-acme/lego/releases/tag/v4.19.0) (2024-10-03)
+## v4.19.0
+
+- Release date: 2024-10-03
+- Tag: [v4.19.0](https://github.com/go-acme/lego/releases/tag/v4.19.0)
### Added
@@ -79,7 +485,10 @@ Cancelled due to CI failure.
- **[dnsprovider]** namesilo: restrict CleanUp
- **[dnsprovider]** godaddy: fix cleanup
-## [v4.18.0](https://github.com/go-acme/lego/releases/tag/v4.18.0) (2024-08-30)
+## v4.18.0
+
+- Release date: 2024-08-30
+- Tag: [v4.18.0](https://github.com/go-acme/lego/releases/tag/v4.18.0)
### Added
@@ -101,13 +510,19 @@ Cancelled due to CI failure.
- **[ari]** fix: avoid Int63n panic in ShouldRenewAt()
-## [v4.17.4](https://github.com/go-acme/lego/releases/tag/v4.17.4) (2024-06-12)
+## v4.17.4
+
+- Release date: 2024-06-12
+- Tag: [v4.17.4](https://github.com/go-acme/lego/releases/tag/v4.17.4)
### Fixed
- **[dnsprovider]** Update dependencies
-## [v4.17.3](https://github.com/go-acme/lego/releases/tag/v4.17.3) (2024-05-28)
+## v4.17.3
+
+- Release date: 2024-05-28
+- Tag: [v4.17.3](https://github.com/go-acme/lego/releases/tag/v4.17.3)
### Added
@@ -135,13 +550,17 @@ Cancelled due to CI failure.
- **[dnsprovider]** pdns: reconstruct zone URLs to enable non-root folder API endpoints
- **[dnsprovider]** alidns: fix link to API documentation
-## v4.17.2 (2024-05-28)
+## v4.17.2
+
+- Release date: 2024-05-28
Canceled due to a release failure related to Snapcraft.
The Snapcraft release are disabled for now.
-## v4.17.1 (2024-05-28)
+## v4.17.1
+
+- Release date: 2024-05-28
Canceled due to a release failure related to oci-go-sdk.
@@ -150,17 +569,25 @@ The module `github.com/oracle/oci-go-sdk/v65` uses `github.com/gofrs/flock` but
Due to that we will remove the Solaris build.
-## v4.17.0 (2024-05-28)
+## v4.17.0
+
+- Release date: 2024-05-28
Canceled due to a release failure related to Snapcraft.
-## [v4.16.1](https://github.com/go-acme/lego/releases/tag/v4.16.1) (2024-03-10)
+## v4.16.1
+
+- Release date: 2024-03-10
+- Tag: [v4.16.1](https://github.com/go-acme/lego/releases/tag/v4.16.1)
### Fixed
- **[cli,ari]** fix: don't generate ARI cert ID if ARI is not enable
-## [v4.16.0](https://github.com/go-acme/lego/releases/tag/v4.16.0) (2024-03-09)
+## v4.16.0
+
+- Release date: 2024-03-09
+- Tag: [v4.16.0](https://github.com/go-acme/lego/releases/tag/v4.16.0)
### Added
@@ -181,7 +608,10 @@ Canceled due to a release failure related to Snapcraft.
- **[dnsprovider]** easydns: fix zone detection
- **[dnsprovider]** ns1: fix record creation
-## [v4.15.0](https://github.com/go-acme/lego/releases/tag/v4.15.0) (2024-01-28)
+## v4.15.0
+
+- Release date: 2024-01-28
+- Tag: [v4.15.0](https://github.com/go-acme/lego/releases/tag/v4.15.0)
### Added
@@ -219,7 +649,10 @@ Canceled due to a release failure related to Snapcraft.
- **[dnsprovider]** nifcloud: fix API requests
- **[dnsprovider]** otc: sequential challenge
-## [v4.14.1](https://github.com/go-acme/lego/releases/tag/v4.14.1) (2023-09-20)
+## v4.14.1
+
+- Release date: 2023-09-20
+- Tag: [v4.14.1](https://github.com/go-acme/lego/releases/tag/v4.14.1)
### Fixed
@@ -227,11 +660,16 @@ Canceled due to a release failure related to Snapcraft.
- **[dnsprovider]** bunny: use NRDCG fork
- **[dnsprovider]** ovh: update client to v1.4.2
-## v4.14.1 (2023-09-19)
+## v4.14.1
+
+- Release date: 2023-09-19
Cancelled due to CI failure.
-## [v4.14.0](https://github.com/go-acme/lego/releases/tag/v4.14.0) (2023-08-20)
+## v4.14.0
+
+- Release date: 2023-08-20
+- Tag: [v4.14.0](https://github.com/go-acme/lego/releases/tag/v4.14.0)
### Added
@@ -250,20 +688,29 @@ Cancelled due to CI failure.
- **[dnsprovider]** pdns: fix notify
- **[dnsprovider]** route53: avoid unexpected records deletion
-## [v4.13.3](https://github.com/go-acme/lego/releases/tag/v4.13.3) (2023-07-25)
+## v4.13.3
+
+- Release date: 2023-07-25
+- Tag: [v4.13.3](https://github.com/go-acme/lego/releases/tag/v4.13.3)
### Fixed
- **[dnsprovider]** azuredns: fix configuration from env vars
- **[dnsprovider]** gcore: change API domain
-## [v4.13.2](https://github.com/go-acme/lego/releases/tag/v4.13.2) (2023-07-21)
+## v4.13.2
+
+- Release date: 2023-07-21
+- Tag: [v4.13.2](https://github.com/go-acme/lego/releases/tag/v4.13.2)
### Fixed
- **[dnsprovider]** servercow: fix regression
-## [v4.13.1](https://github.com/go-acme/lego/releases/tag/v4.13.1) (2023-07-20)
+## v4.13.1
+
+- Release date: 2023-07-20
+- Tag: [v4.13.1](https://github.com/go-acme/lego/releases/tag/v4.13.1)
### Added
@@ -284,24 +731,35 @@ Cancelled due to CI failure.
- **[cli]** fix: list command
- **[lib]** fix: ARI explanationURL
-## v4.13.0 (2023-07-20)
+## v4.13.0
+
+- Release date: 2023-07-20
Cancelled due to a CI issue (no space left on device).
-## [v4.12.2](https://github.com/go-acme/lego/releases/tag/v4.12.2) (2023-06-19)
+## v4.12.2
+
+- Release date: 2023-06-19
+- Tag: [v4.12.2](https://github.com/go-acme/lego/releases/tag/v4.12.2)
### Fixed
- **[dnsprovider]** dnsmadeeasy: fix DeleteRecord
- **[lib]** fix: read status code from response
-## [v4.12.1](https://github.com/go-acme/lego/releases/tag/v4.12.1) (2023-06-06)
+## v4.12.1
+
+- Release date: 2023-06-06
+- Tag: [v4.12.1](https://github.com/go-acme/lego/releases/tag/v4.12.1)
### Fixed
- **[dnsprovider]** pdns: fix record value
-## [v4.12.0](https://github.com/go-acme/lego/releases/tag/v4.12.0) (2023-05-28)
+## v4.12.0
+
+- Release date: 2023-05-28
+- Tag: [v4.12.0](https://github.com/go-acme/lego/releases/tag/v4.12.0)
### Added
@@ -319,7 +777,10 @@ Cancelled due to a CI issue (no space left on device).
- **[dnsprovider]** autodns: fixes wrong zone in api call if CNAME is used
- **[cli]** fix: archive only domain-related files on revoke
-## [v4.11.0](https://github.com/go-acme/lego/releases/tag/v4.11.0) (2023-05-02)
+## v4.11.0
+
+- Release date: 2023-05-02
+- Tag: [v4.11.0](https://github.com/go-acme/lego/releases/tag/v4.11.0)
### Added
@@ -341,18 +802,27 @@ Cancelled due to a CI issue (no space left on device).
- **[dnsprovider]** rimuhosting: fix API base URL
-## [v4.10.2](https://github.com/go-acme/lego/releases/tag/v4.10.2) (2023-02-26)
+## v4.10.2
+
+- Release date: 2023-02-26
+- Tag: [v4.10.2](https://github.com/go-acme/lego/releases/tag/v4.10.2)
Fix Docker image builds.
-## [v4.10.1](https://github.com/go-acme/lego/releases/tag/v4.10.1) (2023-02-25)
+## v4.10.1
+
+- Release date: 2023-02-25
+- Tag: [v4.10.1](https://github.com/go-acme/lego/releases/tag/v4.10.1)
### Fixed
- **[dnsprovider,cname]** acmedns: fix CNAME support
- **[dnsprovider]** dynu: fix subdomain support
-## [v4.10.0](https://github.com/go-acme/lego/releases/tag/v4.10.0) (2023-02-10)
+## v4.10.0
+
+- Release date: 2023-02-10
+- Tag: [v4.10.0](https://github.com/go-acme/lego/releases/tag/v4.10.0)
### Added
@@ -378,7 +848,10 @@ Fix Docker image builds.
- **[dnsprovider]** pdns: fix usage of notify only when zone kind is Master or Slave
- **[dnsprovider]** return an error when extracting record name
-## [v4.9.1](https://github.com/go-acme/lego/releases/tag/v4.9.1) (2022-11-25)
+## v4.9.1
+
+- Release date: 2022-11-25
+- Tag: [v4.9.1](https://github.com/go-acme/lego/releases/tag/v4.9.1)
### Changed
@@ -393,7 +866,10 @@ Fix Docker image builds.
- **[dnsprovider]** hurricane: fix CNAME support
- **[lib,cname]** cname: stop trying to traverse cname if none have been found
-## [v4.9.0](https://github.com/go-acme/lego/releases/tag/v4.9.0) (2022-10-03)
+## v4.9.0
+
+- Release date: 2022-10-03
+- Tag: [v4.9.0](https://github.com/go-acme/lego/releases/tag/v4.9.0)
### Added
@@ -423,7 +899,10 @@ Fix Docker image builds.
- **[dnsprovider]** njalla: fix record id unmarshal error
- **[dnsprovider]** tencentcloud: fix subdomain error
-## [v4.8.0](https://github.com/go-acme/lego/releases/tag/v4.8.0) (2022-06-30)
+## v4.8.0
+
+- Release date: 2022-06-30
+- Tag: [v4.8.0](https://github.com/go-acme/lego/releases/tag/v4.8.0)
### Added
@@ -439,7 +918,10 @@ Fix Docker image builds.
- **[dnsprovider]** hetzner: set min TTL to 60s
- **[docs]** refactoring and cleanup
-## [v4.7.0](https://github.com/go-acme/lego/releases/tag/v4.7.0) (2022-05-27)
+## v4.7.0
+
+- Release date: 2022-05-27
+- Tag: [v4.7.0](https://github.com/go-acme/lego/releases/tag/v4.7.0)
### Added
@@ -461,7 +943,10 @@ Fix Docker image builds.
- **[dnsprovider]** tencentcloud: fix InvalidParameter.DomainInvalid error when using DNS challenges
- **[lib]** fix: panic in certcrypto.ParsePEMPrivateKey
-## [v4.6.0](https://github.com/go-acme/lego/releases/tag/v4.6.0) (2022-01-18)
+## v4.6.0
+
+- Release date: 2022-01-18
+- Tag: [v4.6.0](https://github.com/go-acme/lego/releases/tag/v4.6.0)
### Added
@@ -483,13 +968,19 @@ Fix Docker image builds.
- **[dnsprovider]** mythicbeasts: fix token expiration
- **[dnsprovider]** rackspace: change zone ID to string
-## [v4.5.3](https://github.com/go-acme/lego/releases/tag/v4.5.3) (2021-09-06)
+## v4.5.3
+
+- Release date: 2021-09-06
+- Tag: [v4.5.3](https://github.com/go-acme/lego/releases/tag/v4.5.3)
### Fixed
- **[lib,cli]** fix: missing preferred chain param for renew request
-## [v4.5.2](https://github.com/go-acme/lego/releases/tag/v4.5.2) (2021-09-01)
+## v4.5.2
+
+- Release date: 2021-09-01
+- Tag: [v4.5.2](https://github.com/go-acme/lego/releases/tag/v4.5.2)
### Added
@@ -519,15 +1010,22 @@ Fix Docker image builds.
- **[lib]** lib: use permanent error instead of context cancellation
- **[dnsprovider]** desec: bump to v0.6.0
-## v4.5.1 (2021-09-01)
+## v4.5.1
+
+- Release date: 2021-10-01
Cancelled due to a CI issue, replaced by v4.5.2.
-## v4.5.0 (2021-09-30)
+## v4.5.0
+
+- Release date: 2021-09-30
Cancelled due to a CI issue, replaced by v4.5.2.
-## [v4.4.0](https://github.com/go-acme/lego/releases/tag/v4.4.0) (2021-06-08)
+## v4.4.0
+
+- Release date: 2021-06-08
+- Tag: [v4.4.0](https://github.com/go-acme/lego/releases/tag/v4.4.0)
### Added
@@ -555,13 +1053,19 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** nifcloud: Get zone info from dns01.FindZoneByFqdn
- **[cli,lib]** csr: Support the type `NEW CERTIFICATE REQUEST`
-## [v4.3.1](https://github.com/go-acme/lego/releases/tag/v4.3.1) (2021-03-12)
+## v4.3.1
+
+- Release date: 2021-03-12
+- Tag: [v4.3.1](https://github.com/go-acme/lego/releases/tag/v4.3.1)
### Fixed
- **[dnsprovider]** exoscale: fix dependency version.
-## [v4.3.0](https://github.com/go-acme/lego/releases/tag/v4.3.0) (2021-03-10)
+## v4.3.0
+
+- Release date: 2021-03-10
+- Tag: [v4.3.0](https://github.com/go-acme/lego/releases/tag/v4.3.0)
### Added
@@ -585,7 +1089,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[lib]** Increase HTTP client timeouts
- **[lib]** preferred chain only match root name
-## [v4.2.0](https://github.com/go-acme/lego/releases/tag/v4.2.0) (2021-01-24)
+## v4.2.0
+
+- Release date: 2021-01-24
+- Tag: [v4.2.0](https://github.com/go-acme/lego/releases/tag/v4.2.0)
### Added
@@ -605,26 +1112,38 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** pdns: URL request creation.
- **[lib]** errors: Fix instance not being printed
-## [v4.1.3](https://github.com/go-acme/lego/releases/tag/v4.1.3) (2020-11-25)
+## v4.1.3
+
+- Release date: 2020-11-25
+- Tag: [v4.1.3](https://github.com/go-acme/lego/releases/tag/v4.1.3)
### Fixed
- **[dnsprovider]** azure: fix error handling.
-## [v4.1.2](https://github.com/go-acme/lego/releases/tag/v4.1.2) (2020-11-21)
+## v4.1.2
+
+- Release date: 2020-11-21
+- Tag: [v4.1.2](https://github.com/go-acme/lego/releases/tag/v4.1.2)
### Fixed
- **[lib]** fix: preferred chain support.
-## [v4.1.1](https://github.com/go-acme/lego/releases/tag/v4.1.1) (2020-11-19)
+## v4.1.1
+
+- Release date: 2020-11-19
+- Tag: [v4.1.1](https://github.com/go-acme/lego/releases/tag/v4.1.1)
### Fixed
- **[dnsprovider]** otc: select correct zone if multiple returned
- **[dnsprovider]** azure: fix target must be a non-nil pointer
-## [v4.1.0](https://github.com/go-acme/lego/releases/tag/v4.1.0) (2020-11-06)
+## v4.1.0
+
+- Release date: 2020-11-06
+- Tag: [v4.1.0](https://github.com/go-acme/lego/releases/tag/v4.1.0)
### Added
@@ -642,13 +1161,19 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[lib]** acme/api: use postAsGet instead of post for AccountService.Get
- **[lib]** fix: use http.Header.Set method instead of Add.
-## [v4.0.1](https://github.com/go-acme/lego/releases/tag/v4.0.1) (2020-09-03)
+## v4.0.1
+
+- Release date: 2020-09-03
+- Tag: [v4.0.1](https://github.com/go-acme/lego/releases/tag/v4.0.1)
### Fixed
- **[dnsprovider]** exoscale: change dependency version.
-## [v4.0.0](https://github.com/go-acme/lego/releases/tag/v4.0.0) (2020-09-02)
+## v4.0.0
+
+- Release date: 2020-09-02
+- Tag: [v4.0.0](https://github.com/go-acme/lego/releases/tag/v4.0.0)
### Added
@@ -665,7 +1190,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** Removes old Linode provider
- **[lib]** Removes `AddPreCheck` function
-## [v3.9.0](https://github.com/go-acme/lego/releases/tag/v3.9.0) (2020-09-01)
+## v3.9.0
+
+- Release date: 2020-09-01
+- Tag: [v3.9.0](https://github.com/go-acme/lego/releases/tag/v3.9.0)
### Added
@@ -682,7 +1210,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** namesilo: fix cleanup.
-## [v3.8.0](https://github.com/go-acme/lego/releases/tag/v3.8.0) (2020-07-02)
+## v3.8.0
+
+- Release date: 2020-07-02
+- Tag: [v3.8.0](https://github.com/go-acme/lego/releases/tag/v3.8.0)
### Added
@@ -706,7 +1237,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** hetzner: fix record name.
- **[lib]** Registrar.ResolveAccountByKey: Fix malformed request
-## [v3.7.0](https://github.com/go-acme/lego/releases/tag/v3.7.0) (2020-05-11)
+## v3.7.0
+
+- Release date: 2020-05-11
+- Tag: [v3.7.0](https://github.com/go-acme/lego/releases/tag/v3.7.0)
### Added
@@ -729,7 +1263,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[cli]** fix: renew path information.
- **[cli]** Fix account storage location warning message
-## [v3.6.0](https://github.com/go-acme/lego/releases/tag/v3.6.0) (2020-04-24)
+## v3.6.0
+
+- Release date: 2020-04-24
+- Tag: [v3.6.0](https://github.com/go-acme/lego/releases/tag/v3.6.0)
### Added
@@ -753,7 +1290,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** ns1: fix missing domain in log
- **[dnsprovider]** rimuhosting: use HTTP client from config.
-## [v3.5.0](https://github.com/go-acme/lego/releases/tag/v3.5.0) (2020-03-15)
+## v3.5.0
+
+- Release date: 2020-03-15
+- Tag: [v3.5.0](https://github.com/go-acme/lego/releases/tag/v3.5.0)
### Added
@@ -776,7 +1316,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** gcloud: fixes issues when used with GKE Workload Identity
- **[dnsprovider]** oraclecloud: fix subdomain support
-## [v3.4.0](https://github.com/go-acme/lego/releases/tag/v3.4.0) (2020-02-25)
+## v3.4.0
+
+- Release date: 2020-02-25
+- Tag: [v3.4.0](https://github.com/go-acme/lego/releases/tag/v3.4.0)
### Added
@@ -801,7 +1344,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[lib]** crypto: Treat CommonName as optional
- **[lib]** chore: update cenkalti/backoff to v4.
-## [v3.3.0](https://github.com/go-acme/lego/releases/tag/v3.3.0) (2020-01-08)
+## v3.3.0
+
+- Release date: 2020-01-08
+- Tag: [v3.3.0](https://github.com/go-acme/lego/releases/tag/v3.3.0)
### Added
@@ -817,7 +1363,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** Update dnspod, because of API breaking changes.
-## [v3.2.0](https://github.com/go-acme/lego/releases/tag/v3.2.0) (2019-11-10)
+## v3.2.0
+
+- Release date: 2019-11-10
+- Tag: [v3.2.0](https://github.com/go-acme/lego/releases/tag/v3.2.0)
### Added
@@ -833,7 +1382,10 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** use token as unique ID.
-## [v3.1.0](https://github.com/go-acme/lego/releases/tag/v3.1.0) (2019-10-07)
+## v3.1.0
+
+- Release date: 2019-10-07
+- Tag: [v3.1.0](https://github.com/go-acme/lego/releases/tag/v3.1.0)
### Added
@@ -851,36 +1403,54 @@ Cancelled due to a CI issue, replaced by v4.5.2.
- **[dnsprovider]** ovh: fix int overflow.
- **[dnsprovider]** bindman: fix client version.
-## [v3.0.2](https://github.com/go-acme/lego/releases/tag/v3.0.2) (2019-08-15)
+## v3.0.2
+
+- Release date: 2019-08-15
+- Tag: [v3.0.2](https://github.com/go-acme/lego/releases/tag/v3.0.2)
### Fixed
- Invalid pseudo version (related to Cloudflare client).
-## [v3.0.1](https://github.com/go-acme/lego/releases/tag/v3.0.1) (2019-08-14)
+## v3.0.1
+
+- Release date: 2019-08-14
+- Tag: [v3.0.1](https://github.com/go-acme/lego/releases/tag/v3.0.1)
There was a problem when creating the tag v3.0.1, this tag has been invalidated.
-## [v3.0.0](https://github.com/go-acme/lego/releases/tag/v3.0.0) (2019-08-05)
+## v3.0.0
+
+- Release date: 2019-08-05
+- Tag: [v3.0.0](https://github.com/go-acme/lego/releases/tag/v3.0.0)
### Changed
- migrate to go module (new import github.com/go-acme/lego/v3/)
- update DNS clients
-## [v2.7.2](https://github.com/go-acme/lego/releases/tag/v2.7.2) (2019-07-30)
+## v2.7.2
+
+- Release date: 2019-07-30
+- Tag: [v2.7.2](https://github.com/go-acme/lego/releases/tag/v2.7.2)
### Fixed
- **[dnsprovider]** vultr: quote TXT record
-## [v2.7.1](https://github.com/go-acme/lego/releases/tag/v2.7.1) (2019-07-22)
+## v2.7.1
+
+- Release date: 2019-07-22
+- Tag: [v2.7.1](https://github.com/go-acme/lego/releases/tag/v2.7.1)
### Fixed
- **[dnsprovider]** vultr: invalid record type.
-## [v2.7.0](https://github.com/go-acme/lego/releases/tag/v2.7.0) (2019-07-17)
+## v2.7.0
+
+- Release date: 2019-07-17
+- Tag: [v2.7.0](https://github.com/go-acme/lego/releases/tag/v2.7.0)
### Added
@@ -897,7 +1467,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** otc: Prevent sending empty body.
-## [v2.6.0](https://github.com/go-acme/lego/releases/tag/v2.6.0) (2019-05-27)
+## v2.6.0
+
+- Release date: 2019-05-27
+- Tag: [v2.6.0](https://github.com/go-acme/lego/releases/tag/v2.6.0)
### Added
@@ -919,7 +1492,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[cli]** fix: cli disable-cp option.
- **[dnsprovider]** gcloud: fix zone visibility.
-## [v2.5.0](https://github.com/go-acme/lego/releases/tag/v2.5.0) (2019-04-17)
+## v2.5.0
+
+- Release date: 2019-04-17
+- Tag: [v2.5.0](https://github.com/go-acme/lego/releases/tag/v2.5.0)
### Added
@@ -938,9 +1514,12 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** Disable authz when solve fail.
- Add tzdata to the Docker image.
-## [v2.4.0](https://github.com/go-acme/lego/releases/tag/v2.4.0) (2019-03-25)
+## v2.4.0
-- Migrate from xenolf/lego to go-acme/lego.
+- Release date: 2019-03-25
+- Tag: [v2.4.0](https://github.com/go-acme/lego/releases/tag/v2.4.0)
+
+Migrate from xenolf/lego to go-acme/lego.
### Added
@@ -953,7 +1532,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** hostingde: Use provided ZoneName instead of domain
- **[dnsprovider]** pdns: fix wildcard with SANs
-## [v2.3.0](https://github.com/go-acme/lego/releases/tag/v2.3.0) (2019-03-11)
+## v2.3.0
+
+- Release date: 2019-03-11
+- Tag: [v2.3.0](https://github.com/go-acme/lego/releases/tag/v2.3.0)
### Added
@@ -977,7 +1559,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** vscale: fix TXT records clean up
- **[dnsprovider]** selectel: fix TXT records clean up
-## [v2.2.0](https://github.com/go-acme/lego/releases/tag/v2.2.0) (2019-02-08)
+## v2.2.0
+
+- Release date: 2019-02-08
+- Tag: [v2.2.0](https://github.com/go-acme/lego/releases/tag/v2.2.0)
### Added
@@ -997,7 +1582,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** fastdns: Do not overwrite existing TXT records
- Log wildcard domain correctly in validation
-## [v2.1.0](https://github.com/go-acme/lego/releases/tag/v2.1.0) (2019-01-24)
+## v2.1.0
+
+- Release date: 2019-01-24
+- Tag: [v2.1.0](https://github.com/go-acme/lego/releases/tag/v2.1.0)
### Added
@@ -1014,7 +1602,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** alicloud: fix pagination.
- **[dnsprovider]** namecheap: fix panic.
-## [v2.0.0](https://github.com/go-acme/lego/releases/tag/v2.0.0) (2019-01-09)
+## v2.0.0
+
+- Release date: 2019-01-09
+- Tag: [v2.0.0](https://github.com/go-acme/lego/releases/tag/v2.0.0)
### Added
@@ -1066,7 +1657,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** Azure: Do not overwrite existing TXT records
- **[dnsprovider]** fix: Cloudflare error.
-## [v1.2.0](https://github.com/go-acme/lego/releases/tag/v1.2.0) (2018-11-04)
+## v1.2.0
+
+- Release date: 2018-11-04
+- Tag: [v1.2.0](https://github.com/go-acme/lego/releases/tag/v1.2.0)
### Added
@@ -1087,7 +1681,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[lib]** Do not send a JWS body when POSTing challenges.
- **[lib]** Support POST-as-GET.
-## [v1.1.0](https://github.com/go-acme/lego/releases/tag/v1.1.0) (2018-10-16)
+## v1.1.0
+
+- Release date: 2018-10-16
+- Tag: [v1.1.0](https://github.com/go-acme/lego/releases/tag/v1.1.0)
### Added
@@ -1123,7 +1720,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[lib]** Submit all dns records up front, then validate serially
-## [v1.0.0](https://github.com/go-acme/lego/releases/tag/v1.0.0) (2018-05-30)
+## v1.0.0
+
+- Release date: 2018-05-30
+- Tag: [v1.0.0](https://github.com/go-acme/lego/releases/tag/v1.0.0)
### Changed
@@ -1132,7 +1732,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** Modified Google Cloud provider `gcloud.NewDNSProviderServiceAccount` function to extract the project id directly from the service account file.
- **[dnsprovider]** Made errors more verbose for the Cloudflare provider.
-## [v0.5.0](https://github.com/go-acme/lego/releases/tag/v0.5.0) (2018-05-29)
+## v0.5.0
+
+- Release date: 2018-05-29
+- Tag: [v0.5.0](https://github.com/go-acme/lego/releases/tag/v0.5.0)
### Added
@@ -1166,7 +1769,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- **[dnsprovider]** Exoscale: update to latest egoscale version.
- **[dnsprovider]** Route53: Use NewSessionWithOptions instead of deprecated New.
-## [0.4.1](https://github.com/go-acme/lego/releases/tag/0.4.1) (2017-09-26)
+## 0.4.1
+
+- Release date: 2017-09-26
+- Tag: [0.4.1](https://github.com/go-acme/lego/releases/tag/0.4.1)
### Added
@@ -1179,7 +1785,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- lib: Fixed an authentication issue with the latest Azure SDK.
-## [0.4.0](https://github.com/go-acme/lego/releases/tag/0.4.0) (2017-07-13)
+## 0.4.0
+
+- Release date: 2017-07-13
+- Tag: [0.4.0](https://github.com/go-acme/lego/releases/tag/0.4.0)
### Added
@@ -1232,7 +1841,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- lib: Fixed a condition where we could stall due to an early error condition.
- lib: Fixed an issue where Authz object could end up in an active state after an error condition.
-## [0.3.1](https://github.com/go-acme/lego/releases/tag/0.3.1) (2016-04-19)
+## 0.3.1
+
+- Release date: 2016-04-19
+- Tag: [0.3.1](https://github.com/go-acme/lego/releases/tag/0.3.1)
### Added
@@ -1244,7 +1856,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- lib: handleHTTPError should only try to JSON decode error messages with the right content type.
- lib: The propagation checker for the DNS challenge would not retry on send errors.
-## [0.3.0](https://github.com/go-acme/lego/releases/tag/0.3.0) (2016-03-19)
+## 0.3.0
+
+- Release date: 2016-03-19
+- Tag: [0.3.0](https://github.com/go-acme/lego/releases/tag/0.3.0)
### Added
@@ -1279,7 +1894,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- lib: Fixed an issue where status codes on ACME challenge responses could lead to no action being taken.
- lib: Fixed a regression when calling the Renew function with a SAN certificate.
-## [0.2.0](https://github.com/go-acme/lego/releases/tag/0.2.0) (2016-01-09)
+## 0.2.0
+
+- Release date: 2016-01-09
+- Tag: [0.2.0](https://github.com/go-acme/lego/releases/tag/0.2.0)
### Added
@@ -1309,7 +1927,10 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- CLI: Fix logic using the `--days` parameter for renew
-## [0.1.1](https://github.com/go-acme/lego/releases/tag/0.1.1) (2015-12-18)
+## 0.1.1
+
+- Release date: 2015-12-18
+- Tag: [0.1.1](https://github.com/go-acme/lego/releases/tag/0.1.1)
### Added
@@ -1329,6 +1950,9 @@ There was a problem when creating the tag v3.0.1, this tag has been invalidated.
- lib: Fix possible DOS on GetOCSPForCert
-## [0.1.0](https://github.com/go-acme/lego/releases/tag/0.1.0) (2015-12-03)
+## 0.1.0
-- Initial release
+- Release date: 2015-12-03
+- Tag: [0.1.0](https://github.com/go-acme/lego/releases/tag/0.1.0)
+
+Initial release
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a0005cff8..05e4fa994 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,7 +10,7 @@ To ensure a great and easy experience for everyone, please review the few guidel
- If both of the above do not apply, create a new issue and include as much information as possible.
Bug reports should include all information a person could need to reproduce your problem without the need to
-follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behaviour and the actual behaviour.
+follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behavior and the actual behavior.
## Feature proposals and requests
@@ -20,31 +20,26 @@ It is up to you to make a strong point about your proposal and convince us of th
## Pull requests
+Create an issue and wait for a maintainer to approve it BEFORE opening a pull request.
+
Patches, new features and improvements are a great way to help the project.
Please keep them focused on one thing and do not include unrelated commits.
-All pull requests which alter the behaviour of the program, add new behaviour or somehow alter code in a non-trivial way should **always** include tests.
+All pull requests that alter the behavior of the program,
+add new behavior or somehow alter code in a non-trivial way should **always** include tests.
-If you want to contribute a significant pull request (with a non-trivial workload for you) please **ask first**. We do not want you to spend
-a lot of time on something the project's developers might not want to merge into the project.
-
-**IMPORTANT**: By submitting a patch, you agree to allow the project
-owners to license your work under the terms of the [MIT License](LICENSE).
+**IMPORTANT**: By submitting a patch, you agree to allow the project owners to license your work under the terms of the [MIT License](LICENSE).
### How to create a pull request
Requirements:
-- `go` v1.15+
+- `go` v1.24+
- environment variable: `GO111MODULE=on`
First, you have to install [GoLang](https://golang.org/doc/install) and [golangci-lint](https://github.com/golangci/golangci-lint#install).
```bash
-# Create the root folder
-mkdir -p $GOPATH/src/github.com/go-acme
-cd $GOPATH/src/github.com/go-acme
-
# clone your fork
git clone git@github.com:YOUR_USERNAME/lego.git
cd lego
@@ -56,14 +51,12 @@ git fetch upstream
```bash
# Create your branch
-git checkout -b my-feature
+git switch -c my-feature
## Create your code ##
```
```bash
-# Format
-make fmt
# Linters
make checks
# Tests
diff --git a/Makefile b/Makefile
index 28cb33908..8536dfc40 100644
--- a/Makefile
+++ b/Makefile
@@ -54,10 +54,10 @@ detach:
.PHONY: docs-build docs-serve docs-themes
docs-build: generate-dns
- @make -C ./docs hugo-build
+ @make -C ./docs build
docs-serve: generate-dns
- @make -C ./docs hugo
+ @make -C ./docs serve
docs-themes:
@make -C ./docs hugo-themes
diff --git a/README.md b/README.md
index a430446c3..e90e94962 100644
--- a/README.md
+++ b/README.md
@@ -5,29 +5,36 @@
# Lego
-Let's Encrypt client and ACME library written in Go.
+[ACME](https://www.rfc-editor.org/rfc/rfc8555.html) client and library for Let's Encrypt and other ACME CAs written in Go.
[](https://pkg.go.dev/github.com/go-acme/lego/v4)
[](https://github.com//go-acme/lego/actions)
[](https://hub.docker.com/r/goacme/lego/)
+lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
+
+Everybody thinks that the others will donate, but in the end, nobody does.
+
+So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev).
+
## Features
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses
- - Support [draft-ietf-acme-ari-03](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
+ - Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension
+ - Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension
+- Comes with about [180 DNS providers](https://go-acme.github.io/lego/dns)
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Renew certificates
- Revoke certificates
-- Robust implementation of all ACME challenges
+- Robust implementation of ACME challenges:
- HTTP (http-01)
- DNS (dns-01)
- TLS (tls-alpn-01)
- SAN certificate support
- [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default
-- Comes with multiple optional [DNS providers](https://go-acme.github.io/lego/dns)
- [Custom challenge solvers](https://go-acme.github.io/lego/usage/library/writing-a-challenge-solver/)
- Certificate bundling
- OCSP helper function
@@ -49,77 +56,114 @@ Documentation is hosted live at https://go-acme.github.io/lego/.
Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
+If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml).
+
-If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.md).
+If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml).
diff --git a/acme/api/account.go b/acme/api/account.go
index 85de84ef3..62e5ef9a6 100644
--- a/acme/api/account.go
+++ b/acme/api/account.go
@@ -13,6 +13,7 @@ type AccountService service
// New Creates a new account.
func (a *AccountService) New(req acme.Account) (acme.ExtendedAccount, error) {
var account acme.Account
+
resp, err := a.core.post(a.core.GetDirectory().NewAccountURL, req, &account)
location := getLocation(resp)
@@ -29,9 +30,9 @@ func (a *AccountService) New(req acme.Account) (acme.ExtendedAccount, error) {
// NewEAB Creates a new account with an External Account Binding.
func (a *AccountService) NewEAB(accMsg acme.Account, kid, hmacEncoded string) (acme.ExtendedAccount, error) {
- hmac, err := base64.RawURLEncoding.DecodeString(hmacEncoded)
+ hmac, err := decodeEABHmac(hmacEncoded)
if err != nil {
- return acme.ExtendedAccount{}, fmt.Errorf("acme: could not decode hmac key: %w", err)
+ return acme.ExtendedAccount{}, err
}
eabJWS, err := a.core.signEABContent(a.core.GetDirectory().NewAccountURL, kid, hmac)
@@ -51,10 +52,12 @@ func (a *AccountService) Get(accountURL string) (acme.Account, error) {
}
var account acme.Account
+
_, err := a.core.postAsGet(accountURL, &account)
if err != nil {
return acme.Account{}, err
}
+
return account, nil
}
@@ -65,6 +68,7 @@ func (a *AccountService) Update(accountURL string, req acme.Account) (acme.Accou
}
var account acme.Account
+
_, err := a.core.post(accountURL, req, &account)
if err != nil {
return acme.Account{}, err
@@ -81,5 +85,20 @@ func (a *AccountService) Deactivate(accountURL string) error {
req := acme.Account{Status: acme.StatusDeactivated}
_, err := a.core.post(accountURL, req, nil)
+
return err
}
+
+func decodeEABHmac(hmacEncoded string) ([]byte, error) {
+ hmac, errRaw := base64.RawURLEncoding.DecodeString(hmacEncoded)
+ if errRaw == nil {
+ return hmac, nil
+ }
+
+ hmac, err := base64.URLEncoding.DecodeString(hmacEncoded)
+ if err == nil {
+ return hmac, nil
+ }
+
+ return nil, fmt.Errorf("acme: could not decode hmac key: %w", errors.Join(errRaw, err))
+}
diff --git a/acme/api/account_test.go b/acme/api/account_test.go
new file mode 100644
index 000000000..16bd80741
--- /dev/null
+++ b/acme/api/account_test.go
@@ -0,0 +1,35 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_decodeEABHmac(t *testing.T) {
+ testCases := []struct {
+ desc string
+ hmac string
+ }{
+ {
+ desc: "RawURLEncoding",
+ hmac: "BAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHx",
+ },
+ {
+ desc: "URLEncoding",
+ hmac: "nKTo9Hu8fpCqWPXx-25LVbZrJWxcHISsr4qHrRR0j5U=",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ v, err := decodeEABHmac(test.hmac)
+ require.NoError(t, err)
+
+ assert.NotEmpty(t, v)
+ })
+ }
+}
diff --git a/acme/api/api.go b/acme/api/api.go
index b8c9cf0c9..da1c94d1b 100644
--- a/acme/api/api.go
+++ b/acme/api/api.go
@@ -2,6 +2,7 @@ package api
import (
"bytes"
+ "context"
"crypto"
"encoding/json"
"errors"
@@ -9,7 +10,7 @@ import (
"net/http"
"time"
- "github.com/cenkalti/backoff/v4"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api/internal/nonces"
"github.com/go-acme/lego/v4/acme/api/internal/secure"
@@ -60,7 +61,7 @@ func New(httpClient *http.Client, userAgent, caDirURL, kid string, privateKey cr
// post performs an HTTP POST request and parses the response body as JSON,
// into the provided respBody object.
-func (a *Core) post(uri string, reqBody, response interface{}) (*http.Response, error) {
+func (a *Core) post(uri string, reqBody, response any) (*http.Response, error) {
content, err := json.Marshal(reqBody)
if err != nil {
return nil, errors.New("failed to marshal message")
@@ -71,47 +72,44 @@ func (a *Core) post(uri string, reqBody, response interface{}) (*http.Response,
// postAsGet performs an HTTP POST ("POST-as-GET") request.
// https://www.rfc-editor.org/rfc/rfc8555.html#section-6.3
-func (a *Core) postAsGet(uri string, response interface{}) (*http.Response, error) {
+func (a *Core) postAsGet(uri string, response any) (*http.Response, error) {
return a.retrievablePost(uri, []byte{}, response)
}
-func (a *Core) retrievablePost(uri string, content []byte, response interface{}) (*http.Response, error) {
+func (a *Core) retrievablePost(uri string, content []byte, response any) (*http.Response, error) {
+ ctx := context.Background()
+
// during tests, allow to support ~90% of bad nonce with a minimum of attempts.
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 200 * time.Millisecond
bo.MaxInterval = 5 * time.Second
- bo.MaxElapsedTime = 20 * time.Second
- var resp *http.Response
- operation := func() error {
- var err error
- resp, err = a.signedPost(uri, content, response)
+ operation := func() (*http.Response, error) {
+ resp, err := a.signedPost(uri, content, response)
if err != nil {
// Retry if the nonce was invalidated
var e *acme.NonceError
if errors.As(err, &e) {
- return err
+ return resp, err
}
- return backoff.Permanent(err)
+ return resp, backoff.Permanent(err)
}
- return nil
+ return resp, nil
}
notify := func(err error, duration time.Duration) {
log.Infof("retry due to: %v", err)
}
- err := backoff.RetryNotify(operation, bo, notify)
- if err != nil {
- return resp, err
- }
-
- return resp, nil
+ return backoff.Retry(ctx, operation,
+ backoff.WithBackOff(bo),
+ backoff.WithMaxElapsedTime(20*time.Second),
+ backoff.WithNotify(notify))
}
-func (a *Core) signedPost(uri string, content []byte, response interface{}) (*http.Response, error) {
+func (a *Core) signedPost(uri string, content []byte, response any) (*http.Response, error) {
signedContent, err := a.jws.SignContent(uri, content)
if err != nil {
return nil, fmt.Errorf("failed to post JWS message: failed to sign content: %w", err)
@@ -157,6 +155,7 @@ func getDirectory(do *sender.Doer, caDirURL string) (acme.Directory, error) {
if dir.NewAccountURL == "" {
return dir, errors.New("directory missing new registration URL")
}
+
if dir.NewOrderURL == "" {
return dir, errors.New("directory missing new order URL")
}
diff --git a/acme/api/authorization.go b/acme/api/authorization.go
index a9972aa94..4195bd1fe 100644
--- a/acme/api/authorization.go
+++ b/acme/api/authorization.go
@@ -15,10 +15,12 @@ func (c *AuthorizationService) Get(authzURL string) (acme.Authorization, error)
}
var authz acme.Authorization
+
_, err := c.core.postAsGet(authzURL, &authz)
if err != nil {
return acme.Authorization{}, err
}
+
return authz, nil
}
@@ -29,6 +31,8 @@ func (c *AuthorizationService) Deactivate(authzURL string) error {
}
var disabledAuth acme.Authorization
+
_, err := c.core.post(authzURL, acme.Authorization{Status: acme.StatusDeactivated}, &disabledAuth)
+
return err
}
diff --git a/acme/api/certificate.go b/acme/api/certificate.go
index 5f31968cf..b42296768 100644
--- a/acme/api/certificate.go
+++ b/acme/api/certificate.go
@@ -2,15 +2,12 @@ package api
import (
"bytes"
- "crypto/x509"
"encoding/pem"
"errors"
"io"
"net/http"
"github.com/go-acme/lego/v4/acme"
- "github.com/go-acme/lego/v4/certcrypto"
- "github.com/go-acme/lego/v4/log"
)
// maxBodySize is the maximum size of body that we will read.
@@ -77,62 +74,22 @@ func (c *CertificateService) get(certURL string, bundle bool) (*acme.RawCertific
return nil, resp.Header, err
}
- cert := c.getCertificateChain(data, resp.Header, bundle, certURL)
+ cert := c.getCertificateChain(data, bundle)
return cert, resp.Header, err
}
// getCertificateChain Returns the certificate and the issuer certificate.
-func (c *CertificateService) getCertificateChain(cert []byte, headers http.Header, bundle bool, certURL string) *acme.RawCertificate {
+func (c *CertificateService) getCertificateChain(cert []byte, bundle bool) *acme.RawCertificate {
// Get issuerCert from bundled response from Let's Encrypt
// See https://community.letsencrypt.org/t/acme-v2-no-up-link-in-response/64962
_, issuer := pem.Decode(cert)
- if issuer != nil {
- // If bundle is false, we want to return a single certificate.
- // To do this, we remove the issuer cert(s) from the issued cert.
- if !bundle {
- cert = bytes.TrimSuffix(cert, issuer)
- }
- return &acme.RawCertificate{Cert: cert, Issuer: issuer}
- }
- // The issuer certificate link may be supplied via an "up" link
- // in the response headers of a new certificate.
- // See https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2
- up := getLink(headers, "up")
-
- issuer, err := c.getIssuerFromLink(up)
- if err != nil {
- // If we fail to acquire the issuer cert, return the issued certificate - do not fail.
- log.Warnf("acme: Could not bundle issuer certificate [%s]: %v", certURL, err)
- } else if len(issuer) > 0 {
- // If bundle is true, we want to return a certificate bundle.
- // To do this, we append the issuer cert to the issued cert.
- if bundle {
- cert = append(cert, issuer...)
- }
+ // If bundle is false, we want to return a single certificate.
+ // To do this, we remove the issuer cert(s) from the issued cert.
+ if !bundle {
+ cert = bytes.TrimSuffix(cert, issuer)
}
return &acme.RawCertificate{Cert: cert, Issuer: issuer}
}
-
-// getIssuerFromLink requests the issuer certificate.
-func (c *CertificateService) getIssuerFromLink(up string) ([]byte, error) {
- if up == "" {
- return nil, nil
- }
-
- log.Infof("acme: Requesting issuer cert from %s", up)
-
- cert, _, err := c.get(up, false)
- if err != nil {
- return nil, err
- }
-
- _, err = x509.ParseCertificate(cert.Cert)
- if err != nil {
- return nil, err
- }
-
- return certcrypto.PEMEncode(certcrypto.DERCertificateBytes(cert.Cert)), nil
-}
diff --git a/acme/api/certificate_test.go b/acme/api/certificate_test.go
index 9776cccc5..7220ca1b9 100644
--- a/acme/api/certificate_test.go
+++ b/acme/api/certificate_test.go
@@ -3,11 +3,10 @@ package api
import (
"crypto/rand"
"crypto/rsa"
- "encoding/pem"
- "net/http"
"testing"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -74,56 +73,34 @@ rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2
`
func TestCertificateService_Get_issuerRelUp(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
- mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Link", "<"+apiURL+`/issuer>; rel="up"`)
- _, err := w.Write([]byte(certResponseMock))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- mux.HandleFunc("/issuer", func(w http.ResponseWriter, _ *http.Request) {
- p, _ := pem.Decode([]byte(issuerMock))
- _, err := w.Write(p.Bytes)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ server := tester.MockACMEServer().
+ Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
- cert, issuer, err := core.Certificates.Get(apiURL+"/certificate", true)
+ cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true)
require.NoError(t, err)
assert.Equal(t, certResponseMock, string(cert), "Certificate")
assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate")
}
func TestCertificateService_Get_embeddedIssuer(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
- mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
- _, err := w.Write([]byte(certResponseMock))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ server := tester.MockACMEServer().
+ Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
- cert, issuer, err := core.Certificates.Get(apiURL+"/certificate", true)
+ cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true)
require.NoError(t, err)
assert.Equal(t, certResponseMock, string(cert), "Certificate")
assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate")
diff --git a/acme/api/challenge.go b/acme/api/challenge.go
index 875dede6e..2af55fc1a 100644
--- a/acme/api/challenge.go
+++ b/acme/api/challenge.go
@@ -17,6 +17,7 @@ func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) {
// Challenge initiation is done by sending a JWS payload containing the trivial JSON object `{}`.
// We use an empty struct instance as the postJSON payload here to achieve this result.
var chlng acme.ExtendedChallenge
+
resp, err := c.core.post(chlgURL, struct{}{}, &chlng)
if err != nil {
return acme.ExtendedChallenge{}, err
@@ -24,6 +25,7 @@ func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) {
chlng.AuthorizationURL = getLink(resp.Header, "up")
chlng.RetryAfter = getRetryAfter(resp)
+
return chlng, nil
}
@@ -34,6 +36,7 @@ func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) {
}
var chlng acme.ExtendedChallenge
+
resp, err := c.core.postAsGet(chlgURL, &chlng)
if err != nil {
return acme.ExtendedChallenge{}, err
@@ -41,5 +44,6 @@ func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) {
chlng.AuthorizationURL = getLink(resp.Header, "up")
chlng.RetryAfter = getRetryAfter(resp)
+
return chlng, nil
}
diff --git a/acme/api/identifier.go b/acme/api/identifier.go
new file mode 100644
index 000000000..245ed8515
--- /dev/null
+++ b/acme/api/identifier.go
@@ -0,0 +1,52 @@
+package api
+
+import (
+ "cmp"
+ "net"
+ "slices"
+
+ "github.com/go-acme/lego/v4/acme"
+)
+
+func createIdentifiers(domains []string) []acme.Identifier {
+ uniqIdentifiers := make(map[string]struct{})
+
+ var identifiers []acme.Identifier
+
+ for _, domain := range domains {
+ if _, ok := uniqIdentifiers[domain]; ok {
+ continue
+ }
+
+ ident := acme.Identifier{Value: domain, Type: "dns"}
+
+ if net.ParseIP(domain) != nil {
+ ident.Type = "ip"
+ }
+
+ identifiers = append(identifiers, ident)
+
+ uniqIdentifiers[domain] = struct{}{}
+ }
+
+ return identifiers
+}
+
+// compareIdentifiers compares 2 slices of [acme.Identifier].
+func compareIdentifiers(a, b []acme.Identifier) int {
+ // Clones slices to avoid modifying original slices.
+ right := slices.Clone(a)
+ left := slices.Clone(b)
+
+ slices.SortStableFunc(right, compareIdentifier)
+ slices.SortStableFunc(left, compareIdentifier)
+
+ return slices.CompareFunc(right, left, compareIdentifier)
+}
+
+func compareIdentifier(right, left acme.Identifier) int {
+ return cmp.Or(
+ cmp.Compare(right.Type, left.Type),
+ cmp.Compare(right.Value, left.Value),
+ )
+}
diff --git a/acme/api/identifier_test.go b/acme/api/identifier_test.go
new file mode 100644
index 000000000..586a87986
--- /dev/null
+++ b/acme/api/identifier_test.go
@@ -0,0 +1,111 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/acme"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_compareIdentifiers(t *testing.T) {
+ testCases := []struct {
+ desc string
+ a, b []acme.Identifier
+ expected int
+ }{
+ {
+ desc: "identical identifiers",
+ a: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ b: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ expected: 0,
+ },
+ {
+ desc: "identical identifiers but different order",
+ a: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ b: []acme.Identifier{
+ {Type: "dns", Value: "*.example.com"},
+ {Type: "dns", Value: "example.com"},
+ },
+ expected: 0,
+ },
+ {
+ desc: "duplicate identifiers",
+ a: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ b: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "example.com"},
+ },
+ expected: -1,
+ },
+ {
+ desc: "different identifier values",
+ a: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ b: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.org"},
+ },
+ expected: -1,
+ },
+ {
+ desc: "different identifier types",
+ a: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ b: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "ip", Value: "*.example.com"},
+ },
+ expected: -1,
+ },
+ {
+ desc: "different number of identifiers a>b",
+ a: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ {Type: "dns", Value: "example.org"},
+ },
+ b: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ expected: 1,
+ },
+ {
+ desc: "different number of identifiers b>a",
+ a: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ },
+ b: []acme.Identifier{
+ {Type: "dns", Value: "example.com"},
+ {Type: "dns", Value: "*.example.com"},
+ {Type: "dns", Value: "example.org"},
+ },
+ expected: -1,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, test.expected, compareIdentifiers(test.a, test.b))
+ })
+ }
+}
diff --git a/acme/api/internal/nonces/nonce_manager.go b/acme/api/internal/nonces/nonce_manager.go
index d089cf07c..04a4ac620 100644
--- a/acme/api/internal/nonces/nonce_manager.go
+++ b/acme/api/internal/nonces/nonce_manager.go
@@ -11,10 +11,11 @@ import (
// Manager Manages nonces.
type Manager struct {
+ sync.Mutex
+
do *sender.Doer
nonceURL string
nonces []string
- sync.Mutex
}
// NewManager Creates a new Manager.
@@ -36,6 +37,7 @@ func (n *Manager) Pop() (string, bool) {
nonce := n.nonces[len(n.nonces)-1]
n.nonces = n.nonces[:len(n.nonces)-1]
+
return nonce, true
}
@@ -43,6 +45,7 @@ func (n *Manager) Pop() (string, bool) {
func (n *Manager) Push(nonce string) {
n.Lock()
defer n.Unlock()
+
n.nonces = append(n.nonces, nonce)
}
@@ -51,6 +54,7 @@ func (n *Manager) Nonce() (string, error) {
if nonce, ok := n.Pop(); ok {
return nonce, nil
}
+
return n.getNonce()
}
diff --git a/acme/api/internal/nonces/nonce_manager_test.go b/acme/api/internal/nonces/nonce_manager_test.go
index a172a0b69..4490165df 100644
--- a/acme/api/internal/nonces/nonce_manager_test.go
+++ b/acme/api/internal/nonces/nonce_manager_test.go
@@ -8,45 +8,52 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api/internal/sender"
- "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
)
func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- time.Sleep(250 * time.Millisecond)
- w.Header().Set("Replay-Nonce", "12345")
- w.Header().Set("Retry-After", "0")
- err := tester.WriteJSONResponse(w, &acme.Challenge{Type: "http-01", Status: "Valid", URL: "http://example.com/", Token: "token"})
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }))
- t.Cleanup(server.Close)
+ manager := servermock.NewBuilder(
+ func(server *httptest.Server) (*Manager, error) {
+ doer := sender.NewDoer(server.Client(), "lego-test")
+
+ return NewManager(doer, server.URL), nil
+ }).
+ Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ time.Sleep(250 * time.Millisecond)
+
+ rw.Header().Set("Replay-Nonce", "12345")
+ rw.Header().Set("Retry-After", "0")
+
+ servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req)
+ })).
+ BuildHTTPS(t)
- doer := sender.NewDoer(http.DefaultClient, "lego-test")
- j := NewManager(doer, server.URL)
ch := make(chan bool)
resultCh := make(chan bool)
+
go func() {
- _, errN := j.Nonce()
+ _, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
+
ch <- true
}()
go func() {
- _, errN := j.Nonce()
+ _, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
+
ch <- true
}()
go func() {
<-ch
<-ch
+
resultCh <- true
}()
+
select {
case <-resultCh:
case <-time.After(500 * time.Millisecond):
diff --git a/acme/api/internal/secure/jws.go b/acme/api/internal/secure/jws.go
index 8afd44676..8cd598663 100644
--- a/acme/api/internal/secure/jws.go
+++ b/acme/api/internal/secure/jws.go
@@ -36,6 +36,7 @@ func (j *JWS) SetKid(kid string) {
// SignContent Signs a content with the JWS.
func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, error) {
var alg jose.SignatureAlgorithm
+
switch k := j.privKey.(type) {
case *rsa.PrivateKey:
alg = jose.RS256
@@ -54,7 +55,7 @@ func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, e
options := jose.SignerOptions{
NonceSource: j.nonces,
- ExtraHeaders: map[jose.HeaderKey]interface{}{
+ ExtraHeaders: map[jose.HeaderKey]any{
"url": url,
},
}
@@ -72,12 +73,14 @@ func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, e
if err != nil {
return nil, fmt.Errorf("failed to sign content: %w", err)
}
+
return signed, nil
}
// SignEABContent Signs an external account binding content with the JWS.
func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignature, error) {
jwk := jose.JSONWebKey{Key: j.privKey}
+
jwkJSON, err := jwk.Public().MarshalJSON()
if err != nil {
return nil, fmt.Errorf("acme: error encoding eab jwk key: %w", err)
@@ -87,7 +90,7 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu
jose.SigningKey{Algorithm: jose.HS256, Key: hmac},
&jose.SignerOptions{
EmbedJWK: false,
- ExtraHeaders: map[jose.HeaderKey]interface{}{
+ ExtraHeaders: map[jose.HeaderKey]any{
"kid": kid,
"url": url,
},
@@ -108,6 +111,7 @@ func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignatu
// GetKeyAuthorization Gets the key authorization for a token.
func (j *JWS) GetKeyAuthorization(token string) (string, error) {
var publicKey crypto.PublicKey
+
switch k := j.privKey.(type) {
case *ecdsa.PrivateKey:
publicKey = k.Public()
diff --git a/acme/api/internal/secure/jws_test.go b/acme/api/internal/secure/jws_test.go
index 2e625f24f..d033cb0c4 100644
--- a/acme/api/internal/secure/jws_test.go
+++ b/acme/api/internal/secure/jws_test.go
@@ -9,45 +9,52 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api/internal/nonces"
"github.com/go-acme/lego/v4/acme/api/internal/sender"
- "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
)
func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- time.Sleep(250 * time.Millisecond)
- w.Header().Set("Replay-Nonce", "12345")
- w.Header().Set("Retry-After", "0")
- err := tester.WriteJSONResponse(w, &acme.Challenge{Type: "http-01", Status: "Valid", URL: "http://example.com/", Token: "token"})
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }))
- t.Cleanup(server.Close)
+ manager := servermock.NewBuilder(
+ func(server *httptest.Server) (*nonces.Manager, error) {
+ doer := sender.NewDoer(server.Client(), "lego-test")
+
+ return nonces.NewManager(doer, server.URL), nil
+ }).
+ Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ time.Sleep(250 * time.Millisecond)
+
+ rw.Header().Set("Replay-Nonce", "12345")
+ rw.Header().Set("Retry-After", "0")
+
+ servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req)
+ })).
+ BuildHTTPS(t)
- doer := sender.NewDoer(http.DefaultClient, "lego-test")
- j := nonces.NewManager(doer, server.URL)
ch := make(chan bool)
resultCh := make(chan bool)
+
go func() {
- _, errN := j.Nonce()
+ _, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
+
ch <- true
}()
go func() {
- _, errN := j.Nonce()
+ _, errN := manager.Nonce()
if errN != nil {
t.Log(errN)
}
+
ch <- true
}()
go func() {
<-ch
<-ch
+
resultCh <- true
}()
+
select {
case <-resultCh:
case <-time.After(500 * time.Millisecond):
diff --git a/acme/api/internal/sender/sender.go b/acme/api/internal/sender/sender.go
index 29cd7c9be..d8859edf4 100644
--- a/acme/api/internal/sender/sender.go
+++ b/acme/api/internal/sender/sender.go
@@ -27,6 +27,8 @@ type Doer struct {
// NewDoer Creates a new Doer.
func NewDoer(client *http.Client, userAgent string) *Doer {
+ client.Transport = newHTTPSOnly(client)
+
return &Doer{
httpClient: client,
userAgent: userAgent,
@@ -35,7 +37,7 @@ func NewDoer(client *http.Client, userAgent string) *Doer {
// Get performs a GET request with a proper User-Agent string.
// If "response" is not provided, callers should close resp.Body when done reading from it.
-func (d *Doer) Get(url string, response interface{}) (*http.Response, error) {
+func (d *Doer) Get(url string, response any) (*http.Response, error) {
req, err := d.newRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -57,7 +59,7 @@ func (d *Doer) Head(url string) (*http.Response, error) {
// Post performs a POST request with a proper User-Agent string.
// If "response" is not provided, callers should close resp.Body when done reading from it.
-func (d *Doer) Post(url string, body io.Reader, bodyType string, response interface{}) (*http.Response, error) {
+func (d *Doer) Post(url string, body io.Reader, bodyType string, response any) (*http.Response, error) {
req, err := d.newRequest(http.MethodPost, url, body, contentType(bodyType))
if err != nil {
return nil, err
@@ -84,7 +86,7 @@ func (d *Doer) newRequest(method, uri string, body io.Reader, opts ...RequestOpt
return req, nil
}
-func (d *Doer) do(req *http.Request, response interface{}) (*http.Response, error) {
+func (d *Doer) do(req *http.Request, response any) (*http.Response, error) {
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, err
@@ -118,31 +120,69 @@ func (d *Doer) formatUserAgent() string {
}
func checkError(req *http.Request, resp *http.Response) error {
- if resp.StatusCode >= http.StatusBadRequest {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return fmt.Errorf("%d :: %s :: %s :: %w", resp.StatusCode, req.Method, req.URL, err)
- }
-
- var errorDetails *acme.ProblemDetails
- err = json.Unmarshal(body, &errorDetails)
- if err != nil {
- return fmt.Errorf("%d ::%s :: %s :: %w :: %s", resp.StatusCode, req.Method, req.URL, err, string(body))
- }
-
- errorDetails.Method = req.Method
- errorDetails.URL = req.URL.String()
-
- if errorDetails.HTTPStatus == 0 {
- errorDetails.HTTPStatus = resp.StatusCode
- }
-
- // Check for errors we handle specifically
- if errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr {
- return &acme.NonceError{ProblemDetails: errorDetails}
+ if resp.StatusCode < http.StatusBadRequest {
+ return nil
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("%d :: %s :: %s :: %w", resp.StatusCode, req.Method, req.URL, err)
+ }
+
+ var errorDetails *acme.ProblemDetails
+
+ err = json.Unmarshal(body, &errorDetails)
+ if err != nil {
+ return fmt.Errorf("%d ::%s :: %s :: %w :: %s", resp.StatusCode, req.Method, req.URL, err, string(body))
+ }
+
+ errorDetails.Method = req.Method
+ errorDetails.URL = req.URL.String()
+
+ if errorDetails.HTTPStatus == 0 {
+ errorDetails.HTTPStatus = resp.StatusCode
+ }
+
+ // Check for errors we handle specifically
+ switch {
+ case errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr:
+ return &acme.NonceError{ProblemDetails: errorDetails}
+
+ case errorDetails.HTTPStatus == http.StatusConflict && errorDetails.Type == acme.AlreadyReplacedErr:
+ return &acme.AlreadyReplacedError{ProblemDetails: errorDetails}
+
+ case errorDetails.HTTPStatus == http.StatusTooManyRequests && errorDetails.Type == acme.RateLimitedErr:
+ return &acme.RateLimitedError{
+ ProblemDetails: errorDetails,
+ RetryAfter: resp.Header.Get("Retry-After"),
}
+ default:
return errorDetails
}
- return nil
+}
+
+type httpsOnly struct {
+ rt http.RoundTripper
+}
+
+func newHTTPSOnly(client *http.Client) *httpsOnly {
+ if client.Transport == nil {
+ return &httpsOnly{rt: http.DefaultTransport}
+ }
+
+ return &httpsOnly{rt: client.Transport}
+}
+
+// RoundTrip ensure HTTPS is used.
+// Each ACME function is accomplished by the client sending a sequence of HTTPS requests to the server [RFC2818],
+// carrying JSON messages [RFC8259].
+// Use of HTTPS is REQUIRED.
+// https://datatracker.ietf.org/doc/html/rfc8555#section-6.1
+func (r *httpsOnly) RoundTrip(req *http.Request) (*http.Response, error) {
+ if req.URL.Scheme != "https" {
+ return nil, fmt.Errorf("HTTPS is required: %s", req.URL)
+ }
+
+ return r.rt.RoundTrip(req)
}
diff --git a/acme/api/internal/sender/sender_test.go b/acme/api/internal/sender/sender_test.go
index 2fd43c878..73701ab11 100644
--- a/acme/api/internal/sender/sender_test.go
+++ b/acme/api/internal/sender/sender_test.go
@@ -1,24 +1,28 @@
package sender
import (
+ "bytes"
+ "io"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "github.com/go-acme/lego/v4/acme"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDo_UserAgentOnAllHTTPMethod(t *testing.T) {
var ua, method string
- server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+
+ server := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
method = r.Method
}))
t.Cleanup(server.Close)
- doer := NewDoer(http.DefaultClient, "")
+ doer := NewDoer(server.Client(), "")
testCases := []struct {
method string
@@ -60,8 +64,87 @@ func TestDo_CustomUserAgent(t *testing.T) {
ua := doer.formatUserAgent()
assert.Contains(t, ua, ourUserAgent)
assert.Contains(t, ua, customUA)
+
if strings.HasSuffix(ua, " ") {
t.Errorf("UA should not have trailing spaces; got '%s'", ua)
}
+
assert.Len(t, strings.Split(ua, " "), 5)
}
+
+func TestDo_failWithHTTP(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
+ t.Cleanup(server.Close)
+
+ sender := NewDoer(server.Client(), "test")
+
+ _, err := sender.Post(server.URL, strings.NewReader("data"), "text/plain", nil)
+ require.ErrorContains(t, err, "HTTPS is required: http://")
+}
+
+func Test_checkError(t *testing.T) {
+ testCases := []struct {
+ desc string
+ resp *http.Response
+ assert func(t *testing.T, err error)
+ }{
+ {
+ desc: "default",
+ resp: &http.Response{
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:example","detail":"message","status":404}`)),
+ },
+ assert: errorAs[*acme.ProblemDetails],
+ },
+ {
+ desc: "badNonce",
+ resp: &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:badNonce","detail":"message","status":400}`)),
+ },
+ assert: errorAs[*acme.NonceError],
+ },
+ {
+ desc: "alreadyReplaced",
+ resp: &http.Response{
+ StatusCode: http.StatusConflict,
+ Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:alreadyReplaced","detail":"message","status":409}`)),
+ },
+ assert: errorAs[*acme.AlreadyReplacedError],
+ },
+ {
+ desc: "rateLimited",
+ resp: &http.Response{
+ StatusCode: http.StatusConflict,
+ Header: http.Header{
+ "Retry-After": []string{"1"},
+ },
+ Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:rateLimited","detail":"message","status":429}`)),
+ },
+ assert: errorAs[*acme.RateLimitedError],
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com", nil)
+
+ err := checkError(req, test.resp)
+ require.Error(t, err)
+
+ pb := &acme.ProblemDetails{}
+ assert.ErrorAs(t, err, &pb)
+
+ test.assert(t, err)
+ })
+ }
+}
+
+func errorAs[T error](t *testing.T, err error) {
+ t.Helper()
+
+ var zero T
+ assert.ErrorAs(t, err, &zero)
+}
diff --git a/acme/api/internal/sender/useragent.go b/acme/api/internal/sender/useragent.go
index 08d0d5938..51a1b4770 100644
--- a/acme/api/internal/sender/useragent.go
+++ b/acme/api/internal/sender/useragent.go
@@ -4,10 +4,10 @@ package sender
const (
// ourUserAgent is the User-Agent of this underlying library package.
- ourUserAgent = "xenolf-acme/4.20.3"
+ ourUserAgent = "xenolf-acme/4.32.0"
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release
// NOTE: Update this with each tagged release.
- ourUserAgentComment = "release"
+ ourUserAgentComment = "detach"
)
diff --git a/acme/api/order.go b/acme/api/order.go
index 5179d061a..fad6be2b8 100644
--- a/acme/api/order.go
+++ b/acme/api/order.go
@@ -3,7 +3,8 @@ package api
import (
"encoding/base64"
"errors"
- "net"
+ "fmt"
+ "slices"
"time"
"github.com/go-acme/lego/v4/acme"
@@ -13,9 +14,15 @@ import (
type OrderOptions struct {
NotBefore time.Time
NotAfter time.Time
+
+ // A string uniquely identifying the profile
+ // which will be used to affect issuance of the certificate requested by this Order.
+ // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
+ Profile string
+
// A string uniquely identifying a previously-issued certificate which this
// order is intended to replace.
- // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
+ // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
ReplacesCertID string
}
@@ -28,18 +35,7 @@ func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) {
// NewWithOptions Creates a new order.
func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acme.ExtendedOrder, error) {
- var identifiers []acme.Identifier
- for _, domain := range domains {
- ident := acme.Identifier{Value: domain, Type: "dns"}
-
- if net.ParseIP(domain) != nil {
- ident.Type = "ip"
- }
-
- identifiers = append(identifiers, ident)
- }
-
- orderReq := acme.Order{Identifiers: identifiers}
+ orderReq := acme.Order{Identifiers: createIdentifiers(domains)}
if opts != nil {
if !opts.NotAfter.IsZero() {
@@ -53,12 +49,50 @@ func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acm
if o.core.GetDirectory().RenewalInfo != "" {
orderReq.Replaces = opts.ReplacesCertID
}
+
+ if opts.Profile != "" {
+ orderReq.Profile = opts.Profile
+ }
}
var order acme.Order
+
resp, err := o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order)
if err != nil {
- return acme.ExtendedOrder{}, err
+ are := &acme.AlreadyReplacedError{}
+ if !errors.As(err, &are) {
+ return acme.ExtendedOrder{}, err
+ }
+
+ // If the Server rejects the request because the identified certificate has already been marked as replaced,
+ // it MUST return an HTTP 409 (Conflict) with a problem document of type "alreadyReplaced" (see Section 7.4).
+ // https://www.rfc-editor.org/rfc/rfc9773.html#section-5
+ orderReq.Replaces = ""
+
+ resp, err = o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order)
+ if err != nil {
+ return acme.ExtendedOrder{}, err
+ }
+ }
+
+ // The server MUST return an error if it cannot fulfill the request as specified,
+ // and it MUST NOT issue a certificate with contents other than those requested.
+ // If the server requires the request to be modified in a certain way,
+ // it should indicate the required changes using an appropriate error type and description.
+ // https://www.rfc-editor.org/rfc/rfc8555#section-7.4
+ //
+ // Some ACME servers don't return an error,
+ // and/or change the order identifiers in the response,
+ // so we need to ensure that the identifiers are the same as requested.
+ // Deduplication by the server is allowed.
+ if compareIdentifiers(orderReq.Identifiers, order.Identifiers) != 0 {
+ // Sorts identifiers to avoid error message ambiguities about the order of the identifiers.
+ slices.SortStableFunc(orderReq.Identifiers, compareIdentifier)
+ slices.SortStableFunc(order.Identifiers, compareIdentifier)
+
+ return acme.ExtendedOrder{},
+ fmt.Errorf("order identifiers have been modified by the ACME server (RFC8555 §7.4): %+v != %+v",
+ orderReq.Identifiers, order.Identifiers)
}
return acme.ExtendedOrder{
@@ -74,6 +108,7 @@ func (o *OrderService) Get(orderURL string) (acme.ExtendedOrder, error) {
}
var order acme.Order
+
_, err := o.core.postAsGet(orderURL, &order)
if err != nil {
return acme.ExtendedOrder{}, err
@@ -89,13 +124,14 @@ func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.ExtendedO
}
var order acme.Order
+
_, err := o.core.post(orderURL, csrMsg, &order)
if err != nil {
return acme.ExtendedOrder{}, err
}
if order.Status == acme.StatusInvalid {
- return acme.ExtendedOrder{}, order.Error
+ return acme.ExtendedOrder{}, fmt.Errorf("invalid order: %w", order.Err())
}
return acme.ExtendedOrder{Order: order}, nil
diff --git a/acme/api/order_test.go b/acme/api/order_test.go
index 26aaa3713..f74f473d2 100644
--- a/acme/api/order_test.go
+++ b/acme/api/order_test.go
@@ -11,55 +11,51 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOrderService_NewWithOptions(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
// small value keeps test fast
- privateKey, errK := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, errK := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, errK, "Could not generate test key")
- mux.HandleFunc("/newOrder", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+ server := tester.MockACMEServer().
+ Route("POST /newOrder",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ body, err := readSignedBody(req, privateKey)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
- body, err := readSignedBody(r, privateKey)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
+ order := acme.Order{}
- order := acme.Order{}
- err = json.Unmarshal(body, &order)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
+ err = json.Unmarshal(body, &order)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
- err = tester.WriteJSONResponse(w, acme.Order{
- Status: acme.StatusValid,
- Expires: order.Expires,
- Identifiers: order.Identifiers,
- NotBefore: order.NotBefore,
- NotAfter: order.NotAfter,
- Error: order.Error,
- Authorizations: order.Authorizations,
- Finalize: order.Finalize,
- Certificate: order.Certificate,
- })
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ servermock.JSONEncode(acme.Order{
+ Status: acme.StatusValid,
+ Expires: order.Expires,
+ Identifiers: order.Identifiers,
+ Profile: order.Profile,
+ NotBefore: order.NotBefore,
+ NotAfter: order.NotAfter,
+ Error: order.Error,
+ Authorizations: order.Authorizations,
+ Finalize: order.Finalize,
+ Certificate: order.Certificate,
+ Replaces: order.Replaces,
+ }).ServeHTTP(rw, req)
+ })).
+ BuildHTTPS(t)
- core, err := New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@@ -112,6 +108,7 @@ func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error)
}
sigAlgs := []jose.SignatureAlgorithm{jose.RS256}
+
jws, err := jose.ParseSigned(string(reqBody), sigAlgs)
if err != nil {
return nil, err
diff --git a/acme/api/renewal.go b/acme/api/renewal.go
index 5b4046c69..aca3d8def 100644
--- a/acme/api/renewal.go
+++ b/acme/api/renewal.go
@@ -14,7 +14,7 @@ var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a re
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
//
-// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
+// https://www.rfc-editor.org/rfc/rfc9773.html
func (c *CertificateService) GetRenewalInfo(certID string) (*http.Response, error) {
if c.core.GetDirectory().RenewalInfo == "" {
return nil, ErrNoARI
diff --git a/acme/api/service.go b/acme/api/service.go
index 6f812ee03..22ce05124 100644
--- a/acme/api/service.go
+++ b/acme/api/service.go
@@ -1,8 +1,11 @@
package api
import (
+ "fmt"
"net/http"
"regexp"
+ "strconv"
+ "time"
)
type service struct {
@@ -23,11 +26,13 @@ func getLinks(header http.Header, rel string) []string {
linkExpr := regexp.MustCompile(`<(.+?)>(?:;[^;]+)*?;\s*rel="(.+?)"`)
var links []string
+
for _, link := range header["Link"] {
for _, m := range linkExpr.FindAllStringSubmatch(link, -1) {
if len(m) != 3 {
continue
}
+
if m[2] == rel {
links = append(links, m[1])
}
@@ -54,3 +59,29 @@ func getRetryAfter(resp *http.Response) string {
return resp.Header.Get("Retry-After")
}
+
+// ParseRetryAfter parses the Retry-After header value according to RFC 7231.
+// The header can be either delay-seconds (numeric) or HTTP-date (RFC 1123 format).
+// https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3
+// Returns the duration until the retry time.
+// TODO(ldez): unexposed this function in v5.
+func ParseRetryAfter(value string) (time.Duration, error) {
+ if value == "" {
+ return 0, nil
+ }
+
+ if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
+ return time.Duration(seconds) * time.Second, nil
+ }
+
+ if retryTime, err := time.Parse(time.RFC1123, value); err == nil {
+ duration := time.Until(retryTime)
+ if duration < 0 {
+ return 0, nil
+ }
+
+ return duration, nil
+ }
+
+ return 0, fmt.Errorf("invalid Retry-After value: %q", value)
+}
diff --git a/acme/api/service_test.go b/acme/api/service_test.go
index 2dbd795c9..57ea45708 100644
--- a/acme/api/service_test.go
+++ b/acme/api/service_test.go
@@ -3,8 +3,10 @@ package api
import (
"net/http"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func Test_getLink(t *testing.T) {
@@ -53,3 +55,38 @@ func Test_getLink(t *testing.T) {
})
}
}
+
+func TestParseRetryAfter(t *testing.T) {
+ testCases := []struct {
+ desc string
+ value string
+ expected time.Duration
+ }{
+ {
+ desc: "empty header value",
+ value: "",
+ expected: time.Duration(0),
+ },
+ {
+ desc: "delay-seconds",
+ value: "123",
+ expected: 123 * time.Second,
+ },
+ {
+ desc: "HTTP-date",
+ value: time.Now().Add(3 * time.Second).Format(time.RFC1123),
+ expected: 3 * time.Second,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ rt, err := ParseRetryAfter(test.value)
+ require.NoError(t, err)
+
+ assert.InDelta(t, test.expected.Seconds(), rt.Seconds(), 1)
+ })
+ }
+}
diff --git a/acme/commons.go b/acme/commons.go
index 39aa35ac8..0af623e4e 100644
--- a/acme/commons.go
+++ b/acme/commons.go
@@ -38,7 +38,7 @@ const (
// Directory the ACME directory object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1
-// - https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
+// - https://www.rfc-editor.org/rfc/rfc9773.html
type Directory struct {
NewNonceURL string `json:"newNonce"`
NewAccountURL string `json:"newAccount"`
@@ -74,11 +74,17 @@ type Meta struct {
// then the CA requires that all new-account requests include an "externalAccountBinding" field
// associating the new account with an external account.
ExternalAccountRequired bool `json:"externalAccountRequired"`
+
+ // profiles (optional, object):
+ // A map of profile names to human-readable descriptions of those profiles.
+ // https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-3
+ Profiles map[string]string `json:"profiles"`
}
// ExtendedAccount an extended Account.
type ExtendedAccount struct {
Account
+
// Contains the value of the response header `Location`
Location string `json:"-"`
}
@@ -148,6 +154,12 @@ type Order struct {
// An array of identifier objects that the order pertains to.
Identifiers []Identifier `json:"identifiers"`
+ // profile (string, optional):
+ // A string uniquely identifying the profile
+ // which will be used to affect issuance of the certificate requested by this Order.
+ // https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
+ Profile string `json:"profile,omitempty"`
+
// notBefore (optional, string):
// The requested value of the notBefore field in the certificate,
// in the date format defined in [RFC3339].
@@ -185,10 +197,18 @@ type Order struct {
// replaces (optional, string):
// replaces (string, optional): A string uniquely identifying a
// previously-issued certificate which this order is intended to replace.
- // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
+ // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
Replaces string `json:"replaces,omitempty"`
}
+func (r *Order) Err() error {
+ if r.Error != nil {
+ return r.Error
+ }
+
+ return nil
+}
+
// Authorization the ACME authorization object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4
type Authorization struct {
@@ -201,11 +221,11 @@ type Authorization struct {
// The timestamp after which the server will consider this authorization invalid,
// encoded in the format specified in RFC 3339 [RFC3339].
// This field is REQUIRED for objects with "valid" in the "status" field.
- Expires time.Time `json:"expires,omitempty"`
+ Expires time.Time `json:"expires,omitzero"`
// identifier (required, object):
// The identifier that the account is authorized to represent
- Identifier Identifier `json:"identifier,omitempty"`
+ Identifier Identifier `json:"identifier"`
// challenges (required, array of objects):
// For pending authorizations, the challenges that the client can fulfill in order to prove possession of the identifier.
@@ -225,6 +245,7 @@ type Authorization struct {
// ExtendedChallenge a extended Challenge.
type ExtendedChallenge struct {
Challenge
+
// Contains the value of the response header `Retry-After`
RetryAfter string `json:"-"`
// Contains the value of the response header `Link` rel="up"
@@ -251,7 +272,7 @@ type Challenge struct {
// The time at which the server validated this challenge,
// encoded in the format specified in RFC 3339 [RFC3339].
// This field is REQUIRED if the "status" field is "valid".
- Validated time.Time `json:"validated,omitempty"`
+ Validated time.Time `json:"validated,omitzero"`
// error (optional, object):
// Error that occurred while the server was validating the challenge, if any,
@@ -274,6 +295,14 @@ type Challenge struct {
KeyAuthorization string `json:"keyAuthorization"`
}
+func (c *Challenge) Err() error {
+ if c.Error != nil {
+ return c.Error
+ }
+
+ return nil
+}
+
// Identifier the ACME identifier object.
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-9.7.7
type Identifier struct {
@@ -322,7 +351,7 @@ type Window struct {
}
// RenewalInfoResponse is the response to GET requests made the renewalInfo endpoint.
-// - (4.1. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
+// - (4.1. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html
type RenewalInfoResponse struct {
// SuggestedWindow contains two fields, start and end,
// whose values are timestamps which bound the window of time in which the CA recommends renewing the certificate.
@@ -335,11 +364,11 @@ type RenewalInfoResponse struct {
}
// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
-// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.2
+// - (4.2. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
type RenewalInfoUpdateRequest struct {
// CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the
// certificate's authority key identifier and Serial is the certificate's serial number. For details, see:
- // https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.1
+ // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.1
CertID string `json:"certID"`
// Replaced is required and indicates whether or not the client considers the certificate to have been replaced.
// A certificate is considered replaced when its revocation would not disrupt any ongoing services,
diff --git a/acme/errors.go b/acme/errors.go
index acaea5f65..cd447d7b4 100644
--- a/acme/errors.go
+++ b/acme/errors.go
@@ -2,12 +2,15 @@ package acme
import (
"fmt"
+ "strings"
)
// Errors types.
const (
- errNS = "urn:ietf:params:acme:error:"
- BadNonceErr = errNS + "badNonce"
+ errNS = "urn:ietf:params:acme:error:"
+ BadNonceErr = errNS + "badNonce"
+ AlreadyReplacedErr = errNS + "alreadyReplaced"
+ RateLimitedErr = errNS + "rateLimited"
)
// ProblemDetails the problem details object.
@@ -25,30 +28,34 @@ type ProblemDetails struct {
URL string `json:"url,omitempty"`
}
+func (p *ProblemDetails) Error() string {
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "acme: error: %d", p.HTTPStatus)
+
+ if p.Method != "" || p.URL != "" {
+ _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Method, p.URL)
+ }
+
+ _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Type, p.Detail)
+
+ for _, sub := range p.SubProblems {
+ _, _ = fmt.Fprintf(msg, ", problem: %q :: %s", sub.Type, sub.Detail)
+ }
+
+ if p.Instance != "" {
+ msg.WriteString(", url: " + p.Instance)
+ }
+
+ return msg.String()
+}
+
// SubProblem a "subproblems".
// - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.7.1
type SubProblem struct {
Type string `json:"type,omitempty"`
Detail string `json:"detail,omitempty"`
- Identifier Identifier `json:"identifier,omitempty"`
-}
-
-func (p ProblemDetails) Error() string {
- msg := fmt.Sprintf("acme: error: %d", p.HTTPStatus)
- if p.Method != "" || p.URL != "" {
- msg += fmt.Sprintf(" :: %s :: %s", p.Method, p.URL)
- }
- msg += fmt.Sprintf(" :: %s :: %s", p.Type, p.Detail)
-
- for _, sub := range p.SubProblems {
- msg += fmt.Sprintf(", problem: %q :: %s", sub.Type, sub.Detail)
- }
-
- if p.Instance != "" {
- msg += ", url: " + p.Instance
- }
-
- return msg
+ Identifier Identifier `json:"identifier"`
}
// NonceError represents the error which is returned
@@ -56,3 +63,31 @@ func (p ProblemDetails) Error() string {
type NonceError struct {
*ProblemDetails
}
+
+func (e *NonceError) Unwrap() error {
+ return e.ProblemDetails
+}
+
+// AlreadyReplacedError represents the error which is returned
+// if the Server rejects the request because the identified certificate has already been marked as replaced.
+// - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
+type AlreadyReplacedError struct {
+ *ProblemDetails
+}
+
+func (e *AlreadyReplacedError) Unwrap() error {
+ return e.ProblemDetails
+}
+
+// RateLimitedError represents the error which is returned
+// if the server rejects the request because the client has exceeded the rate limit.
+// - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.6
+type RateLimitedError struct {
+ *ProblemDetails
+
+ RetryAfter string
+}
+
+func (e *RateLimitedError) Unwrap() error {
+ return e.ProblemDetails
+}
diff --git a/buildx.Dockerfile b/buildx.Dockerfile
index 92a86dd3d..37f1dde94 100644
--- a/buildx.Dockerfile
+++ b/buildx.Dockerfile
@@ -1,10 +1,12 @@
# syntax=docker/dockerfile:1.4
FROM alpine:3
+ARG TARGETPLATFORM
+
RUN apk --no-cache --no-progress add git ca-certificates tzdata \
&& rm -rf /var/cache/apk/*
-COPY lego /
+COPY $TARGETPLATFORM/lego /
ENTRYPOINT ["/lego"]
EXPOSE 80
diff --git a/certcrypto/crypto.go b/certcrypto/crypto.go
index 43fa774ae..800bb3f5b 100644
--- a/certcrypto/crypto.go
+++ b/certcrypto/crypto.go
@@ -57,8 +57,10 @@ type DERCertificateBytes []byte
// ParsePEMBundle parses a certificate bundle from top to bottom and returns
// a slice of x509 certificates. This function will error if no certificates are found.
func ParsePEMBundle(bundle []byte) ([]*x509.Certificate, error) {
- var certificates []*x509.Certificate
- var certDERBlock *pem.Block
+ var (
+ certificates []*x509.Certificate
+ certDERBlock *pem.Block
+ )
for {
certDERBlock, bundle = pem.Decode(bundle)
@@ -71,6 +73,7 @@ func ParsePEMBundle(bundle []byte) ([]*x509.Certificate, error) {
if err != nil {
return nil, err
}
+
certificates = append(certificates, cert)
}
}
@@ -135,10 +138,29 @@ func GeneratePrivateKey(keyType KeyType) (crypto.PrivateKey, error) {
return nil, fmt.Errorf("invalid KeyType: %s", keyType)
}
+// Deprecated: uses [CreateCSR] instead.
func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, mustStaple bool) ([]byte, error) {
- var dnsNames []string
- var ipAddresses []net.IP
- for _, altname := range san {
+ return CreateCSR(privateKey, CSROptions{
+ Domain: domain,
+ SAN: san,
+ MustStaple: mustStaple,
+ })
+}
+
+type CSROptions struct {
+ Domain string
+ SAN []string
+ MustStaple bool
+ EmailAddresses []string
+}
+
+func CreateCSR(privateKey crypto.PrivateKey, opts CSROptions) ([]byte, error) {
+ var (
+ dnsNames []string
+ ipAddresses []net.IP
+ )
+
+ for _, altname := range opts.SAN {
if ip := net.ParseIP(altname); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
@@ -147,12 +169,13 @@ func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, must
}
template := x509.CertificateRequest{
- Subject: pkix.Name{CommonName: domain},
- DNSNames: dnsNames,
- IPAddresses: ipAddresses,
+ Subject: pkix.Name{CommonName: opts.Domain},
+ DNSNames: dnsNames,
+ EmailAddresses: opts.EmailAddresses,
+ IPAddresses: ipAddresses,
}
- if mustStaple {
+ if opts.MustStaple {
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
Id: tlsFeatureExtensionOID,
Value: ocspMustStapleFeature,
@@ -162,12 +185,13 @@ func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, must
return x509.CreateCertificateRequest(rand.Reader, &template, privateKey)
}
-func PEMEncode(data interface{}) []byte {
+func PEMEncode(data any) []byte {
return pem.EncodeToMemory(PEMBlock(data))
}
-func PEMBlock(data interface{}) *pem.Block {
+func PEMBlock(data any) *pem.Block {
var pemBlock *pem.Block
+
switch key := data.(type) {
case *ecdsa.PrivateKey:
keyBytes, _ := x509.MarshalECPrivateKey(key)
@@ -218,15 +242,15 @@ func ParsePEMCertificate(cert []byte) (*x509.Certificate, error) {
}
func GetCertificateMainDomain(cert *x509.Certificate) (string, error) {
- return getMainDomain(cert.Subject, cert.DNSNames)
+ return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses)
}
func GetCSRMainDomain(cert *x509.CertificateRequest) (string, error) {
- return getMainDomain(cert.Subject, cert.DNSNames)
+ return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses)
}
-func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) {
- if subject.CommonName == "" && len(dnsNames) == 0 {
+func getMainDomain(subject pkix.Name, dnsNames []string, ips []net.IP) (string, error) {
+ if subject.CommonName == "" && len(dnsNames) == 0 && len(ips) == 0 {
return "", errors.New("missing domain")
}
@@ -234,7 +258,11 @@ func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) {
return subject.CommonName, nil
}
- return dnsNames[0], nil
+ if len(dnsNames) > 0 {
+ return dnsNames[0], nil
+ }
+
+ return ips[0].String(), nil
}
func ExtractDomains(cert *x509.Certificate) []string {
@@ -248,6 +276,7 @@ func ExtractDomains(cert *x509.Certificate) []string {
if sanDomain == cert.Subject.CommonName {
continue
}
+
domains = append(domains, sanDomain)
}
@@ -299,6 +328,7 @@ func GeneratePemCert(privateKey *rsa.PrivateKey, domain string, extensions []pki
func generateDerCert(privateKey *rsa.PrivateKey, expiration time.Time, domain string, extensions []pkix.Extension) ([]byte, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, err
diff --git a/certcrypto/crypto_test.go b/certcrypto/crypto_test.go
index 7aba8b378..f5609fdf4 100644
--- a/certcrypto/crypto_test.go
+++ b/certcrypto/crypto_test.go
@@ -6,7 +6,6 @@ import (
"crypto/rand"
"crypto/rsa"
"encoding/pem"
- "regexp"
"testing"
"time"
@@ -14,6 +13,13 @@ import (
"github.com/stretchr/testify/require"
)
+const (
+ testDomain1 = "lego.example"
+ testDomain2 = "a.lego.example"
+ testDomain3 = "b.lego.example"
+ testDomain4 = "c.lego.example"
+)
+
func TestGeneratePrivateKey(t *testing.T) {
key, err := GeneratePrivateKey(RSA2048)
require.NoError(t, err, "Error generating private key")
@@ -22,7 +28,7 @@ func TestGeneratePrivateKey(t *testing.T) {
}
func TestGenerateCSR(t *testing.T) {
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Error generating private key")
type expected struct {
@@ -33,55 +39,75 @@ func TestGenerateCSR(t *testing.T) {
testCases := []struct {
desc string
privateKey crypto.PrivateKey
- domain string
- san []string
- mustStaple bool
+ opts CSROptions
expected expected
}{
{
desc: "without SAN (nil)",
privateKey: privateKey,
- domain: "lego.acme",
- mustStaple: true,
- expected: expected{len: 245},
+ opts: CSROptions{
+ Domain: testDomain1,
+ MustStaple: true,
+ },
+ expected: expected{len: 382},
},
{
desc: "without SAN (empty)",
privateKey: privateKey,
- domain: "lego.acme",
- san: []string{},
- mustStaple: true,
- expected: expected{len: 245},
+ opts: CSROptions{
+ Domain: testDomain1,
+ SAN: []string{},
+ MustStaple: true,
+ },
+ expected: expected{len: 382},
},
{
desc: "with SAN",
privateKey: privateKey,
- domain: "lego.acme",
- san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"},
- mustStaple: true,
- expected: expected{len: 296},
+ opts: CSROptions{
+ Domain: testDomain1,
+ SAN: []string{testDomain2, testDomain3, testDomain4},
+ MustStaple: true,
+ },
+ expected: expected{len: 442},
},
{
desc: "no domain",
privateKey: privateKey,
- domain: "",
- mustStaple: true,
- expected: expected{len: 225},
+ opts: CSROptions{
+ Domain: "",
+ MustStaple: true,
+ },
+ expected: expected{len: 359},
},
{
desc: "no domain with SAN",
privateKey: privateKey,
- domain: "",
- san: []string{"a.lego.acme", "b.lego.acme", "c.lego.acme"},
- mustStaple: true,
- expected: expected{len: 276},
+ opts: CSROptions{
+ Domain: "",
+ SAN: []string{testDomain2, testDomain3, testDomain4},
+ MustStaple: true,
+ },
+ expected: expected{len: 419},
},
{
desc: "private key nil",
privateKey: nil,
- domain: "fizz.buzz",
- mustStaple: true,
- expected: expected{error: true},
+ opts: CSROptions{
+ Domain: testDomain1,
+ MustStaple: true,
+ },
+ expected: expected{error: true},
+ },
+ {
+ desc: "with email addresses",
+ privateKey: privateKey,
+ opts: CSROptions{
+ Domain: "example.com",
+ SAN: []string{"example.org"},
+ EmailAddresses: []string{"foo@example.com", "bar@example.com"},
+ },
+ expected: expected{len: 421},
},
}
@@ -89,7 +115,7 @@ func TestGenerateCSR(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- csr, err := GenerateCSR(test.privateKey, test.domain, test.san, test.mustStaple)
+ csr, err := CreateCSR(test.privateKey, test.opts)
if test.expected.error {
require.Error(t, err)
@@ -104,17 +130,17 @@ func TestGenerateCSR(t *testing.T) {
}
func TestPEMEncode(t *testing.T) {
- buf := bytes.NewBufferString("TestingRSAIsSoMuchFun")
-
- reader := MockRandReader{b: buf}
- key, err := rsa.GenerateKey(reader, 32)
+ key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Error generating private key")
data := PEMEncode(key)
require.NotNil(t, data)
- exp := regexp.MustCompile(`^-----BEGIN RSA PRIVATE KEY-----\s+\S{60,}\s+-----END RSA PRIVATE KEY-----\s+`)
- assert.Regexp(t, exp, string(data))
+ p, rest := pem.Decode(data)
+
+ assert.Equal(t, "RSA PRIVATE KEY", p.Type)
+ assert.Empty(t, rest)
+ assert.Empty(t, p.Headers)
}
func TestParsePEMCertificate(t *testing.T) {
@@ -149,10 +175,13 @@ func TestParsePEMPrivateKey(t *testing.T) {
pemPrivateKey := PEMEncode(privateKey)
- // Decoding a key should work and create an identical key to the original
+ // Decoding a key should work and create an identical RSA key to the original,
+ // ignoring precomputed values.
decoded, err := ParsePEMPrivateKey(pemPrivateKey)
require.NoError(t, err)
- assert.Equal(t, decoded, privateKey)
+
+ decodedRsaPrivateKey := decoded.(*rsa.PrivateKey)
+ require.True(t, decodedRsaPrivateKey.Equal(privateKey))
// Decoding a PEM block that doesn't contain a private key should error
_, err = ParsePEMPrivateKey(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE"}))
@@ -166,11 +195,3 @@ func TestParsePEMPrivateKey(t *testing.T) {
_, err = ParsePEMPrivateKey([]byte("This is not PEM"))
require.Errorf(t, err, "Expected to return an error for non-PEM input")
}
-
-type MockRandReader struct {
- b *bytes.Buffer
-}
-
-func (r MockRandReader) Read(p []byte) (int, error) {
- return r.b.Read(p)
-}
diff --git a/certificate/authorization.go b/certificate/authorization.go
index 5118912f8..49f958776 100644
--- a/certificate/authorization.go
+++ b/certificate/authorization.go
@@ -29,6 +29,7 @@ func (c *Certifier) getAuthorizations(order acme.ExtendedOrder) ([]acme.Authoriz
var responses []acme.Authorization
failures := newObtainError()
+
for range len(order.Authorizations) {
select {
case res := <-resc:
@@ -52,7 +53,7 @@ func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force boo
for _, authzURL := range order.Authorizations {
auth, err := c.core.Authorizations.Get(authzURL)
if err != nil {
- log.Infof("Unable to get the authorization for: %s", authzURL)
+ log.Infof("Unable to get the authorization for %s: %v", authzURL, err)
continue
}
@@ -62,6 +63,7 @@ func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force boo
}
log.Infof("Deactivating auth: %s", authzURL)
+
if c.core.Authorizations.Deactivate(authzURL) != nil {
log.Infof("Unable to deactivate the authorization: %s", authzURL)
}
diff --git a/certificate/certificates.go b/certificate/certificates.go
index fc139937b..04904e794 100644
--- a/certificate/certificates.go
+++ b/certificate/certificates.go
@@ -65,18 +65,26 @@ type Resource struct {
// If `AlwaysDeactivateAuthorizations` is true, the authorizations are also relinquished if the obtain request was successful.
// See https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2.
type ObtainRequest struct {
- Domains []string
- PrivateKey crypto.PrivateKey
- MustStaple bool
+ Domains []string
+ PrivateKey crypto.PrivateKey
+ MustStaple bool
+ EmailAddresses []string
+
+ NotBefore time.Time
+ NotAfter time.Time
+ Bundle bool
+ PreferredChain string
+
+ // A string uniquely identifying the profile
+ // which will be used to affect issuance of the certificate requested by this Order.
+ // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
+ Profile string
- NotBefore time.Time
- NotAfter time.Time
- Bundle bool
- PreferredChain string
AlwaysDeactivateAuthorizations bool
+
// A string uniquely identifying a previously-issued certificate which this
// order is intended to replace.
- // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
+ // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
ReplacesCertID string
}
@@ -89,14 +97,23 @@ type ObtainRequest struct {
type ObtainForCSRRequest struct {
CSR *x509.CertificateRequest
- NotBefore time.Time
- NotAfter time.Time
- Bundle bool
- PreferredChain string
+ PrivateKey crypto.PrivateKey
+
+ NotBefore time.Time
+ NotAfter time.Time
+ Bundle bool
+ PreferredChain string
+
+ // A string uniquely identifying the profile
+ // which will be used to affect issuance of the certificate requested by this Order.
+ // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4
+ Profile string
+
AlwaysDeactivateAuthorizations bool
+
// A string uniquely identifying a previously-issued certificate which this
// order is intended to replace.
- // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
+ // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5
ReplacesCertID string
}
@@ -108,6 +125,7 @@ type CertifierOptions struct {
KeyType certcrypto.KeyType
Timeout time.Duration
OverallRequestLimit int
+ DisableCommonName bool
}
// Certifier A service to obtain/renew/revoke certificates.
@@ -154,6 +172,7 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) {
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
+ Profile: request.Profile,
ReplacesCertID: request.ReplacesCertID,
}
@@ -179,7 +198,8 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) {
log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
failures := newObtainError()
- cert, err := c.getForOrder(domains, order, request.Bundle, request.PrivateKey, request.MustStaple, request.PreferredChain)
+
+ cert, err := c.getForOrder(domains, order, request)
if err != nil {
for _, auth := range authz {
failures.Add(challenge.GetTargetedDomain(auth), err)
@@ -220,6 +240,7 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
orderOpts := &api.OrderOptions{
NotBefore: request.NotBefore,
NotAfter: request.NotAfter,
+ Profile: request.Profile,
ReplacesCertID: request.ReplacesCertID,
}
@@ -245,7 +266,13 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", "))
failures := newObtainError()
- cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, nil, request.PreferredChain)
+
+ var privateKey []byte
+ if request.PrivateKey != nil {
+ privateKey = certcrypto.PEMEncode(request.PrivateKey)
+ }
+
+ cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, privateKey, request.PreferredChain)
if err != nil {
for _, auth := range authz {
failures.Add(challenge.GetTargetedDomain(auth), err)
@@ -264,9 +291,12 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
return cert, failures.Join()
}
-func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool, preferredChain string) (*Resource, error) {
+func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, request ObtainRequest) (*Resource, error) {
+ privateKey := request.PrivateKey
+
if privateKey == nil {
var err error
+
privateKey, err = certcrypto.GeneratePrivateKey(c.options.KeyType)
if err != nil {
return nil, err
@@ -274,7 +304,7 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund
}
commonName := ""
- if len(domains[0]) <= 64 {
+ if len(domains[0]) <= 64 && !c.options.DisableCommonName {
commonName = domains[0]
}
@@ -296,13 +326,19 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund
}
}
- // TODO: should the CSR be customizable?
- csr, err := certcrypto.GenerateCSR(privateKey, commonName, san, mustStaple)
+ csrOptions := certcrypto.CSROptions{
+ Domain: commonName,
+ SAN: san,
+ MustStaple: request.MustStaple,
+ EmailAddresses: request.EmailAddresses,
+ }
+
+ csr, err := certcrypto.CreateCSR(privateKey, csrOptions)
if err != nil {
return nil, err
}
- return c.getForCSR(domains, order, bundle, csr, certcrypto.PEMEncode(privateKey), preferredChain)
+ return c.getForCSR(domains, order, request.Bundle, csr, certcrypto.PEMEncode(privateKey), request.PreferredChain)
}
func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte, preferredChain string) (*Resource, error) {
@@ -435,11 +471,15 @@ type RenewOptions struct {
NotBefore time.Time
NotAfter time.Time
// If true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
- Bundle bool
- PreferredChain string
+ Bundle bool
+ PreferredChain string
+
+ Profile string
+
AlwaysDeactivateAuthorizations bool
// Not supported for CSR request.
- MustStaple bool
+ MustStaple bool
+ EmailAddresses []string
}
// Renew takes a Resource and tries to renew the certificate.
@@ -452,6 +492,7 @@ type RenewOptions struct {
// If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle.
//
// For private key reuse the PrivateKey property of the passed in Resource should be non-nil.
+//
// Deprecated: use RenewWithOptions instead.
func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredChain string) (*Resource, error) {
return c.RenewWithOptions(certRes, &RenewOptions{
@@ -505,6 +546,7 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (*
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
+ request.Profile = options.Profile
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
@@ -530,6 +572,8 @@ func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (*
request.NotAfter = options.NotAfter
request.Bundle = options.Bundle
request.PreferredChain = options.PreferredChain
+ request.EmailAddresses = options.EmailAddresses
+ request.Profile = options.Profile
request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations
}
@@ -668,7 +712,7 @@ func checkOrderStatus(order acme.ExtendedOrder) (bool, error) {
case acme.StatusValid:
return true, nil
case acme.StatusInvalid:
- return false, order.Error
+ return false, fmt.Errorf("invalid order: %w", order.Err())
default:
return false, nil
}
@@ -681,6 +725,7 @@ func checkOrderStatus(order acme.ExtendedOrder) (bool, error) {
// https://www.rfc-editor.org/rfc/rfc5280.html#section-7
func sanitizeDomain(domains []string) []string {
var sanitizedDomains []string
+
for _, domain := range domains {
sanitizedDomain, err := idna.ToASCII(domain)
if err != nil {
@@ -689,5 +734,6 @@ func sanitizeDomain(domains []string) []string {
sanitizedDomains = append(sanitizedDomains, sanitizedDomain)
}
}
+
return sanitizedDomains
}
diff --git a/certificate/certificates_test.go b/certificate/certificates_test.go
index bff66429d..c0e35e795 100644
--- a/certificate/certificates_test.go
+++ b/certificate/certificates_test.go
@@ -3,7 +3,6 @@ package certificate
import (
"crypto/rand"
"crypto/rsa"
- "encoding/pem"
"fmt"
"net/http"
"testing"
@@ -12,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -175,20 +175,14 @@ Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ
`
func Test_checkResponse(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
- mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
- _, err := w.Write([]byte(certResponseMock))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ server := tester.MockACMEServer().
+ Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@@ -196,7 +190,7 @@ func Test_checkResponse(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
- Certificate: apiURL + "/certificate",
+ Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{}
@@ -205,7 +199,7 @@ func Test_checkResponse(t *testing.T) {
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
- assert.Equal(t, "", certRes.Domain)
+ assert.Empty(t, certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
assert.Nil(t, certRes.CSR)
@@ -215,30 +209,14 @@ func Test_checkResponse(t *testing.T) {
}
func Test_checkResponse_issuerRelUp(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
- mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Link", "<"+apiURL+`/issuer>; rel="up"`)
- _, err := w.Write([]byte(certResponseMock))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- mux.HandleFunc("/issuer", func(w http.ResponseWriter, _ *http.Request) {
- p, _ := pem.Decode([]byte(issuerMock))
- _, err := w.Write(p.Bytes)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ server := tester.MockACMEServer().
+ Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@@ -246,7 +224,7 @@ func Test_checkResponse_issuerRelUp(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
- Certificate: apiURL + "/certificate",
+ Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{}
@@ -255,7 +233,7 @@ func Test_checkResponse_issuerRelUp(t *testing.T) {
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
- assert.Equal(t, "", certRes.Domain)
+ assert.Empty(t, certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
assert.Nil(t, certRes.CSR)
@@ -265,20 +243,14 @@ func Test_checkResponse_issuerRelUp(t *testing.T) {
}
func Test_checkResponse_no_bundle(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
- mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
- _, err := w.Write([]byte(certResponseMock))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ server := tester.MockACMEServer().
+ Route("POST /certificate", servermock.RawStringResponse(certResponseMock)).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@@ -286,7 +258,7 @@ func Test_checkResponse_no_bundle(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
- Certificate: apiURL + "/certificate",
+ Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{}
@@ -295,7 +267,7 @@ func Test_checkResponse_no_bundle(t *testing.T) {
require.NoError(t, err)
assert.True(t, valid)
assert.NotNil(t, certRes)
- assert.Equal(t, "", certRes.Domain)
+ assert.Empty(t, certRes.Domain)
assert.Contains(t, certRes.CertStableURL, "/certificate")
assert.Contains(t, certRes.CertURL, "/certificate")
assert.Nil(t, certRes.CSR)
@@ -305,30 +277,21 @@ func Test_checkResponse_no_bundle(t *testing.T) {
}
func Test_checkResponse_alternate(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().
+ Route("POST /certificate",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ rw.Header().Add("Link",
+ fmt.Sprintf(`;title="foo";rel="alternate"`, req.Context().Value(http.LocalAddrContextKey)))
- mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Add("Link", fmt.Sprintf(`<%s/certificate/1>;title="foo";rel="alternate"`, apiURL))
-
- _, err := w.Write([]byte(certResponseMock))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- mux.HandleFunc("/certificate/1", func(w http.ResponseWriter, _ *http.Request) {
- _, err := w.Write([]byte(certResponseMock2))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ servermock.RawStringResponse(certResponseMock).ServeHTTP(rw, req)
+ })).
+ Route("/certificate/1", servermock.RawStringResponse(certResponseMock2)).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@@ -336,7 +299,7 @@ func Test_checkResponse_alternate(t *testing.T) {
order := acme.ExtendedOrder{
Order: acme.Order{
Status: acme.StatusValid,
- Certificate: apiURL + "/certificate",
+ Certificate: server.URL + "/certificate",
},
}
certRes := &Resource{
@@ -358,37 +321,76 @@ func Test_checkResponse_alternate(t *testing.T) {
}
func Test_Get(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
- mux.HandleFunc("/acme/cert/test-cert", func(w http.ResponseWriter, _ *http.Request) {
- _, err := w.Write([]byte(certResponseMock))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ server := tester.MockACMEServer().
+ Route("POST /acme/cert/test-cert", servermock.RawStringResponse(certResponseMock)).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
- certRes, err := certifier.Get(apiURL+"/acme/cert/test-cert", true)
+ certRes, err := certifier.Get(server.URL+"/acme/cert/test-cert", true)
require.NoError(t, err)
assert.NotNil(t, certRes)
assert.Equal(t, "acme.wtf", certRes.Domain)
- assert.Equal(t, apiURL+"/acme/cert/test-cert", certRes.CertStableURL)
- assert.Equal(t, apiURL+"/acme/cert/test-cert", certRes.CertURL)
+ assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertStableURL)
+ assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertURL)
assert.Nil(t, certRes.CSR)
assert.Nil(t, certRes.PrivateKey)
assert.Equal(t, certResponseMock, string(certRes.Certificate), "Certificate")
assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate")
}
+func Test_checkOrderStatus(t *testing.T) {
+ testCases := []struct {
+ desc string
+ order acme.Order
+ requireErr require.ErrorAssertionFunc
+ expected bool
+ }{
+ {
+ desc: "status valid",
+ order: acme.Order{Status: acme.StatusValid},
+ requireErr: require.NoError,
+ expected: true,
+ },
+ {
+ desc: "status invalid",
+ order: acme.Order{Status: acme.StatusInvalid},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "status invalid with error",
+ order: acme.Order{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "unknown status",
+ order: acme.Order{Status: "foo"},
+ requireErr: require.NoError,
+ expected: false,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ status, err := checkOrderStatus(acme.ExtendedOrder{Order: test.order})
+ test.requireErr(t, err)
+
+ assert.Equal(t, test.expected, status)
+ })
+ }
+}
+
type resolverMock struct {
error error
}
diff --git a/certificate/renewal.go b/certificate/renewal.go
index ab215923d..59d31cfb5 100644
--- a/certificate/renewal.go
+++ b/certificate/renewal.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/go-acme/lego/v4/acme"
+ "github.com/go-acme/lego/v4/acme/api"
)
// RenewalInfoRequest contains the necessary renewal information.
@@ -25,15 +26,15 @@ type RenewalInfoResponse struct {
// RetryAfter header indicating the polling interval that the ACME server recommends.
// Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed,
// as the server may provide a different suggestedWindow.
- // https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.2
+ // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
RetryAfter time.Duration
}
// ShouldRenewAt determines the optimal renewal time based on the current time (UTC),renewal window suggest by ARI, and the client's willingness to sleep.
// It returns a pointer to a time.Time value indicating when the renewal should be attempted or nil if deferred until the next normal wake time.
-// This method implements the RECOMMENDED algorithm described in draft-ietf-acme-ari.
+// This method implements the RECOMMENDED algorithm described in RFC 9773.
//
-// - (4.1-11. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
+// - (4.1-11. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html
func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time {
// Explicitly convert all times to UTC.
now = now.UTC()
@@ -71,7 +72,7 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
//
-// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
+// https://www.rfc-editor.org/rfc/rfc9773.html
func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) {
certID, err := MakeARICertID(req.Cert)
if err != nil {
@@ -85,22 +86,23 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse
defer resp.Body.Close()
var info RenewalInfoResponse
+
err = json.NewDecoder(resp.Body).Decode(&info)
if err != nil {
return nil, err
}
if retry := resp.Header.Get("Retry-After"); retry != "" {
- info.RetryAfter, err = time.ParseDuration(retry + "s")
+ info.RetryAfter, err = api.ParseRetryAfter(retry)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("failed to parse Retry-After header: %w", err)
}
}
return &info, nil
}
-// MakeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-03, section 4.1.
+// MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1.
func MakeARICertID(leaf *x509.Certificate) (string, error) {
if leaf == nil {
return "", errors.New("leaf certificate is nil")
diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go
index 9f20e374e..23209638a 100644
--- a/certificate/renewal_test.go
+++ b/certificate/renewal_test.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -42,31 +43,24 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {
require.NoError(t, err)
// Test with a fake API.
- mux, apiURL := tester.SetupFakeAPI(t)
- mux.HandleFunc("/renewalInfo/"+ariLeafCertID, func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Retry-After", "21600")
- w.WriteHeader(http.StatusOK)
- _, wErr := w.Write([]byte(`{
+ server := tester.MockACMEServer().
+ Route("GET /renewalInfo/"+ariLeafCertID,
+ servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
- "explanationUrl": "https://aricapable.ca/docs/renewal-advice/"
+ "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
- }`))
- require.NoError(t, wErr)
- })
+ }`).
+ WithHeader("Content-Type", "application/json").
+ WithHeader("Retry-After", "21600")).
+ BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
@@ -76,10 +70,46 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
- assert.Equal(t, "https://aricapable.ca/docs/renewal-advice/", ri.ExplanationURL)
+ assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter)
}
+func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) {
+ leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
+ require.NoError(t, err)
+
+ server := tester.MockACMEServer().
+ Route("GET /renewalInfo/"+ariLeafCertID,
+ servermock.RawStringResponse(`{
+ "suggestedWindow": {
+ "start": "2020-03-17T17:51:09Z",
+ "end": "2020-03-17T18:21:09Z"
+ },
+ "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
+ }
+ }`).
+ WithHeader("Content-Type", "application/json").
+ WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))).
+ BuildHTTPS(t)
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err, "Could not generate test key")
+
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
+ require.NoError(t, err)
+
+ certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
+
+ ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf})
+ require.NoError(t, err)
+ require.NotNil(t, ri)
+ assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
+ assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
+ assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
+
+ assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001)
+}
+
func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
@@ -88,24 +118,23 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
require.NoError(t, err, "Could not generate test key")
testCases := []struct {
- desc string
- httpClient *http.Client
- request RenewalInfoRequest
- handler http.HandlerFunc
+ desc string
+ timeout time.Duration
+ request RenewalInfoRequest
+ handler http.HandlerFunc
}{
{
- desc: "API timeout",
- httpClient: &http.Client{Timeout: 500 * time.Millisecond}, // HTTP client that times out after 500ms.
- request: RenewalInfoRequest{leaf},
+ desc: "API timeout",
+ timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms.
+ request: RenewalInfoRequest{leaf},
handler: func(w http.ResponseWriter, r *http.Request) {
// API that takes 2ms to respond.
time.Sleep(2 * time.Millisecond)
},
},
{
- desc: "API error",
- httpClient: http.DefaultClient,
- request: RenewalInfoRequest{leaf},
+ desc: "API error",
+ request: RenewalInfoRequest{leaf},
handler: func(w http.ResponseWriter, r *http.Request) {
// API that responds with error instead of renewal info.
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@@ -117,10 +146,17 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- mux, apiURL := tester.SetupFakeAPI(t)
- mux.HandleFunc("/renewalInfo/"+ariLeafCertID, test.handler)
+ server := tester.MockACMEServer().
+ Route("GET /renewalInfo/"+ariLeafCertID, test.handler).
+ BuildHTTPS(t)
- core, err := api.New(test.httpClient, "lego-test", apiURL+"/dir", "", key)
+ client := server.Client()
+
+ if test.timeout != 0 {
+ client.Timeout = test.timeout
+ }
+
+ core, err := api.New(client, "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
diff --git a/challenge/challenges.go b/challenge/challenges.go
index 39bf3bee2..f6d5cdb28 100644
--- a/challenge/challenges.go
+++ b/challenge/challenges.go
@@ -40,5 +40,6 @@ func GetTargetedDomain(authz acme.Authorization) string {
if authz.Wildcard {
return "*." + authz.Identifier.Value
}
+
return authz.Identifier.Value
}
diff --git a/challenge/dns01/dns_challenge.go b/challenge/dns01/dns_challenge.go
index 8594d2799..1d106d7b7 100644
--- a/challenge/dns01/dns_challenge.go
+++ b/challenge/dns01/dns_challenge.go
@@ -40,6 +40,7 @@ func CondOption(condition bool, opt ChallengeOption) ChallengeOption {
return nil
}
}
+
return opt
}
@@ -118,6 +119,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
info := GetChallengeInfo(authz.Identifier.Value, keyAuth)
var timeout, interval time.Duration
+
switch provider := c.provider.(type) {
case challenge.ProviderTimeout:
timeout, interval = provider.Timeout()
@@ -134,6 +136,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
if !stop || errP != nil {
log.Infof("[%s] acme: Waiting for DNS record propagation.", domain)
}
+
return stop, errP
})
if err != nil {
@@ -141,6 +144,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
}
chlng.KeyAuthorization = keyAuth
+
return c.validate(c.core, domain, chlng)
}
@@ -165,6 +169,7 @@ func (c *Challenge) Sequential() (bool, time.Duration) {
if p, ok := c.provider.(sequential); ok {
return ok, p.Sequential()
}
+
return false, 0
}
@@ -173,6 +178,7 @@ type sequential interface {
}
// GetRecord returns a DNS record which will fulfill the `dns-01` challenge.
+//
// Deprecated: use GetChallengeInfo instead.
func GetRecord(domain, keyAuth string) (fqdn, value string) {
info := GetChallengeInfo(domain, keyAuth)
diff --git a/challenge/dns01/dns_challenge_manual.go b/challenge/dns01/dns_challenge_manual.go
index c00d64041..3821fc157 100644
--- a/challenge/dns01/dns_challenge_manual.go
+++ b/challenge/dns01/dns_challenge_manual.go
@@ -12,9 +12,14 @@ const (
)
// DNSProviderManual is an implementation of the ChallengeProvider interface.
+// TODO(ldez): move this to providers/dns/manual
+//
+// Deprecated: Use the manual.DNSProvider instead.
type DNSProviderManual struct{}
// NewDNSProviderManual returns a DNSProviderManual instance.
+//
+// Deprecated: Use the manual.NewDNSProvider instead.
func NewDNSProviderManual() (*DNSProviderManual, error) {
return &DNSProviderManual{}, nil
}
diff --git a/challenge/dns01/dns_challenge_test.go b/challenge/dns01/dns_challenge_test.go
index 953180326..325f1656c 100644
--- a/challenge/dns01/dns_challenge_test.go
+++ b/challenge/dns01/dns_challenge_test.go
@@ -4,7 +4,6 @@ import (
"crypto/rand"
"crypto/rsa"
"errors"
- "net/http"
"testing"
"time"
@@ -12,6 +11,8 @@ import (
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/dnsmock"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -32,12 +33,12 @@ func (p *providerTimeoutMock) CleanUp(domain, token, keyAuth string) error { ret
func (p *providerTimeoutMock) Timeout() (time.Duration, time.Duration) { return p.timeout, p.interval }
func TestChallenge_PreSolve(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@@ -114,12 +115,16 @@ func TestChallenge_PreSolve(t *testing.T) {
}
func TestChallenge_Solve(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ useAsNameserver(t, dnsmock.NewServer().
+ Query("_acme-challenge.example.com. CNAME", dnsmock.Noop).
+ Build(t))
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ server := tester.MockACMEServer().BuildHTTPS(t)
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@@ -179,6 +184,7 @@ func TestChallenge_Solve(t *testing.T) {
if test.preCheck != nil {
options = append(options, WrapPreCheck(test.preCheck))
}
+
chlg := NewChallenge(core, test.validate, test.provider, options...)
authz := acme.Authorization{
@@ -201,12 +207,12 @@ func TestChallenge_Solve(t *testing.T) {
}
func TestChallenge_CleanUp(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@@ -281,3 +287,55 @@ func TestChallenge_CleanUp(t *testing.T) {
})
}
}
+
+func TestGetChallengeInfo(t *testing.T) {
+ useAsNameserver(t, dnsmock.NewServer().
+ Query("_acme-challenge.example.com. CNAME", dnsmock.Noop).
+ Build(t))
+
+ info := GetChallengeInfo("example.com", "123")
+
+ expected := ChallengeInfo{
+ FQDN: "_acme-challenge.example.com.",
+ EffectiveFQDN: "_acme-challenge.example.com.",
+ Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM",
+ }
+
+ assert.Equal(t, expected, info)
+}
+
+func TestGetChallengeInfo_CNAME(t *testing.T) {
+ useAsNameserver(t, dnsmock.NewServer().
+ Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")).
+ Query("example.org. CNAME", dnsmock.Noop).
+ Build(t))
+
+ info := GetChallengeInfo("example.com", "123")
+
+ expected := ChallengeInfo{
+ FQDN: "_acme-challenge.example.com.",
+ EffectiveFQDN: "example.org.",
+ Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM",
+ }
+
+ assert.Equal(t, expected, info)
+}
+
+func TestGetChallengeInfo_CNAME_disabled(t *testing.T) {
+ useAsNameserver(t, dnsmock.NewServer().
+ // Never called when the env var works.
+ Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")).
+ Build(t))
+
+ t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
+
+ info := GetChallengeInfo("example.com", "123")
+
+ expected := ChallengeInfo{
+ FQDN: "_acme-challenge.example.com.",
+ EffectiveFQDN: "_acme-challenge.example.com.",
+ Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM",
+ }
+
+ assert.Equal(t, expected, info)
+}
diff --git a/challenge/dns01/fixtures/resolv.conf.1 b/challenge/dns01/fixtures/resolv.conf.1
index 3098f99b5..bc2a3c1ac 100644
--- a/challenge/dns01/fixtures/resolv.conf.1
+++ b/challenge/dns01/fixtures/resolv.conf.1
@@ -1,4 +1,4 @@
-domain company.com
+domain example.com
nameserver 10.200.3.249
nameserver 10.200.3.250:5353
nameserver 2001:4860:4860::8844
diff --git a/challenge/dns01/fqdn.go b/challenge/dns01/fqdn.go
index c238c8cf5..11ac3d0c2 100644
--- a/challenge/dns01/fqdn.go
+++ b/challenge/dns01/fqdn.go
@@ -1,12 +1,16 @@
package dns01
+import (
+ "iter"
+
+ "github.com/miekg/dns"
+)
+
// ToFqdn converts the name into a fqdn appending a trailing dot.
+//
+// Deprecated: Use [github.com/miekg/dns.Fqdn] directly.
func ToFqdn(name string) string {
- n := len(name)
- if n == 0 || name[n-1] == '.' {
- return name
- }
- return name + "."
+ return dns.Fqdn(name)
}
// UnFqdn converts the fqdn into a name removing the trailing dot.
@@ -15,5 +19,36 @@ func UnFqdn(name string) string {
if n != 0 && name[n-1] == '.' {
return name[:n-1]
}
+
return name
}
+
+// UnFqdnDomainsSeq generates a sequence of "unFQDNed" domain names derived from a domain (FQDN or not) in descending order.
+func UnFqdnDomainsSeq(fqdn string) iter.Seq[string] {
+ return func(yield func(string) bool) {
+ if fqdn == "" {
+ return
+ }
+
+ for _, index := range dns.Split(fqdn) {
+ if !yield(UnFqdn(fqdn[index:])) {
+ return
+ }
+ }
+ }
+}
+
+// DomainsSeq generates a sequence of domain names derived from a domain (FQDN or not) in descending order.
+func DomainsSeq(fqdn string) iter.Seq[string] {
+ return func(yield func(string) bool) {
+ if fqdn == "" {
+ return
+ }
+
+ for _, index := range dns.Split(fqdn) {
+ if !yield(fqdn[index:]) {
+ return
+ }
+ }
+ }
+}
diff --git a/challenge/dns01/fqdn_test.go b/challenge/dns01/fqdn_test.go
index a902667a2..641e39081 100644
--- a/challenge/dns01/fqdn_test.go
+++ b/challenge/dns01/fqdn_test.go
@@ -1,39 +1,12 @@
package dns01
import (
+ "slices"
"testing"
"github.com/stretchr/testify/assert"
)
-func TestToFqdn(t *testing.T) {
- testCases := []struct {
- desc string
- domain string
- expected string
- }{
- {
- desc: "simple",
- domain: "foo.example.com",
- expected: "foo.example.com.",
- },
- {
- desc: "already FQDN",
- domain: "foo.example.com.",
- expected: "foo.example.com.",
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
-
- fqdn := ToFqdn(test.domain)
- assert.Equal(t, test.expected, fqdn)
- })
- }
-}
-
func TestUnFqdn(t *testing.T) {
testCases := []struct {
desc string
@@ -62,3 +35,103 @@ func TestUnFqdn(t *testing.T) {
})
}
}
+
+func TestUnFqdnDomainsSeq(t *testing.T) {
+ testCases := []struct {
+ desc string
+ fqdn string
+ expected []string
+ }{
+ {
+ desc: "empty",
+ fqdn: "",
+ expected: nil,
+ },
+ {
+ desc: "TLD",
+ fqdn: "com",
+ expected: []string{"com"},
+ },
+ {
+ desc: "2 levels",
+ fqdn: "example.com",
+ expected: []string{"example.com", "com"},
+ },
+ {
+ desc: "3 levels",
+ fqdn: "foo.example.com",
+ expected: []string{"foo.example.com", "example.com", "com"},
+ },
+ }
+
+ for _, test := range testCases {
+ for name, suffix := range map[string]string{"": "", " FQDN": "."} { //nolint:gocritic
+ t.Run(test.desc+name, func(t *testing.T) {
+ t.Parallel()
+
+ actual := slices.Collect(UnFqdnDomainsSeq(test.fqdn + suffix))
+
+ assert.Equal(t, test.expected, actual)
+ })
+ }
+ }
+}
+
+func TestDomainsSeq(t *testing.T) {
+ testCases := []struct {
+ desc string
+ fqdn string
+ expected []string
+ }{
+ {
+ desc: "empty",
+ fqdn: "",
+ expected: nil,
+ },
+ {
+ desc: "empty FQDN",
+ fqdn: ".",
+ expected: nil,
+ },
+ {
+ desc: "TLD FQDN",
+ fqdn: "com",
+ expected: []string{"com"},
+ },
+ {
+ desc: "TLD",
+ fqdn: "com.",
+ expected: []string{"com."},
+ },
+ {
+ desc: "2 levels",
+ fqdn: "example.com",
+ expected: []string{"example.com", "com"},
+ },
+ {
+ desc: "2 levels FQDN",
+ fqdn: "example.com.",
+ expected: []string{"example.com.", "com."},
+ },
+ {
+ desc: "3 levels",
+ fqdn: "foo.example.com",
+ expected: []string{"foo.example.com", "example.com", "com"},
+ },
+ {
+ desc: "3 levels FQDN",
+ fqdn: "foo.example.com.",
+ expected: []string{"foo.example.com.", "example.com.", "com."},
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ actual := slices.Collect(DomainsSeq(test.fqdn))
+
+ assert.Equal(t, test.expected, actual)
+ })
+ }
+}
diff --git a/challenge/dns01/mock_test.go b/challenge/dns01/mock_test.go
new file mode 100644
index 000000000..5dcad3013
--- /dev/null
+++ b/challenge/dns01/mock_test.go
@@ -0,0 +1,81 @@
+package dns01
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/require"
+)
+
+func fakeNS(name, ns string) *dns.NS {
+ return &dns.NS{
+ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 172800},
+ Ns: ns,
+ }
+}
+
+func fakeA(name, ip string) *dns.A {
+ return &dns.A{
+ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 10},
+ A: net.ParseIP(ip),
+ }
+}
+
+func fakeTXT(name, value string) *dns.TXT {
+ return &dns.TXT{
+ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 10},
+ Txt: []string{value},
+ }
+}
+
+// mockResolver modifies the default DNS resolver to use a custom network address during the test execution.
+// IMPORTANT: it modifying global variables.
+func mockResolver(t *testing.T, addr net.Addr) {
+ t.Helper()
+
+ _, port, err := net.SplitHostPort(addr.String())
+ require.NoError(t, err)
+
+ originalDefaultNameserverPort := defaultNameserverPort
+
+ t.Cleanup(func() {
+ defaultNameserverPort = originalDefaultNameserverPort
+ })
+
+ defaultNameserverPort = port
+
+ originalResolver := net.DefaultResolver
+
+ t.Cleanup(func() {
+ net.DefaultResolver = originalResolver
+ })
+
+ net.DefaultResolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{Timeout: 1 * time.Second}
+
+ return d.DialContext(ctx, network, addr.String())
+ },
+ }
+}
+
+func useAsNameserver(t *testing.T, addr net.Addr) {
+ t.Helper()
+
+ ClearFqdnCache()
+ t.Cleanup(func() {
+ ClearFqdnCache()
+ })
+
+ originalRecursiveNameservers := recursiveNameservers
+
+ t.Cleanup(func() {
+ recursiveNameservers = originalRecursiveNameservers
+ })
+
+ recursiveNameservers = ParseNameservers([]string{addr.String()})
+}
diff --git a/challenge/dns01/nameserver.go b/challenge/dns01/nameserver.go
index a8d678af2..554eb7cc2 100644
--- a/challenge/dns01/nameserver.go
+++ b/challenge/dns01/nameserver.go
@@ -81,6 +81,7 @@ func getNameservers(path string, defaults []string) []string {
func ParseNameservers(servers []string) []string {
var resolvers []string
+
for _, resolver := range servers {
// ensure all servers have a port number
if _, _, err := net.SplitHostPort(resolver); err != nil {
@@ -89,6 +90,7 @@ func ParseNameservers(servers []string) []string {
resolvers = append(resolvers, resolver)
}
}
+
return resolvers
}
@@ -132,6 +134,7 @@ func FindPrimaryNsByFqdnCustom(fqdn string, nameservers []string) (string, error
if err != nil {
return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err)
}
+
return soa.primaryNs, nil
}
@@ -148,6 +151,7 @@ func FindZoneByFqdnCustom(fqdn string, nameservers []string) (string, error) {
if err != nil {
return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err)
}
+
return soa.zone, nil
}
@@ -172,13 +176,12 @@ func lookupSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error)
}
func fetchSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) {
- var err error
- var r *dns.Msg
-
- labelIndexes := dns.Split(fqdn)
- for _, index := range labelIndexes {
- domain := fqdn[index:]
+ var (
+ err error
+ r *dns.Msg
+ )
+ for domain := range DomainsSeq(fqdn) {
r, err = dnsQuery(domain, dns.TypeSOA, nameservers, true)
if err != nil {
continue
@@ -232,9 +235,11 @@ func dnsQuery(fqdn string, rtype uint16, nameservers []string, recursive bool) (
return nil, &DNSError{Message: "empty list of nameservers"}
}
- var r *dns.Msg
- var err error
- var errAll error
+ var (
+ r *dns.Msg
+ err error
+ errAll error
+ )
for _, ns := range nameservers {
r, err = sendDNSQuery(m, ns)
@@ -267,6 +272,7 @@ func createDNSMsg(fqdn string, rtype uint16, recursive bool) *dns.Msg {
func sendDNSQuery(m *dns.Msg, ns string) (*dns.Msg, error) {
if ok, _ := strconv.ParseBool(os.Getenv("LEGO_EXPERIMENTAL_DNS_TCP_ONLY")); ok {
tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout}
+
r, _, err := tcp.Exchange(m, ns)
if err != nil {
return r, &DNSError{Message: "DNS call error", MsgIn: m, NS: ns, Err: err}
diff --git a/challenge/dns01/nameserver_test.go b/challenge/dns01/nameserver_test.go
index 15b19beba..dd4d66dcb 100644
--- a/challenge/dns01/nameserver_test.go
+++ b/challenge/dns01/nameserver_test.go
@@ -5,138 +5,237 @@ import (
"sort"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/dnsmock"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestLookupNameserversOK(t *testing.T) {
+func Test_lookupNameserversOK(t *testing.T) {
testCases := []struct {
- fqdn string
- nss []string
+ desc string
+ fakeDNSServer *dnsmock.Builder
+ fqdn string
+ expected []string
}{
{
- fqdn: "en.wikipedia.org.",
- nss: []string{"ns0.wikimedia.org.", "ns1.wikimedia.org.", "ns2.wikimedia.org."},
+ fqdn: "en.wikipedia.org.localhost.",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("en.wikipedia.org.localhost SOA", dnsmock.CNAME("dyna.wikimedia.org.localhost")).
+ Query("wikipedia.org.localhost SOA", dnsmock.SOA("")).
+ Query("wikipedia.org.localhost NS",
+ dnsmock.Answer(
+ fakeNS("wikipedia.org.localhost.", "ns0.wikimedia.org.localhost."),
+ fakeNS("wikipedia.org.localhost.", "ns1.wikimedia.org.localhost."),
+ fakeNS("wikipedia.org.localhost.", "ns2.wikimedia.org.localhost."),
+ ),
+ ),
+ expected: []string{"ns0.wikimedia.org.localhost.", "ns1.wikimedia.org.localhost.", "ns2.wikimedia.org.localhost."},
},
{
- fqdn: "www.google.com.",
- nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."},
+ fqdn: "www.google.com.localhost.",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("www.google.com.localhost. SOA", dnsmock.Noop).
+ Query("google.com.localhost. SOA", dnsmock.SOA("")).
+ Query("google.com.localhost. NS",
+ dnsmock.Answer(
+ fakeNS("google.com.localhost.", "ns1.google.com.localhost."),
+ fakeNS("google.com.localhost.", "ns2.google.com.localhost."),
+ fakeNS("google.com.localhost.", "ns3.google.com.localhost."),
+ fakeNS("google.com.localhost.", "ns4.google.com.localhost."),
+ ),
+ ),
+ expected: []string{"ns1.google.com.localhost.", "ns2.google.com.localhost.", "ns3.google.com.localhost.", "ns4.google.com.localhost."},
},
{
- fqdn: "physics.georgetown.edu.",
- nss: []string{"ns4.georgetown.edu.", "ns5.georgetown.edu.", "ns6.georgetown.edu."},
+ fqdn: "mail.proton.me.localhost.",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("mail.proton.me.localhost. SOA", dnsmock.Noop).
+ Query("proton.me.localhost. SOA", dnsmock.SOA("")).
+ Query("proton.me.localhost. NS",
+ dnsmock.Answer(
+ fakeNS("proton.me.localhost.", "ns1.proton.me.localhost."),
+ fakeNS("proton.me.localhost.", "ns2.proton.me.localhost."),
+ fakeNS("proton.me.localhost.", "ns3.proton.me.localhost."),
+ ),
+ ),
+ expected: []string{"ns1.proton.me.localhost.", "ns2.proton.me.localhost.", "ns3.proton.me.localhost."},
},
}
for _, test := range testCases {
t.Run(test.fqdn, func(t *testing.T) {
- t.Parallel()
+ useAsNameserver(t, test.fakeDNSServer.Build(t))
nss, err := lookupNameservers(test.fqdn)
require.NoError(t, err)
sort.Strings(nss)
- sort.Strings(test.nss)
+ sort.Strings(test.expected)
- assert.EqualValues(t, test.nss, nss)
+ assert.Equal(t, test.expected, nss)
})
}
}
-func TestLookupNameserversErr(t *testing.T) {
+func Test_lookupNameserversErr(t *testing.T) {
testCases := []struct {
- desc string
- fqdn string
- error string
+ desc string
+ fqdn string
+ fakeDNSServer *dnsmock.Builder
+ error string
}{
{
- desc: "invalid tld",
- fqdn: "_null.n0n0.",
- error: "could not find zone",
+ desc: "NXDOMAIN",
+ fqdn: "example.invalid.",
+ fakeDNSServer: dnsmock.NewServer().
+ Query(". SOA", dnsmock.Error(dns.RcodeNameError)),
+ error: "could not find zone: [fqdn=example.invalid.] could not find the start of authority for 'example.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]",
+ },
+ {
+ desc: "NS error",
+ fqdn: "example.com.",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("example.com. SOA", dnsmock.SOA("")).
+ Query("example.com. NS", dnsmock.Error(dns.RcodeServerFailure)),
+ error: "[zone=example.com.] could not determine authoritative nameservers",
+ },
+ {
+ desc: "empty NS",
+ fqdn: "example.com.",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("example.com. SOA", dnsmock.SOA("")).
+ Query("example.me NS", dnsmock.Noop),
+ error: "[zone=example.com.] could not determine authoritative nameservers",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
+ useAsNameserver(t, test.fakeDNSServer.Build(t))
_, err := lookupNameservers(test.fqdn)
require.Error(t, err)
- assert.Contains(t, err.Error(), test.error)
+ assert.EqualError(t, err, test.error)
})
}
}
-var findXByFqdnTestCases = []struct {
+type lookupSoaByFqdnTestCase struct {
desc string
fqdn string
zone string
primaryNs string
nameservers []string
expectedError string
-}{
- {
- desc: "domain is a CNAME",
- fqdn: "mail.google.com.",
- zone: "google.com.",
- primaryNs: "ns1.google.com.",
- nameservers: recursiveNameservers,
- },
- {
- desc: "domain is a non-existent subdomain",
- fqdn: "foo.google.com.",
- zone: "google.com.",
- primaryNs: "ns1.google.com.",
- nameservers: recursiveNameservers,
- },
- {
- desc: "domain is a eTLD",
- fqdn: "example.com.ac.",
- zone: "ac.",
- primaryNs: "a0.nic.ac.",
- nameservers: recursiveNameservers,
- },
- {
- desc: "domain is a cross-zone CNAME",
- fqdn: "cross-zone-example.assets.sh.",
- zone: "assets.sh.",
- primaryNs: "gina.ns.cloudflare.com.",
- nameservers: recursiveNameservers,
- },
- {
- desc: "NXDOMAIN",
- fqdn: "test.lego.zz.",
- zone: "lego.zz.",
- nameservers: []string{"8.8.8.8:53"},
- expectedError: "[fqdn=test.lego.zz.] could not find the start of authority for 'test.lego.zz.' [question='zz. IN SOA', code=NXDOMAIN]",
- },
- {
- desc: "several non existent nameservers",
- fqdn: "mail.google.com.",
- zone: "google.com.",
- primaryNs: "ns1.google.com.",
- nameservers: []string{":7053", ":8053", "8.8.8.8:53"},
- },
- {
- desc: "only non-existent nameservers",
- fqdn: "mail.google.com.",
- zone: "google.com.",
- nameservers: []string{":7053", ":8053", ":9053"},
- // use only the start of the message because the port changes with each call: 127.0.0.1:XXXXX->127.0.0.1:7053.
- expectedError: "[fqdn=mail.google.com.] could not find the start of authority for 'mail.google.com.': DNS call error: read udp ",
- },
- {
- desc: "no nameservers",
- fqdn: "test.ldez.com.",
- zone: "ldez.com.",
- nameservers: []string{},
- expectedError: "[fqdn=test.ldez.com.] could not find the start of authority for 'test.ldez.com.': empty list of nameservers",
- },
+}
+
+func lookupSoaByFqdnTestCases(t *testing.T) []lookupSoaByFqdnTestCase {
+ t.Helper()
+
+ return []lookupSoaByFqdnTestCase{
+ {
+ desc: "domain is a CNAME",
+ fqdn: "mail.example.com.",
+ zone: "example.com.",
+ primaryNs: "ns1.example.com.",
+ nameservers: []string{
+ dnsmock.NewServer().
+ Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")).
+ Query("example.com. SOA", dnsmock.SOA("")).
+ Build(t).
+ String(),
+ },
+ },
+ {
+ desc: "domain is a non-existent subdomain",
+ fqdn: "foo.example.com.",
+ zone: "example.com.",
+ primaryNs: "ns1.example.com.",
+ nameservers: []string{
+ dnsmock.NewServer().
+ Query("foo.example.com. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Query("example.com. SOA", dnsmock.SOA("")).
+ Build(t).
+ String(),
+ },
+ },
+ {
+ desc: "domain is a eTLD",
+ fqdn: "example.com.ac.",
+ zone: "ac.",
+ primaryNs: "ns1.nic.ac.",
+ nameservers: []string{
+ dnsmock.NewServer().
+ Query("example.com.ac. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Query("com.ac. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Query("ac. SOA", dnsmock.SOA("")).
+ Build(t).
+ String(),
+ },
+ },
+ {
+ desc: "domain is a cross-zone CNAME",
+ fqdn: "cross-zone-example.example.com.",
+ zone: "example.com.",
+ primaryNs: "ns1.example.com.",
+ nameservers: []string{
+ dnsmock.NewServer().
+ Query("cross-zone-example.example.com. SOA", dnsmock.CNAME("example.org.")).
+ Query("example.com. SOA", dnsmock.SOA("")).
+ Build(t).
+ String(),
+ },
+ },
+ {
+ desc: "NXDOMAIN",
+ fqdn: "test.lego.invalid.",
+ zone: "lego.invalid.",
+ nameservers: []string{
+ dnsmock.NewServer().
+ Query("test.lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Query("lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Query("invalid. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Build(t).
+ String(),
+ },
+ expectedError: `[fqdn=test.lego.invalid.] could not find the start of authority for 'test.lego.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]`,
+ },
+ {
+ desc: "several non existent nameservers",
+ fqdn: "mail.example.com.",
+ zone: "example.com.",
+ primaryNs: "ns1.example.com.",
+ nameservers: []string{
+ ":7053",
+ ":8053",
+ dnsmock.NewServer().
+ Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")).
+ Query("example.com. SOA", dnsmock.SOA("")).
+ Build(t).
+ String(),
+ },
+ },
+ {
+ desc: "only non-existent nameservers",
+ fqdn: "mail.example.com.",
+ zone: "example.com.",
+ nameservers: []string{":7053", ":8053", ":9053"},
+ // use only the start of the message because the port changes with each call: 127.0.0.1:XXXXX->127.0.0.1:7053.
+ expectedError: "[fqdn=mail.example.com.] could not find the start of authority for 'mail.example.com.': DNS call error: read udp ",
+ },
+ {
+ desc: "no nameservers",
+ fqdn: "test.example.com.",
+ zone: "example.com.",
+ nameservers: []string{},
+ expectedError: "[fqdn=test.example.com.] could not find the start of authority for 'test.example.com.': empty list of nameservers",
+ },
+ }
}
func TestFindZoneByFqdnCustom(t *testing.T) {
- for _, test := range findXByFqdnTestCases {
+ for _, test := range lookupSoaByFqdnTestCases(t) {
t.Run(test.desc, func(t *testing.T) {
ClearFqdnCache()
@@ -153,7 +252,7 @@ func TestFindZoneByFqdnCustom(t *testing.T) {
}
func TestFindPrimaryNsByFqdnCustom(t *testing.T) {
- for _, test := range findXByFqdnTestCases {
+ for _, test := range lookupSoaByFqdnTestCases(t) {
t.Run(test.desc, func(t *testing.T) {
ClearFqdnCache()
@@ -169,7 +268,7 @@ func TestFindPrimaryNsByFqdnCustom(t *testing.T) {
}
}
-func TestResolveConfServers(t *testing.T) {
+func Test_getNameservers_ResolveConfServers(t *testing.T) {
testCases := []struct {
fixture string
expected []string
diff --git a/challenge/dns01/precheck.go b/challenge/dns01/precheck.go
index 706e8dbec..45e17e3ac 100644
--- a/challenge/dns01/precheck.go
+++ b/challenge/dns01/precheck.go
@@ -9,6 +9,10 @@ import (
"github.com/miekg/dns"
)
+// defaultNameserverPort used by authoritative NS.
+// This is for tests only.
+var defaultNameserverPort = "53"
+
// PreCheckFunc checks DNS propagation before notifying ACME that the DNS challenge is ready.
type PreCheckFunc func(fqdn, value string) (bool, error)
@@ -25,6 +29,7 @@ func WrapPreCheck(wrap WrapPreCheckFunc) ChallengeOption {
}
// DisableCompletePropagationRequirement obsolete.
+//
// Deprecated: use DisableAuthoritativeNssPropagationRequirement instead.
func DisableCompletePropagationRequirement() ChallengeOption {
return DisableAuthoritativeNssPropagationRequirement()
@@ -121,7 +126,7 @@ func (p preCheck) checkDNSPropagation(fqdn, value string) (bool, error) {
func checkNameserversPropagation(fqdn, value string, nameservers []string, addPort bool) (bool, error) {
for _, ns := range nameservers {
if addPort {
- ns = net.JoinHostPort(ns, "53")
+ ns = net.JoinHostPort(ns, defaultNameserverPort)
}
r, err := dnsQuery(fqdn, dns.TypeTXT, []string{ns}, false)
@@ -136,9 +141,11 @@ func checkNameserversPropagation(fqdn, value string, nameservers []string, addPo
var records []string
var found bool
+
for _, rr := range r.Answer {
if txt, ok := rr.(*dns.TXT); ok {
record := strings.Join(txt.Txt, "")
+
records = append(records, record)
if record == value {
found = true
diff --git a/challenge/dns01/precheck_test.go b/challenge/dns01/precheck_test.go
index 1f3ecbf7e..bda8c781e 100644
--- a/challenge/dns01/precheck_test.go
+++ b/challenge/dns01/precheck_test.go
@@ -3,40 +3,73 @@ package dns01
import (
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/dnsmock"
+ "github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestCheckDNSPropagation(t *testing.T) {
+func Test_preCheck_checkDNSPropagation(t *testing.T) {
+ mockResolver(t,
+ dnsmock.NewServer().
+ Query("ns0.lego.localhost. A",
+ dnsmock.Answer(fakeA("ns0.lego.localhost.", "127.0.0.1"))).
+ Query("ns1.lego.localhost. A",
+ dnsmock.Answer(fakeA("ns1.lego.localhost.", "127.0.0.1"))).
+ Query("example.com. TXT",
+ dnsmock.Answer(
+ fakeTXT("example.com.", "one"),
+ fakeTXT("example.com.", "two"),
+ fakeTXT("example.com.", "three"),
+ fakeTXT("example.com.", "four"),
+ fakeTXT("example.com.", "five"),
+ ),
+ ).
+ Build(t),
+ )
+
+ useAsNameserver(t,
+ dnsmock.NewServer().
+ Query("acme-staging.api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Query("api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)).
+ Query("example.com. SOA", dnsmock.SOA("")).
+ Query("example.com. NS",
+ dnsmock.Answer(
+ fakeNS("example.com.", "ns0.lego.localhost."),
+ fakeNS("example.com.", "ns1.lego.localhost."),
+ ),
+ ).
+ Build(t),
+ )
+
testCases := []struct {
- desc string
- fqdn string
- value string
- expectError bool
+ desc string
+ fqdn string
+ value string
+ expectedError string
}{
{
desc: "success",
- fqdn: "postman-echo.com.",
- value: "postman-domain-verification=c85de626cb79d941310696e06558e2e790223802f3697dfbdcaf65510152d52c",
+ fqdn: "example.com.",
+ value: "four",
},
{
- desc: "no TXT record",
- fqdn: "acme-staging.api.letsencrypt.org.",
- value: "fe01=",
- expectError: true,
+ desc: "no matching TXT record",
+ fqdn: "acme-staging.api.example.com.",
+ value: "fe01=",
+ expectedError: "did not return the expected TXT record [fqdn: acme-staging.api.example.com., value: fe01=]: one ,two ,three ,four ,five",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
ClearFqdnCache()
check := newPreCheck()
ok, err := check.checkDNSPropagation(test.fqdn, test.value)
- if test.expectError {
- assert.Errorf(t, err, "PreCheckDNS must fail for %s", test.fqdn)
+ if test.expectedError != "" {
+ assert.ErrorContainsf(t, err, test.expectedError, "PreCheckDNS must fail for %s", test.fqdn)
assert.False(t, ok, "PreCheckDNS must fail for %s", test.fqdn)
} else {
assert.NoErrorf(t, err, "PreCheckDNS failed for %s", test.fqdn)
@@ -46,69 +79,67 @@ func TestCheckDNSPropagation(t *testing.T) {
}
}
-func TestCheckAuthoritativeNss(t *testing.T) {
+func Test_checkNameserversPropagation_authoritativeNss(t *testing.T) {
testCases := []struct {
- desc string
- fqdn, value string
- ns []string
- expected bool
+ desc string
+ fqdn, value string
+ fakeDNSServer *dnsmock.Builder
+ expectedError string
}{
{
- desc: "TXT RR w/ expected value",
- fqdn: "8.8.8.8.asn.routeviews.org.",
- value: "151698.8.8.024",
- ns: []string{"asnums.routeviews.org."},
- expected: true,
+ desc: "TXT RR w/ expected value",
+ // NS: asnums.routeviews.org.
+ fqdn: "8.8.8.8.asn.routeviews.org.",
+ value: "151698.8.8.024",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("8.8.8.8.asn.routeviews.org. TXT",
+ dnsmock.Answer(
+ fakeTXT("8.8.8.8.asn.routeviews.org.", "151698.8.8.024"),
+ ),
+ ),
+ },
+ {
+ desc: "TXT RR w/ unexpected value",
+ // NS: asnums.routeviews.org.
+ fqdn: "8.8.8.8.asn.routeviews.org.",
+ value: "fe01=",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("8.8.8.8.asn.routeviews.org. TXT",
+ dnsmock.Answer(
+ fakeTXT("8.8.8.8.asn.routeviews.org.", "15169"),
+ fakeTXT("8.8.8.8.asn.routeviews.org.", "8.8.8.0"),
+ fakeTXT("8.8.8.8.asn.routeviews.org.", "24"),
+ ),
+ ),
+ expectedError: "did not return the expected TXT record [fqdn: 8.8.8.8.asn.routeviews.org., value: fe01=]: 15169 ,8.8.8.0 ,24",
},
{
desc: "No TXT RR",
- fqdn: "ns1.google.com.",
- ns: []string{"ns2.google.com."},
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
- ClearFqdnCache()
-
- ok, _ := checkNameserversPropagation(test.fqdn, test.value, test.ns, true)
- assert.Equal(t, test.expected, ok, test.fqdn)
- })
- }
-}
-
-func TestCheckAuthoritativeNssErr(t *testing.T) {
- testCases := []struct {
- desc string
- fqdn, value string
- ns []string
- error string
- }{
- {
- desc: "TXT RR /w unexpected value",
- fqdn: "8.8.8.8.asn.routeviews.org.",
- value: "fe01=",
- ns: []string{"asnums.routeviews.org."},
- error: "did not return the expected TXT record",
- },
- {
- desc: "No TXT RR",
+ // NS: ns2.google.com.
fqdn: "ns1.google.com.",
value: "fe01=",
- ns: []string{"ns2.google.com."},
- error: "did not return the expected TXT record",
+ fakeDNSServer: dnsmock.NewServer().
+ Query("ns1.google.com.", dnsmock.Noop),
+ expectedError: "did not return the expected TXT record [fqdn: ns1.google.com., value: fe01=]: ",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
ClearFqdnCache()
- _, err := checkNameserversPropagation(test.fqdn, test.value, test.ns, true)
- require.Error(t, err)
- assert.Contains(t, err.Error(), test.error)
+ addr := test.fakeDNSServer.Build(t)
+
+ ok, err := checkNameserversPropagation(test.fqdn, test.value, []string{addr.String()}, false)
+
+ if test.expectedError == "" {
+ require.NoError(t, err)
+ assert.True(t, ok)
+ } else {
+ require.Error(t, err)
+ require.ErrorContains(t, err, test.expectedError)
+ assert.False(t, ok)
+ }
})
}
}
diff --git a/challenge/http01/domain_matcher.go b/challenge/http01/domain_matcher.go
index c31aeed6a..058d1a314 100644
--- a/challenge/http01/domain_matcher.go
+++ b/challenge/http01/domain_matcher.go
@@ -88,6 +88,7 @@ func (m *forwardedMatcher) matches(r *http.Request, domain string) bool {
}
host := fwds[0]["host"]
+
return matchDomain(host, domain)
}
@@ -99,6 +100,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
inquote := false
pos := 0
+
l := len(s)
for i := 0; i < l; i++ {
r := rune(s[i])
@@ -110,6 +112,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
pos = i
inquote = false
}
+
continue
}
@@ -118,6 +121,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
if key == "" {
return nil, fmt.Errorf("unexpected quoted string as pos %d", i)
}
+
inquote = true
pos = i + 1
@@ -137,6 +141,7 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
val = s[pos:i]
cur[key] = val
}
+
elements = append(elements, cur)
cur = make(map[string]string)
key = ""
@@ -159,11 +164,14 @@ func parseForwardedHeader(s string) (elements []map[string]string, err error) {
if pos < len(s) {
val = s[pos:]
}
+
cur[key] = val
}
+
if len(cur) > 0 {
elements = append(elements, cur)
}
+
return elements, nil
}
@@ -178,6 +186,7 @@ func skipWS(s string, i int) int {
for isWS(rune(s[i+1])) {
i++
}
+
return i
}
diff --git a/challenge/http01/domain_matcher_test.go b/challenge/http01/domain_matcher_test.go
index efdc4641d..7bedf9f63 100644
--- a/challenge/http01/domain_matcher_test.go
+++ b/challenge/http01/domain_matcher_test.go
@@ -77,7 +77,7 @@ func Test_parseForwardedHeader(t *testing.T) {
actual, err := parseForwardedHeader(test.input)
if test.err == "" {
require.NoError(t, err)
- assert.EqualValues(t, test.want, actual)
+ assert.Equal(t, test.want, actual)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), test.err)
diff --git a/challenge/http01/http_challenge.go b/challenge/http01/http_challenge.go
index f23e483cf..a042979c2 100644
--- a/challenge/http01/http_challenge.go
+++ b/challenge/http01/http_challenge.go
@@ -2,6 +2,7 @@ package http01
import (
"fmt"
+ "time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
@@ -11,6 +12,16 @@ import (
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
+type ChallengeOption func(*Challenge) error
+
+// SetDelay sets a delay between the start of the HTTP server and the challenge validation.
+func SetDelay(delay time.Duration) ChallengeOption {
+ return func(chlg *Challenge) error {
+ chlg.delay = delay
+ return nil
+ }
+}
+
// ChallengePath returns the URL path for the `http-01` challenge.
func ChallengePath(token string) string {
return "/.well-known/acme-challenge/" + token
@@ -20,14 +31,24 @@ type Challenge struct {
core *api.Core
validate ValidateFunc
provider challenge.Provider
+ delay time.Duration
}
-func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge {
- return &Challenge{
+func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge {
+ chlg := &Challenge{
core: core,
validate: validate,
provider: provider,
}
+
+ for _, opt := range opts {
+ err := opt(chlg)
+ if err != nil {
+ log.Infof("challenge option error: %v", err)
+ }
+ }
+
+ return chlg
}
func (c *Challenge) SetProvider(provider challenge.Provider) {
@@ -53,6 +74,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
if err != nil {
return fmt.Errorf("[%s] acme: error presenting token: %w", domain, err)
}
+
defer func() {
err := c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth)
if err != nil {
@@ -60,6 +82,11 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
}
}()
+ if c.delay > 0 {
+ time.Sleep(c.delay)
+ }
+
chlng.KeyAuthorization = keyAuth
+
return c.validate(c.core, domain, chlng)
}
diff --git a/challenge/http01/http_challenge_server.go b/challenge/http01/http_challenge_server.go
index 009271cec..ab962917e 100644
--- a/challenge/http01/http_challenge_server.go
+++ b/challenge/http01/http_challenge_server.go
@@ -44,6 +44,7 @@ func NewUnixProviderServer(socketPath string, mode fs.FileMode) *ProviderServer
// Present starts a web server and makes the token available at `ChallengePath(token)` for web requests.
func (s *ProviderServer) Present(domain, token, keyAuth string) error {
var err error
+
s.listener, err = net.Listen(s.network, s.GetAddress())
if err != nil {
return fmt.Errorf("could not start HTTP server for challenge: %w", err)
@@ -120,6 +121,7 @@ func (s *ProviderServer) serve(domain, token, keyAuth string) {
}
log.Infof("[%s] Served key authentication", domain)
+
return
}
diff --git a/challenge/http01/http_challenge_test.go b/challenge/http01/http_challenge_test.go
index 3a5aa6bbe..06c555e42 100644
--- a/challenge/http01/http_challenge_test.go
+++ b/challenge/http01/http_challenge_test.go
@@ -67,7 +67,7 @@ func TestProviderServer_GetAddress(t *testing.T) {
}
func TestChallenge(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
providerServer := NewProviderServer("", "23457")
@@ -88,6 +88,7 @@ func TestChallenge(t *testing.T) {
if err != nil {
return err
}
+
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
@@ -97,10 +98,10 @@ func TestChallenge(t *testing.T) {
return nil
}
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
@@ -123,7 +124,7 @@ func TestChallengeUnix(t *testing.T) {
t.Skip("only for UNIX systems")
}
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
dir := t.TempDir()
t.Cleanup(func() { _ = os.RemoveAll(dir) })
@@ -157,6 +158,7 @@ func TestChallengeUnix(t *testing.T) {
if err != nil {
return err
}
+
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
@@ -166,10 +168,10 @@ func TestChallengeUnix(t *testing.T) {
return nil
}
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
@@ -188,12 +190,12 @@ func TestChallengeUnix(t *testing.T) {
}
func TestChallengeInvalidPort(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
- privateKey, err := rsa.GenerateKey(rand.Reader, 128)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
validate := func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }
@@ -224,6 +226,7 @@ func (h *testProxyHeader) update(r *http.Request) {
if h == nil || len(h.values) == 0 {
return
}
+
if h.name == "Host" {
r.Host = h.values[0]
} else if h.name != "" {
@@ -371,7 +374,7 @@ func TestChallengeWithProxy(t *testing.T) {
func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectError bool) {
t.Helper()
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
providerServer := NewProviderServer("localhost", "23457")
if header != nil {
@@ -385,6 +388,7 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro
if err != nil {
return err
}
+
header.update(req)
extra.update(req)
@@ -402,6 +406,7 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro
if err != nil {
return err
}
+
bodyStr := string(body)
if bodyStr != chlng.KeyAuthorization {
@@ -411,10 +416,10 @@ func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectErro
return nil
}
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(core, validate, providerServer)
diff --git a/challenge/resolver/errors.go b/challenge/resolver/errors.go
index 94ccbd76a..65a6ccdb7 100644
--- a/challenge/resolver/errors.go
+++ b/challenge/resolver/errors.go
@@ -3,6 +3,8 @@ package resolver
import (
"bytes"
"fmt"
+ "maps"
+ "slices"
"sort"
)
@@ -16,10 +18,16 @@ func (e obtainError) Error() string {
for domain := range e {
domains = append(domains, domain)
}
+
sort.Strings(domains)
for _, domain := range domains {
_, _ = fmt.Fprintf(buffer, "[%s] %s\n", domain, e[domain])
}
+
return buffer.String()
}
+
+func (e obtainError) Unwrap() []error {
+ return slices.AppendSeq(make([]error, 0, len(e)), maps.Values(e))
+}
diff --git a/challenge/resolver/errors_test.go b/challenge/resolver/errors_test.go
new file mode 100644
index 000000000..d4ab3c481
--- /dev/null
+++ b/challenge/resolver/errors_test.go
@@ -0,0 +1,70 @@
+package resolver
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/go-acme/lego/v4/acme"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_obtainError_Error(t *testing.T) {
+ err := obtainError{
+ "a": &acme.ProblemDetails{Type: "001"},
+ "b": errors.New("oops"),
+ "c": errors.New("I did it again"),
+ }
+
+ require.EqualError(t, err, `error: one or more domains had a problem:
+[a] acme: error: 0 :: 001 ::
+[b] oops
+[c] I did it again
+`)
+}
+
+func Test_obtainError_Unwrap(t *testing.T) {
+ testCases := []struct {
+ desc string
+ err obtainError
+ assert assert.BoolAssertionFunc
+ }{
+ {
+ desc: "one ok",
+ err: obtainError{
+ "a": &acme.ProblemDetails{},
+ "b": errors.New("oops"),
+ "c": errors.New("I did it again"),
+ },
+ assert: assert.True,
+ },
+ {
+ desc: "all ok",
+ err: obtainError{
+ "a": &acme.ProblemDetails{Type: "001"},
+ "b": &acme.ProblemDetails{Type: "002"},
+ "c": &acme.ProblemDetails{Type: "002"},
+ },
+ assert: assert.True,
+ },
+ {
+ desc: "nope",
+ err: obtainError{
+ "a": errors.New("hello"),
+ "b": errors.New("oops"),
+ "c": errors.New("I did it again"),
+ },
+ assert: assert.False,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ var pd *acme.ProblemDetails
+
+ test.assert(t, errors.As(test.err, &pd))
+ })
+ }
+}
diff --git a/challenge/resolver/prober.go b/challenge/resolver/prober.go
index 021facbb5..66b12c7a7 100644
--- a/challenge/resolver/prober.go
+++ b/challenge/resolver/prober.go
@@ -50,11 +50,14 @@ func NewProber(solverManager *SolverManager) *Prober {
func (p *Prober) Solve(authorizations []acme.Authorization) error {
failures := make(obtainError)
- var authSolvers []*selectedAuthSolver
- var authSolversSequential []*selectedAuthSolver
+ var (
+ authSolvers []*selectedAuthSolver
+ authSolversSequential []*selectedAuthSolver
+ )
// Loop through the resources, basically through the domains.
// First pass just selects a solver for each authz.
+
for _, authz := range authorizations {
domain := challenge.GetTargetedDomain(authz)
if authz.Status == acme.StatusValid {
@@ -90,47 +93,88 @@ func (p *Prober) Solve(authorizations []acme.Authorization) error {
if len(failures) > 0 {
return failures
}
+
return nil
}
func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
+ // Some CA are using the same token,
+ // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
+ // In the sequential mode, this is not a problem because we can solve the challenges in order.
+ // But it can reduce the number of call the DNS provider APIs.
+ uniq := make(map[string]struct{})
+
for i, authSolver := range authSolvers {
// Submit the challenge
domain := challenge.GetTargetedDomain(authSolver.authz)
+ chlg, _ := challenge.FindChallenge(challenge.DNS01, authSolver.authz)
+
if solvr, ok := authSolver.solver.(preSolver); ok {
+ if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok && chlg.Token != "" {
+ log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value)
+ continue
+ }
+
err := solvr.PreSolve(authSolver.authz)
if err != nil {
failures[domain] = err
+
cleanUp(authSolver.solver, authSolver.authz)
+
continue
}
+
+ uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{}
}
// Solve challenge
err := authSolver.solver.Solve(authSolver.authz)
if err != nil {
failures[domain] = err
+
cleanUp(authSolver.solver, authSolver.authz)
+
continue
}
- // Clean challenge
- cleanUp(authSolver.solver, authSolver.authz)
+ if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok || chlg.Token == "" {
+ // Clean challenge
+ cleanUp(authSolver.solver, authSolver.authz)
- if len(authSolvers)-1 > i {
- solvr := authSolver.solver.(sequential)
- _, interval := solvr.Sequential()
- log.Infof("sequence: wait for %s", interval)
- time.Sleep(interval)
+ if len(authSolvers)-1 > i {
+ solvr := authSolver.solver.(sequential)
+ _, interval := solvr.Sequential()
+ log.Infof("sequence: wait for %s", interval)
+ time.Sleep(interval)
+ }
+
+ delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
+ } else {
+ log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value)
}
}
}
func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
+ // Some CA are using the same token,
+ // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
+ uniq := make(map[string]struct{})
+
// For all valid preSolvers, first submit the challenges, so they have max time to propagate
for _, authSolver := range authSolvers {
authz := authSolver.authz
+
+ chlg, err := challenge.FindChallenge(challenge.DNS01, authz)
+ if err == nil {
+ if _, ok := uniq[authz.Identifier.Value+chlg.Token]; ok {
+ log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value)
+ continue
+ }
+
+ uniq[authz.Identifier.Value+chlg.Token] = struct{}{}
+ }
+
if solvr, ok := authSolver.solver.(preSolver); ok {
err := solvr.PreSolve(authz)
if err != nil {
@@ -142,6 +186,16 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
defer func() {
// Clean all created TXT records
for _, authSolver := range authSolvers {
+ chlg, err := challenge.FindChallenge(challenge.DNS01, authSolver.authz)
+ if err == nil {
+ if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok {
+ delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
+ } else {
+ log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value)
+ continue
+ }
+ }
+
cleanUp(authSolver.solver, authSolver.authz)
}
}()
@@ -149,6 +203,7 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
// Finally solve all challenges for real
for _, authSolver := range authSolvers {
authz := authSolver.authz
+
domain := challenge.GetTargetedDomain(authz)
if failures[domain] != nil {
// already failed in previous loop
@@ -165,6 +220,7 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
func cleanUp(solvr solver, authz acme.Authorization) {
if solvr, ok := solvr.(cleanup); ok {
domain := challenge.GetTargetedDomain(authz)
+
err := solvr.CleanUp(authz)
if err != nil {
log.Warnf("[%s] acme: cleaning up failed: %v ", domain, err)
diff --git a/challenge/resolver/prober_mock_test.go b/challenge/resolver/prober_mock_test.go
index 5a91fe075..dc7ad8dec 100644
--- a/challenge/resolver/prober_mock_test.go
+++ b/challenge/resolver/prober_mock_test.go
@@ -1,6 +1,7 @@
package resolver
import (
+ "fmt"
"time"
"github.com/go-acme/lego/v4/acme"
@@ -11,34 +12,68 @@ type preSolverMock struct {
preSolve map[string]error
solve map[string]error
cleanUp map[string]error
+
+ preSolveCounter int
+ solveCounter int
+ cleanUpCounter int
}
func (s *preSolverMock) PreSolve(authorization acme.Authorization) error {
+ s.preSolveCounter++
+
return s.preSolve[authorization.Identifier.Value]
}
func (s *preSolverMock) Solve(authorization acme.Authorization) error {
+ s.solveCounter++
+
return s.solve[authorization.Identifier.Value]
}
func (s *preSolverMock) CleanUp(authorization acme.Authorization) error {
+ s.cleanUpCounter++
+
return s.cleanUp[authorization.Identifier.Value]
}
+func (s *preSolverMock) String() string {
+ return fmt.Sprintf("PreSolve: %d, Solve: %d, CleanUp: %d", s.preSolveCounter, s.solveCounter, s.cleanUpCounter)
+}
+
func createStubAuthorizationHTTP01(domain, status string) acme.Authorization {
+ return createStubAuthorization(domain, status, false, acme.Challenge{
+ Type: challenge.HTTP01.String(),
+ Validated: time.Now(),
+ })
+}
+
+func createStubAuthorizationDNS01(domain string, wildcard bool) acme.Authorization {
+ var chlgs []acme.Challenge
+
+ if wildcard {
+ chlgs = append(chlgs, acme.Challenge{
+ Type: challenge.HTTP01.String(),
+ Validated: time.Now(),
+ })
+ }
+
+ chlgs = append(chlgs, acme.Challenge{
+ Type: challenge.DNS01.String(),
+ Validated: time.Now(),
+ })
+
+ return createStubAuthorization(domain, acme.StatusProcessing, wildcard, chlgs...)
+}
+
+func createStubAuthorization(domain, status string, wildcard bool, chlgs ...acme.Challenge) acme.Authorization {
return acme.Authorization{
- Status: status,
- Expires: time.Now(),
+ Wildcard: wildcard,
+ Status: status,
+ Expires: time.Now(),
Identifier: acme.Identifier{
- Type: challenge.HTTP01.String(),
+ Type: "dns",
Value: domain,
},
- Challenges: []acme.Challenge{
- {
- Type: challenge.HTTP01.String(),
- Validated: time.Now(),
- Error: nil,
- },
- },
+ Challenges: chlgs,
}
}
diff --git a/challenge/resolver/prober_test.go b/challenge/resolver/prober_test.go
index 4ee9b1b46..829b16883 100644
--- a/challenge/resolver/prober_test.go
+++ b/challenge/resolver/prober_test.go
@@ -2,19 +2,22 @@ package resolver
import (
"errors"
+ "fmt"
"testing"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/challenge"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProber_Solve(t *testing.T) {
testCases := []struct {
- desc string
- solvers map[challenge.Type]solver
- authz []acme.Authorization
- expectedError string
+ desc string
+ solvers map[challenge.Type]solver
+ authz []acme.Authorization
+ expectedError string
+ expectedCounters map[challenge.Type]string
}{
{
desc: "success",
@@ -26,9 +29,33 @@ func TestProber_Solve(t *testing.T) {
},
},
authz: []acme.Authorization{
- createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
- createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
- createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.com", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
+ },
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 3, Solve: 3, CleanUp: 3",
+ },
+ },
+ {
+ desc: "DNS-01 deduplicate",
+ solvers: map[challenge.Type]solver{
+ challenge.DNS01: &preSolverMock{
+ preSolve: map[string]error{},
+ solve: map[string]error{},
+ cleanUp: map[string]error{},
+ },
+ },
+ authz: []acme.Authorization{
+ createStubAuthorizationDNS01("a.example", false),
+ createStubAuthorizationDNS01("a.example", true),
+ createStubAuthorizationDNS01("b.example", false),
+ createStubAuthorizationDNS01("b.example", true),
+ createStubAuthorizationDNS01("c.example", true),
+ createStubAuthorizationDNS01("d.example", false),
+ },
+ expectedCounters: map[challenge.Type]string{
+ challenge.DNS01: "PreSolve: 4, Solve: 6, CleanUp: 4",
},
},
{
@@ -41,9 +68,12 @@ func TestProber_Solve(t *testing.T) {
},
},
authz: []acme.Authorization{
- createStubAuthorizationHTTP01("acme.wtf", acme.StatusValid),
- createStubAuthorizationHTTP01("lego.wtf", acme.StatusValid),
- createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusValid),
+ createStubAuthorizationHTTP01("example.com", acme.StatusValid),
+ createStubAuthorizationHTTP01("example.org", acme.StatusValid),
+ createStubAuthorizationHTTP01("example.net", acme.StatusValid),
+ },
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 0, Solve: 0, CleanUp: 0",
},
},
{
@@ -51,50 +81,56 @@ func TestProber_Solve(t *testing.T) {
solvers: map[challenge.Type]solver{
challenge.HTTP01: &preSolverMock{
preSolve: map[string]error{
- "acme.wtf": errors.New("preSolve error acme.wtf"),
+ "example.com": errors.New("preSolve error example.com"),
},
solve: map[string]error{
- "acme.wtf": errors.New("solve error acme.wtf"),
+ "example.com": errors.New("solve error example.com"),
},
cleanUp: map[string]error{
- "acme.wtf": errors.New("clean error acme.wtf"),
+ "example.com": errors.New("clean error example.com"),
},
},
},
authz: []acme.Authorization{
- createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
- createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
- createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.com", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
},
expectedError: `error: one or more domains had a problem:
-[acme.wtf] preSolve error acme.wtf
+[example.com] preSolve error example.com
`,
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
+ },
},
{
desc: "errors at different stages",
solvers: map[challenge.Type]solver{
challenge.HTTP01: &preSolverMock{
preSolve: map[string]error{
- "acme.wtf": errors.New("preSolve error acme.wtf"),
+ "example.com": errors.New("preSolve error example.com"),
},
solve: map[string]error{
- "acme.wtf": errors.New("solve error acme.wtf"),
- "lego.wtf": errors.New("solve error lego.wtf"),
+ "example.com": errors.New("solve error example.com"),
+ "example.org": errors.New("solve error example.org"),
},
cleanUp: map[string]error{
- "mydomain.wtf": errors.New("clean error mydomain.wtf"),
+ "example.net": errors.New("clean error example.net"),
},
},
},
authz: []acme.Authorization{
- createStubAuthorizationHTTP01("acme.wtf", acme.StatusProcessing),
- createStubAuthorizationHTTP01("lego.wtf", acme.StatusProcessing),
- createStubAuthorizationHTTP01("mydomain.wtf", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.com", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
+ createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
},
expectedError: `error: one or more domains had a problem:
-[acme.wtf] preSolve error acme.wtf
-[lego.wtf] solve error lego.wtf
+[example.com] preSolve error example.com
+[example.org] solve error example.org
`,
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
+ },
},
}
@@ -112,6 +148,10 @@ func TestProber_Solve(t *testing.T) {
} else {
require.NoError(t, err)
}
+
+ for n, s := range test.solvers {
+ assert.Equal(t, test.expectedCounters[n], fmt.Sprintf("%s", s))
+ }
})
}
}
diff --git a/challenge/resolver/solver_manager.go b/challenge/resolver/solver_manager.go
index 138060bc7..87cf6e2d8 100644
--- a/challenge/resolver/solver_manager.go
+++ b/challenge/resolver/solver_manager.go
@@ -1,13 +1,13 @@
package resolver
import (
+ "context"
"errors"
"fmt"
"sort"
- "strconv"
"time"
- "github.com/cenkalti/backoff/v4"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/challenge"
@@ -15,6 +15,7 @@ import (
"github.com/go-acme/lego/v4/challenge/http01"
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/go-acme/lego/v4/log"
+ "github.com/go-acme/lego/v4/platform/wait"
)
type byType []acme.Challenge
@@ -36,14 +37,14 @@ func NewSolversManager(core *api.Core) *SolverManager {
}
// SetHTTP01Provider specifies a custom provider p that can solve the given HTTP-01 challenge.
-func (c *SolverManager) SetHTTP01Provider(p challenge.Provider) error {
- c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p)
+func (c *SolverManager) SetHTTP01Provider(p challenge.Provider, opts ...http01.ChallengeOption) error {
+ c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p, opts...)
return nil
}
// SetTLSALPN01Provider specifies a custom provider p that can solve the given TLS-ALPN-01 challenge.
-func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider) error {
- c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p)
+func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider, opts ...tlsalpn01.ChallengeOption) error {
+ c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p, opts...)
return nil
}
@@ -69,6 +70,7 @@ func (c *SolverManager) chooseSolver(authz acme.Authorization) solver {
log.Infof("[%s] acme: use %s solver", domain, chlg.Type)
return solvr
}
+
log.Infof("[%s] acme: Could not find solver for: %s", domain, chlg.Type)
}
@@ -91,20 +93,20 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error {
return nil
}
- ra, err := strconv.Atoi(chlng.RetryAfter)
- if err != nil {
+ retryAfter, err := api.ParseRetryAfter(chlng.RetryAfter)
+ if err != nil || retryAfter == 0 {
// The ACME server MUST return a Retry-After.
- // If it doesn't, we'll just poll hard.
+ // If it doesn't, or if it's invalid, we'll just poll hard.
// Boulder does not implement the ability to retry challenges or the Retry-After header.
// https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md#section-82
- ra = 5
+ retryAfter = 5 * time.Second
}
- initialInterval := time.Duration(ra) * time.Second
+
+ ctx := context.Background()
bo := backoff.NewExponentialBackOff()
- bo.InitialInterval = initialInterval
- bo.MaxInterval = 10 * initialInterval
- bo.MaxElapsedTime = 100 * initialInterval
+ bo.InitialInterval = retryAfter
+ bo.MaxInterval = 10 * retryAfter
// After the path is sent, the ACME server will access our server.
// Repeatedly check the server for an updated status on our request.
@@ -124,10 +126,12 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error {
return nil
}
- return errors.New("the server didn't respond to our request")
+ return fmt.Errorf("the server didn't respond to our request (status=%s)", authz.Status)
}
- return backoff.Retry(operation, bo)
+ return wait.Retry(ctx, operation,
+ backoff.WithBackOff(bo),
+ backoff.WithMaxElapsedTime(100*retryAfter))
}
func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) {
@@ -137,9 +141,9 @@ func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) {
case acme.StatusPending, acme.StatusProcessing:
return false, nil
case acme.StatusInvalid:
- return false, chlng.Error
+ return false, fmt.Errorf("invalid challenge: %w", chlng.Err())
default:
- return false, errors.New("the server returned an unexpected state")
+ return false, fmt.Errorf("the server returned an unexpected challenge status: %s", chlng.Status)
}
}
@@ -154,11 +158,12 @@ func checkAuthorizationStatus(authz acme.Authorization) (bool, error) {
case acme.StatusInvalid:
for _, chlg := range authz.Challenges {
if chlg.Status == acme.StatusInvalid && chlg.Error != nil {
- return false, chlg.Error
+ return false, fmt.Errorf("invalid authorization: %w", chlg.Err())
}
}
- return false, fmt.Errorf("the authorization state %s", authz.Status)
+
+ return false, errors.New("invalid authorization")
default:
- return false, errors.New("the server returned an unexpected state")
+ return false, fmt.Errorf("the server returned an unexpected authorization status: %s", authz.Status)
}
}
diff --git a/challenge/resolver/solver_manager_test.go b/challenge/resolver/solver_manager_test.go
index 9249beeba..77149c73a 100644
--- a/challenge/resolver/solver_manager_test.go
+++ b/challenge/resolver/solver_manager_test.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -32,70 +33,50 @@ func TestByType(t *testing.T) {
}
func TestValidate(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
-
var statuses []string
- privateKey, _ := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, _ := rsa.GenerateKey(rand.Reader, 1024)
- mux.HandleFunc("/chlg", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+ server := tester.MockACMEServer().
+ Route("POST /chlg",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ if err := validateNoBody(privateKey, req); err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
- if err := validateNoBody(privateKey, r); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
+ rw.Header().Set("Link",
+ fmt.Sprintf(`; rel="up"`, req.Context().Value(http.LocalAddrContextKey)))
- w.Header().Set("Link", "<"+apiURL+`/my-authz>; rel="up"`)
+ st := statuses[0]
+ statuses = statuses[1:]
- st := statuses[0]
- statuses = statuses[1:]
+ chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"}
- chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"}
- if st == acme.StatusInvalid {
- chlg.Error = &acme.ProblemDetails{}
- }
+ servermock.JSONEncode(chlg).ServeHTTP(rw, req)
+ })).
+ Route("POST /my-authz",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ st := statuses[0]
+ statuses = statuses[1:]
- err := tester.WriteJSONResponse(w, chlg)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ authorization := acme.Authorization{
+ Status: st,
+ Challenges: []acme.Challenge{},
+ }
- mux.HandleFunc("/my-authz", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+ if st == acme.StatusInvalid {
+ chlg := acme.Challenge{
+ Status: acme.StatusInvalid,
+ }
+ authorization.Challenges = append(authorization.Challenges, chlg)
+ }
- st := statuses[0]
- statuses = statuses[1:]
+ servermock.JSONEncode(authorization).ServeHTTP(rw, req)
+ })).
+ BuildHTTPS(t)
- authorization := acme.Authorization{
- Status: st,
- Challenges: []acme.Challenge{},
- }
-
- if st == acme.StatusInvalid {
- chlg := acme.Challenge{
- Status: acme.StatusInvalid,
- Error: &acme.ProblemDetails{},
- }
- authorization.Challenges = append(authorization.Challenges, chlg)
- }
-
- err := tester.WriteJSONResponse(w, authorization)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
testCases := []struct {
@@ -106,7 +87,7 @@ func TestValidate(t *testing.T) {
{
name: "POST-unexpected",
statuses: []string{"weird"},
- want: "unexpected",
+ want: "the server returned an unexpected challenge status: weird",
},
{
name: "POST-valid",
@@ -115,12 +96,12 @@ func TestValidate(t *testing.T) {
{
name: "POST-invalid",
statuses: []string{acme.StatusInvalid},
- want: "error",
+ want: "invalid challenge:",
},
{
name: "POST-pending-unexpected",
statuses: []string{acme.StatusPending, "weird"},
- want: "unexpected",
+ want: "the server returned an unexpected authorization status: weird",
},
{
name: "POST-pending-valid",
@@ -129,7 +110,7 @@ func TestValidate(t *testing.T) {
{
name: "POST-pending-invalid",
statuses: []string{acme.StatusPending, acme.StatusInvalid},
- want: "error",
+ want: "invalid authorization",
},
}
@@ -137,7 +118,7 @@ func TestValidate(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
statuses = test.statuses
- err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: apiURL + "/chlg"})
+ err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: server.URL + "/chlg"})
if test.want == "" {
require.NoError(t, err)
} else {
@@ -148,6 +129,126 @@ func TestValidate(t *testing.T) {
}
}
+func Test_checkChallengeStatus(t *testing.T) {
+ testCases := []struct {
+ desc string
+ challenge acme.Challenge
+ requireErr require.ErrorAssertionFunc
+ expected bool
+ }{
+ {
+ desc: "status valid",
+ challenge: acme.Challenge{Status: acme.StatusValid},
+ requireErr: require.NoError,
+ expected: true,
+ },
+ {
+ desc: "status invalid",
+ challenge: acme.Challenge{Status: acme.StatusInvalid},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "status invalid with error",
+ challenge: acme.Challenge{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "status pending",
+ challenge: acme.Challenge{Status: acme.StatusPending},
+ requireErr: require.NoError,
+ expected: false,
+ },
+ {
+ desc: "status processing",
+ challenge: acme.Challenge{Status: acme.StatusProcessing},
+ requireErr: require.NoError,
+ expected: false,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ status, err := checkChallengeStatus(acme.ExtendedChallenge{Challenge: test.challenge})
+ test.requireErr(t, err)
+
+ assert.Equal(t, test.expected, status)
+ })
+ }
+}
+
+func Test_checkAuthorizationStatus(t *testing.T) {
+ testCases := []struct {
+ desc string
+ authorization acme.Authorization
+ requireErr require.ErrorAssertionFunc
+ expected bool
+ }{
+ {
+ desc: "status valid",
+ authorization: acme.Authorization{Status: acme.StatusValid},
+ requireErr: require.NoError,
+ expected: true,
+ },
+ {
+ desc: "status invalid",
+ authorization: acme.Authorization{Status: acme.StatusInvalid},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "status invalid with error",
+ authorization: acme.Authorization{Status: acme.StatusInvalid, Challenges: []acme.Challenge{{Error: &acme.ProblemDetails{}}}},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "status pending",
+ authorization: acme.Authorization{Status: acme.StatusPending},
+ requireErr: require.NoError,
+ expected: false,
+ },
+ {
+ desc: "status processing",
+ authorization: acme.Authorization{Status: acme.StatusProcessing},
+ requireErr: require.NoError,
+ expected: false,
+ },
+ {
+ desc: "status deactivated",
+ authorization: acme.Authorization{Status: acme.StatusDeactivated},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "status expired",
+ authorization: acme.Authorization{Status: acme.StatusExpired},
+ requireErr: require.Error,
+ expected: false,
+ },
+ {
+ desc: "status revoked",
+ authorization: acme.Authorization{Status: acme.StatusRevoked},
+ requireErr: require.Error,
+ expected: false,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ status, err := checkAuthorizationStatus(test.authorization)
+ test.requireErr(t, err)
+
+ assert.Equal(t, test.expected, status)
+ })
+ }
+}
+
// validateNoBody reads the http.Request POST body, parses the JWS and validates it to read the body.
// If there is an error doing this,
// or if the JWS body is not the empty JSON payload "{}" or a POST-as-GET payload "" an error is returned.
@@ -159,6 +260,7 @@ func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error {
}
sigAlgs := []jose.SignatureAlgorithm{jose.RS256}
+
jws, err := jose.ParseSigned(string(reqBody), sigAlgs)
if err != nil {
return err
@@ -175,5 +277,6 @@ func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error {
if bodyStr := string(body); bodyStr != "{}" && bodyStr != "" {
return fmt.Errorf(`expected JWS POST body "{}" or "", got %q`, bodyStr)
}
+
return nil
}
diff --git a/challenge/tlsalpn01/tls_alpn_challenge.go b/challenge/tlsalpn01/tls_alpn_challenge.go
index 04ba71507..d8e939106 100644
--- a/challenge/tlsalpn01/tls_alpn_challenge.go
+++ b/challenge/tlsalpn01/tls_alpn_challenge.go
@@ -7,6 +7,7 @@ import (
"crypto/x509/pkix"
"encoding/asn1"
"fmt"
+ "time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
@@ -21,18 +22,38 @@ var idPeAcmeIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31}
type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error
+type ChallengeOption func(*Challenge) error
+
+// SetDelay sets a delay between the start of the TLS listener and the challenge validation.
+func SetDelay(delay time.Duration) ChallengeOption {
+ return func(chlg *Challenge) error {
+ chlg.delay = delay
+ return nil
+ }
+}
+
type Challenge struct {
core *api.Core
validate ValidateFunc
provider challenge.Provider
+ delay time.Duration
}
-func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider) *Challenge {
- return &Challenge{
+func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge {
+ chlg := &Challenge{
core: core,
validate: validate,
provider: provider,
}
+
+ for _, opt := range opts {
+ err := opt(chlg)
+ if err != nil {
+ log.Infof("challenge option error: %v", err)
+ }
+ }
+
+ return chlg
}
func (c *Challenge) SetProvider(provider challenge.Provider) {
@@ -59,6 +80,7 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
if err != nil {
return fmt.Errorf("[%s] acme: error presenting token: %w", challenge.GetTargetedDomain(authz), err)
}
+
defer func() {
err := c.provider.CleanUp(domain, chlng.Token, keyAuth)
if err != nil {
@@ -66,7 +88,12 @@ func (c *Challenge) Solve(authz acme.Authorization) error {
}
}()
+ if c.delay > 0 {
+ time.Sleep(c.delay)
+ }
+
chlng.KeyAuthorization = keyAuth
+
return c.validate(c.core, domain, chlng)
}
diff --git a/challenge/tlsalpn01/tls_alpn_challenge_test.go b/challenge/tlsalpn01/tls_alpn_challenge_test.go
index 8725a1360..59c2d61bc 100644
--- a/challenge/tlsalpn01/tls_alpn_challenge_test.go
+++ b/challenge/tlsalpn01/tls_alpn_challenge_test.go
@@ -8,7 +8,6 @@ import (
"crypto/tls"
"encoding/asn1"
"net"
- "net/http"
"testing"
"github.com/go-acme/lego/v4/acme"
@@ -21,7 +20,7 @@ import (
)
func TestChallenge(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
domain := "localhost"
port := "24457"
@@ -43,6 +42,7 @@ func TestChallenge(t *testing.T) {
assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions")
idx := -1
+
for i, ext := range remoteCert.Extensions {
if idPeAcmeIdentifierV1.Equal(ext.Id) {
idx = i
@@ -66,10 +66,10 @@ func TestChallenge(t *testing.T) {
return nil
}
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(
@@ -93,12 +93,12 @@ func TestChallenge(t *testing.T) {
}
func TestChallengeInvalidPort(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
- privateKey, err := rsa.GenerateKey(rand.Reader, 128)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(
@@ -123,7 +123,7 @@ func TestChallengeInvalidPort(t *testing.T) {
}
func TestChallengeIPaddress(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
domain := "127.0.0.1"
port := "24457"
@@ -146,31 +146,37 @@ func TestChallengeIPaddress(t *testing.T) {
assert.True(t, net.ParseIP("127.0.0.1").Equal(remoteCert.IPAddresses[0]), "challenge certificate IPAddress ")
assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions")
- var foundAcmeIdentifier bool
- var extValue []byte
+ var (
+ foundAcmeIdentifier bool
+ extValue []byte
+ )
+
for _, ext := range remoteCert.Extensions {
if idPeAcmeIdentifierV1.Equal(ext.Id) {
assert.True(t, ext.Critical, "Expected the challenge certificate id-pe-acmeIdentifier extension to be marked as critical")
+
foundAcmeIdentifier = true
extValue = ext.Value
+
break
}
}
require.True(t, foundAcmeIdentifier, "Expected the challenge certificate to contain an extension with the id-pe-acmeIdentifier id,")
+
zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization))
value, err := asn1.Marshal(zBytes[:sha256.Size])
require.NoError(t, err, "Expected marshaling of the keyAuth to return no error")
- require.EqualValues(t, value, extValue, "Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth")
+ require.Equal(t, value, extValue, "Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth")
return nil
}
- privateKey, err := rsa.GenerateKey(rand.Reader, 512)
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
solver := NewChallenge(
diff --git a/cmd/accounts_storage.go b/cmd/accounts_storage.go
index b3e4986dd..01db2faf8 100644
--- a/cmd/accounts_storage.go
+++ b/cmd/accounts_storage.go
@@ -2,10 +2,8 @@ package cmd
import (
"crypto"
- "crypto/x509"
"encoding/json"
"encoding/pem"
- "errors"
"net/url"
"os"
"path/filepath"
@@ -18,6 +16,8 @@ import (
"github.com/urfave/cli/v2"
)
+const userIDPlaceholder = "noemail@example.com"
+
const (
baseAccountsRootFolderName = "accounts"
baseKeysFolderName = "keys"
@@ -34,7 +34,7 @@ const (
//
// rootUserPath:
//
-// ./.lego/accounts/localhost_14000/hubert@hubert.com/
+// ./.lego/accounts/localhost_14000/foo@example.com/
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
// │ └── root accounts directory
@@ -42,7 +42,7 @@ const (
//
// keysPath:
//
-// ./.lego/accounts/localhost_14000/hubert@hubert.com/keys/
+// ./.lego/accounts/localhost_14000/foo@example.com/keys/
// │ │ │ │ └── root keys directory
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
@@ -51,7 +51,7 @@ const (
//
// accountFilePath:
//
-// ./.lego/accounts/localhost_14000/hubert@hubert.com/account.json
+// ./.lego/accounts/localhost_14000/foo@example.com/account.json
// │ │ │ │ └── account file
// │ │ │ └── userID ("email" option)
// │ │ └── CA server ("server" option)
@@ -59,6 +59,7 @@ const (
// └── "path" option
type AccountsStorage struct {
userID string
+ email string
rootPath string
rootUserPath string
keysPath string
@@ -68,8 +69,13 @@ type AccountsStorage struct {
// NewAccountsStorage Creates a new AccountsStorage.
func NewAccountsStorage(ctx *cli.Context) *AccountsStorage {
- // TODO: move to account struct? Currently MUST pass email.
- email := getEmail(ctx)
+ // TODO: move to account struct?
+ email := ctx.String(flgEmail)
+
+ userID := email
+ if userID == "" {
+ userID = userIDPlaceholder
+ }
serverURL, err := url.Parse(ctx.String(flgServer))
if err != nil {
@@ -79,10 +85,11 @@ func NewAccountsStorage(ctx *cli.Context) *AccountsStorage {
rootPath := filepath.Join(ctx.String(flgPath), baseAccountsRootFolderName)
serverPath := strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(serverURL.Host)
accountsPath := filepath.Join(rootPath, serverPath)
- rootUserPath := filepath.Join(accountsPath, email)
+ rootUserPath := filepath.Join(accountsPath, userID)
return &AccountsStorage{
- userID: email,
+ userID: userID,
+ email: email,
rootPath: rootPath,
rootUserPath: rootUserPath,
keysPath: filepath.Join(rootUserPath, baseKeysFolderName),
@@ -98,6 +105,7 @@ func (s *AccountsStorage) ExistsAccountFilePath() bool {
} else if err != nil {
log.Fatal(err)
}
+
return true
}
@@ -113,6 +121,10 @@ func (s *AccountsStorage) GetUserID() string {
return s.userID
}
+func (s *AccountsStorage) GetEmail() string {
+ return s.email
+}
+
func (s *AccountsStorage) Save(account *Account) error {
jsonBytes, err := json.MarshalIndent(account, "", "\t")
if err != nil {
@@ -125,13 +137,14 @@ func (s *AccountsStorage) Save(account *Account) error {
func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
fileBytes, err := os.ReadFile(s.accountFilePath)
if err != nil {
- log.Fatalf("Could not load file for account %s: %v", s.userID, err)
+ log.Fatalf("Could not load file for account %s: %v", s.GetUserID(), err)
}
var account Account
+
err = json.Unmarshal(fileBytes, &account)
if err != nil {
- log.Fatalf("Could not parse file for account %s: %v", s.userID, err)
+ log.Fatalf("Could not parse file for account %s: %v", s.GetUserID(), err)
}
account.key = privateKey
@@ -139,13 +152,14 @@ func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
if account.Registration == nil || account.Registration.Body.Status == "" {
reg, err := tryRecoverRegistration(s.ctx, privateKey)
if err != nil {
- log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.userID, err)
+ log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.GetUserID(), err)
}
account.Registration = reg
+
err = s.Save(&account)
if err != nil {
- log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.userID, err)
+ log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.GetUserID(), err)
}
}
@@ -153,18 +167,19 @@ func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account {
}
func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.PrivateKey {
- accKeyPath := filepath.Join(s.keysPath, s.userID+".key")
+ accKeyPath := filepath.Join(s.keysPath, s.GetUserID()+".key")
if _, err := os.Stat(accKeyPath); os.IsNotExist(err) {
- log.Printf("No key found for account %s. Generating a %s key.", s.userID, keyType)
+ log.Printf("No key found for account %s. Generating a %s key.", s.GetUserID(), keyType)
s.createKeysFolder()
privateKey, err := generatePrivateKey(accKeyPath, keyType)
if err != nil {
- log.Fatalf("Could not generate RSA private account key for account %s: %v", s.userID, err)
+ log.Fatalf("Could not generate RSA private account key for account %s: %v", s.GetUserID(), err)
}
log.Printf("Saved key to %s", accKeyPath)
+
return privateKey
}
@@ -178,7 +193,7 @@ func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.Priva
func (s *AccountsStorage) createKeysFolder() {
if err := createNonExistingFolder(s.keysPath); err != nil {
- log.Fatalf("Could not check/create directory for account %s: %v", s.userID, err)
+ log.Fatalf("Could not check/create directory for account %s: %v", s.GetUserID(), err)
}
}
@@ -195,6 +210,7 @@ func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.Private
defer certOut.Close()
pemKey := certcrypto.PEMBlock(privateKey)
+
err = pem.Encode(certOut, pemKey)
if err != nil {
return nil, err
@@ -209,16 +225,12 @@ func loadPrivateKey(file string) (crypto.PrivateKey, error) {
return nil, err
}
- keyBlock, _ := pem.Decode(keyBytes)
-
- switch keyBlock.Type {
- case "RSA PRIVATE KEY":
- return x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
- case "EC PRIVATE KEY":
- return x509.ParseECPrivateKey(keyBlock.Bytes)
+ privateKey, err := certcrypto.ParsePEMPrivateKey(keyBytes)
+ if err != nil {
+ return nil, err
}
- return nil, errors.New("unknown private key type")
+ return privateKey, nil
}
func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*registration.Resource, error) {
@@ -236,5 +248,6 @@ func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*re
if err != nil {
return nil, err
}
+
return reg, nil
}
diff --git a/cmd/certs_storage.go b/cmd/certs_storage.go
index f9bcdade8..25ef58075 100644
--- a/cmd/certs_storage.go
+++ b/cmd/certs_storage.go
@@ -2,7 +2,6 @@ package cmd
import (
"bytes"
- "crypto"
"crypto/x509"
"encoding/json"
"encoding/pem"
@@ -159,6 +158,7 @@ func (s *CertificatesStorage) ExistsFile(domain, extension string) bool {
} else if err != nil {
log.Fatal(err)
}
+
return true
}
@@ -233,27 +233,9 @@ func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.R
return fmt.Errorf("unable to get certificate chain for domain %s: %w", domain, err)
}
- keyPemBlock, _ := pem.Decode(certRes.PrivateKey)
- if keyPemBlock == nil {
- return fmt.Errorf("unable to parse PrivateKey for domain %s", domain)
- }
-
- var privateKey crypto.Signer
- var keyErr error
-
- switch keyPemBlock.Type {
- case "RSA PRIVATE KEY":
- privateKey, keyErr = x509.ParsePKCS1PrivateKey(keyPemBlock.Bytes)
- if keyErr != nil {
- return fmt.Errorf("unable to load RSA PrivateKey for domain %s: %w", domain, keyErr)
- }
- case "EC PRIVATE KEY":
- privateKey, keyErr = x509.ParseECPrivateKey(keyPemBlock.Bytes)
- if keyErr != nil {
- return fmt.Errorf("unable to load EC PrivateKey for domain %s: %w", domain, keyErr)
- }
- default:
- return fmt.Errorf("unsupported PrivateKey type '%s' for domain %s", keyPemBlock.Type, domain)
+ privateKey, err := certcrypto.ParsePEMPrivateKey(certRes.PrivateKey)
+ if err != nil {
+ return fmt.Errorf("unable to parse PrivateKey for domain %s: %w", domain, err)
}
encoder, err := getPFXEncoder(s.pfxFormat)
@@ -302,6 +284,7 @@ func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, er
}
var certChain []*x509.Certificate
+
for chainCertPemBlock != nil {
chainCert, err := x509.ParseCertificate(chainCertPemBlock.Bytes)
if err != nil {
@@ -317,6 +300,7 @@ func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, er
func getPFXEncoder(pfxFormat string) (*pkcs12.Encoder, error) {
var encoder *pkcs12.Encoder
+
switch pfxFormat {
case "SHA256":
encoder = pkcs12.Modern2023
@@ -337,5 +321,6 @@ func sanitizedDomain(domain string) string {
if err != nil {
log.Fatal(err)
}
+
return safe
}
diff --git a/cmd/cmd_dnshelp.go b/cmd/cmd_dnshelp.go
index 1a61cac80..41adf4c8d 100644
--- a/cmd/cmd_dnshelp.go
+++ b/cmd/cmd_dnshelp.go
@@ -58,7 +58,7 @@ type errWriter struct {
err error
}
-func (ew *errWriter) writeln(a ...interface{}) {
+func (ew *errWriter) writeln(a ...any) {
if ew.err != nil {
return
}
@@ -66,7 +66,7 @@ func (ew *errWriter) writeln(a ...interface{}) {
_, ew.err = fmt.Fprintln(ew.w, a...)
}
-func (ew *errWriter) writef(format string, a ...interface{}) {
+func (ew *errWriter) writef(format string, a ...any) {
if ew.err != nil {
return
}
diff --git a/cmd/cmd_list.go b/cmd/cmd_list.go
index bf7b232da..53cd12c3c 100644
--- a/cmd/cmd_list.go
+++ b/cmd/cmd_list.go
@@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
+ "net"
"net/url"
"os"
"path/filepath"
@@ -36,7 +37,7 @@ func createList() *cli.Command {
// fake email, needed by NewAccountsStorage
&cli.StringFlag{
Name: flgEmail,
- Value: "unknown",
+ Value: "",
Hidden: true,
},
},
@@ -67,6 +68,7 @@ func listCertificates(ctx *cli.Context) error {
if !names {
fmt.Println("No certificates found.")
}
+
return nil
}
@@ -99,6 +101,11 @@ func listCertificates(ctx *cli.Context) error {
} else {
fmt.Println(" Certificate Name:", name)
fmt.Println(" Domains:", strings.Join(pCert.DNSNames, ", "))
+
+ if len(pCert.IPAddresses) > 0 {
+ fmt.Println(" IPs:", formatIPAddresses(pCert.IPAddresses))
+ }
+
fmt.Println(" Expiry Date:", pCert.NotAfter)
fmt.Println(" Certificate Path:", filename)
fmt.Println()
@@ -122,6 +129,7 @@ func listAccount(ctx *cli.Context) error {
}
fmt.Println("Found the following accounts:")
+
for _, filename := range matches {
data, err := os.ReadFile(filename)
if err != nil {
@@ -129,6 +137,7 @@ func listAccount(ctx *cli.Context) error {
}
var account Account
+
err = json.Unmarshal(data, &account)
if err != nil {
return err
@@ -147,3 +156,12 @@ func listAccount(ctx *cli.Context) error {
return nil
}
+
+func formatIPAddresses(ipAddresses []net.IP) string {
+ var ips []string
+ for _, ip := range ipAddresses {
+ ips = append(ips, ip.String())
+ }
+
+ return strings.Join(ips, ", ")
+}
diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go
index 1f9c08168..4b41ebc78 100644
--- a/cmd/cmd_renew.go
+++ b/cmd/cmd_renew.go
@@ -20,22 +20,15 @@ import (
// Flag names.
const (
- flgDays = "days"
+ flgRenewDays = "days"
+ flgRenewDynamic = "dynamic"
flgARIDisable = "ari-disable"
flgARIWaitToRenewDuration = "ari-wait-to-renew-duration"
flgReuseKey = "reuse-key"
flgRenewHook = "renew-hook"
+ flgRenewHookTimeout = "renew-hook-timeout"
flgNoRandomSleep = "no-random-sleep"
-)
-
-const (
- renewEnvAccountEmail = "LEGO_ACCOUNT_EMAIL"
- renewEnvCertDomain = "LEGO_CERT_DOMAIN"
- renewEnvCertPath = "LEGO_CERT_PATH"
- renewEnvCertKeyPath = "LEGO_CERT_KEY_PATH"
- renewEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH"
- renewEnvCertPEMPath = "LEGO_CERT_PEM_PATH"
- renewEnvCertPFXPath = "LEGO_CERT_PFX_PATH"
+ flgForceCertDomains = "force-cert-domains"
)
func createRenew() *cli.Command {
@@ -46,24 +39,37 @@ func createRenew() *cli.Command {
Before: func(ctx *cli.Context) error {
// we require either domains or csr, but not both
hasDomains := len(ctx.StringSlice(flgDomains)) > 0
+
hasCsr := ctx.String(flgCSR) != ""
if hasDomains && hasCsr {
- log.Fatal("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR)
+ log.Fatalf("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR)
}
+
if !hasDomains && !hasCsr {
- log.Fatal("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR)
+ log.Fatalf("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR)
}
+
+ if ctx.Bool(flgForceCertDomains) && hasCsr {
+ log.Fatalf("--%s only works with --%s/-d, --%s/-c doesn't support this option.", flgForceCertDomains, flgDomains, flgCSR)
+ }
+
return nil
},
Flags: []cli.Flag{
&cli.IntFlag{
- Name: flgDays,
+ Name: flgRenewDays,
Value: 30,
Usage: "The number of days left on a certificate to renew it.",
},
+ // TODO(ldez): in v5, remove this flag, use this behavior as default.
+ &cli.BoolFlag{
+ Name: flgRenewDynamic,
+ Value: false,
+ Usage: "Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5.",
+ },
&cli.BoolFlag{
Name: flgARIDisable,
- Usage: "Do not use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed.",
+ Usage: "Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed.",
},
&cli.DurationFlag{
Name: flgARIWaitToRenewDuration,
@@ -97,6 +103,10 @@ func createRenew() *cli.Command {
Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." +
" If no match, the default offered chain will be used.",
},
+ &cli.StringFlag{
+ Name: flgProfile,
+ Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.",
+ },
&cli.StringFlag{
Name: flgAlwaysDeactivateAuthorizations,
Usage: "Force the authorizations to be relinquished even if the certificate request was successful.",
@@ -105,18 +115,26 @@ func createRenew() *cli.Command {
Name: flgRenewHook,
Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.",
},
+ &cli.DurationFlag{
+ Name: flgRenewHookTimeout,
+ Usage: "Define the timeout for the hook execution.",
+ Value: 2 * time.Minute,
+ },
&cli.BoolFlag{
Name: flgNoRandomSleep,
Usage: "Do not add a random sleep before the renewal." +
" We do not recommend using this flag if you are doing your renewals in an automated way.",
},
+ &cli.BoolFlag{
+ Name: flgForceCertDomains,
+ Usage: "Check and ensure that the cert's domain list matches those passed in the domains argument.",
+ },
},
}
}
func renew(ctx *cli.Context) error {
- account, client := setup(ctx, NewAccountsStorage(ctx))
- setupChallenges(ctx, client)
+ account, keyType := setupAccount(ctx, NewAccountsStorage(ctx))
if account.Registration == nil {
log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email)
@@ -126,18 +144,20 @@ func renew(ctx *cli.Context) error {
bundle := !ctx.Bool(flgNoBundle)
- meta := map[string]string{renewEnvAccountEmail: account.Email}
+ meta := map[string]string{
+ hookEnvAccountEmail: account.Email,
+ }
// CSR
if ctx.IsSet(flgCSR) {
- return renewForCSR(ctx, client, certsStorage, bundle, meta)
+ return renewForCSR(ctx, account, keyType, certsStorage, bundle, meta)
}
// Domains
- return renewForDomains(ctx, client, certsStorage, bundle, meta)
+ return renewForDomains(ctx, account, keyType, certsStorage, bundle, meta)
}
-func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
+func renewForDomains(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
domains := ctx.StringSlice(flgDomains)
domain := domains[0]
@@ -151,10 +171,16 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
cert := certificates[0]
- var ariRenewalTime *time.Time
- var replacesCertID string
+ var (
+ ariRenewalTime *time.Time
+ replacesCertID string
+ )
+
+ var client *lego.Client
if !ctx.Bool(flgARIDisable) {
+ client = setupClient(ctx, account, keyType)
+
ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client)
if ariRenewalTime != nil {
now := time.Now().UTC()
@@ -172,17 +198,25 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
}
}
- if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) {
+ forceDomains := ctx.Bool(flgForceCertDomains)
+
+ certDomains := certcrypto.ExtractDomains(cert)
+
+ if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) &&
+ (!forceDomains || slices.Equal(certDomains, domains)) {
return nil
}
+ if client == nil {
+ client = setupClient(ctx, account, keyType)
+ }
+
// This is just meant to be informal for the user.
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours()))
- certDomains := certcrypto.ExtractDomains(cert)
-
var privateKey crypto.PrivateKey
+
if ctx.Bool(flgReuseKey) {
keyBytes, errR := certsStorage.ReadFile(domain, keyExt)
if errR != nil {
@@ -200,6 +234,7 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
if !isatty.IsTerminal(os.Stdout.Fd()) && !ctx.Bool(flgNoRandomSleep) {
// https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L472
const jitter = 8 * time.Minute
+
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
sleepTime := time.Duration(rnd.Int63n(int64(jitter)))
@@ -207,14 +242,20 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
time.Sleep(sleepTime)
}
+ renewalDomains := slices.Clone(domains)
+ if !forceDomains {
+ renewalDomains = merge(certDomains, domains)
+ }
+
request := certificate.ObtainRequest{
- Domains: merge(certDomains, domains),
+ Domains: renewalDomains,
PrivateKey: privateKey,
MustStaple: ctx.Bool(flgMustStaple),
NotBefore: getTime(ctx, flgNotBefore),
NotAfter: getTime(ctx, flgNotAfter),
Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
+ Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
@@ -227,14 +268,16 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
log.Fatal(err)
}
+ certRes.Domain = domain
+
certsStorage.SaveResource(certRes)
addPathToMetadata(meta, domain, certRes, certsStorage)
- return launchHook(ctx.String(flgRenewHook), meta)
+ return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta)
}
-func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
+func renewForCSR(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error {
csr, err := readCSRFile(ctx.String(flgCSR))
if err != nil {
log.Fatal(err)
@@ -255,10 +298,16 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
cert := certificates[0]
- var ariRenewalTime *time.Time
- var replacesCertID string
+ var (
+ ariRenewalTime *time.Time
+ replacesCertID string
+ )
+
+ var client *lego.Client
if !ctx.Bool(flgARIDisable) {
+ client = setupClient(ctx, account, keyType)
+
ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client)
if ariRenewalTime != nil {
now := time.Now().UTC()
@@ -276,10 +325,14 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
}
}
- if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) {
+ if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) {
return nil
}
+ if client == nil {
+ client = setupClient(ctx, account, keyType)
+ }
+
// This is just meant to be informal for the user.
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours()))
@@ -290,6 +343,7 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
NotAfter: getTime(ctx, flgNotAfter),
Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
+ Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
@@ -306,24 +360,51 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
addPathToMetadata(meta, domain, certRes, certsStorage)
- return launchHook(ctx.String(flgRenewHook), meta)
+ return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta)
}
-func needRenewal(x509Cert *x509.Certificate, domain string, days int) bool {
+func needRenewal(x509Cert *x509.Certificate, domain string, days int, dynamic bool) bool {
if x509Cert.IsCA {
log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain)
}
- if days >= 0 {
- notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
- if notAfter > days {
- log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.",
- domain, notAfter, days)
- return false
- }
+ if dynamic {
+ return needRenewalDynamic(x509Cert, domain, time.Now())
}
- return true
+ if days < 0 {
+ return true
+ }
+
+ notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
+ if notAfter <= days {
+ return true
+ }
+
+ log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.",
+ domain, notAfter, days)
+
+ return false
+}
+
+func needRenewalDynamic(x509Cert *x509.Certificate, domain string, now time.Time) bool {
+ lifetime := x509Cert.NotAfter.Sub(x509Cert.NotBefore)
+
+ var divisor int64 = 3
+ if lifetime.Round(24*time.Hour).Hours()/24.0 <= 10 {
+ divisor = 2
+ }
+
+ dueDate := x509Cert.NotAfter.Add(-1 * time.Duration(lifetime.Nanoseconds()/divisor))
+
+ if dueDate.Before(now) {
+ return true
+ }
+
+ log.Infof("[%s] The certificate expires at %s, the renewal can be performed in %s: no renewal.",
+ domain, x509Cert.NotAfter.Format(time.RFC3339), dueDate.Sub(now))
+
+ return false
}
// getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint.
@@ -339,16 +420,20 @@ func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string,
log.Warnf("[%s] acme: %v", domain, err)
return nil
}
+
log.Warnf("[%s] acme: calling renewal info endpoint: %v", domain, err)
+
return nil
}
now := time.Now().UTC()
+
renewalTime := renewalInfo.ShouldRenewAt(now, ctx.Duration(flgARIWaitToRenewDuration))
if renewalTime == nil {
log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is not needed", domain)
return nil
}
+
log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is needed", domain)
if renewalInfo.ExplanationURL != "" {
@@ -358,24 +443,6 @@ func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string,
return renewalTime
}
-func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) {
- meta[renewEnvCertDomain] = domain
- meta[renewEnvCertPath] = certsStorage.GetFileName(domain, certExt)
- meta[renewEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt)
-
- if certRes.IssuerCertificate != nil {
- meta[renewEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt)
- }
-
- if certsStorage.pem {
- meta[renewEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt)
- }
-
- if certsStorage.pfx {
- meta[renewEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt)
- }
-}
-
func merge(prevDomains, nextDomains []string) []string {
for _, next := range nextDomains {
if slices.Contains(prevDomains, next) {
diff --git a/cmd/cmd_renew_test.go b/cmd/cmd_renew_test.go
index f88ad74c5..2485c5240 100644
--- a/cmd/cmd_renew_test.go
+++ b/cmd/cmd_renew_test.go
@@ -108,9 +108,62 @@ func Test_needRenewal(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- actual := needRenewal(test.x509Cert, "foo.com", test.days)
+ actual := needRenewal(test.x509Cert, "foo.com", test.days, false)
assert.Equal(t, test.expected, actual)
})
}
}
+
+func Test_needRenewalDynamic(t *testing.T) {
+ testCases := []struct {
+ desc string
+ now time.Time
+ notBefore, notAfter time.Time
+ expected assert.BoolAssertionFunc
+ }{
+ {
+ desc: "higher than 1/3 of the certificate lifetime left (lifetime > 10 days)",
+ now: time.Date(2025, 1, 19, 1, 1, 1, 1, time.UTC),
+ notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
+ notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC),
+ expected: assert.False,
+ },
+ {
+ desc: "lower than 1/3 of the certificate lifetime left(lifetime > 10 days)",
+ now: time.Date(2025, 1, 21, 1, 1, 1, 1, time.UTC),
+ notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
+ notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC),
+ expected: assert.True,
+ },
+ {
+ desc: "higher than 1/2 of the certificate lifetime left (lifetime < 10 days)",
+ now: time.Date(2025, 1, 4, 1, 1, 1, 1, time.UTC),
+ notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
+ notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC),
+ expected: assert.False,
+ },
+ {
+ desc: "lower than 1/2 of the certificate lifetime left (lifetime < 10 days)",
+ now: time.Date(2025, 1, 6, 1, 1, 1, 1, time.UTC),
+ notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
+ notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC),
+ expected: assert.True,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ x509Cert := &x509.Certificate{
+ NotBefore: test.notBefore,
+ NotAfter: test.notAfter,
+ }
+
+ ok := needRenewalDynamic(x509Cert, "example.com", test.now)
+
+ test.expected(t, ok)
+ })
+ }
+}
diff --git a/cmd/cmd_revoke.go b/cmd/cmd_revoke.go
index 2ecfd3017..667bebe12 100644
--- a/cmd/cmd_revoke.go
+++ b/cmd/cmd_revoke.go
@@ -38,12 +38,14 @@ func createRevoke() *cli.Command {
}
func revoke(ctx *cli.Context) error {
- acc, client := setup(ctx, NewAccountsStorage(ctx))
+ account, keyType := setupAccount(ctx, NewAccountsStorage(ctx))
- if acc.Registration == nil {
- log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", acc.Email)
+ if account.Registration == nil {
+ log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email)
}
+ client := newClient(ctx, account, keyType)
+
certsStorage := NewCertificatesStorage(ctx)
certsStorage.CreateRootFolder()
diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go
index a1d4cd514..5924c4b66 100644
--- a/cmd/cmd_run.go
+++ b/cmd/cmd_run.go
@@ -20,9 +20,12 @@ const (
flgMustStaple = "must-staple"
flgNotBefore = "not-before"
flgNotAfter = "not-after"
+ flgPrivateKey = "private-key"
flgPreferredChain = "preferred-chain"
+ flgProfile = "profile"
flgAlwaysDeactivateAuthorizations = "always-deactivate-authorizations"
flgRunHook = "run-hook"
+ flgRunHookTimeout = "run-hook-timeout"
)
func createRun() *cli.Command {
@@ -32,13 +35,16 @@ func createRun() *cli.Command {
Before: func(ctx *cli.Context) error {
// we require either domains or csr, but not both
hasDomains := len(ctx.StringSlice(flgDomains)) > 0
+
hasCsr := ctx.String(flgCSR) != ""
if hasDomains && hasCsr {
log.Fatal("Please specify either --domains/-d or --csr/-c, but not both")
}
+
if !hasDomains && !hasCsr {
log.Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)")
}
+
return nil
},
Action: run,
@@ -62,11 +68,19 @@ func createRun() *cli.Command {
Usage: "Set the notAfter field in the certificate (RFC3339 format)",
Layout: time.RFC3339,
},
+ &cli.StringFlag{
+ Name: flgPrivateKey,
+ Usage: "Path to private key (in PEM encoding) for the certificate. By default, the private key is generated.",
+ },
&cli.StringFlag{
Name: flgPreferredChain,
Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." +
" If no match, the default offered chain will be used.",
},
+ &cli.StringFlag{
+ Name: flgProfile,
+ Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.",
+ },
&cli.StringFlag{
Name: flgAlwaysDeactivateAuthorizations,
Usage: "Force the authorizations to be relinquished even if the certificate request was successful.",
@@ -75,26 +89,32 @@ func createRun() *cli.Command {
Name: flgRunHook,
Usage: "Define a hook. The hook is executed when the certificates are effectively created.",
},
+ &cli.DurationFlag{
+ Name: flgRunHookTimeout,
+ Usage: "Define the timeout for the hook execution.",
+ Value: 2 * time.Minute,
+ },
},
}
}
const rootPathWarningMessage = `!!!! HEADS UP !!!!
-Your account credentials have been saved in your Let's Encrypt
+Your account credentials have been saved in your
configuration directory at "%s".
You should make a secure backup of this folder now. This
-configuration directory will also contain certificates and
-private keys obtained from Let's Encrypt so making regular
-backups of this folder is ideal.
+configuration directory will also contain private keys
+generated by lego and certificates obtained from the ACME
+server. Making regular backups of this folder is ideal.
`
func run(ctx *cli.Context) error {
accountsStorage := NewAccountsStorage(ctx)
- account, client := setup(ctx, accountsStorage)
- setupChallenges(ctx, client)
+ account, keyType := setupAccount(ctx, accountsStorage)
+
+ client := setupClient(ctx, account, keyType)
if account.Registration == nil {
reg, err := register(ctx, client)
@@ -123,12 +143,12 @@ func run(ctx *cli.Context) error {
certsStorage.SaveResource(cert)
meta := map[string]string{
- renewEnvAccountEmail: account.Email,
+ hookEnvAccountEmail: account.Email,
}
addPathToMetadata(meta, cert.Domain, cert, certsStorage)
- return launchHook(ctx.String(flgRunHook), meta)
+ return launchHook(ctx.String(flgRunHook), ctx.Duration(flgRunHookTimeout), meta)
}
func handleTOS(ctx *cli.Context, client *lego.Client) bool {
@@ -138,10 +158,12 @@ func handleTOS(ctx *cli.Context, client *lego.Client) bool {
}
reader := bufio.NewReader(os.Stdin)
+
log.Printf("Please review the TOS at %s", client.GetToSURL())
for {
fmt.Println("Do you accept the TOS? Y/n")
+
text, err := reader.ReadString('\n')
if err != nil {
log.Fatalf("Could not read from console: %v", err)
@@ -191,20 +213,22 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso
// obtain a certificate, generating a new private key
request := certificate.ObtainRequest{
Domains: domains,
- Bundle: bundle,
MustStaple: ctx.Bool(flgMustStaple),
+ NotBefore: getTime(ctx, flgNotBefore),
+ NotAfter: getTime(ctx, flgNotAfter),
+ Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
+ Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
- notBefore := ctx.Timestamp(flgNotBefore)
- if notBefore != nil {
- request.NotBefore = *notBefore
- }
+ if ctx.IsSet(flgPrivateKey) {
+ var err error
- notAfter := ctx.Timestamp(flgNotAfter)
- if notAfter != nil {
- request.NotAfter = *notAfter
+ request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey))
+ if err != nil {
+ return nil, fmt.Errorf("load private key: %w", err)
+ }
}
return client.Certificate.Obtain(request)
@@ -223,8 +247,18 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso
NotAfter: getTime(ctx, flgNotAfter),
Bundle: bundle,
PreferredChain: ctx.String(flgPreferredChain),
+ Profile: ctx.String(flgProfile),
AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations),
}
+ if ctx.IsSet(flgPrivateKey) {
+ var err error
+
+ request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey))
+ if err != nil {
+ return nil, fmt.Errorf("load private key: %w", err)
+ }
+ }
+
return client.Certificate.ObtainForCSR(request)
}
diff --git a/cmd/flags.go b/cmd/flags.go
index 0a8024dff..c7e8371b6 100644
--- a/cmd/flags.go
+++ b/cmd/flags.go
@@ -16,6 +16,7 @@ const (
flgServer = "server"
flgAcceptTOS = "accept-tos"
flgEmail = "email"
+ flgDisableCommonName = "disable-cn"
flgCSR = "csr"
flgEAB = "eab"
flgKID = "kid"
@@ -25,12 +26,14 @@ const (
flgPath = "path"
flgHTTP = "http"
flgHTTPPort = "http.port"
+ flgHTTPDelay = "http.delay"
flgHTTPProxyHeader = "http.proxy-header"
flgHTTPWebroot = "http.webroot"
flgHTTPMemcachedHost = "http.memcached-host"
flgHTTPS3Bucket = "http.s3-bucket"
flgTLS = "tls"
flgTLSPort = "tls.port"
+ flgTLSDelay = "tls.delay"
flgDNS = "dns"
flgDNSDisableCP = "dns.disable-cp"
flgDNSPropagationWait = "dns.propagation-wait"
@@ -49,6 +52,18 @@ const (
flgUserAgent = "user-agent"
)
+const (
+ envEAB = "LEGO_EAB"
+ envEABHMAC = "LEGO_EAB_HMAC"
+ envEABKID = "LEGO_EAB_KID"
+ envEmail = "LEGO_EMAIL"
+ envPath = "LEGO_PATH"
+ envPFX = "LEGO_PFX"
+ envPFXFormat = "LEGO_PFX_FORMAT"
+ envPFXPassword = "LEGO_PFX_PASSWORD"
+ envServer = "LEGO_SERVER"
+)
+
func CreateFlags(defaultPath string) []cli.Flag {
return []cli.Flag{
&cli.StringSliceFlag{
@@ -59,7 +74,7 @@ func CreateFlags(defaultPath string) []cli.Flag {
&cli.StringFlag{
Name: flgServer,
Aliases: []string{"s"},
- EnvVars: []string{"LEGO_SERVER"},
+ EnvVars: []string{envServer},
Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.",
Value: lego.LEDirectoryProduction,
},
@@ -71,8 +86,13 @@ func CreateFlags(defaultPath string) []cli.Flag {
&cli.StringFlag{
Name: flgEmail,
Aliases: []string{"m"},
+ EnvVars: []string{envEmail},
Usage: "Email used for registration and recovery contact.",
},
+ &cli.BoolFlag{
+ Name: flgDisableCommonName,
+ Usage: "Disable the use of the common name in the CSR.",
+ },
&cli.StringFlag{
Name: flgCSR,
Aliases: []string{"c"},
@@ -80,17 +100,17 @@ func CreateFlags(defaultPath string) []cli.Flag {
},
&cli.BoolFlag{
Name: flgEAB,
- EnvVars: []string{"LEGO_EAB"},
+ EnvVars: []string{envEAB},
Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.",
},
&cli.StringFlag{
Name: flgKID,
- EnvVars: []string{"LEGO_EAB_KID"},
+ EnvVars: []string{envEABKID},
Usage: "Key identifier from External CA. Used for External Account Binding.",
},
&cli.StringFlag{
Name: flgHMAC,
- EnvVars: []string{"LEGO_EAB_HMAC"},
+ EnvVars: []string{envEABHMAC},
Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.",
},
&cli.StringFlag{
@@ -105,7 +125,7 @@ func CreateFlags(defaultPath string) []cli.Flag {
},
&cli.StringFlag{
Name: flgPath,
- EnvVars: []string{"LEGO_PATH"},
+ EnvVars: []string{envPath},
Usage: "Directory to use for storing the data.",
Value: defaultPath,
},
@@ -118,6 +138,11 @@ func CreateFlags(defaultPath string) []cli.Flag {
Usage: "Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port.",
Value: ":80",
},
+ &cli.DurationFlag{
+ Name: flgHTTPDelay,
+ Usage: "Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge.",
+ Value: 0,
+ },
&cli.StringFlag{
Name: flgHTTPProxyHeader,
Usage: "Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy.",
@@ -145,6 +170,11 @@ func CreateFlags(defaultPath string) []cli.Flag {
Usage: "Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port.",
Value: ":443",
},
+ &cli.DurationFlag{
+ Name: flgTLSDelay,
+ Usage: "Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge.",
+ Value: 0,
+ },
&cli.StringFlag{
Name: flgDNS,
Usage: "Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage.",
@@ -192,19 +222,19 @@ func CreateFlags(defaultPath string) []cli.Flag {
&cli.BoolFlag{
Name: flgPFX,
Usage: "Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together.",
- EnvVars: []string{"LEGO_PFX"},
+ EnvVars: []string{envPFX},
},
&cli.StringFlag{
Name: flgPFXPass,
Usage: "The password used to encrypt the .pfx (PCKS#12) file.",
Value: pkcs12.DefaultPassword,
- EnvVars: []string{"LEGO_PFX_PASSWORD"},
+ EnvVars: []string{envPFXPassword},
},
&cli.StringFlag{
Name: flgPFXFormat,
Usage: "The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256.",
Value: "RC2",
- EnvVars: []string{"LEGO_PFX_FORMAT"},
+ EnvVars: []string{envPFXFormat},
},
&cli.IntFlag{
Name: flgCertTimeout,
@@ -228,5 +258,6 @@ func getTime(ctx *cli.Context, name string) time.Time {
if value == nil {
return time.Time{}
}
+
return *value
}
diff --git a/cmd/hook.go b/cmd/hook.go
index 0b0ca4038..7883108b6 100644
--- a/cmd/hook.go
+++ b/cmd/hook.go
@@ -1,6 +1,7 @@
package cmd
import (
+ "bufio"
"context"
"errors"
"fmt"
@@ -8,32 +9,70 @@ import (
"os/exec"
"strings"
"time"
+
+ "github.com/go-acme/lego/v4/certificate"
)
-func launchHook(hook string, meta map[string]string) error {
+const (
+ hookEnvAccountEmail = "LEGO_ACCOUNT_EMAIL"
+ hookEnvCertDomain = "LEGO_CERT_DOMAIN"
+ hookEnvCertPath = "LEGO_CERT_PATH"
+ hookEnvCertKeyPath = "LEGO_CERT_KEY_PATH"
+ hookEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH"
+ hookEnvCertPEMPath = "LEGO_CERT_PEM_PATH"
+ hookEnvCertPFXPath = "LEGO_CERT_PFX_PATH"
+)
+
+func launchHook(hook string, timeout time.Duration, meta map[string]string) error {
if hook == "" {
return nil
}
- ctxCmd, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ ctxCmd, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
parts := strings.Fields(hook)
- cmdCtx := exec.CommandContext(ctxCmd, parts[0], parts[1:]...)
- cmdCtx.Env = append(os.Environ(), metaToEnv(meta)...)
+ cmd := exec.CommandContext(ctxCmd, parts[0], parts[1:]...)
- output, err := cmdCtx.CombinedOutput()
+ cmd.Env = append(os.Environ(), metaToEnv(meta)...)
- if len(output) > 0 {
- fmt.Println(string(output))
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("create pipe: %w", err)
}
- if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) {
- return errors.New("hook timed out")
+ cmd.Stderr = cmd.Stdout
+
+ err = cmd.Start()
+ if err != nil {
+ return fmt.Errorf("start command: %w", err)
}
- return err
+ go func() {
+ <-ctxCmd.Done()
+
+ if ctxCmd.Err() != nil {
+ _ = cmd.Process.Kill()
+ _ = stdout.Close()
+ }
+ }()
+
+ scanner := bufio.NewScanner(stdout)
+ for scanner.Scan() {
+ fmt.Println(scanner.Text())
+ }
+
+ err = cmd.Wait()
+ if err != nil {
+ if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) {
+ return errors.New("hook timed out")
+ }
+
+ return fmt.Errorf("wait command: %w", err)
+ }
+
+ return nil
}
func metaToEnv(meta map[string]string) []string {
@@ -45,3 +84,21 @@ func metaToEnv(meta map[string]string) []string {
return envs
}
+
+func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) {
+ meta[hookEnvCertDomain] = domain
+ meta[hookEnvCertPath] = certsStorage.GetFileName(domain, certExt)
+ meta[hookEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt)
+
+ if certRes.IssuerCertificate != nil {
+ meta[hookEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt)
+ }
+
+ if certsStorage.pem {
+ meta[hookEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt)
+ }
+
+ if certsStorage.pfx {
+ meta[hookEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt)
+ }
+}
diff --git a/cmd/hook_test.go b/cmd/hook_test.go
new file mode 100644
index 000000000..d643bba30
--- /dev/null
+++ b/cmd/hook_test.go
@@ -0,0 +1,61 @@
+package cmd
+
+import (
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_launchHook(t *testing.T) {
+ err := launchHook("echo foo", 1*time.Second, map[string]string{})
+ require.NoError(t, err)
+}
+
+func Test_launchHook_errors(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("skipping test on Windows")
+ }
+
+ testCases := []struct {
+ desc string
+ hook string
+ timeout time.Duration
+ expected string
+ }{
+ {
+ desc: "kill the hook",
+ hook: "sleep 5",
+ timeout: 1 * time.Second,
+ expected: "hook timed out",
+ },
+ {
+ desc: "context timeout on Start",
+ hook: "echo foo",
+ timeout: 1 * time.Nanosecond,
+ expected: "start command: context deadline exceeded",
+ },
+ {
+ desc: "multiple short sleeps",
+ hook: "./testdata/sleepy.sh",
+ timeout: 1 * time.Second,
+ expected: "hook timed out",
+ },
+ {
+ desc: "long sleep",
+ hook: "./testdata/sleeping_beauty.sh",
+ timeout: 1 * time.Second,
+ expected: "hook timed out",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ err := launchHook(test.hook, test.timeout, map[string]string{})
+ require.EqualError(t, err, test.expected)
+ })
+ }
+}
diff --git a/cmd/lego/main.go b/cmd/lego/main.go
index 61a3d532a..c301a51f1 100644
--- a/cmd/lego/main.go
+++ b/cmd/lego/main.go
@@ -26,6 +26,7 @@ func main() {
}
var defaultPath string
+
cwd, err := os.Getwd()
if err == nil {
defaultPath = filepath.Join(cwd, ".lego")
diff --git a/cmd/lego/zz_gen_version.go b/cmd/lego/zz_gen_version.go
index c8a6df0ca..cf9ad00ef 100644
--- a/cmd/lego/zz_gen_version.go
+++ b/cmd/lego/zz_gen_version.go
@@ -2,7 +2,7 @@
package main
-const defaultVersion = "v4.20.3+dev-release"
+const defaultVersion = "v4.32.0+dev-detach"
var version = ""
diff --git a/cmd/setup.go b/cmd/setup.go
index 4a802ba13..6d15adad3 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -1,25 +1,38 @@
package cmd
import (
- "crypto/tls"
+ "context"
"crypto/x509"
+ "encoding/json"
"encoding/pem"
"fmt"
+ "io"
"net/http"
"os"
"strings"
"time"
+ "github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/registration"
+ "github.com/hashicorp/go-retryablehttp"
"github.com/urfave/cli/v2"
)
const filePerm os.FileMode = 0o600
-func setup(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, *lego.Client) {
+// setupClient creates a new client with challenge settings.
+func setupClient(ctx *cli.Context, account *Account, keyType certcrypto.KeyType) *lego.Client {
+ client := newClient(ctx, account, keyType)
+
+ setupChallenges(ctx, client)
+
+ return client
+}
+
+func setupAccount(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, certcrypto.KeyType) {
keyType := getKeyType(ctx)
privateKey := accountsStorage.GetPrivateKey(keyType)
@@ -27,12 +40,10 @@ func setup(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, *lego.
if accountsStorage.ExistsAccountFilePath() {
account = accountsStorage.LoadAccount(privateKey)
} else {
- account = &Account{Email: accountsStorage.GetUserID(), key: privateKey}
+ account = &Account{Email: accountsStorage.GetEmail(), key: privateKey}
}
- client := newClient(ctx, account, keyType)
-
- return account, client
+ return account, keyType
}
func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyType) *lego.Client {
@@ -43,6 +54,7 @@ func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyTy
KeyType: keyType,
Timeout: time.Duration(ctx.Int(flgCertTimeout)) * time.Second,
OverallRequestLimit: ctx.Int(flgOverallRequestLimit),
+ DisableCommonName: ctx.Bool(flgDisableCommonName),
}
config.UserAgent = getUserAgent(ctx)
@@ -51,11 +63,26 @@ func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyTy
}
if ctx.Bool(flgTLSSkipVerify) {
- config.HTTPClient.Transport = &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ defaultTransport, ok := config.HTTPClient.Transport.(*http.Transport)
+ if ok { // This is always true because the default client used by the CLI defined the transport.
+ tr := defaultTransport.Clone()
+ tr.TLSClientConfig.InsecureSkipVerify = true
+ config.HTTPClient.Transport = tr
}
}
+ retryClient := retryablehttp.NewClient()
+ retryClient.RetryMax = 5
+ retryClient.HTTPClient = config.HTTPClient
+ retryClient.CheckRetry = checkRetry
+ retryClient.Logger = nil
+
+ if _, v := os.LookupEnv("LEGO_DEBUG_ACME_HTTP_CLIENT"); v {
+ retryClient.Logger = log.Logger
+ }
+
+ config.HTTPClient = retryClient.StandardClient()
+
client, err := lego.NewClient(config)
if err != nil {
log.Fatalf("Could not create client: %v", err)
@@ -87,15 +114,8 @@ func getKeyType(ctx *cli.Context) certcrypto.KeyType {
}
log.Fatalf("Unsupported KeyType: %s", keyType)
- return ""
-}
-func getEmail(ctx *cli.Context) string {
- email := ctx.String(flgEmail)
- if email == "" {
- log.Fatalf("You have to pass an account (email address) to the program using --%s or -m", flgEmail)
- }
- return email
+ return ""
}
func getUserAgent(ctx *cli.Context) string {
@@ -108,6 +128,7 @@ func createNonExistingFolder(path string) error {
} else if err != nil {
return err
}
+
return nil
}
@@ -116,10 +137,12 @@ func readCSRFile(filename string) (*x509.CertificateRequest, error) {
if err != nil {
return nil, err
}
+
raw := bytes
// see if we can find a PEM-encoded CSR
var p *pem.Block
+
rest := bytes
for {
// decode a PEM block
@@ -141,3 +164,49 @@ func readCSRFile(filename string) (*x509.CertificateRequest, error) {
// (if this assumption is wrong, parsing these bytes will fail)
return x509.ParseCertificateRequest(raw)
}
+
+func checkRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
+ rt, err := retryablehttp.ErrorPropagatedRetryPolicy(ctx, resp, err)
+ if err != nil {
+ return rt, err
+ }
+
+ if resp == nil {
+ return rt, nil
+ }
+
+ if resp.StatusCode/100 == 2 {
+ return rt, nil
+ }
+
+ all, err := io.ReadAll(resp.Body)
+ if err == nil {
+ var errorDetails *acme.ProblemDetails
+
+ err = json.Unmarshal(all, &errorDetails)
+ if err != nil {
+ return rt, fmt.Errorf("%s %s: %s", resp.Request.Method, resp.Request.URL.Redacted(), string(all))
+ }
+
+ switch errorDetails.Type {
+ case acme.BadNonceErr:
+ return false, &acme.NonceError{
+ ProblemDetails: errorDetails,
+ }
+
+ case acme.AlreadyReplacedErr:
+ if errorDetails.HTTPStatus == http.StatusConflict {
+ return false, &acme.AlreadyReplacedError{
+ ProblemDetails: errorDetails,
+ }
+ }
+
+ default:
+ log.Warnf("retry: %v", errorDetails)
+
+ return rt, errorDetails
+ }
+ }
+
+ return rt, nil
+}
diff --git a/cmd/setup_challenges.go b/cmd/setup_challenges.go
index 0a59099a8..6968c7ba3 100644
--- a/cmd/setup_challenges.go
+++ b/cmd/setup_challenges.go
@@ -25,14 +25,14 @@ func setupChallenges(ctx *cli.Context, client *lego.Client) {
}
if ctx.Bool(flgHTTP) {
- err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(ctx))
+ err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(ctx), http01.SetDelay(ctx.Duration(flgHTTPDelay)))
if err != nil {
log.Fatal(err)
}
}
if ctx.Bool(flgTLS) {
- err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(ctx))
+ err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(ctx), tlsalpn01.SetDelay(ctx.Duration(flgTLSDelay)))
if err != nil {
log.Fatal(err)
}
@@ -54,18 +54,21 @@ func setupHTTPProvider(ctx *cli.Context) challenge.Provider {
if err != nil {
log.Fatal(err)
}
+
return ps
case ctx.IsSet(flgHTTPMemcachedHost):
ps, err := memcached.NewMemcachedProvider(ctx.StringSlice(flgHTTPMemcachedHost))
if err != nil {
log.Fatal(err)
}
+
return ps
case ctx.IsSet(flgHTTPS3Bucket):
ps, err := s3.NewHTTPProvider(ctx.String(flgHTTPS3Bucket))
if err != nil {
log.Fatal(err)
}
+
return ps
case ctx.IsSet(flgHTTPPort):
iface := ctx.String(flgHTTPPort)
@@ -82,12 +85,14 @@ func setupHTTPProvider(ctx *cli.Context) challenge.Provider {
if header := ctx.String(flgHTTPProxyHeader); header != "" {
srv.SetProxyHeader(header)
}
+
return srv
case ctx.Bool(flgHTTP):
srv := http01.NewProviderServer("", "")
if header := ctx.String(flgHTTPProxyHeader); header != "" {
srv.SetProxyHeader(header)
}
+
return srv
default:
log.Fatal("Invalid HTTP challenge options.")
diff --git a/cmd/testdata/sleeping_beauty.sh b/cmd/testdata/sleeping_beauty.sh
new file mode 100755
index 000000000..96b42a005
--- /dev/null
+++ b/cmd/testdata/sleeping_beauty.sh
@@ -0,0 +1,3 @@
+#!/bin/bash -e
+
+sleep 50
diff --git a/cmd/testdata/sleepy.sh b/cmd/testdata/sleepy.sh
new file mode 100755
index 000000000..60bb903a1
--- /dev/null
+++ b/cmd/testdata/sleepy.sh
@@ -0,0 +1,7 @@
+#!/bin/bash -e
+
+for i in `seq 1 10`
+do
+ echo $i
+ sleep 0.2
+done
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 52eb0f11f..f73f3920b 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -12,17 +12,28 @@ import (
func allDNSCodes() string {
providers := []string{
- "manual",
"acme-dns",
+ "active24",
"alidns",
+ "aliesa",
"allinkl",
+ "alwaysdata",
+ "anexia",
+ "artfiles",
"arvancloud",
"auroradns",
"autodns",
+ "axelname",
+ "azion",
"azure",
"azuredns",
+ "baiducloud",
+ "beget",
+ "binarylane",
"bindman",
"bluecat",
+ "bluecatv2",
+ "bookmyname",
"brandit",
"bunny",
"checkdomain",
@@ -32,15 +43,20 @@ func allDNSCodes() string {
"cloudns",
"cloudru",
"cloudxns",
+ "com35",
"conoha",
+ "conohav3",
"constellix",
"corenetworks",
"cpanel",
+ "czechia",
+ "ddnss",
"derak",
"desec",
"designate",
"digitalocean",
"directadmin",
+ "dnsexit",
"dnshomede",
"dnsimple",
"dnsmadeeasy",
@@ -50,23 +66,33 @@ func allDNSCodes() string {
"dreamhost",
"duckdns",
"dyn",
+ "dyndnsfree",
"dynu",
"easydns",
+ "edgecenter",
"edgedns",
+ "edgeone",
"efficientip",
"epik",
+ "eurodns",
+ "excedo",
"exec",
"exoscale",
+ "f5xc",
"freemyip",
"gandi",
"gandiv5",
"gcloud",
"gcore",
+ "gigahostno",
"glesys",
"godaddy",
"googledomains",
+ "gravity",
"hetzner",
"hostingde",
+ "hostinger",
+ "hostingnl",
"hosttech",
"httpnet",
"httpreq",
@@ -81,9 +107,15 @@ func allDNSCodes() string {
"internetbs",
"inwx",
"ionos",
+ "ionoscloud",
"ipv64",
+ "ispconfig",
+ "ispconfigddns",
"iwantmyname",
+ "jdcloud",
"joker",
+ "keyhelp",
+ "leaseweb",
"liara",
"lightsail",
"limacity",
@@ -92,22 +124,30 @@ func allDNSCodes() string {
"loopia",
"luadns",
"mailinabox",
+ "manageengine",
+ "manual",
"metaname",
+ "metaregistrar",
"mijnhost",
"mittwald",
+ "myaddr",
"mydnsjp",
"mythicbeasts",
"namecheap",
"namedotcom",
"namesilo",
+ "namesurfer",
"nearlyfreespeech",
+ "neodigit",
"netcup",
"netlify",
"nicmanager",
+ "nicru",
"nifcloud",
"njalla",
"nodion",
"ns1",
+ "octenium",
"oraclecloud",
"otc",
"ovh",
@@ -115,6 +155,7 @@ func allDNSCodes() string {
"plesk",
"porkbun",
"rackspace",
+ "rainyun",
"rcodezero",
"regfish",
"regru",
@@ -131,27 +172,35 @@ func allDNSCodes() string {
"shellrent",
"simply",
"sonic",
+ "spaceship",
"stackpath",
+ "syse",
"technitium",
"tencentcloud",
"timewebcloud",
+ "todaynic",
"transip",
"ultradns",
+ "uniteddomains",
"variomedia",
"vegadns",
"vercel",
"versio",
"vinyldns",
+ "virtualname",
"vkcloud",
"volcengine",
"vscale",
"vultr",
"webnames",
+ "webnamesca",
"websupport",
"wedos",
+ "westcn",
"yandex",
"yandex360",
"yandexcloud",
+ "zoneedit",
"zoneee",
"zonomi",
}
@@ -173,12 +222,37 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Credentials:`)
ew.writeln(` - "ACME_DNS_API_BASE": The ACME-DNS API address`)
+ ew.writeln(` - "ACME_DNS_STORAGE_BASE_URL": The ACME-DNS JSON account data server.`)
ew.writeln(` - "ACME_DNS_STORAGE_PATH": The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates.`)
ew.writeln()
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ACME_DNS_ALLOWLIST": Source networks using CIDR notation (multiple values should be separated with a comma).`)
+
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/acme-dns`)
+ case "active24":
+ // generated from: providers/dns/active24/active24.toml
+ ew.writeln(`Configuration for Active24.`)
+ ew.writeln(`Code: 'active24'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ACTIVE24_API_KEY": API key`)
+ ew.writeln(` - "ACTIVE24_SECRET": Secret`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ACTIVE24_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ACTIVE24_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ACTIVE24_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ACTIVE24_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/active24`)
+
case "alidns":
// generated from: providers/dns/alidns/alidns.toml
ew.writeln(`Configuration for Alibaba Cloud DNS.`)
@@ -188,20 +262,45 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Credentials:`)
ew.writeln(` - "ALICLOUD_ACCESS_KEY": Access key ID`)
- ew.writeln(` - "ALICLOUD_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/doc-detail/54579.htm)`)
+ ew.writeln(` - "ALICLOUD_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)`)
ew.writeln(` - "ALICLOUD_SECRET_KEY": Access Key secret`)
ew.writeln(` - "ALICLOUD_SECURITY_TOKEN": STS Security Token (optional)`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "ALICLOUD_LINE": Line (Default: default)`)
+ ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ALICLOUD_REGION_ID": Region ID (Default: cn-hangzhou)`)
+ ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/alidns`)
+ case "aliesa":
+ // generated from: providers/dns/aliesa/aliesa.toml
+ ew.writeln(`Configuration for AlibabaCloud ESA.`)
+ ew.writeln(`Code: 'aliesa'`)
+ ew.writeln(`Since: 'v4.29.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ALIESA_ACCESS_KEY": Access key ID`)
+ ew.writeln(` - "ALIESA_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)`)
+ ew.writeln(` - "ALIESA_SECRET_KEY": Access Key secret`)
+ ew.writeln(` - "ALIESA_SECURITY_TOKEN": STS Security Token (optional)`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ALIESA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ALIESA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ALIESA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ALIESA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/aliesa`)
+
case "allinkl":
// generated from: providers/dns/allinkl/allinkl.toml
ew.writeln(`Configuration for all-inkl.`)
@@ -215,13 +314,76 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "ALL_INKL_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "ALL_INKL_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "ALL_INKL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "ALL_INKL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ALL_INKL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ALL_INKL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/allinkl`)
+ case "alwaysdata":
+ // generated from: providers/dns/alwaysdata/alwaysdata.toml
+ ew.writeln(`Configuration for Alwaysdata.`)
+ ew.writeln(`Code: 'alwaysdata'`)
+ ew.writeln(`Since: 'v4.31.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ALWAYSDATA_API_KEY": API Key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ALWAYSDATA_ACCOUNT": Account name`)
+ ew.writeln(` - "ALWAYSDATA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ALWAYSDATA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ALWAYSDATA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ALWAYSDATA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/alwaysdata`)
+
+ case "anexia":
+ // generated from: providers/dns/anexia/anexia.toml
+ ew.writeln(`Configuration for Anexia CloudDNS.`)
+ ew.writeln(`Code: 'anexia'`)
+ ew.writeln(`Since: 'v4.28.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ANEXIA_TOKEN": API token for Anexia Engine`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ANEXIA_API_URL": API endpoint URL (default: https://engine.anexia-it.com)`)
+ ew.writeln(` - "ANEXIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ANEXIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ANEXIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "ANEXIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/anexia`)
+
+ case "artfiles":
+ // generated from: providers/dns/artfiles/artfiles.toml
+ ew.writeln(`Configuration for ArtFiles.`)
+ ew.writeln(`Code: 'artfiles'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ARTFILES_PASSWORD": API password`)
+ ew.writeln(` - "ARTFILES_USERNAME": API username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ARTFILES_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ARTFILES_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ARTFILES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`)
+ ew.writeln(` - "ARTFILES_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/artfiles`)
+
case "arvancloud":
// generated from: providers/dns/arvancloud/arvancloud.toml
ew.writeln(`Configuration for ArvanCloud.`)
@@ -234,10 +396,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "ARVANCLOUD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "ARVANCLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "ARVANCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "ARVANCLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "ARVANCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ARVANCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ARVANCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "ARVANCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/arvancloud`)
@@ -256,9 +418,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "AURORA_ENDPOINT": API endpoint URL`)
- ew.writeln(` - "AURORA_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "AURORA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "AURORA_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "AURORA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "AURORA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "AURORA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/auroradns`)
@@ -278,14 +440,56 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "AUTODNS_CONTEXT": API context (4 for production, 1 for testing. Defaults to 4)`)
ew.writeln(` - "AUTODNS_ENDPOINT": API endpoint URL, defaults to https://api.autodns.com/v1/`)
- ew.writeln(` - "AUTODNS_HTTP_TIMEOUT": API request timeout, defaults to 30 seconds`)
- ew.writeln(` - "AUTODNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "AUTODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "AUTODNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "AUTODNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "AUTODNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "AUTODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "AUTODNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/autodns`)
+ case "axelname":
+ // generated from: providers/dns/axelname/axelname.toml
+ ew.writeln(`Configuration for Axelname.`)
+ ew.writeln(`Code: 'axelname'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "AXELNAME_NICKNAME": Account nickname`)
+ ew.writeln(` - "AXELNAME_TOKEN": API token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "AXELNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "AXELNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "AXELNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "AXELNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/axelname`)
+
+ case "azion":
+ // generated from: providers/dns/azion/azion.toml
+ ew.writeln(`Configuration for Azion.`)
+ ew.writeln(`Code: 'azion'`)
+ ew.writeln(`Since: 'v4.24.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "AZION_PERSONAL_TOKEN": Your Azion personal token.`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "AZION_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "AZION_PAGE_SIZE": The page size for the API request (Default: 50)`)
+ ew.writeln(` - "AZION_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "AZION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "AZION_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/azion`)
+
case "azure":
// generated from: providers/dns/azure/azure.toml
ew.writeln(`Configuration for Azure (deprecated).`)
@@ -305,10 +509,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "AZURE_METADATA_ENDPOINT": Metadata Service endpoint URL`)
- ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check`)
+ ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "AZURE_PRIVATE_ZONE": Set to true to use Azure Private DNS Zones and not public`)
- ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln(` - "AZURE_ZONE_NAME": Zone name to use inside Azure DNS service to add the TXT record in`)
ew.writeln()
@@ -332,18 +536,79 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(` - "AZURE_AUTH_METHOD": Specify which authentication method to use`)
ew.writeln(` - "AZURE_AUTH_MSI_TIMEOUT": Managed Identity timeout duration`)
ew.writeln(` - "AZURE_ENVIRONMENT": Azure environment, one of: public, usgovernment, and china`)
- ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check`)
+ ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "AZURE_PRIVATE_ZONE": Set to true to use Azure Private DNS Zones and not public`)
- ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln(` - "AZURE_RESOURCE_GROUP": DNS zone resource group`)
ew.writeln(` - "AZURE_SERVICEDISCOVERY_FILTER": Advanced ServiceDiscovery filter using Kusto query condition`)
ew.writeln(` - "AZURE_SUBSCRIPTION_ID": DNS zone subscription ID`)
- ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln(` - "AZURE_ZONE_NAME": Zone name to use inside Azure DNS service to add the TXT record in`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/azuredns`)
+ case "baiducloud":
+ // generated from: providers/dns/baiducloud/baiducloud.toml
+ ew.writeln(`Configuration for Baidu Cloud.`)
+ ew.writeln(`Code: 'baiducloud'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BAIDUCLOUD_ACCESS_KEY_ID": Access key`)
+ ew.writeln(` - "BAIDUCLOUD_SECRET_ACCESS_KEY": Secret access key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BAIDUCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BAIDUCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "BAIDUCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/baiducloud`)
+
+ case "beget":
+ // generated from: providers/dns/beget/beget.toml
+ ew.writeln(`Configuration for Beget.com.`)
+ ew.writeln(`Code: 'beget'`)
+ ew.writeln(`Since: 'v4.27.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BEGET_PASSWORD": API password`)
+ ew.writeln(` - "BEGET_USERNAME": API username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BEGET_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BEGET_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`)
+ ew.writeln(` - "BEGET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "BEGET_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/beget`)
+
+ case "binarylane":
+ // generated from: providers/dns/binarylane/binarylane.toml
+ ew.writeln(`Configuration for Binary Lane.`)
+ ew.writeln(`Code: 'binarylane'`)
+ ew.writeln(`Since: 'v4.26.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BINARYLANE_API_TOKEN": API token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BINARYLANE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BINARYLANE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BINARYLANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "BINARYLANE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/binarylane`)
+
case "bindman":
// generated from: providers/dns/bindman/bindman.toml
ew.writeln(`Configuration for Bindman.`)
@@ -356,9 +621,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "BINDMAN_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "BINDMAN_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "BINDMAN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "BINDMAN_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "BINDMAN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BINDMAN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/bindman`)
@@ -379,15 +644,61 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "BLUECAT_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "BLUECAT_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "BLUECAT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "BLUECAT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BLUECAT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BLUECAT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "BLUECAT_SKIP_DEPLOY": Skip deployements`)
- ew.writeln(` - "BLUECAT_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "BLUECAT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecat`)
+ case "bluecatv2":
+ // generated from: providers/dns/bluecatv2/bluecatv2.toml
+ ew.writeln(`Configuration for Bluecat v2.`)
+ ew.writeln(`Code: 'bluecatv2'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BLUECATV2_CONFIG_NAME": Configuration name`)
+ ew.writeln(` - "BLUECATV2_PASSWORD": API password`)
+ ew.writeln(` - "BLUECATV2_USERNAME": API username`)
+ ew.writeln(` - "BLUECATV2_VIEW_NAME": DNS View Name`)
+ ew.writeln(` - "BLUECAT_SERVER_URL": The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BLUECATV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BLUECATV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BLUECATV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "BLUECATV2_SKIP_DEPLOY": Skip quick deployements`)
+ ew.writeln(` - "BLUECATV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecatv2`)
+
+ case "bookmyname":
+ // generated from: providers/dns/bookmyname/bookmyname.toml
+ ew.writeln(`Configuration for BookMyName.`)
+ ew.writeln(`Code: 'bookmyname'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BOOKMYNAME_PASSWORD": Password`)
+ ew.writeln(` - "BOOKMYNAME_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BOOKMYNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BOOKMYNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BOOKMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "BOOKMYNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/bookmyname`)
+
case "brandit":
// generated from: providers/dns/brandit/brandit.toml
ew.writeln(`Configuration for Brandit (deprecated).`)
@@ -401,10 +712,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "BRANDIT_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "BRANDIT_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "BRANDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "BRANDIT_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "BRANDIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BRANDIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BRANDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`)
+ ew.writeln(` - "BRANDIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/brandit`)
@@ -421,9 +732,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "BUNNY_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "BUNNY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "BUNNY_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "BUNNY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BUNNY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BUNNY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "BUNNY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/bunny`)
@@ -441,10 +753,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "CHECKDOMAIN_ENDPOINT": API endpoint URL, defaults to https://api.checkdomain.de`)
- ew.writeln(` - "CHECKDOMAIN_HTTP_TIMEOUT": API request timeout, defaults to 30 seconds`)
- ew.writeln(` - "CHECKDOMAIN_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CHECKDOMAIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CHECKDOMAIN_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CHECKDOMAIN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CHECKDOMAIN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 300)`)
+ ew.writeln(` - "CHECKDOMAIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 7)`)
+ ew.writeln(` - "CHECKDOMAIN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/checkdomain`)
@@ -461,9 +773,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CIVO_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CIVO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CIVO_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CIVO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`)
+ ew.writeln(` - "CIVO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "CIVO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/civo`)
@@ -482,10 +794,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CLOUDDNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "CLOUDDNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CLOUDDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CLOUDDNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CLOUDDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CLOUDDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "CLOUDDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "CLOUDDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/clouddns`)
@@ -509,10 +821,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CLOUDFLARE_HTTP_TIMEOUT": API request timeout (in seconds)`)
- ew.writeln(` - "CLOUDFLARE_POLLING_INTERVAL": Time between DNS propagation check (in seconds)`)
- ew.writeln(` - "CLOUDFLARE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation (in seconds)`)
- ew.writeln(` - "CLOUDFLARE_TTL": The TTL of the TXT record used for the DNS challenge (in seconds)`)
+ ew.writeln(` - "CLOUDFLARE_BASE_URL": API base URL (Default: https://api.cloudflare.com/client/v4)`)
+ ew.writeln(` - "CLOUDFLARE_HTTP_TIMEOUT": API request timeout in seconds (Default: )`)
+ ew.writeln(` - "CLOUDFLARE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "CLOUDFLARE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "CLOUDFLARE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudflare`)
@@ -530,11 +843,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CLOUDNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "CLOUDNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CLOUDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "CLOUDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CLOUDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "CLOUDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`)
ew.writeln(` - "CLOUDNS_SUB_AUTH_ID": The API sub user ID`)
- ew.writeln(` - "CLOUDNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CLOUDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudns`)
@@ -553,11 +866,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CLOUDRU_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "CLOUDRU_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CLOUDRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CLOUDRU_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "CLOUDRU_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CLOUDRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CLOUDRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "CLOUDRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "CLOUDRU_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`)
+ ew.writeln(` - "CLOUDRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudru`)
@@ -575,17 +888,38 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CLOUDXNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "CLOUDXNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CLOUDXNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CLOUDXNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CLOUDXNS_HTTP_TIMEOUT": API request timeout in seconds (Default: )`)
+ ew.writeln(` - "CLOUDXNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: )`)
+ ew.writeln(` - "CLOUDXNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: )`)
+ ew.writeln(` - "CLOUDXNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: )`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudxns`)
+ case "com35":
+ // generated from: providers/dns/com35/com35.toml
+ ew.writeln(`Configuration for 35.com/三五互联.`)
+ ew.writeln(`Code: 'com35'`)
+ ew.writeln(`Since: 'v4.31.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "COM35_PASSWORD": API password`)
+ ew.writeln(` - "COM35_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "COM35_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "COM35_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "COM35_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "COM35_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/com35`)
+
case "conoha":
// generated from: providers/dns/conoha/conoha.toml
- ew.writeln(`Configuration for ConoHa.`)
+ ew.writeln(`Configuration for ConoHa v2.`)
ew.writeln(`Code: 'conoha'`)
ew.writeln(`Since: 'v1.2.0'`)
ew.writeln()
@@ -597,15 +931,38 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CONOHA_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "CONOHA_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CONOHA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CONOHA_REGION": The region`)
- ew.writeln(` - "CONOHA_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CONOHA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CONOHA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "CONOHA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "CONOHA_REGION": The region (Default: tyo1)`)
+ ew.writeln(` - "CONOHA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/conoha`)
+ case "conohav3":
+ // generated from: providers/dns/conohav3/conohav3.toml
+ ew.writeln(`Configuration for ConoHa v3.`)
+ ew.writeln(`Code: 'conohav3'`)
+ ew.writeln(`Since: 'v4.24.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "CONOHAV3_API_PASSWORD": The API password`)
+ ew.writeln(` - "CONOHAV3_API_USER_ID": The API user ID`)
+ ew.writeln(` - "CONOHAV3_TENANT_ID": Tenant ID`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "CONOHAV3_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CONOHAV3_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "CONOHAV3_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "CONOHAV3_REGION": The region (Default: c3j1)`)
+ ew.writeln(` - "CONOHAV3_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/conohav3`)
+
case "constellix":
// generated from: providers/dns/constellix/constellix.toml
ew.writeln(`Configuration for Constellix.`)
@@ -619,10 +976,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CONSTELLIX_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "CONSTELLIX_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CONSTELLIX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CONSTELLIX_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CONSTELLIX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CONSTELLIX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "CONSTELLIX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "CONSTELLIX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/constellix`)
@@ -640,11 +997,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CORENETWORKS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "CORENETWORKS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CORENETWORKS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CORENETWORKS_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "CORENETWORKS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CORENETWORKS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CORENETWORKS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "CORENETWORKS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "CORENETWORKS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "CORENETWORKS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/corenetworks`)
@@ -663,16 +1020,56 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "CPANEL_HTTP_TIMEOUT": API request timeout`)
+ ew.writeln(` - "CPANEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "CPANEL_MODE": use cpanel API or WHM API (Default: cpanel)`)
- ew.writeln(` - "CPANEL_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "CPANEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "CPANEL_REGION": The region`)
- ew.writeln(` - "CPANEL_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "CPANEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "CPANEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "CPANEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/cpanel`)
+ case "czechia":
+ // generated from: providers/dns/czechia/czechia.toml
+ ew.writeln(`Configuration for Czechia.`)
+ ew.writeln(`Code: 'czechia'`)
+ ew.writeln(`Since: 'v4.33.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "CZECHIA_TOKEN": Authorization token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "CZECHIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "CZECHIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "CZECHIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "CZECHIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/czechia`)
+
+ case "ddnss":
+ // generated from: providers/dns/ddnss/ddnss.toml
+ ew.writeln(`Configuration for DDnss (DynDNS Service).`)
+ ew.writeln(`Code: 'ddnss'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "DDNSS_KEY": Update key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "DDNSS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DDNSS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DDNSS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DDNSS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "DDNSS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/ddnss`)
+
case "derak":
// generated from: providers/dns/derak/derak.toml
ew.writeln(`Configuration for Derak Cloud.`)
@@ -685,10 +1082,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DERAK_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DERAK_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DERAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DERAK_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DERAK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DERAK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "DERAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "DERAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln(` - "DERAK_WEBSITE_ID": Force the zone/website ID`)
ew.writeln()
@@ -706,10 +1103,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DESEC_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DESEC_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DESEC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DESEC_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DESEC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DESEC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`)
+ ew.writeln(` - "DESEC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "DESEC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/desec`)
@@ -734,9 +1131,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DESIGNATE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DESIGNATE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DESIGNATE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DESIGNATE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "DESIGNATE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`)
+ ew.writeln(` - "DESIGNATE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`)
ew.writeln(` - "DESIGNATE_ZONE_NAME": The zone name to use in the OpenStack Project to manage TXT records.`)
ew.writeln(` - "OS_PROJECT_ID": Project ID`)
ew.writeln(` - "OS_TENANT_NAME": Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID)`)
@@ -757,10 +1154,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "DO_API_URL": The URL of the API`)
- ew.writeln(` - "DO_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DO_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DO_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "DO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/digitalocean`)
@@ -779,15 +1176,35 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DIRECTADMIN_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DIRECTADMIN_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DIRECTADMIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DIRECTADMIN_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DIRECTADMIN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DIRECTADMIN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "DIRECTADMIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DIRECTADMIN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`)
ew.writeln(` - "DIRECTADMIN_ZONE_NAME": Zone name used to add the TXT record`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/directadmin`)
+ case "dnsexit":
+ // generated from: providers/dns/dnsexit/dnsexit.toml
+ ew.writeln(`Configuration for DNSExit.`)
+ ew.writeln(`Code: 'dnsexit'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "DNSEXIT_API_KEY": API key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "DNSEXIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DNSEXIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "DNSEXIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "DNSEXIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsexit`)
+
case "dnshomede":
// generated from: providers/dns/dnshomede/dnshomede.toml
ew.writeln(`Configuration for dnsHome.de.`)
@@ -800,10 +1217,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DNSHOMEDE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DNSHOMEDE_POLLING_INTERVAL": Time between DNS propagation checks`)
- ew.writeln(` - "DNSHOMEDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)`)
- ew.writeln(` - "DNSHOMEDE_SEQUENCE_INTERVAL": Time between sequential requests`)
+ ew.writeln(` - "DNSHOMEDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DNSHOMEDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 1200)`)
+ ew.writeln(` - "DNSHOMEDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2)`)
+ ew.writeln(` - "DNSHOMEDE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnshomede`)
@@ -821,9 +1238,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "DNSIMPLE_BASE_URL": API endpoint URL`)
- ew.writeln(` - "DNSIMPLE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DNSIMPLE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DNSIMPLE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DNSIMPLE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DNSIMPLE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DNSIMPLE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsimple`)
@@ -841,11 +1258,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DNSMADEEASY_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DNSMADEEASY_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DNSMADEEASY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "DNSMADEEASY_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "DNSMADEEASY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DNSMADEEASY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "DNSMADEEASY_SANDBOX": Activate the sandbox (boolean)`)
- ew.writeln(` - "DNSMADEEASY_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DNSMADEEASY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsmadeeasy`)
@@ -862,10 +1279,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DNSPOD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DNSPOD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DNSPOD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DNSPOD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DNSPOD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DNSPOD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DNSPOD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DNSPOD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnspod`)
@@ -882,11 +1299,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DODE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DODE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DODE_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "DODE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DODE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DODE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DODE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dode`)
@@ -904,9 +1320,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DOMENESHOP_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DOMENESHOP_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DOMENESHOP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "DOMENESHOP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DOMENESHOP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`)
+ ew.writeln(` - "DOMENESHOP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/domeneshop`)
@@ -923,10 +1339,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DREAMHOST_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DREAMHOST_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DREAMHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DREAMHOST_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DREAMHOST_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DREAMHOST_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`)
+ ew.writeln(` - "DREAMHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dreamhost`)
@@ -943,11 +1358,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DUCKDNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DUCKDNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DUCKDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DUCKDNS_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "DUCKDNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DUCKDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DUCKDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DUCKDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DUCKDNS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/duckdns`)
@@ -966,14 +1380,34 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DYN_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DYN_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DYN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DYN_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DYN_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "DYN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DYN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "DYN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dyn`)
+ case "dyndnsfree":
+ // generated from: providers/dns/dyndnsfree/dyndnsfree.toml
+ ew.writeln(`Configuration for DynDnsFree.de.`)
+ ew.writeln(`Code: 'dyndnsfree'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "DYNDNSFREE_PASSWORD": Password`)
+ ew.writeln(` - "DYNDNSFREE_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "DYNDNSFREE_HTTP_TIMEOUT": Request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DYNDNSFREE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "DYNDNSFREE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/dyndnsfree`)
+
case "dynu":
// generated from: providers/dns/dynu/dynu.toml
ew.writeln(`Configuration for Dynu.`)
@@ -986,10 +1420,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "DYNU_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "DYNU_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "DYNU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "DYNU_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "DYNU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "DYNU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "DYNU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`)
+ ew.writeln(` - "DYNU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/dynu`)
@@ -1008,15 +1442,35 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "EASYDNS_ENDPOINT": The endpoint URL of the API Server`)
- ew.writeln(` - "EASYDNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "EASYDNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "EASYDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "EASYDNS_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "EASYDNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "EASYDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EASYDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "EASYDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "EASYDNS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "EASYDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/easydns`)
+ case "edgecenter":
+ // generated from: providers/dns/edgecenter/edgecenter.toml
+ ew.writeln(`Configuration for EdgeCenter.`)
+ ew.writeln(`Code: 'edgecenter'`)
+ ew.writeln(`Since: 'v4.29.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "EDGECENTER_PERMANENT_API_TOKEN": Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/)`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "EDGECENTER_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "EDGECENTER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`)
+ ew.writeln(` - "EDGECENTER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`)
+ ew.writeln(` - "EDGECENTER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgecenter`)
+
case "edgedns":
// generated from: providers/dns/edgedns/edgedns.toml
ew.writeln(`Configuration for Akamai EdgeDNS.`)
@@ -1034,13 +1488,38 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "AKAMAI_POLLING_INTERVAL": Time between DNS propagation check. Default: 15 seconds`)
- ew.writeln(` - "AKAMAI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation. Default: 3 minutes`)
- ew.writeln(` - "AKAMAI_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "AKAMAI_ACCOUNT_SWITCH_KEY": Target account ID when the DNS zone and credentials belong to different accounts`)
+ ew.writeln(` - "AKAMAI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`)
+ ew.writeln(` - "AKAMAI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`)
+ ew.writeln(` - "AKAMAI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgedns`)
+ case "edgeone":
+ // generated from: providers/dns/edgeone/edgeone.toml
+ ew.writeln(`Configuration for Tencent EdgeOne.`)
+ ew.writeln(`Code: 'edgeone'`)
+ ew.writeln(`Since: 'v4.26.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "EDGEONE_SECRET_ID": Access key ID`)
+ ew.writeln(` - "EDGEONE_SECRET_KEY": Access Key secret`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "EDGEONE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EDGEONE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`)
+ ew.writeln(` - "EDGEONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`)
+ ew.writeln(` - "EDGEONE_REGION": Region`)
+ ew.writeln(` - "EDGEONE_SESSION_TOKEN": Access Key token`)
+ ew.writeln(` - "EDGEONE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
+ ew.writeln(` - "EDGEONE_ZONES_MAPPING": Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2')`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgeone`)
+
case "efficientip":
// generated from: providers/dns/efficientip/efficientip.toml
ew.writeln(`Configuration for Efficient IP.`)
@@ -1056,11 +1535,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "EFFICIENTIP_HTTP_TIMEOUT": API request timeout`)
+ ew.writeln(` - "EFFICIENTIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
ew.writeln(` - "EFFICIENTIP_INSECURE_SKIP_VERIFY": Whether or not to verify EfficientIP API certificate`)
- ew.writeln(` - "EFFICIENTIP_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "EFFICIENTIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "EFFICIENTIP_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "EFFICIENTIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "EFFICIENTIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "EFFICIENTIP_VIEW_NAME": View name (ex: external)`)
ew.writeln()
@@ -1078,14 +1556,56 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "EPIK_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "EPIK_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "EPIK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "EPIK_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "EPIK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EPIK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "EPIK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "EPIK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/epik`)
+ case "eurodns":
+ // generated from: providers/dns/eurodns/eurodns.toml
+ ew.writeln(`Configuration for EuroDNS.`)
+ ew.writeln(`Code: 'eurodns'`)
+ ew.writeln(`Since: 'v4.33.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "EURODNS_API_KEY": API key`)
+ ew.writeln(` - "EURODNS_APP_ID": Application ID`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "EURODNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EURODNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "EURODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "EURODNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/eurodns`)
+
+ case "excedo":
+ // generated from: providers/dns/excedo/excedo.toml
+ ew.writeln(`Configuration for Excedo.`)
+ ew.writeln(`Code: 'excedo'`)
+ ew.writeln(`Since: 'v4.33.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "EXCEDO_API_KEY": API key`)
+ ew.writeln(` - "EXCEDO_API_URL": API base URL`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "EXCEDO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "EXCEDO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "EXCEDO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "EXCEDO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/excedo`)
+
case "exec":
// generated from: providers/dns/exec/exec.toml
ew.writeln(`Configuration for External program.`)
@@ -1110,14 +1630,37 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "EXOSCALE_ENDPOINT": API endpoint URL`)
- ew.writeln(` - "EXOSCALE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "EXOSCALE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "EXOSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "EXOSCALE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "EXOSCALE_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "EXOSCALE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "EXOSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "EXOSCALE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/exoscale`)
+ case "f5xc":
+ // generated from: providers/dns/f5xc/f5xc.toml
+ ew.writeln(`Configuration for F5 XC.`)
+ ew.writeln(`Code: 'f5xc'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "F5XC_API_TOKEN": API token`)
+ ew.writeln(` - "F5XC_GROUP_NAME": Group name`)
+ ew.writeln(` - "F5XC_TENANT_NAME": XC Tenant shortname`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "F5XC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "F5XC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "F5XC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "F5XC_SERVER": Server domain (Default: console.ves.volterra.io)`)
+ ew.writeln(` - "F5XC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/f5xc`)
+
case "freemyip":
// generated from: providers/dns/freemyip/freemyip.toml
ew.writeln(`Configuration for freemyip.com.`)
@@ -1130,11 +1673,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "FREEMYIP_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "FREEMYIP_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "FREEMYIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "FREEMYIP_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "FREEMYIP_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "FREEMYIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "FREEMYIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "FREEMYIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "FREEMYIP_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "FREEMYIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/freemyip`)
@@ -1151,10 +1694,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "GANDI_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "GANDI_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "GANDI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "GANDI_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "GANDI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "GANDI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`)
+ ew.writeln(` - "GANDI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2400)`)
+ ew.writeln(` - "GANDI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/gandi`)
@@ -1172,10 +1715,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "GANDIV5_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "GANDIV5_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "GANDIV5_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "GANDIV5_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "GANDIV5_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "GANDIV5_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`)
+ ew.writeln(` - "GANDIV5_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`)
+ ew.writeln(` - "GANDIV5_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/gandiv5`)
@@ -1196,9 +1739,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "GCE_ALLOW_PRIVATE_ZONE": Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false)`)
- ew.writeln(` - "GCE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "GCE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "GCE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "GCE_IMPERSONATE_SERVICE_ACCOUNT": Service account email to impersonate`)
+ ew.writeln(` - "GCE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "GCE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`)
+ ew.writeln(` - "GCE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln(` - "GCE_ZONE_ID": Allows to skip the automatic detection of the zone`)
ew.writeln()
@@ -1216,14 +1760,36 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "GCORE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "GCORE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "GCORE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "GCORE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "GCORE_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "GCORE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`)
+ ew.writeln(` - "GCORE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`)
+ ew.writeln(` - "GCORE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/gcore`)
+ case "gigahostno":
+ // generated from: providers/dns/gigahostno/gigahostno.toml
+ ew.writeln(`Configuration for Gigahost.no.`)
+ ew.writeln(`Code: 'gigahostno'`)
+ ew.writeln(`Since: 'v4.29.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "GIGAHOSTNO_PASSWORD": Password`)
+ ew.writeln(` - "GIGAHOSTNO_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "GIGAHOSTNO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "GIGAHOSTNO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "GIGAHOSTNO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "GIGAHOSTNO_SECRET": TOTP secret`)
+ ew.writeln(` - "GIGAHOSTNO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/gigahostno`)
+
case "glesys":
// generated from: providers/dns/glesys/glesys.toml
ew.writeln(`Configuration for Glesys.`)
@@ -1237,10 +1803,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "GLESYS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "GLESYS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "GLESYS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "GLESYS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "GLESYS_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "GLESYS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`)
+ ew.writeln(` - "GLESYS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`)
+ ew.writeln(` - "GLESYS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/glesys`)
@@ -1258,10 +1824,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "GODADDY_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "GODADDY_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "GODADDY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "GODADDY_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "GODADDY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "GODADDY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "GODADDY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "GODADDY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/godaddy`)
@@ -1278,13 +1844,35 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "GOOGLE_DOMAINS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "GOOGLE_DOMAINS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "GOOGLE_DOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "GOOGLE_DOMAINS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "GOOGLE_DOMAINS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "GOOGLE_DOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/googledomains`)
+ case "gravity":
+ // generated from: providers/dns/gravity/gravity.toml
+ ew.writeln(`Configuration for Gravity.`)
+ ew.writeln(`Code: 'gravity'`)
+ ew.writeln(`Since: 'v4.30.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "GRAVITY_PASSWORD": Password`)
+ ew.writeln(` - "GRAVITY_SERVER_URL": URL of the server`)
+ ew.writeln(` - "GRAVITY_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "GRAVITY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "GRAVITY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "GRAVITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "GRAVITY_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 1)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/gravity`)
+
case "hetzner":
// generated from: providers/dns/hetzner/hetzner.toml
ew.writeln(`Configuration for Hetzner.`)
@@ -1293,14 +1881,14 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Credentials:`)
- ew.writeln(` - "HETZNER_API_KEY": API key`)
+ ew.writeln(` - "HETZNER_API_TOKEN": API token`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "HETZNER_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "HETZNER_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "HETZNER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "HETZNER_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "HETZNER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "HETZNER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HETZNER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "HETZNER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/hetzner`)
@@ -1317,15 +1905,55 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "HOSTINGDE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "HOSTINGDE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "HOSTINGDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "HOSTINGDE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "HOSTINGDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "HOSTINGDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HOSTINGDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "HOSTINGDE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln(` - "HOSTINGDE_ZONE_NAME": Zone name in ACE format`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostingde`)
+ case "hostinger":
+ // generated from: providers/dns/hostinger/hostinger.toml
+ ew.writeln(`Configuration for Hostinger.`)
+ ew.writeln(`Code: 'hostinger'`)
+ ew.writeln(`Since: 'v4.27.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "HOSTINGER_API_TOKEN": API Token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "HOSTINGER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "HOSTINGER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HOSTINGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "HOSTINGER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostinger`)
+
+ case "hostingnl":
+ // generated from: providers/dns/hostingnl/hostingnl.toml
+ ew.writeln(`Configuration for Hosting.nl.`)
+ ew.writeln(`Code: 'hostingnl'`)
+ ew.writeln(`Since: 'v4.30.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "HOSTINGNL_API_KEY": The API key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "HOSTINGNL_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "HOSTINGNL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HOSTINGNL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "HOSTINGNL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostingnl`)
+
case "hosttech":
// generated from: providers/dns/hosttech/hosttech.toml
ew.writeln(`Configuration for Hosttech.`)
@@ -1339,10 +1967,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "HOSTTECH_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "HOSTTECH_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "HOSTTECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "HOSTTECH_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "HOSTTECH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "HOSTTECH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HOSTTECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "HOSTTECH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/hosttech`)
@@ -1359,10 +1987,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "HTTPNET_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "HTTPNET_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "HTTPNET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "HTTPNET_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "HTTPNET_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "HTTPNET_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HTTPNET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "HTTPNET_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln(` - "HTTPNET_ZONE_NAME": Zone name in ACE format`)
ew.writeln()
@@ -1381,10 +2009,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "HTTPREQ_HTTP_TIMEOUT": API request timeout`)
+ ew.writeln(` - "HTTPREQ_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "HTTPREQ_PASSWORD": Basic authentication password`)
- ew.writeln(` - "HTTPREQ_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "HTTPREQ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "HTTPREQ_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HTTPREQ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "HTTPREQ_USERNAME": Basic authentication username`)
ew.writeln()
@@ -1404,10 +2032,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "HUAWEICLOUD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "HUAWEICLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "HUAWEICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "HUAWEICLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "HUAWEICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "HUAWEICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HUAWEICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "HUAWEICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/huaweicloud`)
@@ -1424,10 +2052,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "HURRICANE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "HURRICANE_POLLING_INTERVAL": Time between DNS propagation checks`)
- ew.writeln(` - "HURRICANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)`)
- ew.writeln(` - "HURRICANE_SEQUENCE_INTERVAL": Time between sequential requests`)
+ ew.writeln(` - "HURRICANE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "HURRICANE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "HURRICANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation (Default: 300)`)
+ ew.writeln(` - "HURRICANE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/hurricane`)
@@ -1441,11 +2069,12 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "HYPERONE_API_URL": Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)`)
+ ew.writeln(` - "HYPERONE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "HYPERONE_LOCATION_ID": Specifies location (region) to be used in API calls. (default pl-waw-1)`)
ew.writeln(` - "HYPERONE_PASSPORT_LOCATION": Allows to pass custom passport file location (default ~/.h1/passport.json)`)
- ew.writeln(` - "HYPERONE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "HYPERONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "HYPERONE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "HYPERONE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`)
+ ew.writeln(` - "HYPERONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2)`)
+ ew.writeln(` - "HYPERONE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/hyperone`)
@@ -1459,14 +2088,14 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Credentials:`)
ew.writeln(` - "SOFTLAYER_API_KEY": Classic Infrastructure API key`)
- ew.writeln(` - "SOFTLAYER_USERNAME": Username (IBM Cloud is _)`)
+ ew.writeln(` - "SOFTLAYER_USERNAME": Username (IBM Cloud is {accountID}_{emailAddress})`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SOFTLAYER_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SOFTLAYER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SOFTLAYER_TIMEOUT": API request timeout`)
- ew.writeln(` - "SOFTLAYER_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SOFTLAYER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "SOFTLAYER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "SOFTLAYER_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SOFTLAYER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/ibmcloud`)
@@ -1485,9 +2114,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "IIJ_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "IIJ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "IIJ_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "IIJ_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`)
+ ew.writeln(` - "IIJ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`)
+ ew.writeln(` - "IIJ_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/iij`)
@@ -1506,9 +2135,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "IIJ_DPF_API_ENDPOINT": API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1`)
- ew.writeln(` - "IIJ_DPF_POLLING_INTERVAL": Time between DNS propagation check, defaults to 5 second`)
- ew.writeln(` - "IIJ_DPF_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation, defaults to 660 second`)
- ew.writeln(` - "IIJ_DPF_TTL": The TTL of the TXT record used for the DNS challenge, default to 300`)
+ ew.writeln(` - "IIJ_DPF_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "IIJ_DPF_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 660)`)
+ ew.writeln(` - "IIJ_DPF_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/iijdpf`)
@@ -1527,14 +2156,15 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "INFOBLOX_DNS_VIEW": The view for the TXT records, default: External`)
- ew.writeln(` - "INFOBLOX_HTTP_TIMEOUT": HTTP request timeout`)
- ew.writeln(` - "INFOBLOX_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "INFOBLOX_PORT": The port for the infoblox grid manager, default: 443`)
- ew.writeln(` - "INFOBLOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "INFOBLOX_SSL_VERIFY": Whether or not to verify the TLS certificate, default: true`)
- ew.writeln(` - "INFOBLOX_TTL": The TTL of the TXT record used for the DNS challenge`)
- ew.writeln(` - "INFOBLOX_WAPI_VERSION": The version of WAPI being used, default: 2.11`)
+ ew.writeln(` - "INFOBLOX_CA_CERTIFICATE": The path to the CA certificate (PEM encoded)`)
+ ew.writeln(` - "INFOBLOX_DNS_VIEW": The view for the TXT records (Default: External)`)
+ ew.writeln(` - "INFOBLOX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "INFOBLOX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "INFOBLOX_PORT": The port for the infoblox grid manager (Default: 443)`)
+ ew.writeln(` - "INFOBLOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "INFOBLOX_SSL_VERIFY": Whether or not to verify the TLS certificate (Default: true)`)
+ ew.writeln(` - "INFOBLOX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+ ew.writeln(` - "INFOBLOX_WAPI_VERSION": The version of WAPI being used (Default: 2.11)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/infoblox`)
@@ -1552,10 +2182,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "INFOMANIAK_ENDPOINT": https://api.infomaniak.com`)
- ew.writeln(` - "INFOMANIAK_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "INFOMANIAK_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "INFOMANIAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "INFOMANIAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds`)
+ ew.writeln(` - "INFOMANIAK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "INFOMANIAK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "INFOMANIAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "INFOMANIAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/infomaniak`)
@@ -1573,10 +2203,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "INTERNET_BS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "INTERNET_BS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "INTERNET_BS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "INTERNET_BS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "INTERNET_BS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "INTERNET_BS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "INTERNET_BS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "INTERNET_BS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/internetbs`)
@@ -1594,11 +2224,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "INWX_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "INWX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation (default 360s)`)
+ ew.writeln(` - "INWX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "INWX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`)
ew.writeln(` - "INWX_SANDBOX": Activate the sandbox (boolean)`)
ew.writeln(` - "INWX_SHARED_SECRET": shared secret related to 2FA`)
- ew.writeln(` - "INWX_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "INWX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/inwx`)
@@ -1615,14 +2245,34 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "IONOS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "IONOS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "IONOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "IONOS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "IONOS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "IONOS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "IONOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`)
+ ew.writeln(` - "IONOS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/ionos`)
+ case "ionoscloud":
+ // generated from: providers/dns/ionoscloud/ionoscloud.toml
+ ew.writeln(`Configuration for Ionos Cloud.`)
+ ew.writeln(`Code: 'ionoscloud'`)
+ ew.writeln(`Since: 'v4.30.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "IONOSCLOUD_API_TOKEN": API token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "IONOSCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "IONOSCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "IONOSCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "IONOSCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/ionoscloud`)
+
case "ipv64":
// generated from: providers/dns/ipv64/ipv64.toml
ew.writeln(`Configuration for IPv64.`)
@@ -1635,17 +2285,60 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "IPV64_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "IPV64_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "IPV64_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "IPV64_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "IPV64_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "IPV64_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "IPV64_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/ipv64`)
+ case "ispconfig":
+ // generated from: providers/dns/ispconfig/ispconfig.toml
+ ew.writeln(`Configuration for ISPConfig 3.`)
+ ew.writeln(`Code: 'ispconfig'`)
+ ew.writeln(`Since: 'v4.31.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ISPCONFIG_PASSWORD": Password`)
+ ew.writeln(` - "ISPCONFIG_SERVER_URL": Server URL`)
+ ew.writeln(` - "ISPCONFIG_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ISPCONFIG_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ISPCONFIG_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`)
+ ew.writeln(` - "ISPCONFIG_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ISPCONFIG_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ISPCONFIG_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/ispconfig`)
+
+ case "ispconfigddns":
+ // generated from: providers/dns/ispconfigddns/ispconfigddns.toml
+ ew.writeln(`Configuration for ISPConfig 3 - Dynamic DNS (DDNS) Module.`)
+ ew.writeln(`Code: 'ispconfigddns'`)
+ ew.writeln(`Since: 'v4.31.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ISPCONFIG_DDNS_SERVER_URL": API server URL (ex: https://panel.example.com:8080)`)
+ ew.writeln(` - "ISPCONFIG_DDNS_TOKEN": DDNS API token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ISPCONFIG_DDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ISPCONFIG_DDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ISPCONFIG_DDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ISPCONFIG_DDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/ispconfigddns`)
+
case "iwantmyname":
// generated from: providers/dns/iwantmyname/iwantmyname.toml
- ew.writeln(`Configuration for iwantmyname.`)
+ ew.writeln(`Configuration for iwantmyname (Deprecated).`)
ew.writeln(`Code: 'iwantmyname'`)
ew.writeln(`Since: 'v4.7.0'`)
ew.writeln()
@@ -1656,14 +2349,36 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "IWANTMYNAME_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "IWANTMYNAME_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "IWANTMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "IWANTMYNAME_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "IWANTMYNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "IWANTMYNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "IWANTMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "IWANTMYNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/iwantmyname`)
+ case "jdcloud":
+ // generated from: providers/dns/jdcloud/jdcloud.toml
+ ew.writeln(`Configuration for JD Cloud.`)
+ ew.writeln(`Code: 'jdcloud'`)
+ ew.writeln(`Since: 'v4.31.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "JDCLOUD_ACCESS_KEY_ID": Access key ID`)
+ ew.writeln(` - "JDCLOUD_ACCESS_KEY_SECRET": Access key secret`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "JDCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "JDCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "JDCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "JDCLOUD_REGION_ID": Region ID (Default: cn-north-1)`)
+ ew.writeln(` - "JDCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/jdcloud`)
+
case "joker":
// generated from: providers/dns/joker/joker.toml
ew.writeln(`Configuration for Joker.`)
@@ -1679,15 +2394,56 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "JOKER_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "JOKER_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "JOKER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "JOKER_SEQUENCE_INTERVAL": Time between sequential requests (only with 'SVC' mode)`)
- ew.writeln(` - "JOKER_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "JOKER_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "JOKER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "JOKER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "JOKER_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60), only with 'SVC' mode`)
+ ew.writeln(` - "JOKER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/joker`)
+ case "keyhelp":
+ // generated from: providers/dns/keyhelp/keyhelp.toml
+ ew.writeln(`Configuration for KeyHelp.`)
+ ew.writeln(`Code: 'keyhelp'`)
+ ew.writeln(`Since: 'v4.26.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "KEYHELP_API_KEY": API key`)
+ ew.writeln(` - "KEYHELP_BASE_URL": Server URL`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "KEYHELP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "KEYHELP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "KEYHELP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "KEYHELP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/keyhelp`)
+
+ case "leaseweb":
+ // generated from: providers/dns/leaseweb/leaseweb.toml
+ ew.writeln(`Configuration for Leaseweb.`)
+ ew.writeln(`Code: 'leaseweb'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "LEASEWEB_API_KEY": API key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "LEASEWEB_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "LEASEWEB_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "LEASEWEB_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "LEASEWEB_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/leaseweb`)
+
case "liara":
// generated from: providers/dns/liara/liara.toml
ew.writeln(`Configuration for Liara.`)
@@ -1700,10 +2456,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "LIARA_TEAM_ID": The team ID to access services in a team`)
+ ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/liara`)
@@ -1723,8 +2480,8 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "AWS_SHARED_CREDENTIALS_FILE": Managed by the AWS client. Shared credentials file.`)
- ew.writeln(` - "LIGHTSAIL_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "LIGHTSAIL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "LIGHTSAIL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "LIGHTSAIL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/lightsail`)
@@ -1741,11 +2498,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "LIMACITY_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "LIMACITY_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "LIMACITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "LIMACITY_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "LIMACITY_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "LIMACITY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "LIMACITY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 80)`)
+ ew.writeln(` - "LIMACITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 480)`)
+ ew.writeln(` - "LIMACITY_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 90)`)
+ ew.writeln(` - "LIMACITY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/limacity`)
@@ -1762,10 +2519,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "LINODE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "LINODE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "LINODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "LINODE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "LINODE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "LINODE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`)
+ ew.writeln(` - "LINODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "LINODE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/linode`)
@@ -1783,10 +2540,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "LWAPI_HTTP_TIMEOUT": Maximum waiting time for the DNS records to be created (not verified)`)
- ew.writeln(` - "LWAPI_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "LWAPI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "LWAPI_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "LWAPI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "LWAPI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "LWAPI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "LWAPI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln(` - "LWAPI_URL": Liquid Web API endpoint`)
ew.writeln(` - "LWAPI_ZONE": DNS Zone`)
@@ -1807,10 +2564,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "LOOPIA_API_URL": API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV`)
- ew.writeln(` - "LOOPIA_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "LOOPIA_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "LOOPIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "LOOPIA_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "LOOPIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "LOOPIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2400)`)
+ ew.writeln(` - "LOOPIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "LOOPIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/loopia`)
@@ -1828,10 +2585,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "LUADNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "LUADNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "LUADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "LUADNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "LUADNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "LUADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "LUADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "LUADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/luadns`)
@@ -1850,12 +2607,43 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "MAILINABOX_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "MAILINABOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "MAILINABOX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "MAILINABOX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`)
+ ew.writeln(` - "MAILINABOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/mailinabox`)
+ case "manageengine":
+ // generated from: providers/dns/manageengine/manageengine.toml
+ ew.writeln(`Configuration for ManageEngine CloudDNS.`)
+ ew.writeln(`Code: 'manageengine'`)
+ ew.writeln(`Since: 'v4.21.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "MANAGEENGINE_CLIENT_ID": Client ID`)
+ ew.writeln(` - "MANAGEENGINE_CLIENT_SECRET": Client Secret`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "MANAGEENGINE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "MANAGEENGINE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "MANAGEENGINE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/manageengine`)
+
+ case "manual":
+ // generated from: providers/dns/manual/manual.toml
+ ew.writeln(`Configuration for Manual.`)
+ ew.writeln(`Code: 'manual'`)
+ ew.writeln(`Since: 'v0.3.0'`)
+ ew.writeln()
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/manual`)
+
case "metaname":
// generated from: providers/dns/metaname/metaname.toml
ew.writeln(`Configuration for Metaname.`)
@@ -1869,13 +2657,33 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "METANAME_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "METANAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "METANAME_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "METANAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "METANAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "METANAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/metaname`)
+ case "metaregistrar":
+ // generated from: providers/dns/metaregistrar/metaregistrar.toml
+ ew.writeln(`Configuration for Metaregistrar.`)
+ ew.writeln(`Code: 'metaregistrar'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "METAREGISTRAR_API_TOKEN": The API token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "METAREGISTRAR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "METAREGISTRAR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "METAREGISTRAR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "METAREGISTRAR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/metaregistrar`)
+
case "mijnhost":
// generated from: providers/dns/mijnhost/mijnhost.toml
ew.writeln(`Configuration for mijn.host.`)
@@ -1888,11 +2696,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "MIJNHOST_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "MIJNHOST_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "MIJNHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "MIJNHOST_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "MIJNHOST_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "MIJNHOST_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "MIJNHOST_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "MIJNHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "MIJNHOST_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "MIJNHOST_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/mijnhost`)
@@ -1909,15 +2717,36 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "MITTWALD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "MITTWALD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "MITTWALD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "MITTWALD_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "MITTWALD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "MITTWALD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "MITTWALD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "MITTWALD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "MITTWALD_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`)
+ ew.writeln(` - "MITTWALD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/mittwald`)
+ case "myaddr":
+ // generated from: providers/dns/myaddr/myaddr.toml
+ ew.writeln(`Configuration for myaddr.{tools,dev,io}.`)
+ ew.writeln(`Code: 'myaddr'`)
+ ew.writeln(`Since: 'v4.22.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "MYADDR_PRIVATE_KEYS_MAPPING": Mapping between subdomains and private keys. The format is: ':,:,:'`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "MYADDR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "MYADDR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "MYADDR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "MYADDR_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 2)`)
+ ew.writeln(` - "MYADDR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/myaddr`)
+
case "mydnsjp":
// generated from: providers/dns/mydnsjp/mydnsjp.toml
ew.writeln(`Configuration for MyDNS.jp.`)
@@ -1931,10 +2760,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "MYDNSJP_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "MYDNSJP_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "MYDNSJP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "MYDNSJP_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "MYDNSJP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "MYDNSJP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "MYDNSJP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/mydnsjp`)
@@ -1954,10 +2782,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "MYTHICBEASTS_API_ENDPOINT": The endpoint for the API (must implement v2)`)
ew.writeln(` - "MYTHICBEASTS_AUTH_API_ENDPOINT": The endpoint for Mythic Beasts' Authentication`)
- ew.writeln(` - "MYTHICBEASTS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "MYTHICBEASTS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "MYTHICBEASTS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "MYTHICBEASTS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "MYTHICBEASTS_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "MYTHICBEASTS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "MYTHICBEASTS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "MYTHICBEASTS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/mythicbeasts`)
@@ -1975,11 +2803,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NAMECHEAP_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NAMECHEAP_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NAMECHEAP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "NAMECHEAP_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "NAMECHEAP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`)
+ ew.writeln(` - "NAMECHEAP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 3600)`)
ew.writeln(` - "NAMECHEAP_SANDBOX": Activate the sandbox (boolean)`)
- ew.writeln(` - "NAMECHEAP_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NAMECHEAP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/namecheap`)
@@ -1997,10 +2825,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NAMECOM_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NAMECOM_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NAMECOM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NAMECOM_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NAMECOM_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "NAMECOM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`)
+ ew.writeln(` - "NAMECOM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`)
+ ew.writeln(` - "NAMECOM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/namedotcom`)
@@ -2017,13 +2845,37 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NAMESILO_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NAMESILO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation, it is better to set larger than 15m`)
- ew.writeln(` - "NAMESILO_TTL": The TTL of the TXT record used for the DNS challenge, should be in [3600, 2592000]`)
+ ew.writeln(` - "NAMESILO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NAMESILO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes`)
+ ew.writeln(` - "NAMESILO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000]`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesilo`)
+ case "namesurfer":
+ // generated from: providers/dns/namesurfer/namesurfer.toml
+ ew.writeln(`Configuration for FusionLayer NameSurfer.`)
+ ew.writeln(`Code: 'namesurfer'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "NAMESURFER_API_KEY": API key name`)
+ ew.writeln(` - "NAMESURFER_API_SECRET": API secret`)
+ ew.writeln(` - "NAMESURFER_BASE_URL": The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "NAMESURFER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NAMESURFER_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`)
+ ew.writeln(` - "NAMESURFER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NAMESURFER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "NAMESURFER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
+ ew.writeln(` - "NAMESURFER_VIEW": DNS view name (optional, default: empty string)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesurfer`)
+
case "nearlyfreespeech":
// generated from: providers/dns/nearlyfreespeech/nearlyfreespeech.toml
ew.writeln(`Configuration for NearlyFreeSpeech.NET.`)
@@ -2037,15 +2889,35 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NEARLYFREESPEECH_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NEARLYFREESPEECH_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NEARLYFREESPEECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NEARLYFREESPEECH_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "NEARLYFREESPEECH_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NEARLYFREESPEECH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NEARLYFREESPEECH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NEARLYFREESPEECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "NEARLYFREESPEECH_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "NEARLYFREESPEECH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/nearlyfreespeech`)
+ case "neodigit":
+ // generated from: providers/dns/neodigit/neodigit.toml
+ ew.writeln(`Configuration for Neodigit.`)
+ ew.writeln(`Code: 'neodigit'`)
+ ew.writeln(`Since: 'v4.30.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "NEODIGIT_TOKEN": API token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "NEODIGIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NEODIGIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "NEODIGIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "NEODIGIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/neodigit`)
+
case "netcup":
// generated from: providers/dns/netcup/netcup.toml
ew.writeln(`Configuration for Netcup.`)
@@ -2060,10 +2932,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NETCUP_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NETCUP_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NETCUP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NETCUP_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NETCUP_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "NETCUP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`)
+ ew.writeln(` - "NETCUP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/netcup`)
@@ -2080,10 +2951,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NETLIFY_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NETLIFY_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NETLIFY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NETLIFY_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NETLIFY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NETLIFY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NETLIFY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "NETLIFY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/netlify`)
@@ -2103,16 +2974,39 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NICMANAGER_API_MODE": mode: 'anycast' or 'zone' (default: 'anycast')`)
+ ew.writeln(` - "NICMANAGER_API_MODE": mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast')`)
ew.writeln(` - "NICMANAGER_API_OTP": TOTP Secret (optional)`)
- ew.writeln(` - "NICMANAGER_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NICMANAGER_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NICMANAGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NICMANAGER_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NICMANAGER_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "NICMANAGER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NICMANAGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "NICMANAGER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 900)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicmanager`)
+ case "nicru":
+ // generated from: providers/dns/nicru/nicru.toml
+ ew.writeln(`Configuration for RU CENTER.`)
+ ew.writeln(`Code: 'nicru'`)
+ ew.writeln(`Since: 'v4.24.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "NICRU_PASSWORD": Password for an account in RU CENTER`)
+ ew.writeln(` - "NICRU_SECRET": Secret for application in DNS-hosting RU CENTER`)
+ ew.writeln(` - "NICRU_SERVICE_ID": Service ID for application in DNS-hosting RU CENTER`)
+ ew.writeln(` - "NICRU_SERVICE_NAME": Service Name for DNS-hosting RU CENTER`)
+ ew.writeln(` - "NICRU_USER": Agreement for an account in RU CENTER`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "NICRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`)
+ ew.writeln(` - "NICRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`)
+ ew.writeln(` - "NICRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicru`)
+
case "nifcloud":
// generated from: providers/dns/nifcloud/nifcloud.toml
ew.writeln(`Configuration for NIFCloud.`)
@@ -2126,10 +3020,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NIFCLOUD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NIFCLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NIFCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NIFCLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NIFCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NIFCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NIFCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "NIFCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/nifcloud`)
@@ -2146,10 +3040,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NJALLA_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NJALLA_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NJALLA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NJALLA_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NJALLA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NJALLA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NJALLA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "NJALLA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/njalla`)
@@ -2166,10 +3060,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NODION_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NODION_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NODION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NODION_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NODION_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NODION_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NODION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "NODION_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/nodion`)
@@ -2186,14 +3080,34 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "NS1_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "NS1_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "NS1_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "NS1_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "NS1_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "NS1_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NS1_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "NS1_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/ns1`)
+ case "octenium":
+ // generated from: providers/dns/octenium/octenium.toml
+ ew.writeln(`Configuration for Octenium.`)
+ ew.writeln(`Code: 'octenium'`)
+ ew.writeln(`Since: 'v4.27.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "OCTENIUM_API_KEY": API key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "OCTENIUM_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "OCTENIUM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "OCTENIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "OCTENIUM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/octenium`)
+
case "oraclecloud":
// generated from: providers/dns/oraclecloud/oraclecloud.toml
ew.writeln(`Configuration for Oracle Cloud.`)
@@ -2203,18 +3117,25 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Credentials:`)
ew.writeln(` - "OCI_COMPARTMENT_OCID": Compartment OCID`)
- ew.writeln(` - "OCI_PRIVKEY_FILE": Private key file`)
- ew.writeln(` - "OCI_PRIVKEY_PASS": Private key password`)
- ew.writeln(` - "OCI_PUBKEY_FINGERPRINT": Public key fingerprint`)
- ew.writeln(` - "OCI_REGION": Region`)
- ew.writeln(` - "OCI_TENANCY_OCID": Tenancy OCID`)
- ew.writeln(` - "OCI_USER_OCID": User OCID`)
+ ew.writeln(` - "OCI_FINGERPRINT": Public key fingerprint (ignored if 'OCI_AUTH_TYPE=instance_principal')`)
+ ew.writeln(` - "OCI_PRIVATE_KEY_PASSWORD": Private key password (ignored if 'OCI_AUTH_TYPE=instance_principal')`)
+ ew.writeln(` - "OCI_PRIVATE_KEY_PATH": Private key file (ignored if 'OCI_AUTH_TYPE=instance_principal')`)
+ ew.writeln(` - "OCI_REGION": Region (it can be empty if 'OCI_AUTH_TYPE=instance_principal').`)
+ ew.writeln(` - "OCI_TENANCY_OCID": Tenancy OCID (ignored if 'OCI_AUTH_TYPE=instance_principal')`)
+ ew.writeln(` - "OCI_USER_OCID": User OCID (ignored if 'OCI_AUTH_TYPE=instance_principal')`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "OCI_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "OCI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "OCI_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "OCI_AUTH_TYPE": Authorization type. Possible values: 'instance_principal', '' (Default: '')`)
+ ew.writeln(` - "OCI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`)
+ ew.writeln(` - "OCI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "OCI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "OCI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+ ew.writeln(` - "TF_VAR_fingerprint": Alias on 'OCI_FINGERPRINT'`)
+ ew.writeln(` - "TF_VAR_private_key_path": Alias on 'OCI_PRIVATE_KEY_PATH'`)
+ ew.writeln(` - "TF_VAR_region": Alias on 'OCI_REGION'`)
+ ew.writeln(` - "TF_VAR_tenancy_ocid": Alias on 'OCI_TENANCY_OCID'`)
+ ew.writeln(` - "TF_VAR_user_ocid": Alias on 'OCI_USER_OCID'`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/oraclecloud`)
@@ -2228,18 +3149,19 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Credentials:`)
ew.writeln(` - "OTC_DOMAIN_NAME": Domain name`)
- ew.writeln(` - "OTC_IDENTITY_ENDPOINT": Identity endpoint URL`)
ew.writeln(` - "OTC_PASSWORD": Password`)
ew.writeln(` - "OTC_PROJECT_NAME": Project name`)
ew.writeln(` - "OTC_USER_NAME": User name`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "OTC_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "OTC_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "OTC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "OTC_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "OTC_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "OTC_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "OTC_IDENTITY_ENDPOINT": Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens)`)
+ ew.writeln(` - "OTC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "OTC_PRIVATE_ZONE": Set to true to use private zones only (default: use public zones only)`)
+ ew.writeln(` - "OTC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "OTC_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "OTC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/otc`)
@@ -2262,10 +3184,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "OVH_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "OVH_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "OVH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "OVH_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "OVH_HTTP_TIMEOUT": API request timeout in seconds (Default: 180)`)
+ ew.writeln(` - "OVH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "OVH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "OVH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/ovh`)
@@ -2284,11 +3206,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "PDNS_API_VERSION": Skip API version autodetection and use the provided version number.`)
- ew.writeln(` - "PDNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "PDNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "PDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "PDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "PDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "PDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln(` - "PDNS_SERVER_NAME": Name of the server in the URL, 'localhost' by default`)
- ew.writeln(` - "PDNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "PDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/pdns`)
@@ -2307,10 +3229,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "PLESK_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "PLESK_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "PLESK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "PLESK_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "PLESK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "PLESK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "PLESK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "PLESK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/plesk`)
@@ -2328,10 +3250,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "PORKBUN_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "PORKBUN_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "PORKBUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "PORKBUN_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "PORKBUN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "PORKBUN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "PORKBUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`)
+ ew.writeln(` - "PORKBUN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/porkbun`)
@@ -2349,14 +3271,34 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "RACKSPACE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "RACKSPACE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "RACKSPACE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "RACKSPACE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "RACKSPACE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "RACKSPACE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 3)`)
+ ew.writeln(` - "RACKSPACE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "RACKSPACE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/rackspace`)
+ case "rainyun":
+ // generated from: providers/dns/rainyun/rainyun.toml
+ ew.writeln(`Configuration for Rain Yun/雨云.`)
+ ew.writeln(`Code: 'rainyun'`)
+ ew.writeln(`Since: 'v4.21.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "RAINYUN_API_KEY": API key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "RAINYUN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "RAINYUN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "RAINYUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "RAINYUN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/rainyun`)
+
case "rcodezero":
// generated from: providers/dns/rcodezero/rcodezero.toml
ew.writeln(`Configuration for RcodeZero.`)
@@ -2369,10 +3311,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "RCODEZERO_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "RCODEZERO_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "RCODEZERO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "RCODEZERO_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "RCODEZERO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "RCODEZERO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "RCODEZERO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`)
+ ew.writeln(` - "RCODEZERO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/rcodezero`)
@@ -2389,10 +3331,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "REGFISH_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "REGFISH_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "REGFISH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "REGFISH_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "REGFISH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "REGFISH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "REGFISH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "REGFISH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/regfish`)
@@ -2410,12 +3352,12 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "REGRU_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "REGRU_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "REGRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "REGRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "REGRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "REGRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "REGRU_TLS_CERT": authentication certificate`)
ew.writeln(` - "REGRU_TLS_KEY": authentication private key`)
- ew.writeln(` - "REGRU_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "REGRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/regru`)
@@ -2435,12 +3377,12 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "RFC2136_DNS_TIMEOUT": API request timeout`)
- ew.writeln(` - "RFC2136_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "RFC2136_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "RFC2136_SEQUENCE_INTERVAL": Time between sequential requests`)
+ ew.writeln(` - "RFC2136_DNS_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "RFC2136_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "RFC2136_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "RFC2136_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
ew.writeln(` - "RFC2136_TSIG_FILE": Path to a key file generated by tsig-keygen`)
- ew.writeln(` - "RFC2136_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "RFC2136_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/rfc2136`)
@@ -2457,10 +3399,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "RIMUHOSTING_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "RIMUHOSTING_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "RIMUHOSTING_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "RIMUHOSTING_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "RIMUHOSTING_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "RIMUHOSTING_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "RIMUHOSTING_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "RIMUHOSTING_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/rimuhosting`)
@@ -2486,17 +3428,18 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "AWS_MAX_RETRIES": The number of maximum returns the service will use to make an individual API request`)
- ew.writeln(` - "AWS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "AWS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "AWS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`)
+ ew.writeln(` - "AWS_PRIVATE_ZONE": Set to true to use private zones only (default: use public zones only)`)
+ ew.writeln(` - "AWS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
ew.writeln(` - "AWS_SHARED_CREDENTIALS_FILE": Managed by the AWS client. Shared credentials file.`)
- ew.writeln(` - "AWS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "AWS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/route53`)
case "safedns":
// generated from: providers/dns/safedns/safedns.toml
- ew.writeln(`Configuration for UKFast SafeDNS.`)
+ ew.writeln(`Configuration for ANS SafeDNS.`)
ew.writeln(`Code: 'safedns'`)
ew.writeln(`Since: 'v4.6.0'`)
ew.writeln()
@@ -2506,10 +3449,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SAFEDNS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SAFEDNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SAFEDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SAFEDNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SAFEDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SAFEDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "SAFEDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "SAFEDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/safedns`)
@@ -2527,10 +3470,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SAKURACLOUD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SAKURACLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SAKURACLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SAKURACLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SAKURACLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "SAKURACLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "SAKURACLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "SAKURACLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/sakuracloud`)
@@ -2549,9 +3492,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "SCW_ACCESS_KEY": Access key`)
- ew.writeln(` - "SCW_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SCW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SCW_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SCW_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SCW_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "SCW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "SCW_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/scaleway`)
@@ -2569,10 +3513,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "SELECTEL_BASE_URL": API endpoint URL`)
- ew.writeln(` - "SELECTEL_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SELECTEL_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SELECTEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SELECTEL_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SELECTEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SELECTEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "SELECTEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "SELECTEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectel`)
@@ -2592,11 +3536,14 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "SELECTELV2_AUTH_REGION": Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1')`)
+ ew.writeln(` - "SELECTELV2_AUTH_URL": Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/')`)
ew.writeln(` - "SELECTELV2_BASE_URL": API endpoint URL`)
- ew.writeln(` - "SELECTELV2_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SELECTELV2_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SELECTELV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SELECTELV2_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SELECTELV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SELECTELV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "SELECTELV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "SELECTELV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
+ ew.writeln(` - "SELECTELV2_USER_DOMAIN_NAME": To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectelv2`)
@@ -2615,10 +3562,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SELFHOSTDE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SELFHOSTDE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SELFHOSTDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SELFHOSTDE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SELFHOSTDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SELFHOSTDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`)
+ ew.writeln(` - "SELFHOSTDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`)
+ ew.writeln(` - "SELFHOSTDE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/selfhostde`)
@@ -2636,10 +3583,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SERVERCOW_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SERVERCOW_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SERVERCOW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SERVERCOW_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SERVERCOW_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SERVERCOW_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "SERVERCOW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "SERVERCOW_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/servercow`)
@@ -2657,10 +3604,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SHELLRENT_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SHELLRENT_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SHELLRENT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SHELLRENT_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SHELLRENT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SHELLRENT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "SHELLRENT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "SHELLRENT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/shellrent`)
@@ -2678,10 +3625,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SIMPLY_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SIMPLY_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SIMPLY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SIMPLY_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SIMPLY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SIMPLY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "SIMPLY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "SIMPLY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/simply`)
@@ -2699,15 +3646,36 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "SONIC_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "SONIC_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "SONIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "SONIC_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "SONIC_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "SONIC_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "SONIC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "SONIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "SONIC_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "SONIC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/sonic`)
+ case "spaceship":
+ // generated from: providers/dns/spaceship/spaceship.toml
+ ew.writeln(`Configuration for Spaceship.`)
+ ew.writeln(`Code: 'spaceship'`)
+ ew.writeln(`Since: 'v4.22.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "SPACESHIP_API_KEY": API key`)
+ ew.writeln(` - "SPACESHIP_API_SECRET": API secret`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "SPACESHIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SPACESHIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "SPACESHIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "SPACESHIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/spaceship`)
+
case "stackpath":
// generated from: providers/dns/stackpath/stackpath.toml
ew.writeln(`Configuration for Stackpath.`)
@@ -2722,13 +3690,33 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "STACKPATH_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "STACKPATH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "STACKPATH_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "STACKPATH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "STACKPATH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "STACKPATH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/stackpath`)
+ case "syse":
+ // generated from: providers/dns/syse/syse.toml
+ ew.writeln(`Configuration for Syse.`)
+ ew.writeln(`Code: 'syse'`)
+ ew.writeln(`Since: 'v4.30.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "SYSE_CREDENTIALS": Comma-separated list of 'zone:password' credential pairs`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "SYSE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "SYSE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "SYSE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`)
+ ew.writeln(` - "SYSE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/syse`)
+
case "technitium":
// generated from: providers/dns/technitium/technitium.toml
ew.writeln(`Configuration for Technitium.`)
@@ -2742,10 +3730,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "TECHNITIUM_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "TECHNITIUM_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "TECHNITIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "TECHNITIUM_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "TECHNITIUM_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "TECHNITIUM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "TECHNITIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "TECHNITIUM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/technitium`)
@@ -2763,12 +3751,12 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "TENCENTCLOUD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "TENCENTCLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "TENCENTCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "TENCENTCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "TENCENTCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "TENCENTCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "TENCENTCLOUD_REGION": Region`)
ew.writeln(` - "TENCENTCLOUD_SESSION_TOKEN": Access Key token`)
- ew.writeln(` - "TENCENTCLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "TENCENTCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/tencentcloud`)
@@ -2785,13 +3773,34 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "TIMEWEBCLOUD_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "TIMEWEBCLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "TIMEWEBCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "TIMEWEBCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "TIMEWEBCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "TIMEWEBCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/timewebcloud`)
+ case "todaynic":
+ // generated from: providers/dns/todaynic/todaynic.toml
+ ew.writeln(`Configuration for TodayNIC/时代互联.`)
+ ew.writeln(`Code: 'todaynic'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "TODAYNIC_API_KEY": API key`)
+ ew.writeln(` - "TODAYNIC_AUTH_USER_ID": account ID`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "TODAYNIC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "TODAYNIC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "TODAYNIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "TODAYNIC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/todaynic`)
+
case "transip":
// generated from: providers/dns/transip/transip.toml
ew.writeln(`Configuration for TransIP.`)
@@ -2805,9 +3814,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "TRANSIP_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "TRANSIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "TRANSIP_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "TRANSIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "TRANSIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "TRANSIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`)
+ ew.writeln(` - "TRANSIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/transip`)
@@ -2826,13 +3836,33 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "ULTRADNS_ENDPOINT": API endpoint URL, defaults to https://api.ultradns.com/`)
- ew.writeln(` - "ULTRADNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "ULTRADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "ULTRADNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "ULTRADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`)
+ ew.writeln(` - "ULTRADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "ULTRADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/ultradns`)
+ case "uniteddomains":
+ // generated from: providers/dns/uniteddomains/uniteddomains.toml
+ ew.writeln(`Configuration for United-Domains.`)
+ ew.writeln(`Code: 'uniteddomains'`)
+ ew.writeln(`Since: 'v4.29.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "UNITEDDOMAINS_API_KEY": API key '.' https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "UNITEDDOMAINS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "UNITEDDOMAINS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "UNITEDDOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`)
+ ew.writeln(` - "UNITEDDOMAINS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/uniteddomains`)
+
case "variomedia":
// generated from: providers/dns/variomedia/variomedia.toml
ew.writeln(`Configuration for Variomedia.`)
@@ -2845,11 +3875,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "VARIOMEDIA_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "VARIOMEDIA_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VARIOMEDIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "VARIOMEDIA_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "VARIOMEDIA_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VARIOMEDIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "VARIOMEDIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "VARIOMEDIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "VARIOMEDIA_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "VARIOMEDIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/variomedia`)
@@ -2868,9 +3898,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "VEGADNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VEGADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "VEGADNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VEGADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`)
+ ew.writeln(` - "VEGADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 720)`)
+ ew.writeln(` - "VEGADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/vegadns`)
@@ -2887,11 +3917,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "VERCEL_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "VERCEL_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VERCEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "VERCEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "VERCEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "VERCEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln(` - "VERCEL_TEAM_ID": Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx)`)
- ew.writeln(` - "VERCEL_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VERCEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/vercel`)
@@ -2910,11 +3940,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "VERSIO_ENDPOINT": The endpoint URL of the API Server`)
- ew.writeln(` - "VERSIO_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "VERSIO_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VERSIO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "VERSIO_SEQUENCE_INTERVAL": Time between sequential requests, default 60s`)
- ew.writeln(` - "VERSIO_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VERSIO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "VERSIO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "VERSIO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "VERSIO_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "VERSIO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/versio`)
@@ -2933,13 +3963,35 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "VINYLDNS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VINYLDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "VINYLDNS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VINYLDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "VINYLDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`)
+ ew.writeln(` - "VINYLDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "VINYLDNS_QUOTE_VALUE": Adds quotes around the TXT record value (Default: false)`)
+ ew.writeln(` - "VINYLDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/vinyldns`)
+ case "virtualname":
+ // generated from: providers/dns/virtualname/virtualname.toml
+ ew.writeln(`Configuration for Virtualname.`)
+ ew.writeln(`Code: 'virtualname'`)
+ ew.writeln(`Since: 'v4.30.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "VIRTUALNAME_TOKEN": API token`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "VIRTUALNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "VIRTUALNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "VIRTUALNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "VIRTUALNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/virtualname`)
+
case "vkcloud":
// generated from: providers/dns/vkcloud/vkcloud.toml
ew.writeln(`Configuration for VK Cloud.`)
@@ -2957,9 +4009,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(` - "VK_CLOUD_DNS_ENDPOINT": URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds`)
ew.writeln(` - "VK_CLOUD_DOMAIN_NAME": Openstack users domain name. Defaults to 'users' but can be changed for usage with private clouds`)
ew.writeln(` - "VK_CLOUD_IDENTITY_ENDPOINT": URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds`)
- ew.writeln(` - "VK_CLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VK_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "VK_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VK_CLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "VK_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "VK_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/vkcloud`)
@@ -2978,12 +4030,12 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "VOLC_HOST": API host`)
- ew.writeln(` - "VOLC_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "VOLC_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VOLC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "VOLC_HTTP_TIMEOUT": API request timeout in seconds (Default: 15)`)
+ ew.writeln(` - "VOLC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "VOLC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`)
ew.writeln(` - "VOLC_REGION": Region`)
ew.writeln(` - "VOLC_SCHEME": API scheme`)
- ew.writeln(` - "VOLC_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VOLC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/volcengine`)
@@ -3001,10 +4053,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "VSCALE_BASE_URL": API endpoint URL`)
- ew.writeln(` - "VSCALE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "VSCALE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "VSCALE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VSCALE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "VSCALE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "VSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "VSCALE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/vscale`)
@@ -3021,34 +4073,54 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "VULTR_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "VULTR_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "VULTR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "VULTR_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "VULTR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "VULTR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "VULTR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "VULTR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/vultr`)
case "webnames":
// generated from: providers/dns/webnames/webnames.toml
- ew.writeln(`Configuration for Webnames.`)
+ ew.writeln(`Configuration for webnames.ru.`)
ew.writeln(`Code: 'webnames'`)
ew.writeln(`Since: 'v4.15.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
- ew.writeln(` - "WEBNAMES_API_KEY": Domain API key`)
+ ew.writeln(` - "WEBNAMESRU_API_KEY": Domain API key`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "WEBNAMES_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "WEBNAMES_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "WEBNAMES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "WEBNAMES_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "WEBNAMESRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "WEBNAMESRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "WEBNAMESRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/webnames`)
+ case "webnamesca":
+ // generated from: providers/dns/webnamesca/webnamesca.toml
+ ew.writeln(`Configuration for webnames.ca.`)
+ ew.writeln(`Code: 'webnamesca'`)
+ ew.writeln(`Since: 'v4.28.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "WEBNAMESCA_API_KEY": API key`)
+ ew.writeln(` - "WEBNAMESCA_API_USER": API username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "WEBNAMESCA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "WEBNAMESCA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "WEBNAMESCA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "WEBNAMESCA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/webnamesca`)
+
case "websupport":
// generated from: providers/dns/websupport/websupport.toml
ew.writeln(`Configuration for Websupport.`)
@@ -3062,11 +4134,11 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "WEBSUPPORT_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "WEBSUPPORT_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "WEBSUPPORT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "WEBSUPPORT_SEQUENCE_INTERVAL": Time between sequential requests`)
- ew.writeln(` - "WEBSUPPORT_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "WEBSUPPORT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "WEBSUPPORT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "WEBSUPPORT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "WEBSUPPORT_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`)
+ ew.writeln(` - "WEBSUPPORT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/websupport`)
@@ -3084,14 +4156,35 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "WEDOS_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "WEDOS_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "WEDOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "WEDOS_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "WEDOS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "WEDOS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "WEDOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`)
+ ew.writeln(` - "WEDOS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/wedos`)
+ case "westcn":
+ // generated from: providers/dns/westcn/westcn.toml
+ ew.writeln(`Configuration for West.cn/西部数码.`)
+ ew.writeln(`Code: 'westcn'`)
+ ew.writeln(`Since: 'v4.21.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "WESTCN_PASSWORD": API password`)
+ ew.writeln(` - "WESTCN_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "WESTCN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "WESTCN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`)
+ ew.writeln(` - "WESTCN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "WESTCN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/westcn`)
+
case "yandex":
// generated from: providers/dns/yandex/yandex.toml
ew.writeln(`Configuration for Yandex PDD.`)
@@ -3104,10 +4197,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "YANDEX_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "YANDEX_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "YANDEX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "YANDEX_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "YANDEX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "YANDEX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "YANDEX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "YANDEX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandex`)
@@ -3125,10 +4218,10 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "YANDEX360_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "YANDEX360_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "YANDEX360_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "YANDEX360_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "YANDEX360_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "YANDEX360_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "YANDEX360_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "YANDEX360_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandex360`)
@@ -3146,13 +4239,33 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "YANDEX_CLOUD_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "YANDEX_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "YANDEX_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "YANDEX_CLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "YANDEX_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "YANDEX_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandexcloud`)
+ case "zoneedit":
+ // generated from: providers/dns/zoneedit/zoneedit.toml
+ ew.writeln(`Configuration for ZoneEdit.`)
+ ew.writeln(`Code: 'zoneedit'`)
+ ew.writeln(`Since: 'v4.25.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ZONEEDIT_AUTH_TOKEN": Authentication token`)
+ ew.writeln(` - "ZONEEDIT_USER": User ID`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ZONEEDIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ZONEEDIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ZONEEDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/zoneedit`)
+
case "zoneee":
// generated from: providers/dns/zoneee/zoneee.toml
ew.writeln(`Configuration for Zone.ee.`)
@@ -3167,10 +4280,9 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "ZONEEE_ENDPOINT": API endpoint URL`)
- ew.writeln(` - "ZONEEE_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "ZONEEE_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "ZONEEE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "ZONEEE_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "ZONEEE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ZONEEE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`)
+ ew.writeln(` - "ZONEEE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/zoneee`)
@@ -3187,16 +4299,14 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`Additional Configuration:`)
- ew.writeln(` - "ZONOMI_HTTP_TIMEOUT": API request timeout`)
- ew.writeln(` - "ZONOMI_POLLING_INTERVAL": Time between DNS propagation check`)
- ew.writeln(` - "ZONOMI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
- ew.writeln(` - "ZONOMI_TTL": The TTL of the TXT record used for the DNS challenge`)
+ ew.writeln(` - "ZONOMI_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ZONOMI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ZONOMI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ZONOMI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/zonomi`)
- case "manual":
- ew.writeln(`Solving the DNS-01 challenge using CLI prompt.`)
default:
return fmt.Errorf("%q is not yet supported", name)
}
diff --git a/docs/Makefile b/docs/Makefile
index 8e32681d1..6c84c7d1d 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -1,14 +1,14 @@
-.PHONY: default clean hugo hugo-build
+.PHONY: default clean serve build
-default: clean hugo
+default: clean serve
clean:
rm -rf public/
-hugo-build: clean
+build: clean
hugo --enableGitInfo --source .
-hugo:
+serve:
hugo server --disableFastRender --enableGitInfo --watch --source .
# hugo server -D
diff --git a/docs/content/_index.md b/docs/content/_index.md
index 6d9fc3f1a..95e411afc 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -7,23 +7,34 @@ chapter: false
Let's Encrypt client and ACME library written in Go.
+{{% notice important %}}
+lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
+
+This project is not owned by a company. I'm not an employee of a company.
+
+I don't have gifted domains/accounts from DNS companies.
+
+I've been maintaining it for about 10 years.
+{{% /notice %}}
+
## Features
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): issues certificates for IP addresses
- - Support [draft-ietf-acme-ari-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
+ - Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension
+ - Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension
+- Comes with about [180 DNS providers]({{% ref "dns" %}})
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Renew certificates
- Revoke certificates
-- Robust implementation of all ACME challenges
+- Robust implementation of ACME challenges:
- HTTP (http-01)
- DNS (dns-01)
- TLS (tls-alpn-01)
- SAN certificate support
- [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default
-- Comes with multiple optional [DNS providers]({{% ref "dns" %}})
- [Custom challenge solvers]({{% ref "usage/library/Writing-a-Challenge-Solver" %}})
- Certificate bundling
- OCSP helper function
diff --git a/docs/content/dns/_index.md b/docs/content/dns/_index.md
index 7ccfeb53d..2b6f0489c 100644
--- a/docs/content/dns/_index.md
+++ b/docs/content/dns/_index.md
@@ -5,6 +5,16 @@ draft: false
weight: 3
---
+{{% notice important %}}
+lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
+
+This project is not owned by a company. I'm not an employee of a company.
+
+I don't have gifted domains/accounts from DNS companies.
+
+I've been maintaining it for about 10 years.
+{{% /notice %}}
+
## Configuration and Credentials
Credentials and DNS configuration for DNS providers must be passed through environment variables.
diff --git a/docs/content/dns/zz_gen_acme-dns.md b/docs/content/dns/zz_gen_acme-dns.md
index 0d57146ff..5564dba1b 100644
--- a/docs/content/dns/zz_gen_acme-dns.md
+++ b/docs/content/dns/zz_gen_acme-dns.md
@@ -28,7 +28,13 @@ Here is an example bash command using the Joohoi's ACME-DNS provider:
```bash
ACME_DNS_API_BASE=http://10.0.0.8:4443 \
ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \
-lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run
+lego --dns "acme-dns" -d '*.example.com' -d example.com run
+
+# or
+
+ACME_DNS_API_BASE=http://10.0.0.8:4443 \
+ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \
+lego --dns "acme-dns" -d '*.example.com' -d example.com run
```
@@ -39,20 +45,29 @@ lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ACME_DNS_API_BASE` | The ACME-DNS API address |
+| `ACME_DNS_STORAGE_BASE_URL` | The ACME-DNS JSON account data server. |
| `ACME_DNS_STORAGE_PATH` | The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates. |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ACME_DNS_ALLOWLIST` | Source networks using CIDR notation (multiple values should be separated with a comma). |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
-
-
## More information
- [API documentation](https://github.com/joohoi/acme-dns#api)
-- [Go client](https://github.com/cpu/goacmedns)
+- [Go client](https://github.com/nrdcg/goacmedns)
diff --git a/docs/content/dns/zz_gen_active24.md b/docs/content/dns/zz_gen_active24.md
new file mode 100644
index 000000000..6ec5c467a
--- /dev/null
+++ b/docs/content/dns/zz_gen_active24.md
@@ -0,0 +1,69 @@
+---
+title: "Active24"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: active24
+dnsprovider:
+ since: "v4.23.0"
+ code: "active24"
+ url: "https://www.active24.cz"
+---
+
+
+
+
+
+
+Configuration for [Active24](https://www.active24.cz).
+
+
+
+
+- Code: `active24`
+- Since: v4.23.0
+
+
+Here is an example bash command using the Active24 provider:
+
+```bash
+ACTIVE24_API_KEY="xxx" \
+ACTIVE24_SECRET="yyy" \
+lego --dns active24 -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ACTIVE24_API_KEY` | API key |
+| `ACTIVE24_SECRET` | Secret |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ACTIVE24_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ACTIVE24_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ACTIVE24_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ACTIVE24_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://rest.active24.cz/v2/docs)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_alidns.md b/docs/content/dns/zz_gen_alidns.md
index d822ecea6..4ded782ab 100644
--- a/docs/content/dns/zz_gen_alidns.md
+++ b/docs/content/dns/zz_gen_alidns.md
@@ -28,13 +28,13 @@ Here is an example bash command using the Alibaba Cloud DNS provider:
```bash
# Setup using instance RAM role
ALICLOUD_RAM_ROLE=lego \
-lego --email you@example.com --dns alidns -d '*.example.com' -d example.com run
+lego --dns alidns -d '*.example.com' -d example.com run
# Or, using credentials
ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx \
ALICLOUD_SECRET_KEY=your-secret-key \
ALICLOUD_SECURITY_TOKEN=your-sts-token \
-lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com run
+lego --dns alidns - -d '*.example.com' -d example.com run
```
@@ -45,7 +45,7 @@ lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com ru
| Environment Variable Name | Description |
|-----------------------|-------------|
| `ALICLOUD_ACCESS_KEY` | Access key ID |
-| `ALICLOUD_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/doc-detail/54579.htm) |
+| `ALICLOUD_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) |
| `ALICLOUD_SECRET_KEY` | Access Key secret |
| `ALICLOUD_SECURITY_TOKEN` | STS Security Token (optional) |
@@ -57,10 +57,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `ALICLOUD_HTTP_TIMEOUT` | API request timeout |
-| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `ALICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `ALICLOUD_LINE` | Line (Default: default) |
+| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ALICLOUD_REGION_ID` | Region ID (Default: cn-hangzhou) |
+| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -71,7 +73,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
- [API documentation](https://www.alibabacloud.com/help/en/alibaba-cloud-dns/latest/api-alidns-2015-01-09-dir-parsing-records)
-- [Go client](https://github.com/aliyun/alibaba-cloud-sdk-go)
+- [Go client](https://github.com/alibabacloud-go/alidns-20150109)
diff --git a/docs/content/dns/zz_gen_aliesa.md b/docs/content/dns/zz_gen_aliesa.md
new file mode 100644
index 000000000..af28f9a4e
--- /dev/null
+++ b/docs/content/dns/zz_gen_aliesa.md
@@ -0,0 +1,78 @@
+---
+title: "AlibabaCloud ESA"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: aliesa
+dnsprovider:
+ since: "v4.29.0"
+ code: "aliesa"
+ url: "https://www.alibabacloud.com/en/product/esa"
+---
+
+
+
+
+
+
+Configuration for [AlibabaCloud ESA](https://www.alibabacloud.com/en/product/esa).
+
+
+
+
+- Code: `aliesa`
+- Since: v4.29.0
+
+
+Here is an example bash command using the AlibabaCloud ESA provider:
+
+```bash
+# Setup using instance RAM role
+ALIESA_RAM_ROLE=lego \
+lego --dns aliesa -d '*.example.com' -d example.com run
+
+# Or, using credentials
+ALIESA_ACCESS_KEY=abcdefghijklmnopqrstuvwx \
+ALIESA_SECRET_KEY=your-secret-key \
+ALIESA_SECURITY_TOKEN=your-sts-token \
+lego --dns aliesa - -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ALIESA_ACCESS_KEY` | Access key ID |
+| `ALIESA_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) |
+| `ALIESA_SECRET_KEY` | Access Key secret |
+| `ALIESA_SECURITY_TOKEN` | STS Security Token (optional) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ALIESA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ALIESA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ALIESA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ALIESA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-overview?spm=a2c63.p38356.help-menu-2673927.d_6_0_0.20b224c28PSZDc#:~:text=DNS-,DNS%20records,-DNS%20records)
+- [Go client](https://github.com/alibabacloud-go/esa-20240910)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_allinkl.md b/docs/content/dns/zz_gen_allinkl.md
index 08e354f87..2db6ae2c5 100644
--- a/docs/content/dns/zz_gen_allinkl.md
+++ b/docs/content/dns/zz_gen_allinkl.md
@@ -28,7 +28,7 @@ Here is an example bash command using the all-inkl provider:
```bash
ALL_INKL_LOGIN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
ALL_INKL_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \
-lego --email you@example.com --dns allinkl -d '*.example.com' -d example.com run
+lego --dns allinkl -d '*.example.com' -d example.com run
```
@@ -49,9 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `ALL_INKL_HTTP_TIMEOUT` | API request timeout |
-| `ALL_INKL_POLLING_INTERVAL` | Time between DNS propagation check |
-| `ALL_INKL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `ALL_INKL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ALL_INKL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ALL_INKL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_alwaysdata.md b/docs/content/dns/zz_gen_alwaysdata.md
new file mode 100644
index 000000000..6ec332d16
--- /dev/null
+++ b/docs/content/dns/zz_gen_alwaysdata.md
@@ -0,0 +1,68 @@
+---
+title: "Alwaysdata"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: alwaysdata
+dnsprovider:
+ since: "v4.31.0"
+ code: "alwaysdata"
+ url: "https://alwaysdata.com/"
+---
+
+
+
+
+
+
+Configuration for [Alwaysdata](https://alwaysdata.com/).
+
+
+
+
+- Code: `alwaysdata`
+- Since: v4.31.0
+
+
+Here is an example bash command using the Alwaysdata provider:
+
+```bash
+ALWAYSDATA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns alwaysdata -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ALWAYSDATA_API_KEY` | API Key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ALWAYSDATA_ACCOUNT` | Account name |
+| `ALWAYSDATA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ALWAYSDATA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ALWAYSDATA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ALWAYSDATA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://help.alwaysdata.com/en/api/resources/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_anexia.md b/docs/content/dns/zz_gen_anexia.md
new file mode 100644
index 000000000..e12ec7cfd
--- /dev/null
+++ b/docs/content/dns/zz_gen_anexia.md
@@ -0,0 +1,73 @@
+---
+title: "Anexia CloudDNS"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: anexia
+dnsprovider:
+ since: "v4.28.0"
+ code: "anexia"
+ url: "https://www.anexia-it.com/"
+---
+
+
+
+
+
+
+Configuration for [Anexia CloudDNS](https://www.anexia-it.com/).
+
+
+
+
+- Code: `anexia`
+- Since: v4.28.0
+
+
+Here is an example bash command using the Anexia CloudDNS provider:
+
+```bash
+ANEXIA_TOKEN=xxx \
+lego --dns anexia -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ANEXIA_TOKEN` | API token for Anexia Engine |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ANEXIA_API_URL` | API endpoint URL (default: https://engine.anexia-it.com) |
+| `ANEXIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ANEXIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ANEXIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `ANEXIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+## Description
+
+You need to create an API token in the [Anexia Engine](https://engine.anexia-it.com/).
+
+The token must have permissions to manage DNS zones and records.
+
+
+
+## More information
+
+- [API documentation](https://engine.anexia-it.com/docs/en/module/clouddns/api)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_artfiles.md b/docs/content/dns/zz_gen_artfiles.md
new file mode 100644
index 000000000..15ac2d964
--- /dev/null
+++ b/docs/content/dns/zz_gen_artfiles.md
@@ -0,0 +1,69 @@
+---
+title: "ArtFiles"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: artfiles
+dnsprovider:
+ since: "v4.32.0"
+ code: "artfiles"
+ url: "https://www.artfiles.de/extras/domains/"
+---
+
+
+
+
+
+
+Configuration for [ArtFiles](https://www.artfiles.de/extras/domains/).
+
+
+
+
+- Code: `artfiles`
+- Since: v4.32.0
+
+
+Here is an example bash command using the ArtFiles provider:
+
+```bash
+ARTFILES_USERNAME="xxx" \
+ARTFILES_PASSWORD="yyy" \
+lego --dns artfiles -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ARTFILES_PASSWORD` | API password |
+| `ARTFILES_USERNAME` | API username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ARTFILES_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ARTFILES_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ARTFILES_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) |
+| `ARTFILES_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://support.artfiles.de/DCP-API#dns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_arvancloud.md b/docs/content/dns/zz_gen_arvancloud.md
index ff03f22e1..96d495f71 100644
--- a/docs/content/dns/zz_gen_arvancloud.md
+++ b/docs/content/dns/zz_gen_arvancloud.md
@@ -27,7 +27,7 @@ Here is an example bash command using the ArvanCloud provider:
```bash
ARVANCLOUD_API_KEY="Apikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
-lego --email you@example.com --dns arvancloud -d '*.example.com' -d example.com run
+lego --dns arvancloud -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `ARVANCLOUD_HTTP_TIMEOUT` | API request timeout |
-| `ARVANCLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `ARVANCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `ARVANCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `ARVANCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ARVANCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ARVANCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `ARVANCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_auroradns.md b/docs/content/dns/zz_gen_auroradns.md
index d3fa5a1df..d608c85bb 100644
--- a/docs/content/dns/zz_gen_auroradns.md
+++ b/docs/content/dns/zz_gen_auroradns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Aurora DNS provider:
```bash
AURORA_API_KEY=xxxxx \
AURORA_SECRET=yyyyyy \
-lego --email you@example.com --dns auroradns -d '*.example.com' -d example.com run
+lego --dns auroradns -d '*.example.com' -d example.com run
```
@@ -50,9 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `AURORA_ENDPOINT` | API endpoint URL |
-| `AURORA_POLLING_INTERVAL` | Time between DNS propagation check |
-| `AURORA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `AURORA_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `AURORA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `AURORA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `AURORA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_autodns.md b/docs/content/dns/zz_gen_autodns.md
index 584f21770..f1f25e916 100644
--- a/docs/content/dns/zz_gen_autodns.md
+++ b/docs/content/dns/zz_gen_autodns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Autodns provider:
```bash
AUTODNS_API_USER=username \
AUTODNS_API_PASSWORD=supersecretpassword \
-lego --email you@example.com --dns autodns -d '*.example.com' -d example.com run
+lego --dns autodns -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
|--------------------------------|-------------|
| `AUTODNS_CONTEXT` | API context (4 for production, 1 for testing. Defaults to 4) |
| `AUTODNS_ENDPOINT` | API endpoint URL, defaults to https://api.autodns.com/v1/ |
-| `AUTODNS_HTTP_TIMEOUT` | API request timeout, defaults to 30 seconds |
-| `AUTODNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `AUTODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `AUTODNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `AUTODNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `AUTODNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `AUTODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `AUTODNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_axelname.md b/docs/content/dns/zz_gen_axelname.md
new file mode 100644
index 000000000..91476e521
--- /dev/null
+++ b/docs/content/dns/zz_gen_axelname.md
@@ -0,0 +1,69 @@
+---
+title: "Axelname"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: axelname
+dnsprovider:
+ since: "v4.23.0"
+ code: "axelname"
+ url: "https://axelname.ru"
+---
+
+
+
+
+
+
+Configuration for [Axelname](https://axelname.ru).
+
+
+
+
+- Code: `axelname`
+- Since: v4.23.0
+
+
+Here is an example bash command using the Axelname provider:
+
+```bash
+AXELNAME_NICKNAME="yyy" \
+AXELNAME_TOKEN="xxx" \
+lego --dns axelname -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `AXELNAME_NICKNAME` | Account nickname |
+| `AXELNAME_TOKEN` | API token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `AXELNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `AXELNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `AXELNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `AXELNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://axelname.ru/static/content/files/axelname_api_rest_lite.pdf)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_azion.md b/docs/content/dns/zz_gen_azion.md
new file mode 100644
index 000000000..c5ca33552
--- /dev/null
+++ b/docs/content/dns/zz_gen_azion.md
@@ -0,0 +1,69 @@
+---
+title: "Azion"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: azion
+dnsprovider:
+ since: "v4.24.0"
+ code: "azion"
+ url: "https://www.azion.com/en/products/edge-dns/"
+---
+
+
+
+
+
+
+Configuration for [Azion](https://www.azion.com/en/products/edge-dns/).
+
+
+
+
+- Code: `azion`
+- Since: v4.24.0
+
+
+Here is an example bash command using the Azion provider:
+
+```bash
+AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
+lego --dns azion -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `AZION_PERSONAL_TOKEN` | Your Azion personal token. |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `AZION_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `AZION_PAGE_SIZE` | The page size for the API request (Default: 50) |
+| `AZION_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `AZION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `AZION_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://api.azion.com/)
+- [Go client](https://github.com/aziontech/azionapi-go-sdk)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_azure.md b/docs/content/dns/zz_gen_azure.md
index e1ecd9506..5063c202f 100644
--- a/docs/content/dns/zz_gen_azure.md
+++ b/docs/content/dns/zz_gen_azure.md
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `AZURE_METADATA_ENDPOINT` | Metadata Service endpoint URL |
-| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check |
+| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `AZURE_PRIVATE_ZONE` | Set to true to use Azure Private DNS Zones and not public |
-| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
| `AZURE_ZONE_NAME` | Zone name to use inside Azure DNS service to add the TXT record in |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_azuredns.md b/docs/content/dns/zz_gen_azuredns.md
index 4b762e675..3b2586711 100644
--- a/docs/content/dns/zz_gen_azuredns.md
+++ b/docs/content/dns/zz_gen_azuredns.md
@@ -31,32 +31,32 @@ Here is an example bash command using the Azure DNS provider:
AZURE_CLIENT_ID= \
AZURE_TENANT_ID= \
AZURE_CLIENT_SECRET= \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using client certificate
AZURE_CLIENT_ID= \
AZURE_TENANT_ID= \
AZURE_CLIENT_CERTIFICATE_PATH= \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using Azure CLI
az login \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using Managed Identity (Azure VM)
AZURE_TENANT_ID= \
AZURE_RESOURCE_GROUP= \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using Managed Identity (Azure Arc)
AZURE_TENANT_ID= \
IMDS_ENDPOINT=http://localhost:40342 \
IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
```
@@ -83,13 +83,13 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| `AZURE_AUTH_METHOD` | Specify which authentication method to use |
| `AZURE_AUTH_MSI_TIMEOUT` | Managed Identity timeout duration |
| `AZURE_ENVIRONMENT` | Azure environment, one of: public, usgovernment, and china |
-| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check |
+| `AZURE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `AZURE_PRIVATE_ZONE` | Set to true to use Azure Private DNS Zones and not public |
-| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
| `AZURE_RESOURCE_GROUP` | DNS zone resource group |
| `AZURE_SERVICEDISCOVERY_FILTER` | Advanced ServiceDiscovery filter using Kusto query condition |
| `AZURE_SUBSCRIPTION_ID` | DNS zone subscription ID |
-| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
| `AZURE_ZONE_NAME` | Zone name to use inside Azure DNS service to add the TXT record in |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
@@ -229,6 +229,10 @@ This authentication method can be specifically used by setting the `AZURE_AUTH_M
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`.
+### Azure DevOps Pipelines
+
+It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`.
+
diff --git a/docs/content/dns/zz_gen_baiducloud.md b/docs/content/dns/zz_gen_baiducloud.md
new file mode 100644
index 000000000..59a2f9a2d
--- /dev/null
+++ b/docs/content/dns/zz_gen_baiducloud.md
@@ -0,0 +1,69 @@
+---
+title: "Baidu Cloud"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: baiducloud
+dnsprovider:
+ since: "v4.23.0"
+ code: "baiducloud"
+ url: "https://cloud.baidu.com"
+---
+
+
+
+
+
+
+Configuration for [Baidu Cloud](https://cloud.baidu.com).
+
+
+
+
+- Code: `baiducloud`
+- Since: v4.23.0
+
+
+Here is an example bash command using the Baidu Cloud provider:
+
+```bash
+BAIDUCLOUD_ACCESS_KEY_ID="xxx" \
+BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \
+lego --dns baiducloud -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `BAIDUCLOUD_ACCESS_KEY_ID` | Access key |
+| `BAIDUCLOUD_SECRET_ACCESS_KEY` | Secret access key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `BAIDUCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BAIDUCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `BAIDUCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://cloud.baidu.com/doc/DNS/s/El4s7lssr)
+- [Go client](https://github.com/baidubce/bce-sdk-go)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_beget.md b/docs/content/dns/zz_gen_beget.md
new file mode 100644
index 000000000..3f03a2ac5
--- /dev/null
+++ b/docs/content/dns/zz_gen_beget.md
@@ -0,0 +1,69 @@
+---
+title: "Beget.com"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: beget
+dnsprovider:
+ since: "v4.27.0"
+ code: "beget"
+ url: "https://beget.com/"
+---
+
+
+
+
+
+
+Configuration for [Beget.com](https://beget.com/).
+
+
+
+
+- Code: `beget`
+- Since: v4.27.0
+
+
+Here is an example bash command using the Beget.com provider:
+
+```bash
+BEGET_USERNAME=xxxxxx \
+BEGET_PASSWORD=yyyyyy \
+lego --dns beget -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `BEGET_PASSWORD` | API password |
+| `BEGET_USERNAME` | API username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `BEGET_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BEGET_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) |
+| `BEGET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `BEGET_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://beget.com/ru/kb/api/funkczii-upravleniya-dns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_binarylane.md b/docs/content/dns/zz_gen_binarylane.md
new file mode 100644
index 000000000..eebf3c54e
--- /dev/null
+++ b/docs/content/dns/zz_gen_binarylane.md
@@ -0,0 +1,67 @@
+---
+title: "Binary Lane"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: binarylane
+dnsprovider:
+ since: "v4.26.0"
+ code: "binarylane"
+ url: "https://www.binarylane.com.au/"
+---
+
+
+
+
+
+
+Configuration for [Binary Lane](https://www.binarylane.com.au/).
+
+
+
+
+- Code: `binarylane`
+- Since: v4.26.0
+
+
+Here is an example bash command using the Binary Lane provider:
+
+```bash
+BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns binarylane -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `BINARYLANE_API_TOKEN` | API token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `BINARYLANE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BINARYLANE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BINARYLANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `BINARYLANE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://api.binarylane.com.au/reference/#tag/Domains)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_bindman.md b/docs/content/dns/zz_gen_bindman.md
index c74273a7f..fcceb8962 100644
--- a/docs/content/dns/zz_gen_bindman.md
+++ b/docs/content/dns/zz_gen_bindman.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Bindman provider:
```bash
BINDMAN_MANAGER_ADDRESS= \
-lego --email you@example.com --dns bindman -d '*.example.com' -d example.com run
+lego --dns bindman -d '*.example.com' -d example.com run
```
@@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `BINDMAN_HTTP_TIMEOUT` | API request timeout |
-| `BINDMAN_POLLING_INTERVAL` | Time between DNS propagation check |
-| `BINDMAN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `BINDMAN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `BINDMAN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BINDMAN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_bluecat.md b/docs/content/dns/zz_gen_bluecat.md
index 3b0ebf898..2d9eb5b48 100644
--- a/docs/content/dns/zz_gen_bluecat.md
+++ b/docs/content/dns/zz_gen_bluecat.md
@@ -32,7 +32,7 @@ BLUECAT_USER_NAME=myusername \
BLUECAT_CONFIG_NAME=myconfig \
BLUECAT_SERVER_URL=https://bam.example.com \
BLUECAT_TTL=30 \
-lego --email you@example.com --dns bluecat -d '*.example.com' -d example.com run
+lego --dns bluecat -d '*.example.com' -d example.com run
```
@@ -56,11 +56,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `BLUECAT_HTTP_TIMEOUT` | API request timeout |
-| `BLUECAT_POLLING_INTERVAL` | Time between DNS propagation check |
-| `BLUECAT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `BLUECAT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BLUECAT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BLUECAT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `BLUECAT_SKIP_DEPLOY` | Skip deployements |
-| `BLUECAT_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `BLUECAT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_bluecatv2.md b/docs/content/dns/zz_gen_bluecatv2.md
new file mode 100644
index 000000000..7d748df99
--- /dev/null
+++ b/docs/content/dns/zz_gen_bluecatv2.md
@@ -0,0 +1,76 @@
+---
+title: "Bluecat v2"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: bluecatv2
+dnsprovider:
+ since: "v4.32.0"
+ code: "bluecatv2"
+ url: "https://www.bluecatnetworks.com"
+---
+
+
+
+
+
+
+Configuration for [Bluecat v2](https://www.bluecatnetworks.com).
+
+
+
+
+- Code: `bluecatv2`
+- Since: v4.32.0
+
+
+Here is an example bash command using the Bluecat v2 provider:
+
+```bash
+BLUECATV2_SERVER_URL="https://example.com" \
+BLUECATV2_USERNAME="xxx" \
+BLUECATV2_PASSWORD="yyy" \
+BLUECATV2_CONFIG_NAME="myConfiguration" \
+BLUECATV2_VIEW_NAME="myView" \
+lego --dns bluecatv2 -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `BLUECATV2_CONFIG_NAME` | Configuration name |
+| `BLUECATV2_PASSWORD` | API password |
+| `BLUECATV2_USERNAME` | API username |
+| `BLUECATV2_VIEW_NAME` | DNS View Name |
+| `BLUECAT_SERVER_URL` | The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `BLUECATV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BLUECATV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BLUECATV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `BLUECATV2_SKIP_DEPLOY` | Skip quick deployements |
+| `BLUECATV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_bookmyname.md b/docs/content/dns/zz_gen_bookmyname.md
new file mode 100644
index 000000000..cb7e1d3a1
--- /dev/null
+++ b/docs/content/dns/zz_gen_bookmyname.md
@@ -0,0 +1,69 @@
+---
+title: "BookMyName"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: bookmyname
+dnsprovider:
+ since: "v4.23.0"
+ code: "bookmyname"
+ url: "https://www.bookmyname.com/"
+---
+
+
+
+
+
+
+Configuration for [BookMyName](https://www.bookmyname.com/).
+
+
+
+
+- Code: `bookmyname`
+- Since: v4.23.0
+
+
+Here is an example bash command using the BookMyName provider:
+
+```bash
+BOOKMYNAME_USERNAME="xxx" \
+BOOKMYNAME_PASSWORD="yyy" \
+lego --dns bookmyname -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `BOOKMYNAME_PASSWORD` | Password |
+| `BOOKMYNAME_USERNAME` | Username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `BOOKMYNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BOOKMYNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BOOKMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `BOOKMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://fr.faqs.bookmyname.com/frfaqs/dyndns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_brandit.md b/docs/content/dns/zz_gen_brandit.md
index c2264f71c..fdb538684 100644
--- a/docs/content/dns/zz_gen_brandit.md
+++ b/docs/content/dns/zz_gen_brandit.md
@@ -31,7 +31,7 @@ Here is an example bash command using the Brandit (deprecated) provider:
```bash
BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \
BRANDIT_API_USERNAME=yyyyyyyyyyyyyyyyyyyy \
-lego --email you@example.com --dns brandit -d '*.example.com' -d example.com run
+lego --dns brandit -d '*.example.com' -d example.com run
```
@@ -52,10 +52,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `BRANDIT_HTTP_TIMEOUT` | API request timeout |
-| `BRANDIT_POLLING_INTERVAL` | Time between DNS propagation check |
-| `BRANDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `BRANDIT_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `BRANDIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BRANDIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BRANDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) |
+| `BRANDIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_bunny.md b/docs/content/dns/zz_gen_bunny.md
index f945b9153..63c30782a 100644
--- a/docs/content/dns/zz_gen_bunny.md
+++ b/docs/content/dns/zz_gen_bunny.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Bunny provider:
```bash
BUNNY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-lego --email you@example.com --dns bunny -d '*.example.com' -d example.com run
+lego --dns bunny -d '*.example.com' -d example.com run
```
@@ -47,9 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `BUNNY_POLLING_INTERVAL` | Time between DNS propagation check |
-| `BUNNY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `BUNNY_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `BUNNY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BUNNY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `BUNNY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `BUNNY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_checkdomain.md b/docs/content/dns/zz_gen_checkdomain.md
index 694b8cc67..e0275f6c9 100644
--- a/docs/content/dns/zz_gen_checkdomain.md
+++ b/docs/content/dns/zz_gen_checkdomain.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Checkdomain provider:
```bash
CHECKDOMAIN_TOKEN=yoursecrettoken \
-lego --email you@example.com --dns checkdomain -d '*.example.com' -d example.com run
+lego --dns checkdomain -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `CHECKDOMAIN_ENDPOINT` | API endpoint URL, defaults to https://api.checkdomain.de |
-| `CHECKDOMAIN_HTTP_TIMEOUT` | API request timeout, defaults to 30 seconds |
-| `CHECKDOMAIN_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CHECKDOMAIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CHECKDOMAIN_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CHECKDOMAIN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CHECKDOMAIN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 300) |
+| `CHECKDOMAIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 7) |
+| `CHECKDOMAIN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_civo.md b/docs/content/dns/zz_gen_civo.md
index 73f04140d..61303b539 100644
--- a/docs/content/dns/zz_gen_civo.md
+++ b/docs/content/dns/zz_gen_civo.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Civo provider:
```bash
CIVO_TOKEN=xxxxxx \
-lego --email you@example.com --dns civo -d '*.example.com' -d example.com run
+lego --dns civo -d '*.example.com' -d example.com run
```
@@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CIVO_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CIVO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CIVO_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CIVO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) |
+| `CIVO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `CIVO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_clouddns.md b/docs/content/dns/zz_gen_clouddns.md
index 4754cebca..d10d1d6a1 100644
--- a/docs/content/dns/zz_gen_clouddns.md
+++ b/docs/content/dns/zz_gen_clouddns.md
@@ -29,7 +29,7 @@ Here is an example bash command using the CloudDNS provider:
CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \
CLOUDDNS_EMAIL=you@example.com \
CLOUDDNS_PASSWORD=b9841238feb177a84330f \
-lego --email you@example.com --dns clouddns -d '*.example.com' -d example.com run
+lego --dns clouddns -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CLOUDDNS_HTTP_TIMEOUT` | API request timeout |
-| `CLOUDDNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CLOUDDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CLOUDDNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CLOUDDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CLOUDDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `CLOUDDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `CLOUDDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_cloudflare.md b/docs/content/dns/zz_gen_cloudflare.md
index 55fbaeae3..f3390a5fd 100644
--- a/docs/content/dns/zz_gen_cloudflare.md
+++ b/docs/content/dns/zz_gen_cloudflare.md
@@ -28,12 +28,12 @@ Here is an example bash command using the Cloudflare provider:
```bash
CLOUDFLARE_EMAIL=you@example.com \
CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \
-lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run
+lego --dns cloudflare -d '*.example.com' -d example.com run
# or
CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \
-lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run
+lego --dns cloudflare -d '*.example.com' -d example.com run
```
@@ -60,10 +60,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CLOUDFLARE_HTTP_TIMEOUT` | API request timeout (in seconds) |
-| `CLOUDFLARE_POLLING_INTERVAL` | Time between DNS propagation check (in seconds) |
-| `CLOUDFLARE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation (in seconds) |
-| `CLOUDFLARE_TTL` | The TTL of the TXT record used for the DNS challenge (in seconds) |
+| `CLOUDFLARE_BASE_URL` | API base URL (Default: https://api.cloudflare.com/client/v4) |
+| `CLOUDFLARE_HTTP_TIMEOUT` | API request timeout in seconds (Default: ) |
+| `CLOUDFLARE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `CLOUDFLARE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `CLOUDFLARE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_cloudns.md b/docs/content/dns/zz_gen_cloudns.md
index f063d835f..26bd838f2 100644
--- a/docs/content/dns/zz_gen_cloudns.md
+++ b/docs/content/dns/zz_gen_cloudns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the ClouDNS provider:
```bash
CLOUDNS_AUTH_ID=xxxx \
CLOUDNS_AUTH_PASSWORD=yyyy \
-lego --email you@example.com --dns cloudns -d '*.example.com' -d example.com run
+lego --dns cloudns -d '*.example.com' -d example.com run
```
@@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CLOUDNS_HTTP_TIMEOUT` | API request timeout |
-| `CLOUDNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CLOUDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `CLOUDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CLOUDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `CLOUDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) |
| `CLOUDNS_SUB_AUTH_ID` | The API sub user ID |
-| `CLOUDNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CLOUDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_cloudru.md b/docs/content/dns/zz_gen_cloudru.md
index b4cb9dcac..6dc3b0030 100644
--- a/docs/content/dns/zz_gen_cloudru.md
+++ b/docs/content/dns/zz_gen_cloudru.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Cloud.ru provider:
CLOUDRU_SERVICE_INSTANCE_ID=ppp \
CLOUDRU_KEY_ID=xxx \
CLOUDRU_SECRET=yyy \
-lego --email you@example.com --dns cloudru -d '*.example.com' -d example.com run
+lego --dns cloudru -d '*.example.com' -d example.com run
```
@@ -51,11 +51,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CLOUDRU_HTTP_TIMEOUT` | API request timeout |
-| `CLOUDRU_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CLOUDRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CLOUDRU_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `CLOUDRU_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CLOUDRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CLOUDRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `CLOUDRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `CLOUDRU_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) |
+| `CLOUDRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_cloudxns.md b/docs/content/dns/zz_gen_cloudxns.md
index c63a773e1..b26e5ddb5 100644
--- a/docs/content/dns/zz_gen_cloudxns.md
+++ b/docs/content/dns/zz_gen_cloudxns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the CloudXNS (Deprecated) provider:
```bash
CLOUDXNS_API_KEY=xxxx \
CLOUDXNS_SECRET_KEY=yyyy \
-lego --email you@example.com --dns cloudxns -d '*.example.com' -d example.com run
+lego --dns cloudxns -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CLOUDXNS_HTTP_TIMEOUT` | API request timeout |
-| `CLOUDXNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CLOUDXNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CLOUDXNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CLOUDXNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: ) |
+| `CLOUDXNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: ) |
+| `CLOUDXNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: ) |
+| `CLOUDXNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: ) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_com35.md b/docs/content/dns/zz_gen_com35.md
new file mode 100644
index 000000000..e2552e57c
--- /dev/null
+++ b/docs/content/dns/zz_gen_com35.md
@@ -0,0 +1,69 @@
+---
+title: "35.com/三五互联"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: com35
+dnsprovider:
+ since: "v4.31.0"
+ code: "com35"
+ url: "https://www.35.cn/"
+---
+
+
+
+
+
+
+Configuration for [35.com/三五互联](https://www.35.cn/).
+
+
+
+
+- Code: `com35`
+- Since: v4.31.0
+
+
+Here is an example bash command using the 35.com/三五互联 provider:
+
+```bash
+COM35_USERNAME="xxx" \
+COM35_PASSWORD="yyy" \
+lego --dns com35 -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `COM35_PASSWORD` | API password |
+| `COM35_USERNAME` | Username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `COM35_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `COM35_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `COM35_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `COM35_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://api.35.cn/CustomerCenter/doc/domain_v2.html)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_conoha.md b/docs/content/dns/zz_gen_conoha.md
index c5de0d20e..08a979b31 100644
--- a/docs/content/dns/zz_gen_conoha.md
+++ b/docs/content/dns/zz_gen_conoha.md
@@ -1,5 +1,5 @@
---
-title: "ConoHa"
+title: "ConoHa v2"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: conoha
@@ -14,7 +14,7 @@ dnsprovider:
-Configuration for [ConoHa](https://www.conoha.jp/).
+Configuration for [ConoHa v2](https://www.conoha.jp/).
@@ -23,13 +23,13 @@ Configuration for [ConoHa](https://www.conoha.jp/).
- Since: v1.2.0
-Here is an example bash command using the ConoHa provider:
+Here is an example bash command using the ConoHa v2 provider:
```bash
CONOHA_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \
CONOHA_API_USERNAME=xxxx \
CONOHA_API_PASSWORD=yyyy \
-lego --email you@example.com --dns conoha -d '*.example.com' -d example.com run
+lego --dns conoha -d '*.example.com' -d example.com run
```
@@ -51,11 +51,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CONOHA_HTTP_TIMEOUT` | API request timeout |
-| `CONOHA_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CONOHA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CONOHA_REGION` | The region |
-| `CONOHA_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CONOHA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CONOHA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `CONOHA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `CONOHA_REGION` | The region (Default: tyo1) |
+| `CONOHA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -65,7 +65,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
-- [API documentation](https://www.conoha.jp/docs/)
+- [API documentation](https://doc.conoha.jp/reference/api-vps2/api-dns-vps2)
diff --git a/docs/content/dns/zz_gen_conohav3.md b/docs/content/dns/zz_gen_conohav3.md
new file mode 100644
index 000000000..e473f9434
--- /dev/null
+++ b/docs/content/dns/zz_gen_conohav3.md
@@ -0,0 +1,72 @@
+---
+title: "ConoHa v3"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: conohav3
+dnsprovider:
+ since: "v4.24.0"
+ code: "conohav3"
+ url: "https://www.conoha.jp/"
+---
+
+
+
+
+
+
+Configuration for [ConoHa v3](https://www.conoha.jp/).
+
+
+
+
+- Code: `conohav3`
+- Since: v4.24.0
+
+
+Here is an example bash command using the ConoHa v3 provider:
+
+```bash
+CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \
+CONOHAV3_API_USER_ID=xxxx \
+CONOHAV3_API_PASSWORD=yyyy \
+lego --dns conohav3 -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `CONOHAV3_API_PASSWORD` | The API password |
+| `CONOHAV3_API_USER_ID` | The API user ID |
+| `CONOHAV3_TENANT_ID` | Tenant ID |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `CONOHAV3_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CONOHAV3_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `CONOHAV3_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `CONOHAV3_REGION` | The region (Default: c3j1) |
+| `CONOHAV3_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_constellix.md b/docs/content/dns/zz_gen_constellix.md
index 69040353d..d4ce02bac 100644
--- a/docs/content/dns/zz_gen_constellix.md
+++ b/docs/content/dns/zz_gen_constellix.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Constellix provider:
```bash
CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-lego --email you@example.com --dns constellix -d '*.example.com' -d example.com run
+lego --dns constellix -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CONSTELLIX_HTTP_TIMEOUT` | API request timeout |
-| `CONSTELLIX_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CONSTELLIX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CONSTELLIX_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CONSTELLIX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CONSTELLIX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `CONSTELLIX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `CONSTELLIX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_corenetworks.md b/docs/content/dns/zz_gen_corenetworks.md
index 0b61bbc77..05468b1a3 100644
--- a/docs/content/dns/zz_gen_corenetworks.md
+++ b/docs/content/dns/zz_gen_corenetworks.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Core-Networks provider:
```bash
CORENETWORKS_LOGIN="xxxx" \
CORENETWORKS_PASSWORD="yyyy" \
-lego --email you@example.com --dns corenetworks -d '*.example.com' -d example.com run
+lego --dns corenetworks -d '*.example.com' -d example.com run
```
@@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CORENETWORKS_HTTP_TIMEOUT` | API request timeout |
-| `CORENETWORKS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `CORENETWORKS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `CORENETWORKS_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `CORENETWORKS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CORENETWORKS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CORENETWORKS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `CORENETWORKS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `CORENETWORKS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `CORENETWORKS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_cpanel.md b/docs/content/dns/zz_gen_cpanel.md
index 9e939ca59..e5c0cc047 100644
--- a/docs/content/dns/zz_gen_cpanel.md
+++ b/docs/content/dns/zz_gen_cpanel.md
@@ -28,18 +28,18 @@ Here is an example bash command using the CPanel/WHM provider:
```bash
### CPANEL (default)
-CPANEL_USERNAME = "yyyy"
-CPANEL_TOKEN = "xxxx"
-CPANEL_BASE_URL = "https://example.com:2083" \
-lego --email you@example.com --dns cpanel -d '*.example.com' -d example.com run
+CPANEL_USERNAME="yyyy" \
+CPANEL_TOKEN="xxxx" \
+CPANEL_BASE_URL="https://example.com:2083" \
+lego --dns cpanel -d '*.example.com' -d example.com 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 -d '*.example.com' -d example.com run
+CPANEL_MODE=whm \
+CPANEL_USERNAME="yyyy" \
+CPANEL_TOKEN="xxxx" \
+CPANEL_BASE_URL="https://example.com:2087" \
+lego --dns cpanel -d '*.example.com' -d example.com run
```
@@ -61,12 +61,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `CPANEL_HTTP_TIMEOUT` | API request timeout |
+| `CPANEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `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_REGION` | The region |
-| `CPANEL_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `CPANEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `CPANEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `CPANEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_czechia.md b/docs/content/dns/zz_gen_czechia.md
new file mode 100644
index 000000000..7b1cdd1ae
--- /dev/null
+++ b/docs/content/dns/zz_gen_czechia.md
@@ -0,0 +1,67 @@
+---
+title: "Czechia"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: czechia
+dnsprovider:
+ since: "v4.33.0"
+ code: "czechia"
+ url: "https://www.czechia.com/"
+---
+
+
+
+
+
+
+Configuration for [Czechia](https://www.czechia.com/).
+
+
+
+
+- Code: `czechia`
+- Since: v4.33.0
+
+
+Here is an example bash command using the Czechia provider:
+
+```bash
+CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns czechia -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `CZECHIA_TOKEN` | Authorization token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `CZECHIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `CZECHIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `CZECHIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `CZECHIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://api.czechia.com/swagger/index.html)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_ddnss.md b/docs/content/dns/zz_gen_ddnss.md
new file mode 100644
index 000000000..e159d58b4
--- /dev/null
+++ b/docs/content/dns/zz_gen_ddnss.md
@@ -0,0 +1,68 @@
+---
+title: "DDnss (DynDNS Service)"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: ddnss
+dnsprovider:
+ since: "v4.32.0"
+ code: "ddnss"
+ url: "https://ddnss.de/"
+---
+
+
+
+
+
+
+Configuration for [DDnss (DynDNS Service)](https://ddnss.de/).
+
+
+
+
+- Code: `ddnss`
+- Since: v4.32.0
+
+
+Here is an example bash command using the DDnss (DynDNS Service) provider:
+
+```bash
+DDNSS_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns ddnss -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `DDNSS_KEY` | Update key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `DDNSS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DDNSS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DDNSS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DDNSS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `DDNSS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://ddnss.de/info.php)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_derak.md b/docs/content/dns/zz_gen_derak.md
index a5daf76db..c5c8c7bc6 100644
--- a/docs/content/dns/zz_gen_derak.md
+++ b/docs/content/dns/zz_gen_derak.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Derak Cloud provider:
```bash
DERAK_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns derak -d '*.example.com' -d example.com run
+lego --dns derak -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DERAK_HTTP_TIMEOUT` | API request timeout |
-| `DERAK_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DERAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DERAK_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DERAK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DERAK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `DERAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `DERAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
| `DERAK_WEBSITE_ID` | Force the zone/website ID |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_desec.md b/docs/content/dns/zz_gen_desec.md
index 45e5fabc6..4dbc713d6 100644
--- a/docs/content/dns/zz_gen_desec.md
+++ b/docs/content/dns/zz_gen_desec.md
@@ -27,7 +27,7 @@ Here is an example bash command using the deSEC.io provider:
```bash
DESEC_TOKEN=x-xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns desec -d '*.example.com' -d example.com run
+lego --dns desec -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DESEC_HTTP_TIMEOUT` | API request timeout |
-| `DESEC_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DESEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DESEC_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DESEC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DESEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) |
+| `DESEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `DESEC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_designate.md b/docs/content/dns/zz_gen_designate.md
index cbbdfa557..9703f094d 100644
--- a/docs/content/dns/zz_gen_designate.md
+++ b/docs/content/dns/zz_gen_designate.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Designate DNSaaS for Openstack provide
```bash
# With a `clouds.yaml`
OS_CLOUD=my_openstack \
-lego --email you@example.com --dns designate -d '*.example.com' -d example.com run
+lego --dns designate -d '*.example.com' -d example.com run
# or
@@ -37,7 +37,7 @@ OS_REGION_NAME=RegionOne \
OS_PROJECT_ID=23d4522a987d4ab529f722a007c27846
OS_USERNAME=myuser \
OS_PASSWORD=passw0rd \
-lego --email you@example.com --dns designate -d '*.example.com' -d example.com run
+lego --dns designate -d '*.example.com' -d example.com run
# or
@@ -46,7 +46,7 @@ OS_REGION_NAME=RegionOne \
OS_AUTH_TYPE=v3applicationcredential \
OS_APPLICATION_CREDENTIAL_ID=imn74uq0or7dyzz20dwo1ytls4me8dry \
OS_APPLICATION_CREDENTIAL_SECRET=68FuSPSdQqkFQYH5X1OoriEIJOwyLtQ8QSqXZOc9XxFK1A9tzZT6He2PfPw0OMja \
-lego --email you@example.com --dns designate -d '*.example.com' -d example.com run
+lego --dns designate -d '*.example.com' -d example.com run
```
@@ -74,9 +74,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DESIGNATE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DESIGNATE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DESIGNATE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DESIGNATE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `DESIGNATE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) |
+| `DESIGNATE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) |
| `DESIGNATE_ZONE_NAME` | The zone name to use in the OpenStack Project to manage TXT records. |
| `OS_PROJECT_ID` | Project ID |
| `OS_TENANT_NAME` | Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID) |
diff --git a/docs/content/dns/zz_gen_digitalocean.md b/docs/content/dns/zz_gen_digitalocean.md
index 3bf57f59d..4dc43886d 100644
--- a/docs/content/dns/zz_gen_digitalocean.md
+++ b/docs/content/dns/zz_gen_digitalocean.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Digital Ocean provider:
```bash
DO_AUTH_TOKEN=xxxxxx \
-lego --email you@example.com --dns digitalocean -d '*.example.com' -d example.com run
+lego --dns digitalocean -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `DO_API_URL` | The URL of the API |
-| `DO_HTTP_TIMEOUT` | API request timeout |
-| `DO_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DO_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `DO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_directadmin.md b/docs/content/dns/zz_gen_directadmin.md
index 252c69ccf..1d03dcc4e 100644
--- a/docs/content/dns/zz_gen_directadmin.md
+++ b/docs/content/dns/zz_gen_directadmin.md
@@ -29,7 +29,7 @@ Here is an example bash command using the DirectAdmin provider:
DIRECTADMIN_API_URL="http://example.com:2222" \
DIRECTADMIN_USERNAME=xxxx \
DIRECTADMIN_PASSWORD=yyy \
-lego --email you@example.com --dns directadmin -d '*.example.com' -d example.com run
+lego --dns directadmin -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DIRECTADMIN_HTTP_TIMEOUT` | API request timeout |
-| `DIRECTADMIN_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DIRECTADMIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DIRECTADMIN_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DIRECTADMIN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DIRECTADMIN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `DIRECTADMIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DIRECTADMIN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) |
| `DIRECTADMIN_ZONE_NAME` | Zone name used to add the TXT record |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_dnsexit.md b/docs/content/dns/zz_gen_dnsexit.md
new file mode 100644
index 000000000..aca5357e8
--- /dev/null
+++ b/docs/content/dns/zz_gen_dnsexit.md
@@ -0,0 +1,67 @@
+---
+title: "DNSExit"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: dnsexit
+dnsprovider:
+ since: "v4.32.0"
+ code: "dnsexit"
+ url: "https://dnsexit.com"
+---
+
+
+
+
+
+
+Configuration for [DNSExit](https://dnsexit.com).
+
+
+
+
+- Code: `dnsexit`
+- Since: v4.32.0
+
+
+Here is an example bash command using the DNSExit provider:
+
+```bash
+DNSEXIT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns dnsexit -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `DNSEXIT_API_KEY` | API key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `DNSEXIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DNSEXIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `DNSEXIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `DNSEXIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://dnsexit.com/dns/dns-api/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_dnshomede.md b/docs/content/dns/zz_gen_dnshomede.md
index 56825f38d..ca7f83523 100644
--- a/docs/content/dns/zz_gen_dnshomede.md
+++ b/docs/content/dns/zz_gen_dnshomede.md
@@ -27,10 +27,10 @@ Here is an example bash command using the dnsHome.de provider:
```bash
DNSHOMEDE_CREDENTIALS=example.org:password \
-lego --email you@example.com --dns dnshomede -d '*.example.com' -d example.com run
+lego --dns dnshomede -d '*.example.com' -d example.com run
DNSHOMEDE_CREDENTIALS=my.example.org:password1,demo.example.org:password2 \
-lego --email you@example.com --dns dnshomede -d my.example.org -d demo.example.org
+lego --dns dnshomede -d my.example.org -d demo.example.org
```
@@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DNSHOMEDE_HTTP_TIMEOUT` | API request timeout |
-| `DNSHOMEDE_POLLING_INTERVAL` | Time between DNS propagation checks |
-| `DNSHOMEDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation; defaults to 300s (5 minutes) |
-| `DNSHOMEDE_SEQUENCE_INTERVAL` | Time between sequential requests |
+| `DNSHOMEDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DNSHOMEDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 1200) |
+| `DNSHOMEDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2) |
+| `DNSHOMEDE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_dnsimple.md b/docs/content/dns/zz_gen_dnsimple.md
index 188d7c895..7799ece88 100644
--- a/docs/content/dns/zz_gen_dnsimple.md
+++ b/docs/content/dns/zz_gen_dnsimple.md
@@ -27,7 +27,7 @@ Here is an example bash command using the DNSimple provider:
```bash
DNSIMPLE_OAUTH_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \
-lego --email you@example.com --dns dnsimple -d '*.example.com' -d example.com run
+lego --dns dnsimple -d '*.example.com' -d example.com run
```
@@ -48,9 +48,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `DNSIMPLE_BASE_URL` | API endpoint URL |
-| `DNSIMPLE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DNSIMPLE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DNSIMPLE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DNSIMPLE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DNSIMPLE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DNSIMPLE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_dnsmadeeasy.md b/docs/content/dns/zz_gen_dnsmadeeasy.md
index d6f1cb56b..e7f260889 100644
--- a/docs/content/dns/zz_gen_dnsmadeeasy.md
+++ b/docs/content/dns/zz_gen_dnsmadeeasy.md
@@ -28,7 +28,7 @@ Here is an example bash command using the DNS Made Easy provider:
```bash
DNSMADEEASY_API_KEY=xxxxxx \
DNSMADEEASY_API_SECRET=yyyyy \
-lego --email you@example.com --dns dnsmadeeasy -d '*.example.com' -d example.com run
+lego --dns dnsmadeeasy -d '*.example.com' -d example.com run
```
@@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DNSMADEEASY_HTTP_TIMEOUT` | API request timeout |
-| `DNSMADEEASY_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DNSMADEEASY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `DNSMADEEASY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `DNSMADEEASY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DNSMADEEASY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `DNSMADEEASY_SANDBOX` | Activate the sandbox (boolean) |
-| `DNSMADEEASY_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DNSMADEEASY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_dnspod.md b/docs/content/dns/zz_gen_dnspod.md
index 2a654d640..86112a5ce 100644
--- a/docs/content/dns/zz_gen_dnspod.md
+++ b/docs/content/dns/zz_gen_dnspod.md
@@ -27,7 +27,7 @@ Here is an example bash command using the DNSPod (deprecated) provider:
```bash
DNSPOD_API_KEY=xxxxxx \
-lego --email you@example.com --dns dnspod -d '*.example.com' -d example.com run
+lego --dns dnspod -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DNSPOD_HTTP_TIMEOUT` | API request timeout |
-| `DNSPOD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DNSPOD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DNSPOD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DNSPOD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DNSPOD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DNSPOD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DNSPOD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_dode.md b/docs/content/dns/zz_gen_dode.md
index b73fa70df..28eebe5fa 100644
--- a/docs/content/dns/zz_gen_dode.md
+++ b/docs/content/dns/zz_gen_dode.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Domain Offensive (do.de) provider:
```bash
DODE_TOKEN=xxxxxx \
-lego --email you@example.com --dns dode -d '*.example.com' -d example.com run
+lego --dns dode -d '*.example.com' -d example.com run
```
@@ -47,11 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DODE_HTTP_TIMEOUT` | API request timeout |
-| `DODE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DODE_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `DODE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DODE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DODE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DODE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_domeneshop.md b/docs/content/dns/zz_gen_domeneshop.md
index 24a19a056..0530ab365 100644
--- a/docs/content/dns/zz_gen_domeneshop.md
+++ b/docs/content/dns/zz_gen_domeneshop.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Domeneshop provider:
```bash
DOMENESHOP_API_TOKEN= \
DOMENESHOP_API_SECRET= \
-lego --email example@example.com --dns domeneshop -d '*.example.com' -d example.com run
+lego --dns domeneshop -d '*.example.com' -d example.com run
```
@@ -49,9 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DOMENESHOP_HTTP_TIMEOUT` | API request timeout |
-| `DOMENESHOP_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DOMENESHOP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `DOMENESHOP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DOMENESHOP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) |
+| `DOMENESHOP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_dreamhost.md b/docs/content/dns/zz_gen_dreamhost.md
index 9d9663971..b9d273099 100644
--- a/docs/content/dns/zz_gen_dreamhost.md
+++ b/docs/content/dns/zz_gen_dreamhost.md
@@ -27,7 +27,7 @@ Here is an example bash command using the DreamHost provider:
```bash
DREAMHOST_API_KEY="YOURAPIKEY" \
-lego --email you@example.com --dns dreamhost -d '*.example.com' -d example.com run
+lego --dns dreamhost -d '*.example.com' -d example.com run
```
@@ -47,10 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DREAMHOST_HTTP_TIMEOUT` | API request timeout |
-| `DREAMHOST_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DREAMHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DREAMHOST_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DREAMHOST_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DREAMHOST_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) |
+| `DREAMHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_duckdns.md b/docs/content/dns/zz_gen_duckdns.md
index 515097c77..8b60780d2 100644
--- a/docs/content/dns/zz_gen_duckdns.md
+++ b/docs/content/dns/zz_gen_duckdns.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Duck DNS provider:
```bash
DUCKDNS_TOKEN=xxxxxx \
-lego --email you@example.com --dns duckdns -d '*.example.com' -d example.com run
+lego --dns duckdns -d '*.example.com' -d example.com run
```
@@ -47,11 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DUCKDNS_HTTP_TIMEOUT` | API request timeout |
-| `DUCKDNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DUCKDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DUCKDNS_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `DUCKDNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DUCKDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DUCKDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DUCKDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DUCKDNS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_dyn.md b/docs/content/dns/zz_gen_dyn.md
index 32f902394..e31a90e45 100644
--- a/docs/content/dns/zz_gen_dyn.md
+++ b/docs/content/dns/zz_gen_dyn.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Dyn provider:
DYN_CUSTOMER_NAME=xxxxxx \
DYN_USER_NAME=yyyyy \
DYN_PASSWORD=zzzz \
-lego --email you@example.com --dns dyn -d '*.example.com' -d example.com run
+lego --dns dyn -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DYN_HTTP_TIMEOUT` | API request timeout |
-| `DYN_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DYN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DYN_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DYN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `DYN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DYN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `DYN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_dyndnsfree.md b/docs/content/dns/zz_gen_dyndnsfree.md
new file mode 100644
index 000000000..ea549b4e2
--- /dev/null
+++ b/docs/content/dns/zz_gen_dyndnsfree.md
@@ -0,0 +1,68 @@
+---
+title: "DynDnsFree.de"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: dyndnsfree
+dnsprovider:
+ since: "v4.23.0"
+ code: "dyndnsfree"
+ url: "https://www.dyndnsfree.de"
+---
+
+
+
+
+
+
+Configuration for [DynDnsFree.de](https://www.dyndnsfree.de).
+
+
+
+
+- Code: `dyndnsfree`
+- Since: v4.23.0
+
+
+Here is an example bash command using the DynDnsFree.de provider:
+
+```bash
+DYNDNSFREE_USERNAME="xxx" \
+DYNDNSFREE_PASSWORD="yyy" \
+lego --dns dyndnsfree -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `DYNDNSFREE_PASSWORD` | Password |
+| `DYNDNSFREE_USERNAME` | Username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `DYNDNSFREE_HTTP_TIMEOUT` | Request timeout in seconds (Default: 30) |
+| `DYNDNSFREE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `DYNDNSFREE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.dyndnsfree.de/user/hilfe.php?hsm=2)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_dynu.md b/docs/content/dns/zz_gen_dynu.md
index d59fa23f5..a1f3e762e 100644
--- a/docs/content/dns/zz_gen_dynu.md
+++ b/docs/content/dns/zz_gen_dynu.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Dynu provider:
```bash
DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \
-lego --email you@example.com --dns dynu -d '*.example.com' -d example.com run
+lego --dns dynu -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `DYNU_HTTP_TIMEOUT` | API request timeout |
-| `DYNU_POLLING_INTERVAL` | Time between DNS propagation check |
-| `DYNU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `DYNU_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `DYNU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `DYNU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `DYNU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) |
+| `DYNU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_easydns.md b/docs/content/dns/zz_gen_easydns.md
index f4c44164c..12f69e09c 100644
--- a/docs/content/dns/zz_gen_easydns.md
+++ b/docs/content/dns/zz_gen_easydns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the EasyDNS provider:
```bash
EASYDNS_TOKEN=xxx \
EASYDNS_KEY=yyy \
-lego --email you@example.com --dns easydns -d '*.example.com' -d example.com run
+lego --dns easydns -d '*.example.com' -d example.com run
```
@@ -50,11 +50,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `EASYDNS_ENDPOINT` | The endpoint URL of the API Server |
-| `EASYDNS_HTTP_TIMEOUT` | API request timeout |
-| `EASYDNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `EASYDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `EASYDNS_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `EASYDNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `EASYDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EASYDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `EASYDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `EASYDNS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `EASYDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_edgecenter.md b/docs/content/dns/zz_gen_edgecenter.md
new file mode 100644
index 000000000..1fd9fe5fa
--- /dev/null
+++ b/docs/content/dns/zz_gen_edgecenter.md
@@ -0,0 +1,67 @@
+---
+title: "EdgeCenter"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: edgecenter
+dnsprovider:
+ since: "v4.29.0"
+ code: "edgecenter"
+ url: "https://edgecenter.ru/dns"
+---
+
+
+
+
+
+
+Configuration for [EdgeCenter](https://edgecenter.ru/dns).
+
+
+
+
+- Code: `edgecenter`
+- Since: v4.29.0
+
+
+Here is an example bash command using the EdgeCenter provider:
+
+```bash
+EDGECENTER_PERMANENT_API_TOKEN=xxxxx \
+lego --dns edgecenter -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `EDGECENTER_PERMANENT_API_TOKEN` | Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `EDGECENTER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `EDGECENTER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) |
+| `EDGECENTER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) |
+| `EDGECENTER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://apidocs.edgecenter.ru/dns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_edgedns.md b/docs/content/dns/zz_gen_edgedns.md
index 3ba5fffea..31b191168 100644
--- a/docs/content/dns/zz_gen_edgedns.md
+++ b/docs/content/dns/zz_gen_edgedns.md
@@ -30,7 +30,7 @@ AKAMAI_CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz1234567890ABCDEFG= \
AKAMAI_CLIENT_TOKEN=akab-mnbvcxzlkjhgfdsapoiuytrewq1234567 \
AKAMAI_HOST=akab-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.luna.akamaiapis.net \
AKAMAI_ACCESS_TOKEN=akab-1234567890qwerty-asdfghjklzxcvtnu \
-lego --email you@example.com --dns edgedns -d '*.example.com' -d example.com run
+lego --dns edgedns -d '*.example.com' -d example.com run
```
@@ -55,9 +55,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `AKAMAI_POLLING_INTERVAL` | Time between DNS propagation check. Default: 15 seconds |
-| `AKAMAI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation. Default: 3 minutes |
-| `AKAMAI_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `AKAMAI_ACCOUNT_SWITCH_KEY` | Target account ID when the DNS zone and credentials belong to different accounts |
+| `AKAMAI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) |
+| `AKAMAI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) |
+| `AKAMAI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -88,6 +89,7 @@ See also:
- [.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/pkg/edgegrid/config.go#L118)
+- [Manage many accounts](https://techdocs.akamai.com/developer/docs/manage-many-accounts-with-one-api-client)
diff --git a/docs/content/dns/zz_gen_edgeone.md b/docs/content/dns/zz_gen_edgeone.md
new file mode 100644
index 000000000..ba5de5ba2
--- /dev/null
+++ b/docs/content/dns/zz_gen_edgeone.md
@@ -0,0 +1,73 @@
+---
+title: "Tencent EdgeOne"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: edgeone
+dnsprovider:
+ since: "v4.26.0"
+ code: "edgeone"
+ url: "https://edgeone.ai"
+---
+
+
+
+
+
+
+Configuration for [Tencent EdgeOne](https://edgeone.ai).
+
+
+
+
+- Code: `edgeone`
+- Since: v4.26.0
+
+
+Here is an example bash command using the Tencent EdgeOne provider:
+
+```bash
+EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \
+EDGEONE_SECRET_KEY=your-secret-key \
+lego --dns edgeone -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `EDGEONE_SECRET_ID` | Access key ID |
+| `EDGEONE_SECRET_KEY` | Access Key secret |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `EDGEONE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EDGEONE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) |
+| `EDGEONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) |
+| `EDGEONE_REGION` | Region |
+| `EDGEONE_SESSION_TOKEN` | Access Key token |
+| `EDGEONE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
+| `EDGEONE_ZONES_MAPPING` | Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2') |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://edgeone.ai/document/50454#dns-record-apis)
+- [Go client](https://github.com/tencentcloud/tencentcloud-sdk-go)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_efficientip.md b/docs/content/dns/zz_gen_efficientip.md
index cfdfb9bba..acca3ebb7 100644
--- a/docs/content/dns/zz_gen_efficientip.md
+++ b/docs/content/dns/zz_gen_efficientip.md
@@ -30,7 +30,7 @@ EFFICIENTIP_USERNAME="user" \
EFFICIENTIP_PASSWORD="secret" \
EFFICIENTIP_HOSTNAME="ipam.example.org" \
EFFICIENTIP_DNS_NAME="dns.smart" \
-lego --email you@example.com --dns efficientip -d '*.example.com' -d example.com run
+lego --dns efficientip -d '*.example.com' -d example.com run
```
@@ -53,11 +53,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `EFFICIENTIP_HTTP_TIMEOUT` | API request timeout |
+| `EFFICIENTIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
| `EFFICIENTIP_INSECURE_SKIP_VERIFY` | Whether or not to verify EfficientIP API certificate |
-| `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_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `EFFICIENTIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `EFFICIENTIP_VIEW_NAME` | View name (ex: external) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_epik.md b/docs/content/dns/zz_gen_epik.md
index 861efb640..a7fc029d3 100644
--- a/docs/content/dns/zz_gen_epik.md
+++ b/docs/content/dns/zz_gen_epik.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Epik provider:
```bash
EPIK_SIGNATURE=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns epik -d '*.example.com' -d example.com run
+lego --dns epik -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `EPIK_HTTP_TIMEOUT` | API request timeout |
-| `EPIK_POLLING_INTERVAL` | Time between DNS propagation check |
-| `EPIK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `EPIK_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `EPIK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EPIK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `EPIK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `EPIK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_eurodns.md b/docs/content/dns/zz_gen_eurodns.md
new file mode 100644
index 000000000..cb5a0418d
--- /dev/null
+++ b/docs/content/dns/zz_gen_eurodns.md
@@ -0,0 +1,69 @@
+---
+title: "EuroDNS"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: eurodns
+dnsprovider:
+ since: "v4.33.0"
+ code: "eurodns"
+ url: "https://www.eurodns.com/"
+---
+
+
+
+
+
+
+Configuration for [EuroDNS](https://www.eurodns.com/).
+
+
+
+
+- Code: `eurodns`
+- Since: v4.33.0
+
+
+Here is an example bash command using the EuroDNS provider:
+
+```bash
+EURODNS_APP_ID="xxx" \
+EURODNS_API_KEY="yyy" \
+lego --dns eurodns -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `EURODNS_API_KEY` | API key |
+| `EURODNS_APP_ID` | Application ID |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `EURODNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EURODNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `EURODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `EURODNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://docapi.eurodns.com/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_excedo.md b/docs/content/dns/zz_gen_excedo.md
new file mode 100644
index 000000000..456e6f60a
--- /dev/null
+++ b/docs/content/dns/zz_gen_excedo.md
@@ -0,0 +1,69 @@
+---
+title: "Excedo"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: excedo
+dnsprovider:
+ since: "v4.33.0"
+ code: "excedo"
+ url: "https://excedo.se/"
+---
+
+
+
+
+
+
+Configuration for [Excedo](https://excedo.se/).
+
+
+
+
+- Code: `excedo`
+- Since: v4.33.0
+
+
+Here is an example bash command using the Excedo provider:
+
+```bash
+EXCEDO_API_KEY=your-api-key \
+EXCEDO_API_URL=your-base-url \
+lego --dns excedo -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `EXCEDO_API_KEY` | API key |
+| `EXCEDO_API_URL` | API base URL |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `EXCEDO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `EXCEDO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `EXCEDO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `EXCEDO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](none)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_exec.md b/docs/content/dns/zz_gen_exec.md
index f2f5f9619..ad2e6906e 100644
--- a/docs/content/dns/zz_gen_exec.md
+++ b/docs/content/dns/zz_gen_exec.md
@@ -26,7 +26,7 @@ Here is an example bash command using the External program provider:
```bash
EXEC_PATH=/the/path/to/myscript.sh \
-lego --email you@example.com --dns exec -d '*.example.com' -d example.com run
+lego --dns exec -d '*.example.com' -d example.com run
```
@@ -43,11 +43,11 @@ lego --email you@example.com --dns exec -d '*.example.com' -d example.com run
## Additional Configuration
-| Environment Variable Name | Description |
-|----------------------------|-------------------------------------------|
-| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check. |
-| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation. |
-| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests. |
+| Environment Variable Name | Description |
+|----------------------------|--------------------------------------------------------------------|
+| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3). |
+| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60). |
+| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60). |
## Description
@@ -61,7 +61,7 @@ For example, requesting a certificate for the domain 'my.example.org' can be ach
```bash
EXEC_PATH=./update-dns.sh \
-lego --email you@example.com --dns exec --d my.example.org run
+lego --dns exec --d my.example.org run
```
It will then call the program './update-dns.sh' with like this:
@@ -81,7 +81,7 @@ If you want to use the raw domain, token, and keyAuth values with your program,
```bash
EXEC_MODE=RAW \
EXEC_PATH=./update-dns.sh \
-lego --email you@example.com --dns exec -d my.example.org run
+lego --dns exec -d my.example.org run
```
It will then call the program `./update-dns.sh` like this:
diff --git a/docs/content/dns/zz_gen_exoscale.md b/docs/content/dns/zz_gen_exoscale.md
index ffd3da1e4..e599d6487 100644
--- a/docs/content/dns/zz_gen_exoscale.md
+++ b/docs/content/dns/zz_gen_exoscale.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Exoscale provider:
```bash
EXOSCALE_API_KEY=abcdefghijklmnopqrstuvwx \
EXOSCALE_API_SECRET=xxxxxxx \
-lego --email you@example.com --dns exoscale -d '*.example.com' -d example.com run
+lego --dns exoscale -d '*.example.com' -d example.com run
```
@@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `EXOSCALE_ENDPOINT` | API endpoint URL |
-| `EXOSCALE_HTTP_TIMEOUT` | API request timeout |
-| `EXOSCALE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `EXOSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `EXOSCALE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `EXOSCALE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `EXOSCALE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `EXOSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `EXOSCALE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_f5xc.md b/docs/content/dns/zz_gen_f5xc.md
new file mode 100644
index 000000000..0fd8fe58a
--- /dev/null
+++ b/docs/content/dns/zz_gen_f5xc.md
@@ -0,0 +1,72 @@
+---
+title: "F5 XC"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: f5xc
+dnsprovider:
+ since: "v4.23.0"
+ code: "f5xc"
+ url: "https://www.f5.com/products/distributed-cloud-services"
+---
+
+
+
+
+
+
+Configuration for [F5 XC](https://www.f5.com/products/distributed-cloud-services).
+
+
+
+
+- Code: `f5xc`
+- Since: v4.23.0
+
+
+Here is an example bash command using the F5 XC provider:
+
+```bash
+F5XC_API_TOKEN="xxx" \
+F5XC_TENANT_NAME="yyy" \
+F5XC_GROUP_NAME="zzz" \
+lego --dns f5xc -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `F5XC_API_TOKEN` | API token |
+| `F5XC_GROUP_NAME` | Group name |
+| `F5XC_TENANT_NAME` | XC Tenant shortname |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `F5XC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `F5XC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `F5XC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `F5XC_SERVER` | Server domain (Default: console.ves.volterra.io) |
+| `F5XC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_freemyip.md b/docs/content/dns/zz_gen_freemyip.md
index 421361205..215f8eb84 100644
--- a/docs/content/dns/zz_gen_freemyip.md
+++ b/docs/content/dns/zz_gen_freemyip.md
@@ -27,7 +27,7 @@ Here is an example bash command using the freemyip.com provider:
```bash
FREEMYIP_TOKEN=xxxxxx \
-lego --email you@example.com --dns freemyip -d '*.example.com' -d example.com run
+lego --dns freemyip -d '*.example.com' -d example.com run
```
@@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `FREEMYIP_HTTP_TIMEOUT` | API request timeout |
-| `FREEMYIP_POLLING_INTERVAL` | Time between DNS propagation check |
-| `FREEMYIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `FREEMYIP_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `FREEMYIP_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `FREEMYIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `FREEMYIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `FREEMYIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `FREEMYIP_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `FREEMYIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_gandi.md b/docs/content/dns/zz_gen_gandi.md
index fa7ae6fe0..b02d97819 100644
--- a/docs/content/dns/zz_gen_gandi.md
+++ b/docs/content/dns/zz_gen_gandi.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Gandi provider:
```bash
GANDI_API_KEY=abcdefghijklmnopqrstuvwx \
-lego --email you@example.com --dns gandi -d '*.example.com' -d example.com run
+lego --dns gandi -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `GANDI_HTTP_TIMEOUT` | API request timeout |
-| `GANDI_POLLING_INTERVAL` | Time between DNS propagation check |
-| `GANDI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `GANDI_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `GANDI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `GANDI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) |
+| `GANDI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2400) |
+| `GANDI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_gandiv5.md b/docs/content/dns/zz_gen_gandiv5.md
index c3f0e2d20..78824abbe 100644
--- a/docs/content/dns/zz_gen_gandiv5.md
+++ b/docs/content/dns/zz_gen_gandiv5.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Gandi Live DNS (v5) provider:
```bash
GANDIV5_PERSONAL_ACCESS_TOKEN=abcdefghijklmnopqrstuvwx \
-lego --email you@example.com --dns gandiv5 -d '*.example.com' -d example.com run
+lego --dns gandiv5 -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `GANDIV5_HTTP_TIMEOUT` | API request timeout |
-| `GANDIV5_POLLING_INTERVAL` | Time between DNS propagation check |
-| `GANDIV5_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `GANDIV5_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `GANDIV5_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `GANDIV5_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) |
+| `GANDIV5_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) |
+| `GANDIV5_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_gcloud.md b/docs/content/dns/zz_gen_gcloud.md
index 556bffe3d..64acc1d1e 100644
--- a/docs/content/dns/zz_gen_gcloud.md
+++ b/docs/content/dns/zz_gen_gcloud.md
@@ -26,9 +26,21 @@ Configuration for [Google Cloud](https://cloud.google.com).
Here is an example bash command using the Google Cloud provider:
```bash
+# Using a service account file
GCE_PROJECT="gc-project-id" \
GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \
-lego --email you@email.com --dns gcloud -d '*.example.com' -d example.com run
+lego --dns gcloud -d '*.example.com' -d example.com run
+
+# Using default credentials with impersonation
+GCE_PROJECT="gc-project-id" \
+GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \
+lego --dns gcloud -d '*.example.com' -d example.com run
+
+# Using service account key with impersonation
+GCE_PROJECT="gc-project-id" \
+GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \
+GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \
+lego --dns gcloud -d '*.example.com' -d example.com run
```
@@ -52,14 +64,20 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `GCE_ALLOW_PRIVATE_ZONE` | Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false) |
-| `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 |
+| `GCE_IMPERSONATE_SERVICE_ACCOUNT` | Service account email to impersonate |
+| `GCE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `GCE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) |
+| `GCE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
| `GCE_ZONE_ID` | Allows to skip the automatic detection of the zone |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+Supports service account impersonation to access Google Cloud DNS resources across different projects or with restricted permissions.
+
+When using impersonation, the source service account must have:
+1. The "Service Account Token Creator" role on the source service account
+2. The "https://www.googleapis.com/auth/cloud-platform" scope
diff --git a/docs/content/dns/zz_gen_gcore.md b/docs/content/dns/zz_gen_gcore.md
index 7dbb3cec8..21a7ee9b1 100644
--- a/docs/content/dns/zz_gen_gcore.md
+++ b/docs/content/dns/zz_gen_gcore.md
@@ -27,7 +27,7 @@ Here is an example bash command using the G-Core provider:
```bash
GCORE_PERMANENT_API_TOKEN=xxxxx \
-lego --email you@example.com --dns gcore -d '*.example.com' -d example.com run
+lego --dns gcore -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `GCORE_HTTP_TIMEOUT` | API request timeout |
-| `GCORE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `GCORE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `GCORE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `GCORE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `GCORE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) |
+| `GCORE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) |
+| `GCORE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_gigahostno.md b/docs/content/dns/zz_gen_gigahostno.md
new file mode 100644
index 000000000..a59ffc401
--- /dev/null
+++ b/docs/content/dns/zz_gen_gigahostno.md
@@ -0,0 +1,70 @@
+---
+title: "Gigahost.no"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: gigahostno
+dnsprovider:
+ since: "v4.29.0"
+ code: "gigahostno"
+ url: "https://gigahost.no/"
+---
+
+
+
+
+
+
+Configuration for [Gigahost.no](https://gigahost.no/).
+
+
+
+
+- Code: `gigahostno`
+- Since: v4.29.0
+
+
+Here is an example bash command using the Gigahost.no provider:
+
+```bash
+GIGAHOSTNO_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \
+GIGAHOSTNO_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \
+lego --dns gigahostno -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `GIGAHOSTNO_PASSWORD` | Password |
+| `GIGAHOSTNO_USERNAME` | Username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `GIGAHOSTNO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `GIGAHOSTNO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `GIGAHOSTNO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `GIGAHOSTNO_SECRET` | TOTP secret |
+| `GIGAHOSTNO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://gigahost.no/api-dokumentasjon)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_glesys.md b/docs/content/dns/zz_gen_glesys.md
index e49209d85..2d2608330 100644
--- a/docs/content/dns/zz_gen_glesys.md
+++ b/docs/content/dns/zz_gen_glesys.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Glesys provider:
```bash
GLESYS_API_USER=xxxxx \
GLESYS_API_KEY=yyyyy \
-lego --email you@example.com --dns glesys -d '*.example.com' -d example.com run
+lego --dns glesys -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `GLESYS_HTTP_TIMEOUT` | API request timeout |
-| `GLESYS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `GLESYS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `GLESYS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `GLESYS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `GLESYS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) |
+| `GLESYS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) |
+| `GLESYS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_godaddy.md b/docs/content/dns/zz_gen_godaddy.md
index 9852a00d0..bc51cd69b 100644
--- a/docs/content/dns/zz_gen_godaddy.md
+++ b/docs/content/dns/zz_gen_godaddy.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Go Daddy provider:
```bash
GODADDY_API_KEY=xxxxxxxx \
GODADDY_API_SECRET=yyyyyyyy \
-lego --email you@example.com --dns godaddy -d '*.example.com' -d example.com run
+lego --dns godaddy -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `GODADDY_HTTP_TIMEOUT` | API request timeout |
-| `GODADDY_POLLING_INTERVAL` | Time between DNS propagation check |
-| `GODADDY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `GODADDY_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `GODADDY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `GODADDY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `GODADDY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `GODADDY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_googledomains.md b/docs/content/dns/zz_gen_googledomains.md
index a7ccb031e..2421184c0 100644
--- a/docs/content/dns/zz_gen_googledomains.md
+++ b/docs/content/dns/zz_gen_googledomains.md
@@ -6,15 +6,15 @@ slug: googledomains
dnsprovider:
since: "v4.11.0"
code: "googledomains"
- url: "https://domains.google"
+ url: "https://github.com/go-acme/lego/issues/2553"
---
+The Google Domains DNS provider has shut down.
-Configuration for [Google Domains](https://domains.google).
@@ -27,7 +27,7 @@ Here is an example bash command using the Google Domains provider:
```bash
GOOGLE_DOMAINS_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns googledomains -d '*.example.com' -d example.com run
+lego --dns googledomains -d '*.example.com' -d example.com run
```
@@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `GOOGLE_DOMAINS_HTTP_TIMEOUT` | API request timeout |
-| `GOOGLE_DOMAINS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `GOOGLE_DOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `GOOGLE_DOMAINS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `GOOGLE_DOMAINS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `GOOGLE_DOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_gravity.md b/docs/content/dns/zz_gen_gravity.md
new file mode 100644
index 000000000..654ad8424
--- /dev/null
+++ b/docs/content/dns/zz_gen_gravity.md
@@ -0,0 +1,71 @@
+---
+title: "Gravity"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: gravity
+dnsprovider:
+ since: "v4.30.0"
+ code: "gravity"
+ url: "https://gravity.beryju.io/"
+---
+
+
+
+
+
+
+Configuration for [Gravity](https://gravity.beryju.io/).
+
+
+
+
+- Code: `gravity`
+- Since: v4.30.0
+
+
+Here is an example bash command using the Gravity provider:
+
+```bash
+GRAVITY_SERVER_URL="https://example.org:1234" \
+GRAVITY_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \
+GRAVITY_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \
+lego --dns gravity -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `GRAVITY_PASSWORD` | Password |
+| `GRAVITY_SERVER_URL` | URL of the server |
+| `GRAVITY_USERNAME` | Username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `GRAVITY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `GRAVITY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `GRAVITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `GRAVITY_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 1) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://gravity.beryju.io/docs/api/reference/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_hetzner.md b/docs/content/dns/zz_gen_hetzner.md
index 1e28e4445..4e81bd4d9 100644
--- a/docs/content/dns/zz_gen_hetzner.md
+++ b/docs/content/dns/zz_gen_hetzner.md
@@ -26,8 +26,8 @@ Configuration for [Hetzner](https://hetzner.com).
Here is an example bash command using the Hetzner provider:
```bash
-HETZNER_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run
+HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns hetzner -d '*.example.com' -d example.com run
```
@@ -37,7 +37,7 @@ lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run
| Environment Variable Name | Description |
|-----------------------|-------------|
-| `HETZNER_API_KEY` | API key |
+| `HETZNER_API_TOKEN` | API token |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `HETZNER_HTTP_TIMEOUT` | API request timeout |
-| `HETZNER_POLLING_INTERVAL` | Time between DNS propagation check |
-| `HETZNER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `HETZNER_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `HETZNER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `HETZNER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HETZNER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `HETZNER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -60,7 +60,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
-- [API documentation](https://dns.hetzner.com/api-docs)
+- [API documentation](https://docs.hetzner.cloud/reference/cloud#dns)
diff --git a/docs/content/dns/zz_gen_hostingde.md b/docs/content/dns/zz_gen_hostingde.md
index b2e575c4c..4a66fe0f1 100644
--- a/docs/content/dns/zz_gen_hostingde.md
+++ b/docs/content/dns/zz_gen_hostingde.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Hosting.de provider:
```bash
HOSTINGDE_API_KEY=xxxxxxxx \
-lego --email you@example.com --dns hostingde -d '*.example.com' -d example.com run
+lego --dns hostingde -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `HOSTINGDE_HTTP_TIMEOUT` | API request timeout |
-| `HOSTINGDE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `HOSTINGDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `HOSTINGDE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `HOSTINGDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `HOSTINGDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HOSTINGDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `HOSTINGDE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
| `HOSTINGDE_ZONE_NAME` | Zone name in ACE format |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_hostinger.md b/docs/content/dns/zz_gen_hostinger.md
new file mode 100644
index 000000000..c05b3f003
--- /dev/null
+++ b/docs/content/dns/zz_gen_hostinger.md
@@ -0,0 +1,67 @@
+---
+title: "Hostinger"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: hostinger
+dnsprovider:
+ since: "v4.27.0"
+ code: "hostinger"
+ url: "https://www.hostinger.com/"
+---
+
+
+
+
+
+
+Configuration for [Hostinger](https://www.hostinger.com/).
+
+
+
+
+- Code: `hostinger`
+- Since: v4.27.0
+
+
+Here is an example bash command using the Hostinger provider:
+
+```bash
+HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns hostinger -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `HOSTINGER_API_TOKEN` | API Token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `HOSTINGER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `HOSTINGER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HOSTINGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `HOSTINGER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://developers.hostinger.com/#tag/dns-zone)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_hostingnl.md b/docs/content/dns/zz_gen_hostingnl.md
new file mode 100644
index 000000000..09cb69b47
--- /dev/null
+++ b/docs/content/dns/zz_gen_hostingnl.md
@@ -0,0 +1,67 @@
+---
+title: "Hosting.nl"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: hostingnl
+dnsprovider:
+ since: "v4.30.0"
+ code: "hostingnl"
+ url: "https://hosting.nl"
+---
+
+
+
+
+
+
+Configuration for [Hosting.nl](https://hosting.nl).
+
+
+
+
+- Code: `hostingnl`
+- Since: v4.30.0
+
+
+Here is an example bash command using the Hosting.nl provider:
+
+```bash
+HOSTINGNL_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns hostingnl -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `HOSTINGNL_API_KEY` | The API key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `HOSTINGNL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `HOSTINGNL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HOSTINGNL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `HOSTINGNL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://api.hosting.nl/api/documentation)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_hosttech.md b/docs/content/dns/zz_gen_hosttech.md
index e2881c4fa..9435cc562 100644
--- a/docs/content/dns/zz_gen_hosttech.md
+++ b/docs/content/dns/zz_gen_hosttech.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Hosttech provider:
```bash
HOSTTECH_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns hosttech -d '*.example.com' -d example.com run
+lego --dns hosttech -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `HOSTTECH_HTTP_TIMEOUT` | API request timeout |
-| `HOSTTECH_POLLING_INTERVAL` | Time between DNS propagation check |
-| `HOSTTECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `HOSTTECH_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `HOSTTECH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `HOSTTECH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HOSTTECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `HOSTTECH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_httpnet.md b/docs/content/dns/zz_gen_httpnet.md
index 8e333992f..862909697 100644
--- a/docs/content/dns/zz_gen_httpnet.md
+++ b/docs/content/dns/zz_gen_httpnet.md
@@ -27,7 +27,7 @@ Here is an example bash command using the http.net provider:
```bash
HTTPNET_API_KEY=xxxxxxxx \
-lego --email you@example.com --dns httpnet -d '*.example.com' -d example.com run
+lego --dns httpnet -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `HTTPNET_HTTP_TIMEOUT` | API request timeout |
-| `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 in seconds (Default: 30) |
+| `HTTPNET_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HTTPNET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `HTTPNET_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
| `HTTPNET_ZONE_NAME` | Zone name in ACE format |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_httpreq.md b/docs/content/dns/zz_gen_httpreq.md
index 81a761d4c..7f6a8d576 100644
--- a/docs/content/dns/zz_gen_httpreq.md
+++ b/docs/content/dns/zz_gen_httpreq.md
@@ -27,7 +27,7 @@ Here is an example bash command using the HTTP request provider:
```bash
HTTPREQ_ENDPOINT=http://my.server.com:9090 \
-lego --email you@example.com --dns httpreq -d '*.example.com' -d example.com run
+lego --dns httpreq -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `HTTPREQ_HTTP_TIMEOUT` | API request timeout |
+| `HTTPREQ_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `HTTPREQ_PASSWORD` | Basic authentication password |
-| `HTTPREQ_POLLING_INTERVAL` | Time between DNS propagation check |
-| `HTTPREQ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `HTTPREQ_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HTTPREQ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `HTTPREQ_USERNAME` | Basic authentication username |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_huaweicloud.md b/docs/content/dns/zz_gen_huaweicloud.md
index d5911eff6..46d121265 100644
--- a/docs/content/dns/zz_gen_huaweicloud.md
+++ b/docs/content/dns/zz_gen_huaweicloud.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Huawei Cloud provider:
HUAWEICLOUD_ACCESS_KEY_ID=your-access-key-id \
HUAWEICLOUD_SECRET_ACCESS_KEY=your-secret-access-key \
HUAWEICLOUD_REGION=cn-south-1 \
-lego --email you@example.com --dns huaweicloud -d '*.example.com' -d example.com run
+lego --dns huaweicloud -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `HUAWEICLOUD_HTTP_TIMEOUT` | API request timeout |
-| `HUAWEICLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `HUAWEICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `HUAWEICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `HUAWEICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `HUAWEICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HUAWEICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `HUAWEICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_hurricane.md b/docs/content/dns/zz_gen_hurricane.md
index 385e6501b..0c195d19c 100644
--- a/docs/content/dns/zz_gen_hurricane.md
+++ b/docs/content/dns/zz_gen_hurricane.md
@@ -27,10 +27,10 @@ Here is an example bash command using the Hurricane Electric DNS provider:
```bash
HURRICANE_TOKENS=example.org:token \
-lego --email you@example.com --dns hurricane -d '*.example.com' -d example.com run
+lego --dns hurricane -d '*.example.com' -d example.com run
HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \
-lego --email you@example.com --dns hurricane -d my.example.org -d demo.example.org
+lego --dns hurricane -d my.example.org -d demo.example.org
```
@@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `HURRICANE_HTTP_TIMEOUT` | API request timeout |
-| `HURRICANE_POLLING_INTERVAL` | Time between DNS propagation checks |
-| `HURRICANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation; defaults to 300s (5 minutes) |
-| `HURRICANE_SEQUENCE_INTERVAL` | Time between sequential requests |
+| `HURRICANE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `HURRICANE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `HURRICANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation (Default: 300) |
+| `HURRICANE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_hyperone.md b/docs/content/dns/zz_gen_hyperone.md
index b533de5d5..bc496f7bc 100644
--- a/docs/content/dns/zz_gen_hyperone.md
+++ b/docs/content/dns/zz_gen_hyperone.md
@@ -26,7 +26,7 @@ Configuration for [HyperOne](https://www.hyperone.com).
Here is an example bash command using the HyperOne provider:
```bash
-lego --email you@example.com --dns hyperone -d '*.example.com' -d example.com run
+lego --dns hyperone -d '*.example.com' -d example.com run
```
@@ -39,11 +39,12 @@ lego --email you@example.com --dns hyperone -d '*.example.com' -d example.com ru
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `HYPERONE_API_URL` | Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2) |
+| `HYPERONE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `HYPERONE_LOCATION_ID` | Specifies location (region) to be used in API calls. (default pl-waw-1) |
| `HYPERONE_PASSPORT_LOCATION` | Allows to pass custom passport file location (default ~/.h1/passport.json) |
-| `HYPERONE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `HYPERONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `HYPERONE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `HYPERONE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) |
+| `HYPERONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2) |
+| `HYPERONE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_ibmcloud.md b/docs/content/dns/zz_gen_ibmcloud.md
index 365377d2b..c5a48d2ad 100644
--- a/docs/content/dns/zz_gen_ibmcloud.md
+++ b/docs/content/dns/zz_gen_ibmcloud.md
@@ -28,7 +28,7 @@ Here is an example bash command using the IBM Cloud (SoftLayer) provider:
```bash
SOFTLAYER_USERNAME=xxxxx \
SOFTLAYER_API_KEY=yyyyy \
-lego --email you@example.com --dns ibmcloud -d '*.example.com' -d example.com run
+lego --dns ibmcloud -d '*.example.com' -d example.com run
```
@@ -39,7 +39,7 @@ lego --email you@example.com --dns ibmcloud -d '*.example.com' -d example.com ru
| Environment Variable Name | Description |
|-----------------------|-------------|
| `SOFTLAYER_API_KEY` | Classic Infrastructure API key |
-| `SOFTLAYER_USERNAME` | Username (IBM Cloud is _) |
+| `SOFTLAYER_USERNAME` | Username (IBM Cloud is {accountID}_{emailAddress}) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SOFTLAYER_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SOFTLAYER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SOFTLAYER_TIMEOUT` | API request timeout |
-| `SOFTLAYER_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SOFTLAYER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `SOFTLAYER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `SOFTLAYER_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SOFTLAYER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_iij.md b/docs/content/dns/zz_gen_iij.md
index b5e458db2..c7acfe3a0 100644
--- a/docs/content/dns/zz_gen_iij.md
+++ b/docs/content/dns/zz_gen_iij.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Internet Initiative Japan provider:
IIJ_API_ACCESS_KEY=xxxxxxxx \
IIJ_API_SECRET_KEY=yyyyyy \
IIJ_DO_SERVICE_CODE=zzzzzz \
-lego --email you@example.com --dns iij -d '*.example.com' -d example.com run
+lego --dns iij -d '*.example.com' -d example.com run
```
@@ -51,9 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `IIJ_POLLING_INTERVAL` | Time between DNS propagation check |
-| `IIJ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `IIJ_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `IIJ_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) |
+| `IIJ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) |
+| `IIJ_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_iijdpf.md b/docs/content/dns/zz_gen_iijdpf.md
index b9635ac06..12e126f49 100644
--- a/docs/content/dns/zz_gen_iijdpf.md
+++ b/docs/content/dns/zz_gen_iijdpf.md
@@ -28,7 +28,7 @@ Here is an example bash command using the IIJ DNS Platform Service provider:
```bash
IIJ_DPF_API_TOKEN=xxxxxxxx \
IIJ_DPF_DPM_SERVICE_CODE=yyyyyy \
-lego --email you@example.com --dns iijdpf -d '*.example.com' -d example.com run
+lego --dns iijdpf -d '*.example.com' -d example.com run
```
@@ -50,9 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `IIJ_DPF_API_ENDPOINT` | API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1 |
-| `IIJ_DPF_POLLING_INTERVAL` | Time between DNS propagation check, defaults to 5 second |
-| `IIJ_DPF_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation, defaults to 660 second |
-| `IIJ_DPF_TTL` | The TTL of the TXT record used for the DNS challenge, default to 300 |
+| `IIJ_DPF_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `IIJ_DPF_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 660) |
+| `IIJ_DPF_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_infoblox.md b/docs/content/dns/zz_gen_infoblox.md
index ba7af4855..74b80b2d1 100644
--- a/docs/content/dns/zz_gen_infoblox.md
+++ b/docs/content/dns/zz_gen_infoblox.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Infoblox provider:
INFOBLOX_USERNAME=api-user-529 \
INFOBLOX_PASSWORD=b9841238feb177a84330febba8a83208921177bffe733 \
INFOBLOX_HOST=infoblox.example.org
-lego --email you@example.com --dns infoblox -d '*.example.com' -d example.com run
+lego --dns infoblox -d '*.example.com' -d example.com run
```
@@ -51,14 +51,15 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `INFOBLOX_DNS_VIEW` | The view for the TXT records, default: External |
-| `INFOBLOX_HTTP_TIMEOUT` | HTTP request timeout |
-| `INFOBLOX_POLLING_INTERVAL` | Time between DNS propagation check |
-| `INFOBLOX_PORT` | The port for the infoblox grid manager, default: 443 |
-| `INFOBLOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `INFOBLOX_SSL_VERIFY` | Whether or not to verify the TLS certificate, default: true |
-| `INFOBLOX_TTL` | The TTL of the TXT record used for the DNS challenge |
-| `INFOBLOX_WAPI_VERSION` | The version of WAPI being used, default: 2.11 |
+| `INFOBLOX_CA_CERTIFICATE` | The path to the CA certificate (PEM encoded) |
+| `INFOBLOX_DNS_VIEW` | The view for the TXT records (Default: External) |
+| `INFOBLOX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `INFOBLOX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `INFOBLOX_PORT` | The port for the infoblox grid manager (Default: 443) |
+| `INFOBLOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `INFOBLOX_SSL_VERIFY` | Whether or not to verify the TLS certificate (Default: true) |
+| `INFOBLOX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+| `INFOBLOX_WAPI_VERSION` | The version of WAPI being used (Default: 2.11) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_infomaniak.md b/docs/content/dns/zz_gen_infomaniak.md
index 4b737d4af..7254241b1 100644
--- a/docs/content/dns/zz_gen_infomaniak.md
+++ b/docs/content/dns/zz_gen_infomaniak.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Infomaniak provider:
```bash
INFOMANIAK_ACCESS_TOKEN=1234567898765432 \
-lego --email you@example.com --dns infomaniak -d '*.example.com' -d example.com run
+lego --dns infomaniak -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `INFOMANIAK_ENDPOINT` | https://api.infomaniak.com |
-| `INFOMANIAK_HTTP_TIMEOUT` | API request timeout |
-| `INFOMANIAK_POLLING_INTERVAL` | Time between DNS propagation check |
-| `INFOMANIAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `INFOMANIAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds |
+| `INFOMANIAK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `INFOMANIAK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `INFOMANIAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `INFOMANIAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_internetbs.md b/docs/content/dns/zz_gen_internetbs.md
index 3725bcb07..f0d9df3c1 100644
--- a/docs/content/dns/zz_gen_internetbs.md
+++ b/docs/content/dns/zz_gen_internetbs.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Internet.bs provider:
```bash
INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \
INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \
-lego --email you@example.com --dns internetbs -d '*.example.com' -d example.com run
+lego --dns internetbs -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `INTERNET_BS_HTTP_TIMEOUT` | API request timeout |
-| `INTERNET_BS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `INTERNET_BS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `INTERNET_BS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `INTERNET_BS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `INTERNET_BS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `INTERNET_BS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `INTERNET_BS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_inwx.md b/docs/content/dns/zz_gen_inwx.md
index b51d58c07..3e7d999e9 100644
--- a/docs/content/dns/zz_gen_inwx.md
+++ b/docs/content/dns/zz_gen_inwx.md
@@ -28,13 +28,13 @@ Here is an example bash command using the INWX provider:
```bash
INWX_USERNAME=xxxxxxxxxx \
INWX_PASSWORD=yyyyyyyyyy \
-lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run
+lego --dns inwx -d '*.example.com' -d example.com run
# 2FA
INWX_USERNAME=xxxxxxxxxx \
INWX_PASSWORD=yyyyyyyyyy \
INWX_SHARED_SECRET=zzzzzzzzzz \
-lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run
+lego --dns inwx -d '*.example.com' -d example.com run
```
@@ -55,11 +55,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `INWX_POLLING_INTERVAL` | Time between DNS propagation check |
-| `INWX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation (default 360s) |
+| `INWX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `INWX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) |
| `INWX_SANDBOX` | Activate the sandbox (boolean) |
| `INWX_SHARED_SECRET` | shared secret related to 2FA |
-| `INWX_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `INWX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_ionos.md b/docs/content/dns/zz_gen_ionos.md
index 54d694da0..78bd3ffb1 100644
--- a/docs/content/dns/zz_gen_ionos.md
+++ b/docs/content/dns/zz_gen_ionos.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Ionos provider:
```bash
IONOS_API_KEY=xxxxxxxx \
-lego --email you@example.com --dns ionos -d '*.example.com' -d example.com run
+lego --dns ionos -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `IONOS_HTTP_TIMEOUT` | API request timeout |
-| `IONOS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `IONOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `IONOS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `IONOS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `IONOS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `IONOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) |
+| `IONOS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_ionoscloud.md b/docs/content/dns/zz_gen_ionoscloud.md
new file mode 100644
index 000000000..6007670a7
--- /dev/null
+++ b/docs/content/dns/zz_gen_ionoscloud.md
@@ -0,0 +1,67 @@
+---
+title: "Ionos Cloud"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: ionoscloud
+dnsprovider:
+ since: "v4.30.0"
+ code: "ionoscloud"
+ url: "https://cloud.ionos.de/network/cloud-dns"
+---
+
+
+
+
+
+
+Configuration for [Ionos Cloud](https://cloud.ionos.de/network/cloud-dns).
+
+
+
+
+- Code: `ionoscloud`
+- Since: v4.30.0
+
+
+Here is an example bash command using the Ionos Cloud provider:
+
+```bash
+IONOSCLOUD_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns ionoscloud -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `IONOSCLOUD_API_TOKEN` | API token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `IONOSCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `IONOSCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `IONOSCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `IONOSCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://api.ionos.com/docs/dns/v1/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_ipv64.md b/docs/content/dns/zz_gen_ipv64.md
index 6d7bcd24c..00a0292a6 100644
--- a/docs/content/dns/zz_gen_ipv64.md
+++ b/docs/content/dns/zz_gen_ipv64.md
@@ -27,7 +27,7 @@ Here is an example bash command using the IPv64 provider:
```bash
IPV64_API_KEY=xxxxxx \
-lego --email you@example.com --dns ipv64 -d '*.example.com' -d example.com run
+lego --dns ipv64 -d '*.example.com' -d example.com run
```
@@ -47,10 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `IPV64_HTTP_TIMEOUT` | API request timeout |
-| `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 in seconds (Default: 30) |
+| `IPV64_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `IPV64_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_ispconfig.md b/docs/content/dns/zz_gen_ispconfig.md
new file mode 100644
index 000000000..e56f1f0b1
--- /dev/null
+++ b/docs/content/dns/zz_gen_ispconfig.md
@@ -0,0 +1,72 @@
+---
+title: "ISPConfig 3"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: ispconfig
+dnsprovider:
+ since: "v4.31.0"
+ code: "ispconfig"
+ url: "https://www.ispconfig.org/"
+---
+
+
+
+
+
+
+Configuration for [ISPConfig 3](https://www.ispconfig.org/).
+
+
+
+
+- Code: `ispconfig`
+- Since: v4.31.0
+
+
+Here is an example bash command using the ISPConfig 3 provider:
+
+```bash
+ISPCONFIG_SERVER_URL="https://example.com:8080/remote/json.php" \
+ISPCONFIG_USERNAME="xxx" \
+ISPCONFIG_PASSWORD="yyy" \
+lego --dns ispconfig -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ISPCONFIG_PASSWORD` | Password |
+| `ISPCONFIG_SERVER_URL` | Server URL |
+| `ISPCONFIG_USERNAME` | Username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ISPCONFIG_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ISPCONFIG_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate |
+| `ISPCONFIG_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ISPCONFIG_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ISPCONFIG_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/index.html)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_ispconfigddns.md b/docs/content/dns/zz_gen_ispconfigddns.md
new file mode 100644
index 000000000..3d1dd83c3
--- /dev/null
+++ b/docs/content/dns/zz_gen_ispconfigddns.md
@@ -0,0 +1,74 @@
+---
+title: "ISPConfig 3 - Dynamic DNS (DDNS) Module"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: ispconfigddns
+dnsprovider:
+ since: "v4.31.0"
+ code: "ispconfigddns"
+ url: "https://www.ispconfig.org/"
+---
+
+
+
+
+
+
+Configuration for [ISPConfig 3 - Dynamic DNS (DDNS) Module](https://www.ispconfig.org/).
+
+
+
+
+- Code: `ispconfigddns`
+- Since: v4.31.0
+
+
+Here is an example bash command using the ISPConfig 3 - Dynamic DNS (DDNS) Module provider:
+
+```bash
+ISPCONFIG_DDNS_SERVER_URL="https://panel.example.com:8080" \
+ISPCONFIG_DDNS_TOKEN=xxxxxx \
+lego --dns ispconfigddns -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ISPCONFIG_DDNS_SERVER_URL` | API server URL (ex: https://panel.example.com:8080) |
+| `ISPCONFIG_DDNS_TOKEN` | DDNS API token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ISPCONFIG_DDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ISPCONFIG_DDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ISPCONFIG_DDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ISPCONFIG_DDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+ISPConfig DNS provider supports leveraging the [ISPConfig 3 Dynamic DNS (DDNS) Module](https://github.com/mhofer117/ispconfig-ddns-module).
+
+Requires the DDNS module described at https://www.ispconfig.org/ispconfig/download/
+
+See https://www.howtoforge.com/community/threads/ispconfig-3-danymic-dns-ddns-module.87967/ for additional details.
+
+
+
+## More information
+
+- [API documentation](https://github.com/mhofer117/ispconfig-ddns-module/tree/master/lib/updater)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_iwantmyname.md b/docs/content/dns/zz_gen_iwantmyname.md
index 8146a36ed..4638e1379 100644
--- a/docs/content/dns/zz_gen_iwantmyname.md
+++ b/docs/content/dns/zz_gen_iwantmyname.md
@@ -1,5 +1,5 @@
---
-title: "iwantmyname"
+title: "iwantmyname (Deprecated)"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: iwantmyname
@@ -13,8 +13,10 @@ dnsprovider:
+The iwantmyname API has shut down.
+
+https://github.com/go-acme/lego/issues/2563
-Configuration for [iwantmyname](https://iwantmyname.com).
@@ -23,12 +25,12 @@ Configuration for [iwantmyname](https://iwantmyname.com).
- Since: v4.7.0
-Here is an example bash command using the iwantmyname provider:
+Here is an example bash command using the iwantmyname (Deprecated) provider:
```bash
IWANTMYNAME_USERNAME=xxxxxxxx \
IWANTMYNAME_PASSWORD=xxxxxxxx \
-lego --email you@example.com --dns iwantmyname -d '*.example.com' -d example.com run
+lego --dns iwantmyname -d '*.example.com' -d example.com run
```
@@ -49,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `IWANTMYNAME_HTTP_TIMEOUT` | API request timeout |
-| `IWANTMYNAME_POLLING_INTERVAL` | Time between DNS propagation check |
-| `IWANTMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `IWANTMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `IWANTMYNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `IWANTMYNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `IWANTMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `IWANTMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_jdcloud.md b/docs/content/dns/zz_gen_jdcloud.md
new file mode 100644
index 000000000..a37cc3520
--- /dev/null
+++ b/docs/content/dns/zz_gen_jdcloud.md
@@ -0,0 +1,71 @@
+---
+title: "JD Cloud"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: jdcloud
+dnsprovider:
+ since: "v4.31.0"
+ code: "jdcloud"
+ url: "https://www.jdcloud.com/"
+---
+
+
+
+
+
+
+Configuration for [JD Cloud](https://www.jdcloud.com/).
+
+
+
+
+- Code: `jdcloud`
+- Since: v4.31.0
+
+
+Here is an example bash command using the JD Cloud provider:
+
+```bash
+JDCLOUD_ACCESS_KEY_ID="xxx" \
+JDCLOUD_ACCESS_KEY_SECRET="yyy" \
+lego --dns jdcloud -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `JDCLOUD_ACCESS_KEY_ID` | Access key ID |
+| `JDCLOUD_ACCESS_KEY_SECRET` | Access key secret |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `JDCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `JDCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `JDCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `JDCLOUD_REGION_ID` | Region ID (Default: cn-north-1) |
+| `JDCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview)
+- [Go client](https://github.com/jdcloud-api/jdcloud-sdk-go)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_joker.md b/docs/content/dns/zz_gen_joker.md
index 2c0a6eafc..a5ecd47de 100644
--- a/docs/content/dns/zz_gen_joker.md
+++ b/docs/content/dns/zz_gen_joker.md
@@ -30,17 +30,17 @@ Here is an example bash command using the Joker provider:
JOKER_API_MODE=SVC \
JOKER_USERNAME= \
JOKER_PASSWORD= \
-lego --email you@example.com --dns joker -d '*.example.com' -d example.com run
+lego --dns joker -d '*.example.com' -d example.com run
# DMAPI
JOKER_API_MODE=DMAPI \
JOKER_USERNAME= \
JOKER_PASSWORD= \
-lego --email you@example.com --dns joker -d '*.example.com' -d example.com run
+lego --dns joker -d '*.example.com' -d example.com run
## or
JOKER_API_MODE=DMAPI \
JOKER_API_KEY= \
-lego --email you@example.com --dns joker -d '*.example.com' -d example.com run
+lego --dns joker -d '*.example.com' -d example.com run
```
@@ -63,11 +63,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `JOKER_HTTP_TIMEOUT` | API request timeout |
-| `JOKER_POLLING_INTERVAL` | Time between DNS propagation check |
-| `JOKER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `JOKER_SEQUENCE_INTERVAL` | Time between sequential requests (only with 'SVC' mode) |
-| `JOKER_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `JOKER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `JOKER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `JOKER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `JOKER_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60), only with 'SVC' mode |
+| `JOKER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_keyhelp.md b/docs/content/dns/zz_gen_keyhelp.md
new file mode 100644
index 000000000..e39d3ce82
--- /dev/null
+++ b/docs/content/dns/zz_gen_keyhelp.md
@@ -0,0 +1,69 @@
+---
+title: "KeyHelp"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: keyhelp
+dnsprovider:
+ since: "v4.26.0"
+ code: "keyhelp"
+ url: "https://www.keyweb.de/en/keyhelp/keyhelp/"
+---
+
+
+
+
+
+
+Configuration for [KeyHelp](https://www.keyweb.de/en/keyhelp/keyhelp/).
+
+
+
+
+- Code: `keyhelp`
+- Since: v4.26.0
+
+
+Here is an example bash command using the KeyHelp provider:
+
+```bash
+KEYHELP_BASE_URL="https://keyhelp.example.com" \
+KEYHELP_API_KEY="xxx" \
+lego --dns keyhelp -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `KEYHELP_API_KEY` | API key |
+| `KEYHELP_BASE_URL` | Server URL |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `KEYHELP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `KEYHELP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `KEYHELP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `KEYHELP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://app.swaggerhub.com/apis-docs/keyhelp/api/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_leaseweb.md b/docs/content/dns/zz_gen_leaseweb.md
new file mode 100644
index 000000000..13ded490a
--- /dev/null
+++ b/docs/content/dns/zz_gen_leaseweb.md
@@ -0,0 +1,67 @@
+---
+title: "Leaseweb"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: leaseweb
+dnsprovider:
+ since: "v4.32.0"
+ code: "leaseweb"
+ url: "https://www.leaseweb.com/en/"
+---
+
+
+
+
+
+
+Configuration for [Leaseweb](https://www.leaseweb.com/en/).
+
+
+
+
+- Code: `leaseweb`
+- Since: v4.32.0
+
+
+Here is an example bash command using the Leaseweb provider:
+
+```bash
+LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns leaseweb -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `LEASEWEB_API_KEY` | API key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `LEASEWEB_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `LEASEWEB_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `LEASEWEB_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `LEASEWEB_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://developer.leaseweb.com/docs/#tag/DNS)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_liara.md b/docs/content/dns/zz_gen_liara.md
index 23bde4d79..658ce8077 100644
--- a/docs/content/dns/zz_gen_liara.md
+++ b/docs/content/dns/zz_gen_liara.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Liara provider:
```bash
LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns liara -d '*.example.com' -d example.com run
+lego --dns liara -d '*.example.com' -d example.com run
```
@@ -47,10 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `LIARA_HTTP_TIMEOUT` | API request timeout |
-| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check |
-| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `LIARA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `LIARA_TEAM_ID` | The team ID to access services in a team |
+| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -60,7 +61,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
-- [API documentation](https://dns-service.iran.liara.ir/swagger)
+- [API documentation](https://openapi.liara.ir/?urls.primaryName=DNS)
diff --git a/docs/content/dns/zz_gen_lightsail.md b/docs/content/dns/zz_gen_lightsail.md
index f2bbaefb7..8e738611b 100644
--- a/docs/content/dns/zz_gen_lightsail.md
+++ b/docs/content/dns/zz_gen_lightsail.md
@@ -47,8 +47,8 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `AWS_SHARED_CREDENTIALS_FILE` | Managed by the AWS client. Shared credentials file. |
-| `LIGHTSAIL_POLLING_INTERVAL` | Time between DNS propagation check |
-| `LIGHTSAIL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `LIGHTSAIL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `LIGHTSAIL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_limacity.md b/docs/content/dns/zz_gen_limacity.md
index fdaae55e6..29bc6e0a7 100644
--- a/docs/content/dns/zz_gen_limacity.md
+++ b/docs/content/dns/zz_gen_limacity.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Lima-City provider:
```bash
LIMACITY_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns limacity -d '*.example.com' -d example.com run
+lego --dns limacity -d '*.example.com' -d example.com run
```
@@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `LIMACITY_HTTP_TIMEOUT` | API request timeout |
-| `LIMACITY_POLLING_INTERVAL` | Time between DNS propagation check |
-| `LIMACITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `LIMACITY_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `LIMACITY_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `LIMACITY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `LIMACITY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 80) |
+| `LIMACITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 480) |
+| `LIMACITY_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 90) |
+| `LIMACITY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_linode.md b/docs/content/dns/zz_gen_linode.md
index 8b97123b2..e41ba7cd9 100644
--- a/docs/content/dns/zz_gen_linode.md
+++ b/docs/content/dns/zz_gen_linode.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Linode (v4) provider:
```bash
LINODE_TOKEN=xxxxx \
-lego --email you@example.com --dns linode -d '*.example.com' -d example.com run
+lego --dns linode -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `LINODE_HTTP_TIMEOUT` | API request timeout |
-| `LINODE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `LINODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `LINODE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `LINODE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `LINODE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) |
+| `LINODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `LINODE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_liquidweb.md b/docs/content/dns/zz_gen_liquidweb.md
index 511ba9c92..bd2ce63b6 100644
--- a/docs/content/dns/zz_gen_liquidweb.md
+++ b/docs/content/dns/zz_gen_liquidweb.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Liquid Web provider:
```bash
LWAPI_USERNAME=someuser \
LWAPI_PASSWORD=somepass \
-lego --email you@example.com --dns liquidweb -d '*.example.com' -d example.com run
+lego --dns liquidweb -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `LWAPI_HTTP_TIMEOUT` | Maximum waiting time for the DNS records to be created (not verified) |
-| `LWAPI_POLLING_INTERVAL` | Time between DNS propagation check |
-| `LWAPI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `LWAPI_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `LWAPI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `LWAPI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `LWAPI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `LWAPI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
| `LWAPI_URL` | Liquid Web API endpoint |
| `LWAPI_ZONE` | DNS Zone |
diff --git a/docs/content/dns/zz_gen_loopia.md b/docs/content/dns/zz_gen_loopia.md
index 79827d325..bb3120c00 100644
--- a/docs/content/dns/zz_gen_loopia.md
+++ b/docs/content/dns/zz_gen_loopia.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Loopia provider:
```bash
LOOPIA_API_USER=xxxxxxxx \
LOOPIA_API_PASSWORD=yyyyyyyy \
-lego --email you@example.com --dns loopia -d '*.example.com' -d example.com run
+lego --dns loopia -d '*.example.com' -d example.com run
```
@@ -50,10 +50,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `LOOPIA_API_URL` | API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV |
-| `LOOPIA_HTTP_TIMEOUT` | API request timeout |
-| `LOOPIA_POLLING_INTERVAL` | Time between DNS propagation check |
-| `LOOPIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `LOOPIA_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `LOOPIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `LOOPIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2400) |
+| `LOOPIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `LOOPIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_luadns.md b/docs/content/dns/zz_gen_luadns.md
index 2a6a02dd9..8bf718ba3 100644
--- a/docs/content/dns/zz_gen_luadns.md
+++ b/docs/content/dns/zz_gen_luadns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the LuaDNS provider:
```bash
LUADNS_API_USERNAME=youremail \
LUADNS_API_TOKEN=xxxxxxxx \
-lego --email you@example.com --dns luadns -d '*.example.com' -d example.com run
+lego --dns luadns -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `LUADNS_HTTP_TIMEOUT` | API request timeout |
-| `LUADNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `LUADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `LUADNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `LUADNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `LUADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `LUADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `LUADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_mailinabox.md b/docs/content/dns/zz_gen_mailinabox.md
index f3269620f..62a6bdb57 100644
--- a/docs/content/dns/zz_gen_mailinabox.md
+++ b/docs/content/dns/zz_gen_mailinabox.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Mail-in-a-Box provider:
MAILINABOX_EMAIL=user@example.com \
MAILINABOX_PASSWORD=yyyy \
MAILINABOX_BASE_URL=https://box.example.com \
-lego --email you@example.com --dns mailinabox -d '*.example.com' -d example.com run
+lego --dns mailinabox -d '*.example.com' -d example.com run
```
@@ -51,8 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `MAILINABOX_POLLING_INTERVAL` | Time between DNS propagation check |
-| `MAILINABOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `MAILINABOX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `MAILINABOX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) |
+| `MAILINABOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_manageengine.md b/docs/content/dns/zz_gen_manageengine.md
new file mode 100644
index 000000000..a39db8208
--- /dev/null
+++ b/docs/content/dns/zz_gen_manageengine.md
@@ -0,0 +1,68 @@
+---
+title: "ManageEngine CloudDNS"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: manageengine
+dnsprovider:
+ since: "v4.21.0"
+ code: "manageengine"
+ url: "https://clouddns.manageengine.com"
+---
+
+
+
+
+
+
+Configuration for [ManageEngine CloudDNS](https://clouddns.manageengine.com).
+
+
+
+
+- Code: `manageengine`
+- Since: v4.21.0
+
+
+Here is an example bash command using the ManageEngine CloudDNS provider:
+
+```bash
+MANAGEENGINE_CLIENT_ID="xxx" \
+MANAGEENGINE_CLIENT_SECRET="yyy" \
+lego --dns manageengine -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `MANAGEENGINE_CLIENT_ID` | Client ID |
+| `MANAGEENGINE_CLIENT_SECRET` | Client Secret |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `MANAGEENGINE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `MANAGEENGINE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `MANAGEENGINE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_manual.md b/docs/content/dns/zz_gen_manual.md
new file mode 100644
index 000000000..832ccaf58
--- /dev/null
+++ b/docs/content/dns/zz_gen_manual.md
@@ -0,0 +1,98 @@
+---
+title: "Manual"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: manual
+dnsprovider:
+ since: "v0.3.0"
+ code: "manual"
+ url: ""
+---
+
+
+
+
+
+Solving the DNS-01 challenge using CLI prompt.
+
+
+
+
+- Code: `manual`
+- Since: v0.3.0
+
+
+Here is an example bash command using the Manual provider:
+
+```bash
+lego --dns manual -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Example
+
+To start using the CLI prompt "provider", start lego with `--dns manual`:
+
+```console
+$ lego --dns manual -d example.com run
+```
+
+What follows are a few log print-outs, interspersed with some prompts, asking for you to do perform some actions:
+
+```txt
+No key found for account you@example.com. Generating a P256 key.
+Saved key to ./.lego/accounts/acme-v02.api.letsencrypt.org/you@example.com/keys/you@example.com.key
+Please review the TOS at https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
+Do you accept the TOS? Y/n
+```
+
+If you accept the linked Terms of Service, hit `Enter`.
+
+```txt
+[INFO] acme: Registering account for you@example.com
+!!!! HEADS UP !!!!
+
+Your account credentials have been saved in your
+configuration directory at "./.lego/accounts".
+
+You should make a secure backup of this folder now. This
+configuration directory will also contain private keys
+generated by lego and certificates obtained from the ACME
+server. Making regular backups of this folder is ideal.
+[INFO] [example.com] acme: Obtaining bundled SAN certificate
+[INFO] [example.com] AuthURL: https://acme-v02.api.letsencrypt.org/acme/authz-v3/2345678901
+[INFO] [example.com] acme: Could not find solver for: tls-alpn-01
+[INFO] [example.com] acme: Could not find solver for: http-01
+[INFO] [example.com] acme: use dns-01 solver
+[INFO] [example.com] acme: Preparing to solve DNS-01
+lego: Please create the following TXT record in your example.com. zone:
+_acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ"
+lego: Press 'Enter' when you are done
+```
+
+Do as instructed, and create the TXT records, and hit `Enter`.
+
+```txt
+[INFO] [example.com] acme: Trying to solve DNS-01
+[INFO] [example.com] acme: Checking DNS record propagation using [192.168.8.1:53]
+[INFO] Wait for propagation [timeout: 1m0s, interval: 2s]
+[INFO] [example.com] acme: Waiting for DNS record propagation.
+[INFO] [example.com] The server validated our request
+[INFO] [example.com] acme: Cleaning DNS-01 challenge
+lego: You can now remove this TXT record from your example.com. zone:
+_acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ"
+[INFO] [example.com] acme: Validations succeeded; requesting certificates
+[INFO] [example.com] Server responded with a certificate.
+```
+
+As mentioned, you can now remove the TXT record again.
+
+
+
+
+
+
+
+
diff --git a/docs/content/dns/zz_gen_metaname.md b/docs/content/dns/zz_gen_metaname.md
index ea794d4e5..156cf15eb 100644
--- a/docs/content/dns/zz_gen_metaname.md
+++ b/docs/content/dns/zz_gen_metaname.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Metaname provider:
```bash
METANAME_ACCOUNT_REFERENCE=xxxx \
METANAME_API_KEY=yyyyyyy \
-lego --email you@example.com --dns metaname -d '*.example.com' -d example.com run
+lego --dns metaname -d '*.example.com' -d example.com run
```
@@ -49,9 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `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 |
+| `METANAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `METANAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `METANAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_metaregistrar.md b/docs/content/dns/zz_gen_metaregistrar.md
new file mode 100644
index 000000000..22de046e2
--- /dev/null
+++ b/docs/content/dns/zz_gen_metaregistrar.md
@@ -0,0 +1,67 @@
+---
+title: "Metaregistrar"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: metaregistrar
+dnsprovider:
+ since: "v4.23.0"
+ code: "metaregistrar"
+ url: "https://metaregistrar.com/"
+---
+
+
+
+
+
+
+Configuration for [Metaregistrar](https://metaregistrar.com/).
+
+
+
+
+- Code: `metaregistrar`
+- Since: v4.23.0
+
+
+Here is an example bash command using the Metaregistrar provider:
+
+```bash
+METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns metaregistrar -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `METAREGISTRAR_API_TOKEN` | The API token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `METAREGISTRAR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `METAREGISTRAR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `METAREGISTRAR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `METAREGISTRAR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://metaregistrar.dev/docu/metaapi/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_mijnhost.md b/docs/content/dns/zz_gen_mijnhost.md
index 65c1d953d..3d8f71aff 100644
--- a/docs/content/dns/zz_gen_mijnhost.md
+++ b/docs/content/dns/zz_gen_mijnhost.md
@@ -27,7 +27,7 @@ Here is an example bash command using the mijn.host provider:
```bash
MIJNHOST_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns mijnhost -d '*.example.com' -d example.com run
+lego --dns mijnhost -d '*.example.com' -d example.com run
```
@@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `MIJNHOST_HTTP_TIMEOUT` | API request timeout |
-| `MIJNHOST_POLLING_INTERVAL` | Time between DNS propagation check |
-| `MIJNHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `MIJNHOST_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `MIJNHOST_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `MIJNHOST_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `MIJNHOST_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `MIJNHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `MIJNHOST_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `MIJNHOST_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_mittwald.md b/docs/content/dns/zz_gen_mittwald.md
index c1edfe084..7714ef54f 100644
--- a/docs/content/dns/zz_gen_mittwald.md
+++ b/docs/content/dns/zz_gen_mittwald.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Mittwald provider:
```bash
MITTWALD_TOKEN=my-token \
-lego --email you@example.com --dns mittwald -d '*.example.com' -d example.com run
+lego --dns mittwald -d '*.example.com' -d example.com run
```
@@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `MITTWALD_HTTP_TIMEOUT` | API request timeout |
-| `MITTWALD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `MITTWALD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `MITTWALD_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `MITTWALD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `MITTWALD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `MITTWALD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `MITTWALD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `MITTWALD_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) |
+| `MITTWALD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_myaddr.md b/docs/content/dns/zz_gen_myaddr.md
new file mode 100644
index 000000000..4a52a058b
--- /dev/null
+++ b/docs/content/dns/zz_gen_myaddr.md
@@ -0,0 +1,68 @@
+---
+title: "myaddr.{tools,dev,io}"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: myaddr
+dnsprovider:
+ since: "v4.22.0"
+ code: "myaddr"
+ url: "https://myaddr.tools/"
+---
+
+
+
+
+
+
+Configuration for [myaddr.{tools,dev,io}](https://myaddr.tools/).
+
+
+
+
+- Code: `myaddr`
+- Since: v4.22.0
+
+
+Here is an example bash command using the myaddr.{tools,dev,io} provider:
+
+```bash
+MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \
+lego --dns myaddr -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `MYADDR_PRIVATE_KEYS_MAPPING` | Mapping between subdomains and private keys. The format is: `:,:,:` |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `MYADDR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `MYADDR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `MYADDR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `MYADDR_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 2) |
+| `MYADDR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://myaddr.tools/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_mydnsjp.md b/docs/content/dns/zz_gen_mydnsjp.md
index 4fc899bf0..0a49404bb 100644
--- a/docs/content/dns/zz_gen_mydnsjp.md
+++ b/docs/content/dns/zz_gen_mydnsjp.md
@@ -28,7 +28,7 @@ Here is an example bash command using the MyDNS.jp provider:
```bash
MYDNSJP_MASTER_ID=xxxxx \
MYDNSJP_PASSWORD=xxxxx \
-lego --email you@example.com --dns mydnsjp -d '*.example.com' -d example.com run
+lego --dns mydnsjp -d '*.example.com' -d example.com run
```
@@ -49,10 +49,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `MYDNSJP_HTTP_TIMEOUT` | API request timeout |
-| `MYDNSJP_POLLING_INTERVAL` | Time between DNS propagation check |
-| `MYDNSJP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `MYDNSJP_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `MYDNSJP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `MYDNSJP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `MYDNSJP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_mythicbeasts.md b/docs/content/dns/zz_gen_mythicbeasts.md
index 86e2ae5fd..70e38d249 100644
--- a/docs/content/dns/zz_gen_mythicbeasts.md
+++ b/docs/content/dns/zz_gen_mythicbeasts.md
@@ -28,7 +28,7 @@ Here is an example bash command using the MythicBeasts provider:
```bash
MYTHICBEASTS_USERNAME=myuser \
MYTHICBEASTS_PASSWORD=mypass \
-lego --email you@example.com --dns mythicbeasts -d '*.example.com' -d example.com run
+lego --dns mythicbeasts -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
|--------------------------------|-------------|
| `MYTHICBEASTS_API_ENDPOINT` | The endpoint for the API (must implement v2) |
| `MYTHICBEASTS_AUTH_API_ENDPOINT` | The endpoint for Mythic Beasts' Authentication |
-| `MYTHICBEASTS_HTTP_TIMEOUT` | API request timeout |
-| `MYTHICBEASTS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `MYTHICBEASTS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `MYTHICBEASTS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `MYTHICBEASTS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `MYTHICBEASTS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `MYTHICBEASTS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `MYTHICBEASTS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_namecheap.md b/docs/content/dns/zz_gen_namecheap.md
index 850a9ef8b..9d7143d84 100644
--- a/docs/content/dns/zz_gen_namecheap.md
+++ b/docs/content/dns/zz_gen_namecheap.md
@@ -33,7 +33,7 @@ Here is an example bash command using the Namecheap provider:
```bash
NAMECHEAP_API_USER=user \
NAMECHEAP_API_KEY=key \
-lego --email you@example.com --dns namecheap -d '*.example.com' -d example.com run
+lego --dns namecheap -d '*.example.com' -d example.com run
```
@@ -54,11 +54,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NAMECHEAP_HTTP_TIMEOUT` | API request timeout |
-| `NAMECHEAP_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NAMECHEAP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `NAMECHEAP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `NAMECHEAP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) |
+| `NAMECHEAP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 3600) |
| `NAMECHEAP_SANDBOX` | Activate the sandbox (boolean) |
-| `NAMECHEAP_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NAMECHEAP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_namedotcom.md b/docs/content/dns/zz_gen_namedotcom.md
index df4c94559..2860ff0ae 100644
--- a/docs/content/dns/zz_gen_namedotcom.md
+++ b/docs/content/dns/zz_gen_namedotcom.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Name.com provider:
```bash
NAMECOM_USERNAME=foo.bar \
NAMECOM_API_TOKEN=a379a6f6eeafb9a55e378c118034e2751e682fab \
-lego --email you@example.com --dns namedotcom -d '*.example.com' -d example.com run
+lego --dns namedotcom -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NAMECOM_HTTP_TIMEOUT` | API request timeout |
-| `NAMECOM_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NAMECOM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NAMECOM_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NAMECOM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `NAMECOM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) |
+| `NAMECOM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) |
+| `NAMECOM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_namesilo.md b/docs/content/dns/zz_gen_namesilo.md
index 1b69a3524..207a1603f 100644
--- a/docs/content/dns/zz_gen_namesilo.md
+++ b/docs/content/dns/zz_gen_namesilo.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Namesilo provider:
```bash
NAMESILO_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \
-lego --email you@example.com --dns namesilo -d '*.example.com' -d example.com run
+lego --dns namesilo -d '*.example.com' -d example.com run
```
@@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NAMESILO_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NAMESILO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation, it is better to set larger than 15m |
-| `NAMESILO_TTL` | The TTL of the TXT record used for the DNS challenge, should be in [3600, 2592000] |
+| `NAMESILO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NAMESILO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes |
+| `NAMESILO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000] |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_namesurfer.md b/docs/content/dns/zz_gen_namesurfer.md
new file mode 100644
index 000000000..9a2802d0e
--- /dev/null
+++ b/docs/content/dns/zz_gen_namesurfer.md
@@ -0,0 +1,73 @@
+---
+title: "FusionLayer NameSurfer"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: namesurfer
+dnsprovider:
+ since: "v4.32.0"
+ code: "namesurfer"
+ url: "https://www.fusionlayer.com/"
+---
+
+
+
+
+
+
+Configuration for [FusionLayer NameSurfer](https://www.fusionlayer.com/).
+
+
+
+
+- Code: `namesurfer`
+- Since: v4.32.0
+
+
+Here is an example bash command using the FusionLayer NameSurfer provider:
+
+```bash
+NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \
+NAMESURFER_API_KEY=xxx \
+NAMESURFER_API_SECRET=yyy \
+lego --dns namesurfer -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `NAMESURFER_API_KEY` | API key name |
+| `NAMESURFER_API_SECRET` | API secret |
+| `NAMESURFER_BASE_URL` | The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `NAMESURFER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `NAMESURFER_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate |
+| `NAMESURFER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NAMESURFER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `NAMESURFER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
+| `NAMESURFER_VIEW` | DNS view name (optional, default: empty string) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_nearlyfreespeech.md b/docs/content/dns/zz_gen_nearlyfreespeech.md
index 1649fd34c..31402d2d2 100644
--- a/docs/content/dns/zz_gen_nearlyfreespeech.md
+++ b/docs/content/dns/zz_gen_nearlyfreespeech.md
@@ -28,7 +28,7 @@ Here is an example bash command using the NearlyFreeSpeech.NET provider:
```bash
NEARLYFREESPEECH_API_KEY=xxxxxx \
NEARLYFREESPEECH_LOGIN=xxxx \
-lego --email you@example.com --dns nearlyfreespeech -d '*.example.com' -d example.com run
+lego --dns nearlyfreespeech -d '*.example.com' -d example.com run
```
@@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NEARLYFREESPEECH_HTTP_TIMEOUT` | API request timeout |
-| `NEARLYFREESPEECH_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NEARLYFREESPEECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NEARLYFREESPEECH_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `NEARLYFREESPEECH_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NEARLYFREESPEECH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `NEARLYFREESPEECH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NEARLYFREESPEECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `NEARLYFREESPEECH_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `NEARLYFREESPEECH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_neodigit.md b/docs/content/dns/zz_gen_neodigit.md
new file mode 100644
index 000000000..aefeef4bf
--- /dev/null
+++ b/docs/content/dns/zz_gen_neodigit.md
@@ -0,0 +1,67 @@
+---
+title: "Neodigit"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: neodigit
+dnsprovider:
+ since: "v4.30.0"
+ code: "neodigit"
+ url: "https://www.neodigit.net"
+---
+
+
+
+
+
+
+Configuration for [Neodigit](https://www.neodigit.net).
+
+
+
+
+- Code: `neodigit`
+- Since: v4.30.0
+
+
+Here is an example bash command using the Neodigit provider:
+
+```bash
+NEODIGIT_TOKEN=xxxxxx \
+lego --dns neodigit -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `NEODIGIT_TOKEN` | API token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `NEODIGIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `NEODIGIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `NEODIGIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `NEODIGIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://developers.neodigit.net/#dns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_netcup.md b/docs/content/dns/zz_gen_netcup.md
index e1973c814..29def3285 100644
--- a/docs/content/dns/zz_gen_netcup.md
+++ b/docs/content/dns/zz_gen_netcup.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Netcup provider:
NETCUP_CUSTOMER_NUMBER=xxxx \
NETCUP_API_KEY=yyyy \
NETCUP_API_PASSWORD=zzzz \
-lego --email you@example.com --dns netcup -d '*.example.com' -d example.com run
+lego --dns netcup -d '*.example.com' -d example.com run
```
@@ -51,10 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NETCUP_HTTP_TIMEOUT` | API request timeout |
-| `NETCUP_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NETCUP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NETCUP_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NETCUP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `NETCUP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) |
+| `NETCUP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_netlify.md b/docs/content/dns/zz_gen_netlify.md
index ad41146dc..76651d9ef 100644
--- a/docs/content/dns/zz_gen_netlify.md
+++ b/docs/content/dns/zz_gen_netlify.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Netlify provider:
```bash
NETLIFY_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns netlify -d '*.example.com' -d example.com run
+lego --dns netlify -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NETLIFY_HTTP_TIMEOUT` | API request timeout |
-| `NETLIFY_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NETLIFY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NETLIFY_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NETLIFY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `NETLIFY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NETLIFY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `NETLIFY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_nicmanager.md b/docs/content/dns/zz_gen_nicmanager.md
index 1ae8806cc..a29d72120 100644
--- a/docs/content/dns/zz_gen_nicmanager.md
+++ b/docs/content/dns/zz_gen_nicmanager.md
@@ -34,7 +34,7 @@ NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
-lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run
+lego --dns nicmanager -d '*.example.com' -d example.com run
## Login using account name + username
@@ -45,7 +45,7 @@ NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
-lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run
+lego --dns nicmanager -d '*.example.com' -d example.com run
```
@@ -68,12 +68,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NICMANAGER_API_MODE` | mode: 'anycast' or 'zone' (default: 'anycast') |
+| `NICMANAGER_API_MODE` | mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast') |
| `NICMANAGER_API_OTP` | TOTP Secret (optional) |
-| `NICMANAGER_HTTP_TIMEOUT` | API request timeout |
-| `NICMANAGER_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NICMANAGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NICMANAGER_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NICMANAGER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `NICMANAGER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NICMANAGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `NICMANAGER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 900) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -81,7 +81,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## Description
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`.
+Optionally, if TOTP is configured for your account, set `NICMANAGER_API_OTP`.
diff --git a/docs/content/dns/zz_gen_nicru.md b/docs/content/dns/zz_gen_nicru.md
new file mode 100644
index 000000000..3ac8d99cf
--- /dev/null
+++ b/docs/content/dns/zz_gen_nicru.md
@@ -0,0 +1,83 @@
+---
+title: "RU CENTER"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: nicru
+dnsprovider:
+ since: "v4.24.0"
+ code: "nicru"
+ url: "https://nic.ru/"
+---
+
+
+
+
+
+
+Configuration for [RU CENTER](https://nic.ru/).
+
+
+
+
+- Code: `nicru`
+- Since: v4.24.0
+
+
+Here is an example bash command using the RU CENTER provider:
+
+```bash
+NICRU_USER="" \
+NICRU_PASSWORD="" \
+NICRU_SERVICE_ID="" \
+NICRU_SECRET="" \
+lego --dns nicru -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `NICRU_PASSWORD` | Password for an account in RU CENTER |
+| `NICRU_SECRET` | Secret for application in DNS-hosting RU CENTER |
+| `NICRU_SERVICE_ID` | Service ID for application in DNS-hosting RU CENTER |
+| `NICRU_SERVICE_NAME` | Service Name for DNS-hosting RU CENTER |
+| `NICRU_USER` | Agreement for an account in RU CENTER |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `NICRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) |
+| `NICRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) |
+| `NICRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+## Credential information
+
+You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list
+
+| ENV Variable | Parameter from page | Example |
+|---------------------|--------------------------------|-------------------|
+| NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D |
+| NICRU_PASSWORD | Password account | |
+| NICRU_SERVICE_ID | Application ID | hex-based, len 32 |
+| NICRU_SECRET | Identity endpoint | string len 91 |
+
+
+
+## More information
+
+- [API documentation](https://www.nic.ru/help/api-dns-hostinga_3643.html)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_nifcloud.md b/docs/content/dns/zz_gen_nifcloud.md
index bd5d25321..66f38223b 100644
--- a/docs/content/dns/zz_gen_nifcloud.md
+++ b/docs/content/dns/zz_gen_nifcloud.md
@@ -28,7 +28,7 @@ Here is an example bash command using the NIFCloud provider:
```bash
NIFCLOUD_ACCESS_KEY_ID=xxxx \
NIFCLOUD_SECRET_ACCESS_KEY=yyyy \
-lego --email you@example.com --dns nifcloud -d '*.example.com' -d example.com run
+lego --dns nifcloud -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NIFCLOUD_HTTP_TIMEOUT` | API request timeout |
-| `NIFCLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NIFCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NIFCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NIFCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `NIFCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NIFCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `NIFCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_njalla.md b/docs/content/dns/zz_gen_njalla.md
index f846cf1e8..9a312df8b 100644
--- a/docs/content/dns/zz_gen_njalla.md
+++ b/docs/content/dns/zz_gen_njalla.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Njalla provider:
```bash
NJALLA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns njalla -d '*.example.com' -d example.com run
+lego --dns njalla -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NJALLA_HTTP_TIMEOUT` | API request timeout |
-| `NJALLA_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NJALLA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NJALLA_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NJALLA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `NJALLA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NJALLA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `NJALLA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_nodion.md b/docs/content/dns/zz_gen_nodion.md
index fc1f820f8..8d61eb834 100644
--- a/docs/content/dns/zz_gen_nodion.md
+++ b/docs/content/dns/zz_gen_nodion.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Nodion provider:
```bash
NODION_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns nodion -d '*.example.com' -d example.com run
+lego --dns nodion -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NODION_HTTP_TIMEOUT` | API request timeout |
-| `NODION_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NODION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NODION_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NODION_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `NODION_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NODION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `NODION_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_ns1.md b/docs/content/dns/zz_gen_ns1.md
index 9e4c906ad..b2262169d 100644
--- a/docs/content/dns/zz_gen_ns1.md
+++ b/docs/content/dns/zz_gen_ns1.md
@@ -27,7 +27,7 @@ Here is an example bash command using the NS1 provider:
```bash
NS1_API_KEY=xxxx \
-lego --email you@example.com --dns ns1 -d '*.example.com' -d example.com run
+lego --dns ns1 -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `NS1_HTTP_TIMEOUT` | API request timeout |
-| `NS1_POLLING_INTERVAL` | Time between DNS propagation check |
-| `NS1_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `NS1_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `NS1_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `NS1_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `NS1_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `NS1_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_octenium.md b/docs/content/dns/zz_gen_octenium.md
new file mode 100644
index 000000000..f25da4f44
--- /dev/null
+++ b/docs/content/dns/zz_gen_octenium.md
@@ -0,0 +1,67 @@
+---
+title: "Octenium"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: octenium
+dnsprovider:
+ since: "v4.27.0"
+ code: "octenium"
+ url: "https://octenium.com/"
+---
+
+
+
+
+
+
+Configuration for [Octenium](https://octenium.com/).
+
+
+
+
+- Code: `octenium`
+- Since: v4.27.0
+
+
+Here is an example bash command using the Octenium provider:
+
+```bash
+OCTENIUM_API_KEY="xxx" \
+lego --dns octenium -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `OCTENIUM_API_KEY` | API key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `OCTENIUM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `OCTENIUM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `OCTENIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `OCTENIUM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://octenium.com/api#tag/Domains-DNS)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_oraclecloud.md b/docs/content/dns/zz_gen_oraclecloud.md
index 1b6647ce5..b7192f380 100644
--- a/docs/content/dns/zz_gen_oraclecloud.md
+++ b/docs/content/dns/zz_gen_oraclecloud.md
@@ -26,14 +26,21 @@ Configuration for [Oracle Cloud](https://cloud.oracle.com/home).
Here is an example bash command using the Oracle Cloud provider:
```bash
-OCI_PRIVKEY_FILE="~/.oci/oci_api_key.pem" \
-OCI_PRIVKEY_PASS="secret" \
+# Using API Key authentication:
+OCI_PRIVATE_KEY_PATH="~/.oci/oci_api_key.pem" \
+OCI_PRIVATE_KEY_PASSWORD="secret" \
OCI_TENANCY_OCID="ocid1.tenancy.oc1..secret" \
OCI_USER_OCID="ocid1.user.oc1..secret" \
-OCI_PUBKEY_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \
+OCI_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \
OCI_REGION="us-phoenix-1" \
OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \
-lego --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com run
+lego --dns oraclecloud -d '*.example.com' -d example.com run
+
+# Using Instance Principal authentication (when running on OCI compute instances):
+# https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm
+OCI_AUTH_TYPE="instance_principal" \
+OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \
+lego --dns oraclecloud -d '*.example.com' -d example.com run
```
@@ -44,12 +51,12 @@ lego --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com
| Environment Variable Name | Description |
|-----------------------|-------------|
| `OCI_COMPARTMENT_OCID` | Compartment OCID |
-| `OCI_PRIVKEY_FILE` | Private key file |
-| `OCI_PRIVKEY_PASS` | Private key password |
-| `OCI_PUBKEY_FINGERPRINT` | Public key fingerprint |
-| `OCI_REGION` | Region |
-| `OCI_TENANCY_OCID` | Tenancy OCID |
-| `OCI_USER_OCID` | User OCID |
+| `OCI_FINGERPRINT` | Public key fingerprint (ignored if `OCI_AUTH_TYPE=instance_principal`) |
+| `OCI_PRIVATE_KEY_PASSWORD` | Private key password (ignored if `OCI_AUTH_TYPE=instance_principal`) |
+| `OCI_PRIVATE_KEY_PATH` | Private key file (ignored if `OCI_AUTH_TYPE=instance_principal`) |
+| `OCI_REGION` | Region (it can be empty if `OCI_AUTH_TYPE=instance_principal`). |
+| `OCI_TENANCY_OCID` | Tenancy OCID (ignored if `OCI_AUTH_TYPE=instance_principal`) |
+| `OCI_USER_OCID` | User OCID (ignored if `OCI_AUTH_TYPE=instance_principal`) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -59,9 +66,16 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `OCI_POLLING_INTERVAL` | Time between DNS propagation check |
-| `OCI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `OCI_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `OCI_AUTH_TYPE` | Authorization type. Possible values: 'instance_principal', '' (Default: '') |
+| `OCI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) |
+| `OCI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `OCI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `OCI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+| `TF_VAR_fingerprint` | Alias on `OCI_FINGERPRINT` |
+| `TF_VAR_private_key_path` | Alias on `OCI_PRIVATE_KEY_PATH` |
+| `TF_VAR_region` | Alias on `OCI_REGION` |
+| `TF_VAR_tenancy_ocid` | Alias on `OCI_TENANCY_OCID` |
+| `TF_VAR_user_ocid` | Alias on `OCI_USER_OCID` |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_otc.md b/docs/content/dns/zz_gen_otc.md
index 0de59fd64..9da69c694 100644
--- a/docs/content/dns/zz_gen_otc.md
+++ b/docs/content/dns/zz_gen_otc.md
@@ -23,9 +23,15 @@ Configuration for [Open Telekom Cloud](https://cloud.telekom.de/en).
- Since: v0.4.1
-{{% notice note %}}
-_Please contribute by adding a CLI example._
-{{% /notice %}}
+Here is an example bash command using the Open Telekom Cloud provider:
+
+```bash
+OTC_DOMAIN_NAME=domain_name \
+OTC_USER_NAME=user_name \
+OTC_PASSWORD=password \
+OTC_PROJECT_NAME=project_name \
+lego --dns otc -d '*.example.com' -d example.com run
+```
@@ -35,7 +41,6 @@ _Please contribute by adding a CLI example._
| Environment Variable Name | Description |
|-----------------------|-------------|
| `OTC_DOMAIN_NAME` | Domain name |
-| `OTC_IDENTITY_ENDPOINT` | Identity endpoint URL |
| `OTC_PASSWORD` | Password |
| `OTC_PROJECT_NAME` | Project name |
| `OTC_USER_NAME` | User name |
@@ -48,11 +53,13 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `OTC_HTTP_TIMEOUT` | API request timeout |
-| `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 in seconds (Default: 10) |
+| `OTC_IDENTITY_ENDPOINT` | Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens) |
+| `OTC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `OTC_PRIVATE_ZONE` | Set to true to use private zones only (default: use public zones only) |
+| `OTC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `OTC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `OTC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_ovh.md b/docs/content/dns/zz_gen_ovh.md
index fad507cbd..aaafded85 100644
--- a/docs/content/dns/zz_gen_ovh.md
+++ b/docs/content/dns/zz_gen_ovh.md
@@ -32,20 +32,20 @@ OVH_APPLICATION_KEY=1234567898765432 \
OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \
OVH_CONSUMER_KEY=256vfsd347245sdfg \
OVH_ENDPOINT=ovh-eu \
-lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run
+lego --dns ovh -d '*.example.com' -d example.com run
# Or Access Token:
OVH_ACCESS_TOKEN=xxx \
OVH_ENDPOINT=ovh-eu \
-lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run
+lego --dns ovh -d '*.example.com' -d example.com run
# Or OAuth2:
OVH_CLIENT_ID=yyy \
OVH_CLIENT_SECRET=xxx \
OVH_ENDPOINT=ovh-eu \
-lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run
+lego --dns ovh -d '*.example.com' -d example.com run
```
@@ -71,10 +71,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `OVH_HTTP_TIMEOUT` | API request timeout |
-| `OVH_POLLING_INTERVAL` | Time between DNS propagation check |
-| `OVH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `OVH_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `OVH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 180) |
+| `OVH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `OVH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `OVH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_pdns.md b/docs/content/dns/zz_gen_pdns.md
index 31870fbc0..7c2a8c663 100644
--- a/docs/content/dns/zz_gen_pdns.md
+++ b/docs/content/dns/zz_gen_pdns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the PowerDNS provider:
```bash
PDNS_API_URL=http://pdns-server:80/ \
PDNS_API_KEY=xxxx \
-lego --email you@example.com --dns pdns -d '*.example.com' -d example.com run
+lego --dns pdns -d '*.example.com' -d example.com run
```
@@ -50,11 +50,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `PDNS_API_VERSION` | Skip API version autodetection and use the provided version number. |
-| `PDNS_HTTP_TIMEOUT` | API request timeout |
-| `PDNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `PDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `PDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `PDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `PDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
| `PDNS_SERVER_NAME` | Name of the server in the URL, 'localhost' by default |
-| `PDNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `PDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_plesk.md b/docs/content/dns/zz_gen_plesk.md
index 5c9d060cf..73ec9a55d 100644
--- a/docs/content/dns/zz_gen_plesk.md
+++ b/docs/content/dns/zz_gen_plesk.md
@@ -29,7 +29,7 @@ Here is an example bash command using the plesk.com provider:
PLESK_SERVER_BASE_URL="https://plesk.myserver.com:8443" \
PLESK_USERNAME=xxxxxx \
PLESK_PASSWORD=yyyyyy \
-lego --email you@example.com --dns plesk -d '*.example.com' -d example.com run
+lego --dns plesk -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `PLESK_HTTP_TIMEOUT` | API request timeout |
-| `PLESK_POLLING_INTERVAL` | Time between DNS propagation check |
-| `PLESK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `PLESK_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `PLESK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `PLESK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `PLESK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `PLESK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_porkbun.md b/docs/content/dns/zz_gen_porkbun.md
index 5e96e239e..f54e6f688 100644
--- a/docs/content/dns/zz_gen_porkbun.md
+++ b/docs/content/dns/zz_gen_porkbun.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Porkbun provider:
```bash
PORKBUN_SECRET_API_KEY=xxxxxx \
PORKBUN_API_KEY=yyyyyy \
-lego --email you@example.com --dns porkbun -d '*.example.com' -d example.com run
+lego --dns porkbun -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `PORKBUN_HTTP_TIMEOUT` | API request timeout |
-| `PORKBUN_POLLING_INTERVAL` | Time between DNS propagation check |
-| `PORKBUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `PORKBUN_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `PORKBUN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `PORKBUN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `PORKBUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) |
+| `PORKBUN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_rackspace.md b/docs/content/dns/zz_gen_rackspace.md
index bbdd8cbfb..b9a2ab710 100644
--- a/docs/content/dns/zz_gen_rackspace.md
+++ b/docs/content/dns/zz_gen_rackspace.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Rackspace provider:
```bash
RACKSPACE_USER=xxxx \
RACKSPACE_API_KEY=yyyy \
-lego --email you@example.com --dns rackspace -d '*.example.com' -d example.com run
+lego --dns rackspace -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `RACKSPACE_HTTP_TIMEOUT` | API request timeout |
-| `RACKSPACE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `RACKSPACE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `RACKSPACE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `RACKSPACE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `RACKSPACE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3) |
+| `RACKSPACE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `RACKSPACE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_rainyun.md b/docs/content/dns/zz_gen_rainyun.md
new file mode 100644
index 000000000..680eb845a
--- /dev/null
+++ b/docs/content/dns/zz_gen_rainyun.md
@@ -0,0 +1,67 @@
+---
+title: "Rain Yun/雨云"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: rainyun
+dnsprovider:
+ since: "v4.21.0"
+ code: "rainyun"
+ url: "https://www.rainyun.com"
+---
+
+
+
+
+
+
+Configuration for [Rain Yun/雨云](https://www.rainyun.com).
+
+
+
+
+- Code: `rainyun`
+- Since: v4.21.0
+
+
+Here is an example bash command using the Rain Yun/雨云 provider:
+
+```bash
+RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns rainyun -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `RAINYUN_API_KEY` | API key |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `RAINYUN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `RAINYUN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `RAINYUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `RAINYUN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.apifox.cn/apidoc/shared-a4595cc8-44c5-4678-a2a3-eed7738dab03/api-151416609)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_rcodezero.md b/docs/content/dns/zz_gen_rcodezero.md
index 8677de764..a544df420 100644
--- a/docs/content/dns/zz_gen_rcodezero.md
+++ b/docs/content/dns/zz_gen_rcodezero.md
@@ -27,7 +27,7 @@ Here is an example bash command using the RcodeZero provider:
```bash
RCODEZERO_API_TOKEN= \
-lego --email you@example.com --dns rcodezero -d '*.example.com' -d example.com run
+lego --dns rcodezero -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `RCODEZERO_HTTP_TIMEOUT` | API request timeout |
-| `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 in seconds (Default: 30) |
+| `RCODEZERO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `RCODEZERO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) |
+| `RCODEZERO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_regfish.md b/docs/content/dns/zz_gen_regfish.md
index f5310db53..357ce0764 100644
--- a/docs/content/dns/zz_gen_regfish.md
+++ b/docs/content/dns/zz_gen_regfish.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Regfish provider:
```bash
REGFISH_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns regfish -d '*.example.com' -d example.com run
+lego --dns regfish -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `REGFISH_HTTP_TIMEOUT` | API request timeout |
-| `REGFISH_POLLING_INTERVAL` | Time between DNS propagation check |
-| `REGFISH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `REGFISH_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `REGFISH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `REGFISH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `REGFISH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `REGFISH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_regru.md b/docs/content/dns/zz_gen_regru.md
index 8c6bea662..eaf163a13 100644
--- a/docs/content/dns/zz_gen_regru.md
+++ b/docs/content/dns/zz_gen_regru.md
@@ -28,7 +28,7 @@ Here is an example bash command using the reg.ru provider:
```bash
REGRU_USERNAME=xxxxxx \
REGRU_PASSWORD=yyyyyy \
-lego --email you@example.com --dns regru -d '*.example.com' -d example.com run
+lego --dns regru -d '*.example.com' -d example.com run
```
@@ -49,12 +49,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `REGRU_HTTP_TIMEOUT` | API request timeout |
-| `REGRU_POLLING_INTERVAL` | Time between DNS propagation check |
-| `REGRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `REGRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `REGRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `REGRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `REGRU_TLS_CERT` | authentication certificate |
| `REGRU_TLS_KEY` | authentication private key |
-| `REGRU_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `REGRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_rfc2136.md b/docs/content/dns/zz_gen_rfc2136.md
index ad52005d4..1b1d43dd5 100644
--- a/docs/content/dns/zz_gen_rfc2136.md
+++ b/docs/content/dns/zz_gen_rfc2136.md
@@ -30,7 +30,7 @@ RFC2136_NAMESERVER=127.0.0.1 \
RFC2136_TSIG_KEY=example.com \
RFC2136_TSIG_ALGORITHM=hmac-sha256. \
RFC2136_TSIG_SECRET=YWJjZGVmZGdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNDU= \
-lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run
+lego --dns rfc2136 -d '*.example.com' -d example.com run
## ---
@@ -38,7 +38,7 @@ keyname=example.com; keyfile=example.com.key; tsig-keygen $keyname > $keyfile
RFC2136_NAMESERVER=127.0.0.1 \
RFC2136_TSIG_FILE="$keyfile" \
-lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run
+lego --dns rfc2136 -d '*.example.com' -d example.com run
```
@@ -61,12 +61,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `RFC2136_DNS_TIMEOUT` | API request timeout |
-| `RFC2136_POLLING_INTERVAL` | Time between DNS propagation check |
-| `RFC2136_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `RFC2136_SEQUENCE_INTERVAL` | Time between sequential requests |
+| `RFC2136_DNS_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `RFC2136_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `RFC2136_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `RFC2136_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
| `RFC2136_TSIG_FILE` | Path to a key file generated by tsig-keygen |
-| `RFC2136_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `RFC2136_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_rimuhosting.md b/docs/content/dns/zz_gen_rimuhosting.md
index 46687484c..acb829e93 100644
--- a/docs/content/dns/zz_gen_rimuhosting.md
+++ b/docs/content/dns/zz_gen_rimuhosting.md
@@ -27,7 +27,7 @@ Here is an example bash command using the RimuHosting provider:
```bash
RIMUHOSTING_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns rimuhosting -d '*.example.com' -d example.com run
+lego --dns rimuhosting -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `RIMUHOSTING_HTTP_TIMEOUT` | API request timeout |
-| `RIMUHOSTING_POLLING_INTERVAL` | Time between DNS propagation check |
-| `RIMUHOSTING_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `RIMUHOSTING_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `RIMUHOSTING_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `RIMUHOSTING_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `RIMUHOSTING_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `RIMUHOSTING_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_route53.md b/docs/content/dns/zz_gen_route53.md
index cd18a5c1d..59e489d6a 100644
--- a/docs/content/dns/zz_gen_route53.md
+++ b/docs/content/dns/zz_gen_route53.md
@@ -30,7 +30,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 \
-lego --email you@example.com --dns route53 -d '*.example.com' -d example.com run
+lego --dns route53 -d '*.example.com' -d example.com run
```
@@ -59,10 +59,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `AWS_MAX_RETRIES` | The number of maximum returns the service will use to make an individual API request |
-| `AWS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `AWS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `AWS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) |
+| `AWS_PRIVATE_ZONE` | Set to true to use private zones only (default: use public zones only) |
+| `AWS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
| `AWS_SHARED_CREDENTIALS_FILE` | Managed by the AWS client. Shared credentials file. |
-| `AWS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `AWS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_safedns.md b/docs/content/dns/zz_gen_safedns.md
index c6d4cd745..4c20fca6a 100644
--- a/docs/content/dns/zz_gen_safedns.md
+++ b/docs/content/dns/zz_gen_safedns.md
@@ -1,12 +1,12 @@
---
-title: "UKFast SafeDNS"
+title: "ANS SafeDNS"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: safedns
dnsprovider:
since: "v4.6.0"
code: "safedns"
- url: "https://www.ukfast.co.uk/dns-hosting.html"
+ url: "https://www.ans.co.uk/"
---
@@ -14,7 +14,7 @@ dnsprovider:
-Configuration for [UKFast SafeDNS](https://www.ukfast.co.uk/dns-hosting.html).
+Configuration for [ANS SafeDNS](https://www.ans.co.uk/).
@@ -23,11 +23,11 @@ Configuration for [UKFast SafeDNS](https://www.ukfast.co.uk/dns-hosting.html).
- Since: v4.6.0
-Here is an example bash command using the UKFast SafeDNS provider:
+Here is an example bash command using the ANS SafeDNS provider:
```bash
SAFEDNS_AUTH_TOKEN=xxxxxx \
-lego --email you@example.com --dns safedns -d '*.example.com' -d example.com run
+lego --dns safedns -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SAFEDNS_HTTP_TIMEOUT` | API request timeout |
-| `SAFEDNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SAFEDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SAFEDNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SAFEDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SAFEDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `SAFEDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `SAFEDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_sakuracloud.md b/docs/content/dns/zz_gen_sakuracloud.md
index e0af53acf..b43f83ef4 100644
--- a/docs/content/dns/zz_gen_sakuracloud.md
+++ b/docs/content/dns/zz_gen_sakuracloud.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Sakura Cloud provider:
```bash
SAKURACLOUD_ACCESS_TOKEN=xxxxx \
SAKURACLOUD_ACCESS_TOKEN_SECRET=yyyyy \
-lego --email you@example.com --dns sakuracloud -d '*.example.com' -d example.com run
+lego --dns sakuracloud -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SAKURACLOUD_HTTP_TIMEOUT` | API request timeout |
-| `SAKURACLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SAKURACLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SAKURACLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SAKURACLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `SAKURACLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `SAKURACLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `SAKURACLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_scaleway.md b/docs/content/dns/zz_gen_scaleway.md
index 111d18a42..4033a9bd6 100644
--- a/docs/content/dns/zz_gen_scaleway.md
+++ b/docs/content/dns/zz_gen_scaleway.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Scaleway provider:
```bash
SCW_SECRET_KEY=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \
-lego --email you@example.com --dns scaleway -d '*.example.com' -d example.com run
+lego --dns scaleway -d '*.example.com' -d example.com run
```
@@ -49,9 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `SCW_ACCESS_KEY` | Access key |
-| `SCW_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SCW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SCW_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SCW_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SCW_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `SCW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `SCW_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_selectel.md b/docs/content/dns/zz_gen_selectel.md
index 00e5b5bad..d994d6633 100644
--- a/docs/content/dns/zz_gen_selectel.md
+++ b/docs/content/dns/zz_gen_selectel.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Selectel provider:
```bash
SELECTEL_API_TOKEN=xxxxx \
-lego --email you@example.com --dns selectel -d '*.example.com' -d example.com run
+lego --dns selectel -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `SELECTEL_BASE_URL` | API endpoint URL |
-| `SELECTEL_HTTP_TIMEOUT` | API request timeout |
-| `SELECTEL_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SELECTEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SELECTEL_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SELECTEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SELECTEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `SELECTEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `SELECTEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_selectelv2.md b/docs/content/dns/zz_gen_selectelv2.md
index bb09241aa..0873d810c 100644
--- a/docs/content/dns/zz_gen_selectelv2.md
+++ b/docs/content/dns/zz_gen_selectelv2.md
@@ -30,7 +30,7 @@ SELECTELV2_USERNAME=trex \
SELECTELV2_PASSWORD=xxxxx \
SELECTELV2_ACCOUNT_ID=1234567 \
SELECTELV2_PROJECT_ID=111a11111aaa11aa1a11aaa11111aa1a \
-lego --email you@example.com --dns selectelv2 -d '*.example.com' -d example.com run
+lego --dns selectelv2 -d '*.example.com' -d example.com run
```
@@ -53,11 +53,14 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
+| `SELECTELV2_AUTH_REGION` | Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1') |
+| `SELECTELV2_AUTH_URL` | Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/') |
| `SELECTELV2_BASE_URL` | API endpoint URL |
-| `SELECTELV2_HTTP_TIMEOUT` | API request timeout |
-| `SELECTELV2_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SELECTELV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SELECTELV2_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SELECTELV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SELECTELV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `SELECTELV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `SELECTELV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
+| `SELECTELV2_USER_DOMAIN_NAME` | To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_selfhostde.md b/docs/content/dns/zz_gen_selfhostde.md
index 81abe85c1..363f782e0 100644
--- a/docs/content/dns/zz_gen_selfhostde.md
+++ b/docs/content/dns/zz_gen_selfhostde.md
@@ -29,7 +29,7 @@ Here is an example bash command using the SelfHost.(de|eu) provider:
SELFHOSTDE_USERNAME=xxx \
SELFHOSTDE_PASSWORD=yyy \
SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \
-lego --email you@example.com --dns selfhostde -d '*.example.com' -d example.com run
+lego --dns selfhostde -d '*.example.com' -d example.com run
```
@@ -51,10 +51,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SELFHOSTDE_HTTP_TIMEOUT` | API request timeout |
-| `SELFHOSTDE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SELFHOSTDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SELFHOSTDE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SELFHOSTDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SELFHOSTDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) |
+| `SELFHOSTDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) |
+| `SELFHOSTDE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_servercow.md b/docs/content/dns/zz_gen_servercow.md
index ce47077df..7d00a6306 100644
--- a/docs/content/dns/zz_gen_servercow.md
+++ b/docs/content/dns/zz_gen_servercow.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Servercow provider:
```bash
SERVERCOW_USERNAME=xxxxxxxx \
SERVERCOW_PASSWORD=xxxxxxxx \
-lego --email you@example.com --dns servercow -d '*.example.com' -d example.com run
+lego --dns servercow -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SERVERCOW_HTTP_TIMEOUT` | API request timeout |
-| `SERVERCOW_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SERVERCOW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SERVERCOW_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SERVERCOW_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SERVERCOW_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `SERVERCOW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `SERVERCOW_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -62,7 +62,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
-- [API documentation](https://cp.servercow.de/client/plugin/support_manager/knowledgebase/view/34/dns-api-v1/7/)
+- [API documentation](https://wiki.servercow.de/en/domains/dns_api/api-syntax/)
diff --git a/docs/content/dns/zz_gen_shellrent.md b/docs/content/dns/zz_gen_shellrent.md
index 1719e07c9..cbbc172e2 100644
--- a/docs/content/dns/zz_gen_shellrent.md
+++ b/docs/content/dns/zz_gen_shellrent.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Shellrent provider:
```bash
SHELLRENT_USERNAME=xxxx \
SHELLRENT_TOKEN=yyyy \
-lego --email you@example.com --dns shellrent -d '*.example.com' -d example.com run
+lego --dns shellrent -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SHELLRENT_HTTP_TIMEOUT` | API request timeout |
-| `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 in seconds (Default: 30) |
+| `SHELLRENT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `SHELLRENT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `SHELLRENT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_simply.md b/docs/content/dns/zz_gen_simply.md
index 1603ee53f..edfa14380 100644
--- a/docs/content/dns/zz_gen_simply.md
+++ b/docs/content/dns/zz_gen_simply.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Simply.com provider:
```bash
SIMPLY_ACCOUNT_NAME=xxxxxx \
SIMPLY_API_KEY=yyyyyy \
-lego --email you@example.com --dns simply -d '*.example.com' -d example.com run
+lego --dns simply -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SIMPLY_HTTP_TIMEOUT` | API request timeout |
-| `SIMPLY_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SIMPLY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SIMPLY_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SIMPLY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SIMPLY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `SIMPLY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `SIMPLY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_sonic.md b/docs/content/dns/zz_gen_sonic.md
index 2adb435a9..20729bc1a 100644
--- a/docs/content/dns/zz_gen_sonic.md
+++ b/docs/content/dns/zz_gen_sonic.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Sonic provider:
```bash
SONIC_USER_ID=12345 \
SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \
-lego --email you@example.com --dns sonic -d '*.example.com' -d example.com run
+lego --dns sonic -d '*.example.com' -d example.com run
```
@@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `SONIC_HTTP_TIMEOUT` | API request timeout |
-| `SONIC_POLLING_INTERVAL` | Time between DNS propagation check |
-| `SONIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `SONIC_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `SONIC_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `SONIC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `SONIC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `SONIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `SONIC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `SONIC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_spaceship.md b/docs/content/dns/zz_gen_spaceship.md
new file mode 100644
index 000000000..9f3b51e43
--- /dev/null
+++ b/docs/content/dns/zz_gen_spaceship.md
@@ -0,0 +1,69 @@
+---
+title: "Spaceship"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: spaceship
+dnsprovider:
+ since: "v4.22.0"
+ code: "spaceship"
+ url: "https://www.spaceship.com/"
+---
+
+
+
+
+
+
+Configuration for [Spaceship](https://www.spaceship.com/).
+
+
+
+
+- Code: `spaceship`
+- Since: v4.22.0
+
+
+Here is an example bash command using the Spaceship provider:
+
+```bash
+SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns spaceship -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `SPACESHIP_API_KEY` | API key |
+| `SPACESHIP_API_SECRET` | API secret |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `SPACESHIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SPACESHIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `SPACESHIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `SPACESHIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://docs.spaceship.dev/#tag/DNS-records)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_stackpath.md b/docs/content/dns/zz_gen_stackpath.md
index cbafa4289..b881176f4 100644
--- a/docs/content/dns/zz_gen_stackpath.md
+++ b/docs/content/dns/zz_gen_stackpath.md
@@ -29,7 +29,7 @@ Here is an example bash command using the Stackpath provider:
STACKPATH_CLIENT_ID=xxxxx \
STACKPATH_CLIENT_SECRET=yyyyy \
STACKPATH_STACK_ID=zzzzz \
-lego --email you@example.com --dns stackpath -d '*.example.com' -d example.com run
+lego --dns stackpath -d '*.example.com' -d example.com run
```
@@ -51,9 +51,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `STACKPATH_POLLING_INTERVAL` | Time between DNS propagation check |
-| `STACKPATH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `STACKPATH_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `STACKPATH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `STACKPATH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `STACKPATH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_syse.md b/docs/content/dns/zz_gen_syse.md
new file mode 100644
index 000000000..a1a952bc5
--- /dev/null
+++ b/docs/content/dns/zz_gen_syse.md
@@ -0,0 +1,70 @@
+---
+title: "Syse"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: syse
+dnsprovider:
+ since: "v4.30.0"
+ code: "syse"
+ url: "https://www.syse.no/"
+---
+
+
+
+
+
+
+Configuration for [Syse](https://www.syse.no/).
+
+
+
+
+- Code: `syse`
+- Since: v4.30.0
+
+
+Here is an example bash command using the Syse provider:
+
+```bash
+SYSE_CREDENTIALS=example.com:password \
+lego --dns syse -d '*.example.com' -d example.com run
+
+SYSE_CREDENTIALS=example.org:password1,example.com:password2 \
+lego --dns syse -d '*.example.org' -d example.org -d '*.example.com' -d example.com
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `SYSE_CREDENTIALS` | Comma-separated list of `zone:password` credential pairs |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `SYSE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `SYSE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `SYSE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) |
+| `SYSE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.syse.no/api/dns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_technitium.md b/docs/content/dns/zz_gen_technitium.md
index ecfa204ce..ff7f2e6ed 100644
--- a/docs/content/dns/zz_gen_technitium.md
+++ b/docs/content/dns/zz_gen_technitium.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Technitium provider:
```bash
TECHNITIUM_SERVER_BASE_URL="https://localhost:5380" \
TECHNITIUM_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns technitium -d '*.example.com' -d example.com run
+lego --dns technitium -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `TECHNITIUM_HTTP_TIMEOUT` | API request timeout |
-| `TECHNITIUM_POLLING_INTERVAL` | Time between DNS propagation check |
-| `TECHNITIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `TECHNITIUM_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `TECHNITIUM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `TECHNITIUM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `TECHNITIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `TECHNITIUM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_tencentcloud.md b/docs/content/dns/zz_gen_tencentcloud.md
index bc93c225e..178ffcf43 100644
--- a/docs/content/dns/zz_gen_tencentcloud.md
+++ b/docs/content/dns/zz_gen_tencentcloud.md
@@ -6,7 +6,7 @@ slug: tencentcloud
dnsprovider:
since: "v4.6.0"
code: "tencentcloud"
- url: "https://cloud.tencent.com/product/cns"
+ url: "https://cloud.tencent.com/product/dns"
---
@@ -14,7 +14,7 @@ dnsprovider:
-Configuration for [Tencent Cloud DNS](https://cloud.tencent.com/product/cns).
+Configuration for [Tencent Cloud DNS](https://cloud.tencent.com/product/dns).
@@ -28,7 +28,7 @@ Here is an example bash command using the Tencent Cloud DNS provider:
```bash
TENCENTCLOUD_SECRET_ID=abcdefghijklmnopqrstuvwx \
TENCENTCLOUD_SECRET_KEY=your-secret-key \
-lego --email you@example.com --dns tencentcloud -d '*.example.com' -d example.com run
+lego --dns tencentcloud -d '*.example.com' -d example.com run
```
@@ -49,12 +49,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `TENCENTCLOUD_HTTP_TIMEOUT` | API request timeout |
-| `TENCENTCLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `TENCENTCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `TENCENTCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `TENCENTCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `TENCENTCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `TENCENTCLOUD_REGION` | Region |
| `TENCENTCLOUD_SESSION_TOKEN` | Access Key token |
-| `TENCENTCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `TENCENTCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_timewebcloud.md b/docs/content/dns/zz_gen_timewebcloud.md
index e933043a4..83d5b831b 100644
--- a/docs/content/dns/zz_gen_timewebcloud.md
+++ b/docs/content/dns/zz_gen_timewebcloud.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Timeweb Cloud provider:
```bash
TIMEWEBCLOUD_AUTH_TOKEN=xxxxxx \
-lego --email you@example.com --dns timewebcloud -d '*.example.com' -d example.com run
+lego --dns timewebcloud -d '*.example.com' -d example.com run
```
@@ -47,9 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `TIMEWEBCLOUD_HTTP_TIMEOUT` | API request timeout |
-| `TIMEWEBCLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `TIMEWEBCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `TIMEWEBCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `TIMEWEBCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `TIMEWEBCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_todaynic.md b/docs/content/dns/zz_gen_todaynic.md
new file mode 100644
index 000000000..7b06c012d
--- /dev/null
+++ b/docs/content/dns/zz_gen_todaynic.md
@@ -0,0 +1,69 @@
+---
+title: "TodayNIC/时代互联"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: todaynic
+dnsprovider:
+ since: "v4.32.0"
+ code: "todaynic"
+ url: "https://www.todaynic.com/"
+---
+
+
+
+
+
+
+Configuration for [TodayNIC/时代互联](https://www.todaynic.com/).
+
+
+
+
+- Code: `todaynic`
+- Since: v4.32.0
+
+
+Here is an example bash command using the TodayNIC/时代互联 provider:
+
+```bash
+TODAYNIC_AUTH_USER_ID="xxx" \
+TODAYNIC_API_KEY="yyy" \
+lego --dns todaynic -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `TODAYNIC_API_KEY` | API key |
+| `TODAYNIC_AUTH_USER_ID` | account ID |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `TODAYNIC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `TODAYNIC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `TODAYNIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `TODAYNIC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.todaynic.com/partner/mode_Http_Api_detail.php)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_transip.md b/docs/content/dns/zz_gen_transip.md
index 64db62dc6..a66a25879 100644
--- a/docs/content/dns/zz_gen_transip.md
+++ b/docs/content/dns/zz_gen_transip.md
@@ -28,7 +28,7 @@ Here is an example bash command using the TransIP provider:
```bash
TRANSIP_ACCOUNT_NAME = "Account name" \
TRANSIP_PRIVATE_KEY_PATH = "transip.key" \
-lego --email you@example.com --dns transip -d '*.example.com' -d example.com run
+lego --dns transip -d '*.example.com' -d example.com run
```
@@ -49,9 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `TRANSIP_POLLING_INTERVAL` | Time between DNS propagation check |
-| `TRANSIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `TRANSIP_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `TRANSIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `TRANSIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `TRANSIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) |
+| `TRANSIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_ultradns.md b/docs/content/dns/zz_gen_ultradns.md
index 36a233ae2..d6d89c77b 100644
--- a/docs/content/dns/zz_gen_ultradns.md
+++ b/docs/content/dns/zz_gen_ultradns.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Ultradns provider:
```bash
ULTRADNS_USERNAME=username \
ULTRADNS_PASSWORD=password \
-lego --email you@example.com --dns ultradns -d '*.example.com' -d example.com run
+lego --dns ultradns -d '*.example.com' -d example.com run
```
@@ -50,9 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ULTRADNS_ENDPOINT` | API endpoint URL, defaults to https://api.ultradns.com/ |
-| `ULTRADNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `ULTRADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `ULTRADNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `ULTRADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) |
+| `ULTRADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `ULTRADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_uniteddomains.md b/docs/content/dns/zz_gen_uniteddomains.md
new file mode 100644
index 000000000..e837644d5
--- /dev/null
+++ b/docs/content/dns/zz_gen_uniteddomains.md
@@ -0,0 +1,67 @@
+---
+title: "United-Domains"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: uniteddomains
+dnsprovider:
+ since: "v4.29.0"
+ code: "uniteddomains"
+ url: "https://www.united-domains.de/"
+---
+
+
+
+
+
+
+Configuration for [United-Domains](https://www.united-domains.de/).
+
+
+
+
+- Code: `uniteddomains`
+- Since: v4.29.0
+
+
+Here is an example bash command using the United-Domains provider:
+
+```bash
+UNITEDDOMAINS_API_KEY=xxxxxxxx \
+lego --dns uniteddomains -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `UNITEDDOMAINS_API_KEY` | API key `.` https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/ |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `UNITEDDOMAINS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `UNITEDDOMAINS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `UNITEDDOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) |
+| `UNITEDDOMAINS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.united-domains.de/dns-apidoc/)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_variomedia.md b/docs/content/dns/zz_gen_variomedia.md
index 5fc6dfea6..f9771c867 100644
--- a/docs/content/dns/zz_gen_variomedia.md
+++ b/docs/content/dns/zz_gen_variomedia.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Variomedia provider:
```bash
VARIOMEDIA_API_TOKEN=xxxx \
-lego --email you@example.com --dns variomedia -d '*.example.com' -d example.com run
+lego --dns variomedia -d '*.example.com' -d example.com run
```
@@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `VARIOMEDIA_HTTP_TIMEOUT` | API request timeout |
-| `VARIOMEDIA_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VARIOMEDIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `VARIOMEDIA_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `VARIOMEDIA_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VARIOMEDIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `VARIOMEDIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `VARIOMEDIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `VARIOMEDIA_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `VARIOMEDIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_vegadns.md b/docs/content/dns/zz_gen_vegadns.md
index b9fe43c1f..e06eebce7 100644
--- a/docs/content/dns/zz_gen_vegadns.md
+++ b/docs/content/dns/zz_gen_vegadns.md
@@ -46,9 +46,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `VEGADNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VEGADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `VEGADNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VEGADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) |
+| `VEGADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 720) |
+| `VEGADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_vercel.md b/docs/content/dns/zz_gen_vercel.md
index e092b4fff..71f2eeed5 100644
--- a/docs/content/dns/zz_gen_vercel.md
+++ b/docs/content/dns/zz_gen_vercel.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Vercel provider:
```bash
VERCEL_API_TOKEN=xxxxxx \
-lego --email you@example.com --dns vercel -d '*.example.com' -d example.com run
+lego --dns vercel -d '*.example.com' -d example.com run
```
@@ -47,11 +47,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `VERCEL_HTTP_TIMEOUT` | API request timeout |
-| `VERCEL_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VERCEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `VERCEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `VERCEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `VERCEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
| `VERCEL_TEAM_ID` | Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx) |
-| `VERCEL_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VERCEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_versio.md b/docs/content/dns/zz_gen_versio.md
index 3941605c4..5d2cc0118 100644
--- a/docs/content/dns/zz_gen_versio.md
+++ b/docs/content/dns/zz_gen_versio.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Versio.[nl|eu|uk] provider:
```bash
VERSIO_USERNAME= \
VERSIO_PASSWORD= \
-lego --email you@example.com --dns versio -d '*.example.com' -d example.com run
+lego --dns versio -d '*.example.com' -d example.com run
```
@@ -50,11 +50,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `VERSIO_ENDPOINT` | The endpoint URL of the API Server |
-| `VERSIO_HTTP_TIMEOUT` | API request timeout |
-| `VERSIO_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VERSIO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `VERSIO_SEQUENCE_INTERVAL` | Time between sequential requests, default 60s |
-| `VERSIO_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VERSIO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `VERSIO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `VERSIO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `VERSIO_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `VERSIO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_vinyldns.md b/docs/content/dns/zz_gen_vinyldns.md
index 92e0138dd..3280d6f0a 100644
--- a/docs/content/dns/zz_gen_vinyldns.md
+++ b/docs/content/dns/zz_gen_vinyldns.md
@@ -29,7 +29,7 @@ Here is an example bash command using the VinylDNS provider:
VINYLDNS_ACCESS_KEY=xxxxxx \
VINYLDNS_SECRET_KEY=yyyyy \
VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \
-lego --email you@example.com --dns vinyldns -d '*.example.com' -d example.com run
+lego --dns vinyldns -d '*.example.com' -d example.com run
```
@@ -51,9 +51,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `VINYLDNS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VINYLDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `VINYLDNS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VINYLDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `VINYLDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) |
+| `VINYLDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `VINYLDNS_QUOTE_VALUE` | Adds quotes around the TXT record value (Default: false) |
+| `VINYLDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_virtualname.md b/docs/content/dns/zz_gen_virtualname.md
new file mode 100644
index 000000000..a00e5105f
--- /dev/null
+++ b/docs/content/dns/zz_gen_virtualname.md
@@ -0,0 +1,67 @@
+---
+title: "Virtualname"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: virtualname
+dnsprovider:
+ since: "v4.30.0"
+ code: "virtualname"
+ url: "https://www.virtualname.es/"
+---
+
+
+
+
+
+
+Configuration for [Virtualname](https://www.virtualname.es/).
+
+
+
+
+- Code: `virtualname`
+- Since: v4.30.0
+
+
+Here is an example bash command using the Virtualname provider:
+
+```bash
+VIRTUALNAME_TOKEN=xxxxxx \
+lego --dns virtualname -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `VIRTUALNAME_TOKEN` | API token |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `VIRTUALNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `VIRTUALNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `VIRTUALNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `VIRTUALNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://developers.virtualname.net/#dns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_vkcloud.md b/docs/content/dns/zz_gen_vkcloud.md
index d3c33e9c2..76fd557a5 100644
--- a/docs/content/dns/zz_gen_vkcloud.md
+++ b/docs/content/dns/zz_gen_vkcloud.md
@@ -29,7 +29,7 @@ Here is an example bash command using the VK Cloud provider:
VK_CLOUD_PROJECT_ID="" \
VK_CLOUD_USERNAME="" \
VK_CLOUD_PASSWORD="" \
-lego --email you@example.com --dns vkcloud -d '*.example.com' -d example.com run
+lego --dns vkcloud -d '*.example.com' -d example.com run
```
@@ -54,9 +54,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| `VK_CLOUD_DNS_ENDPOINT` | URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds |
| `VK_CLOUD_DOMAIN_NAME` | Openstack users domain name. Defaults to `users` but can be changed for usage with private clouds |
| `VK_CLOUD_IDENTITY_ENDPOINT` | URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds |
-| `VK_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VK_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `VK_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VK_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `VK_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `VK_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_volcengine.md b/docs/content/dns/zz_gen_volcengine.md
index a1eb5d4ec..587ce1e74 100644
--- a/docs/content/dns/zz_gen_volcengine.md
+++ b/docs/content/dns/zz_gen_volcengine.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Volcano Engine/火山引擎 provider:
```bash
VOLC_ACCESSKEY=xxx \
VOLC_SECRETKEY=yyy \
-lego --email you@example.com --dns volcengine -d '*.example.com' -d example.com run
+lego --dns volcengine -d '*.example.com' -d example.com run
```
@@ -50,12 +50,12 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `VOLC_HOST` | API host |
-| `VOLC_HTTP_TIMEOUT` | API request timeout |
-| `VOLC_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VOLC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `VOLC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 15) |
+| `VOLC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `VOLC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) |
| `VOLC_REGION` | Region |
| `VOLC_SCHEME` | API scheme |
-| `VOLC_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VOLC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_vscale.md b/docs/content/dns/zz_gen_vscale.md
index 696d404d8..c33e2f7b5 100644
--- a/docs/content/dns/zz_gen_vscale.md
+++ b/docs/content/dns/zz_gen_vscale.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Vscale provider:
```bash
VSCALE_API_TOKEN=xxxxx \
-lego --email you@example.com --dns vscale -d '*.example.com' -d example.com run
+lego --dns vscale -d '*.example.com' -d example.com run
```
@@ -48,10 +48,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `VSCALE_BASE_URL` | API endpoint URL |
-| `VSCALE_HTTP_TIMEOUT` | API request timeout |
-| `VSCALE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `VSCALE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VSCALE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `VSCALE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `VSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `VSCALE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_vultr.md b/docs/content/dns/zz_gen_vultr.md
index 0334a69ad..4160fbcf3 100644
--- a/docs/content/dns/zz_gen_vultr.md
+++ b/docs/content/dns/zz_gen_vultr.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Vultr provider:
```bash
VULTR_API_KEY=xxxxx \
-lego --email you@example.com --dns vultr -d '*.example.com' -d example.com run
+lego --dns vultr -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `VULTR_HTTP_TIMEOUT` | API request timeout |
-| `VULTR_POLLING_INTERVAL` | Time between DNS propagation check |
-| `VULTR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `VULTR_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `VULTR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `VULTR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `VULTR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `VULTR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_webnames.md b/docs/content/dns/zz_gen_webnames.md
index 2fdc09cd3..cad02c287 100644
--- a/docs/content/dns/zz_gen_webnames.md
+++ b/docs/content/dns/zz_gen_webnames.md
@@ -1,5 +1,5 @@
---
-title: "Webnames"
+title: "webnames.ru"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: webnames
@@ -14,7 +14,7 @@ dnsprovider:
-Configuration for [Webnames](https://www.webnames.ru/).
+Configuration for [webnames.ru](https://www.webnames.ru/).
@@ -23,11 +23,11 @@ Configuration for [Webnames](https://www.webnames.ru/).
- Since: v4.15.0
-Here is an example bash command using the Webnames provider:
+Here is an example bash command using the webnames.ru provider:
```bash
-WEBNAMES_API_KEY=xxxxxx \
-lego --email you@example.com --dns webnames -d '*.example.com' -d example.com run
+WEBNAMESRU_API_KEY=xxxxxx \
+lego --dns webnamesru -d '*.example.com' -d example.com run
```
@@ -37,7 +37,7 @@ lego --email you@example.com --dns webnames -d '*.example.com' -d example.com ru
| Environment Variable Name | Description |
|-----------------------|-------------|
-| `WEBNAMES_API_KEY` | Domain API key |
+| `WEBNAMESRU_API_KEY` | Domain API key |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -47,10 +47,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `WEBNAMES_HTTP_TIMEOUT` | API request timeout |
-| `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 |
+| `WEBNAMESRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `WEBNAMESRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `WEBNAMESRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_webnamesca.md b/docs/content/dns/zz_gen_webnamesca.md
new file mode 100644
index 000000000..4a7d3794f
--- /dev/null
+++ b/docs/content/dns/zz_gen_webnamesca.md
@@ -0,0 +1,69 @@
+---
+title: "webnames.ca"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: webnamesca
+dnsprovider:
+ since: "v4.28.0"
+ code: "webnamesca"
+ url: "https://www.webnames.ca/"
+---
+
+
+
+
+
+
+Configuration for [webnames.ca](https://www.webnames.ca/).
+
+
+
+
+- Code: `webnamesca`
+- Since: v4.28.0
+
+
+Here is an example bash command using the webnames.ca provider:
+
+```bash
+WEBNAMESCA_API_USER="xxx" \
+WEBNAMESCA_API_KEY="yyy" \
+lego --dns webnamesca -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `WEBNAMESCA_API_KEY` | API key |
+| `WEBNAMESCA_API_USER` | API username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `WEBNAMESCA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `WEBNAMESCA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `WEBNAMESCA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `WEBNAMESCA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.webnames.ca/_/swagger/index.html)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_websupport.md b/docs/content/dns/zz_gen_websupport.md
index c48181a54..67ae394d7 100644
--- a/docs/content/dns/zz_gen_websupport.md
+++ b/docs/content/dns/zz_gen_websupport.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Websupport provider:
```bash
WEBSUPPORT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
WEBSUPPORT_SECRET="yyyyyyyyyyyyyyyyyyyyy" \
-lego --email you@example.com --dns websupport -d '*.example.com' -d example.com run
+lego --dns websupport -d '*.example.com' -d example.com run
```
@@ -49,11 +49,11 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `WEBSUPPORT_HTTP_TIMEOUT` | API request timeout |
-| `WEBSUPPORT_POLLING_INTERVAL` | Time between DNS propagation check |
-| `WEBSUPPORT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `WEBSUPPORT_SEQUENCE_INTERVAL` | Time between sequential requests |
-| `WEBSUPPORT_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `WEBSUPPORT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `WEBSUPPORT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `WEBSUPPORT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `WEBSUPPORT_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) |
+| `WEBSUPPORT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
@@ -63,7 +63,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
## More information
-- [API documentation](https://rest.websupport.sk/docs/v1.zone)
+- [API documentation](https://rest.websupport.sk/v2/docs)
diff --git a/docs/content/dns/zz_gen_wedos.md b/docs/content/dns/zz_gen_wedos.md
index 1762cf4ca..16139f4d4 100644
--- a/docs/content/dns/zz_gen_wedos.md
+++ b/docs/content/dns/zz_gen_wedos.md
@@ -28,7 +28,7 @@ Here is an example bash command using the WEDOS provider:
```bash
WEDOS_USERNAME=xxxxxxxx \
WEDOS_WAPI_PASSWORD=xxxxxxxx \
-lego --email you@example.com --dns wedos -d '*.example.com' -d example.com run
+lego --dns wedos -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `WEDOS_HTTP_TIMEOUT` | API request timeout |
-| `WEDOS_POLLING_INTERVAL` | Time between DNS propagation check |
-| `WEDOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `WEDOS_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `WEDOS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `WEDOS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `WEDOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) |
+| `WEDOS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_westcn.md b/docs/content/dns/zz_gen_westcn.md
new file mode 100644
index 000000000..a5523b955
--- /dev/null
+++ b/docs/content/dns/zz_gen_westcn.md
@@ -0,0 +1,69 @@
+---
+title: "West.cn/西部数码"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: westcn
+dnsprovider:
+ since: "v4.21.0"
+ code: "westcn"
+ url: "https://www.west.cn"
+---
+
+
+
+
+
+
+Configuration for [West.cn/西部数码](https://www.west.cn).
+
+
+
+
+- Code: `westcn`
+- Since: v4.21.0
+
+
+Here is an example bash command using the West.cn/西部数码 provider:
+
+```bash
+WESTCN_USERNAME="xxx" \
+WESTCN_PASSWORD="yyy" \
+lego --dns westcn -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `WESTCN_PASSWORD` | API password |
+| `WESTCN_USERNAME` | Username |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `WESTCN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `WESTCN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) |
+| `WESTCN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) |
+| `WESTCN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.west.cn/CustomerCenter/doc/domain_v2.html)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_yandex.md b/docs/content/dns/zz_gen_yandex.md
index 60b8a0ac3..4a1cf1f99 100644
--- a/docs/content/dns/zz_gen_yandex.md
+++ b/docs/content/dns/zz_gen_yandex.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Yandex PDD provider:
```bash
YANDEX_PDD_TOKEN= \
-lego --email you@example.com --dns yandex -d '*.example.com' -d example.com run
+lego --dns yandex -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `YANDEX_HTTP_TIMEOUT` | API request timeout |
-| `YANDEX_POLLING_INTERVAL` | Time between DNS propagation check |
-| `YANDEX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `YANDEX_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `YANDEX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `YANDEX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `YANDEX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `YANDEX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_yandex360.md b/docs/content/dns/zz_gen_yandex360.md
index 04eeab45c..d831fdfc2 100644
--- a/docs/content/dns/zz_gen_yandex360.md
+++ b/docs/content/dns/zz_gen_yandex360.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Yandex 360 provider:
```bash
YANDEX360_OAUTH_TOKEN= \
YANDEX360_ORG_ID= \
-lego --email you@example.com --dns yandex360 -d '*.example.com' -d example.com run
+lego --dns yandex360 -d '*.example.com' -d example.com run
```
@@ -49,10 +49,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `YANDEX360_HTTP_TIMEOUT` | API request timeout |
-| `YANDEX360_POLLING_INTERVAL` | Time between DNS propagation check |
-| `YANDEX360_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `YANDEX360_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `YANDEX360_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `YANDEX360_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `YANDEX360_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `YANDEX360_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_yandexcloud.md b/docs/content/dns/zz_gen_yandexcloud.md
index 0831e8c49..0564e93d2 100644
--- a/docs/content/dns/zz_gen_yandexcloud.md
+++ b/docs/content/dns/zz_gen_yandexcloud.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Yandex Cloud provider:
```bash
YANDEX_CLOUD_IAM_TOKEN= \
YANDEX_CLOUD_FOLDER_ID= \
-lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run
+lego --dns yandexcloud -d '*.example.com' -d example.com run
# ---
@@ -41,7 +41,7 @@ YANDEX_CLOUD_IAM_TOKEN=$(echo '{ \
"private_key": "-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----" \
}' | base64) \
YANDEX_CLOUD_FOLDER_ID= \
-lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run
+lego --dns yandexcloud -d '*.example.com' -d example.com run
```
@@ -62,9 +62,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `YANDEX_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check |
-| `YANDEX_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `YANDEX_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `YANDEX_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `YANDEX_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `YANDEX_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_zoneedit.md b/docs/content/dns/zz_gen_zoneedit.md
new file mode 100644
index 000000000..c7f88b3fe
--- /dev/null
+++ b/docs/content/dns/zz_gen_zoneedit.md
@@ -0,0 +1,68 @@
+---
+title: "ZoneEdit"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: zoneedit
+dnsprovider:
+ since: "v4.25.0"
+ code: "zoneedit"
+ url: "https://www.zoneedit.com"
+---
+
+
+
+
+
+
+Configuration for [ZoneEdit](https://www.zoneedit.com).
+
+
+
+
+- Code: `zoneedit`
+- Since: v4.25.0
+
+
+Here is an example bash command using the ZoneEdit provider:
+
+```bash
+ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \
+ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns zoneedit -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ZONEEDIT_AUTH_TOKEN` | Authentication token |
+| `ZONEEDIT_USER` | User ID |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ZONEEDIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ZONEEDIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ZONEEDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns)
+
+
+
+
diff --git a/docs/content/dns/zz_gen_zoneee.md b/docs/content/dns/zz_gen_zoneee.md
index a6df03b56..65678a3dc 100644
--- a/docs/content/dns/zz_gen_zoneee.md
+++ b/docs/content/dns/zz_gen_zoneee.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Zone.ee provider:
```bash
ZONEEE_API_USER=xxxxx \
ZONEEE_API_KEY=yyyyy \
-lego --email you@example.com --dns zoneee -d '*.example.com' -d example.com run
+lego --dns zoneee -d '*.example.com' -d example.com run
```
@@ -50,10 +50,9 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ZONEEE_ENDPOINT` | API endpoint URL |
-| `ZONEEE_HTTP_TIMEOUT` | API request timeout |
-| `ZONEEE_POLLING_INTERVAL` | Time between DNS propagation check |
-| `ZONEEE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `ZONEEE_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `ZONEEE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ZONEEE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) |
+| `ZONEEE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/dns/zz_gen_zonomi.md b/docs/content/dns/zz_gen_zonomi.md
index 51c25d95d..fd8757f82 100644
--- a/docs/content/dns/zz_gen_zonomi.md
+++ b/docs/content/dns/zz_gen_zonomi.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Zonomi provider:
```bash
ZONOMI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns zonomi -d '*.example.com' -d example.com run
+lego --dns zonomi -d '*.example.com' -d example.com run
```
@@ -47,10 +47,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
-| `ZONOMI_HTTP_TIMEOUT` | API request timeout |
-| `ZONOMI_POLLING_INTERVAL` | Time between DNS propagation check |
-| `ZONOMI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
-| `ZONOMI_TTL` | The TTL of the TXT record used for the DNS challenge |
+| `ZONOMI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ZONOMI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ZONOMI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ZONOMI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here]({{% ref "dns#configuration-and-credentials" %}}).
diff --git a/docs/content/usage/cli/Obtain-a-Certificate.md b/docs/content/usage/cli/Obtain-a-Certificate.md
index c92f4ecf0..c7f25dfc0 100644
--- a/docs/content/usage/cli/Obtain-a-Certificate.md
+++ b/docs/content/usage/cli/Obtain-a-Certificate.md
@@ -58,6 +58,21 @@ GANDI_API_KEY=xxx \
lego --email "you@example.com" --dns gandi --domains "example.org" --domains "*.example.org" run
```
+{{% notice title="For a zone that has multiple SOAs" icon="info-circle" %}}
+
+This can often be found where your DNS provider has a zone entry for an internal network (i.e. a corporate network, or home LAN) as well as the public internet.
+In this case, point lego at an external authoritative server for the zone using the additional parameter `--dns.resolvers`.
+
+```bash
+GANDI_API_KEY=xxx \
+lego --email "you@example.com" --dns gandi --dns.resolvers 9.9.9.9:53 --domains "example.org" --domains "*.example.org" run
+
+```
+
+[More information about resolvers.]({{% ref "options#dns-resolvers-and-challenge-verification" %}})
+
+{{% /notice %}}
+
## Using a custom certificate signing request (CSR)
diff --git a/docs/content/usage/cli/Options.md b/docs/content/usage/cli/Options.md
index a6484de23..7b5df027a 100644
--- a/docs/content/usage/cli/Options.md
+++ b/docs/content/usage/cli/Options.md
@@ -142,3 +142,32 @@ Example:
```bash
LEGO_DEBUG_CLIENT_VERBOSE_ERROR=true
```
+
+### LEGO_DEBUG_DNS_API_HTTP_CLIENT
+
+> **⚠️ WARNING: This will expose credentials in the log output! ⚠️**
+>
+> Do not run this in production environments, or if you can't be sure that logs aren't accessed by third parties or tools (like log collectors).
+>
+> You have been warned. Here be dragons.
+
+The environment variable `LEGO_DEBUG_DNS_API_HTTP_CLIENT` allows debugging the DNS API interaction.
+It will dump the full request and response to the log output.
+
+Some DNS providers don't support this option.
+
+Example:
+
+```bash
+LEGO_DEBUG_DNS_API_HTTP_CLIENT=true
+```
+
+### LEGO_DEBUG_ACME_HTTP_CLIENT
+
+The environment variable `LEGO_DEBUG_ACME_HTTP_CLIENT` allows debug the calls to the ACME server.
+
+Example:
+
+```bash
+LEGO_DEBUG_ACME_HTTP_CLIENT=true
+```
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index ad95fe40d..139143b17 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -22,7 +22,8 @@ GLOBAL OPTIONS:
--domains value, -d value [ --domains value, -d value ] Add a domain to the process. Can be specified multiple times.
--server value, -s value CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v02.api.letsencrypt.org/directory") [$LEGO_SERVER]
--accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. (default: false)
- --email value, -m value Email used for registration and recovery contact.
+ --email value, -m value Email used for registration and recovery contact. [$LEGO_EMAIL]
+ --disable-cn Disable the use of the common name in the CSR. (default: false)
--csr value, -c value Certificate signing request filename, if an external CSR is to be used.
--eab Use External Account Binding for account registration. Requires --kid and --hmac. (default: false) [$LEGO_EAB]
--kid value Key identifier from External CA. Used for External Account Binding. [$LEGO_EAB_KID]
@@ -32,12 +33,14 @@ GLOBAL OPTIONS:
--path value Directory to use for storing the data. (default: "./.lego") [$LEGO_PATH]
--http Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges. (default: false)
--http.port value Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port. (default: ":80")
+ --http.delay value Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge. (default: 0s)
--http.proxy-header value Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy. (default: "Host")
--http.webroot value Set the webroot folder to use for HTTP-01 based challenges to write directly to the .well-known/acme-challenge file. This disables the built-in server and expects the given directory to be publicly served with access to .well-known/acme-challenge
--http.memcached-host value [ --http.memcached-host value ] Set the memcached host(s) to use for HTTP-01 based challenges. Challenges will be written to all specified hosts.
--http.s3-bucket value Set the S3 bucket name to use for HTTP-01 based challenges. Challenges will be written to the S3 bucket.
--tls Use the TLS-ALPN-01 challenge to solve challenges. Can be mixed with other types of challenges. (default: false)
--tls.port value Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port. (default: ":443")
+ --tls.delay value Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge. (default: 0s)
--dns value Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage.
--dns.disable-cp (deprecated) use dns.propagation-disable-ans instead. (default: false)
--dns.propagation-disable-ans By setting this flag to true, disables the need to await propagation of the TXT record to all authoritative name servers. (default: false)
@@ -71,9 +74,12 @@ OPTIONS:
--must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. (default: false)
--not-before value Set the notBefore field in the certificate (RFC3339 format)
--not-after value Set the notAfter field in the certificate (RFC3339 format)
+ --private-key value Path to private key (in PEM encoding) for the certificate. By default, the private key is generated.
--preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used.
+ --profile value If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.
--always-deactivate-authorizations value Force the authorizations to be relinquished even if the certificate request was successful.
--run-hook value Define a hook. The hook is executed when the certificates are effectively created.
+ --run-hook-timeout value Define the timeout for the hook execution. (default: 2m0s)
--help, -h show help
"""
@@ -88,7 +94,8 @@ USAGE:
OPTIONS:
--days value The number of days left on a certificate to renew it. (default: 30)
- --ari-disable Do not use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed. (default: false)
+ --dynamic Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5. (default: false)
+ --ari-disable Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. (default: false)
--ari-wait-to-renew-duration value The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s)
--reuse-key Used to indicate you want to reuse your current private key for the new certificate. (default: false)
--no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. (default: false)
@@ -96,9 +103,12 @@ OPTIONS:
--not-before value Set the notBefore field in the certificate (RFC3339 format)
--not-after value Set the notAfter field in the certificate (RFC3339 format)
--preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used.
+ --profile value If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.
--always-deactivate-authorizations value Force the authorizations to be relinquished even if the certificate request was successful.
--renew-hook value Define a hook. The hook is executed only when the certificates are effectively renewed.
+ --renew-hook-timeout value Define the timeout for the hook execution. (default: 2m0s)
--no-random-sleep Do not add a random sleep before the renewal. We do not recommend using this flag if you are doing your renewals in an automated way. (default: false)
+ --force-cert-domains Check and ensure that the cert's domain list matches those passed in the domains argument. (default: false)
--help, -h show help
"""
@@ -142,7 +152,7 @@ To display the documentation for a specific DNS provider, run:
$ lego dnshelp -c code
Supported DNS providers:
- acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manual, metaname, mijnhost, mittwald, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, yandex, yandex360, yandexcloud, zoneee, zonomi
+ acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, czechia, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, eurodns, excedo, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, leaseweb, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""
diff --git a/docs/go.mod b/docs/go.mod
index 5cb2add45..2240eb1e6 100644
--- a/docs/go.mod
+++ b/docs/go.mod
@@ -2,4 +2,4 @@ module github.com/go-acme/lego/docs
go 1.20
-require github.com/McShelby/hugo-theme-relearn v0.0.0-20240802145348-259f21f89851
+require github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb
diff --git a/docs/go.sum b/docs/go.sum
index 1ed963e87..b62d5c809 100644
--- a/docs/go.sum
+++ b/docs/go.sum
@@ -1,2 +1,2 @@
-github.com/McShelby/hugo-theme-relearn v0.0.0-20240802145348-259f21f89851 h1:JpmKIb1bRzuAcgnphwSb35Xz9rk/Alq19uRWVGSwScA=
-github.com/McShelby/hugo-theme-relearn v0.0.0-20240802145348-259f21f89851/go.mod h1:mKQQdxZNIlLvAj8X3tMq+RzntIJSr9z7XdzuMomt0IM=
+github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb h1:iTGWOs8uKUaYmd7+wHRyPGXxt+SS5Bhvx2RRboYRXlI=
+github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb/go.mod h1:mKQQdxZNIlLvAj8X3tMq+RzntIJSr9z7XdzuMomt0IM=
diff --git a/docs/hugo.toml b/docs/hugo.toml
index a974cea73..fe076a306 100644
--- a/docs/hugo.toml
+++ b/docs/hugo.toml
@@ -2,47 +2,20 @@ baseURL = "https://go-acme.github.io/lego/"
languageCode = "en-us"
title = "Lego"
-# Code highlighting settings
-pygmentsCodefences = true
-pygmentsCodeFencesGuesSsyntax = false
-pygmentsOptions = ""
-pygmentsStyle = "monokai"
-# The monokai stylesheet is included in the base template.
-pygmentsUseClasses = true
-
[permalinks]
dns = "/dns/:slug/"
[params]
- # Prefix URL to edit current page. Will display an "Edit this page" button on top right hand corner of every page.
- # Useful to give opportunity to people to create merge request for your doc.
- # See the config.toml file from this documentation site to have an example.
-# editURL = ""
# Description of the site, will be used in meta information
# description = ""
# Shows a checkmark for visited pages on the menu
showVisitedLinks = true
- # Disable search function. It will hide search bar
-# disableSearch = false
- # Javascript and CSS cache are automatically busted when new version of site is generated.
- # Set this to true to disable this behavior (some proxies don't handle well this optimization)
-# disableAssetsBusting = false
- # Set this to true to disable copy-to-clipboard button for inline code.
-# disableInlineCopyToClipBoard = true
- # A title for shortcuts in menu is set by default. Set this to true to disable it.
-# disableShortcutsTitle = false
- # When using mulitlingual website, disable the switch language button.
-# disableLanguageSwitchingButton = false
- # Hide breadcrumbs in the header and only show the current page title
-# disableBreadcrumb = true
- # Hide Next and Previous page buttons normally displayed full height beside content
-# disableNextPrev = true
- # Order sections in menu by "weight" or "title". Default to "weight"
-# ordersectionsby = "weight"
# Change default color scheme with a variant one. Can be "red", "blue", "green".
themeVariant = "blue"
custom_css = ["css/theme-custom.css"]
disableLandingPageButton = true
+ hideAuthorEmail = true
+ hideAuthorName = true
# Author of the site, will be used in meta information
[params.author]
@@ -71,7 +44,7 @@ pygmentsUseClasses = true
weight = 12
[outputs]
- home = [ "html", "rss", "search", "searchpage"]
+ home = ['html', 'rss', 'print']
[module]
[[module.imports]]
diff --git a/docs/static/.nojekyll b/docs/static/.nojekyll
new file mode 100644
index 000000000..e69de29bb
diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go
index cbf364c57..be1d23131 100644
--- a/e2e/challenges_test.go
+++ b/e2e/challenges_test.go
@@ -5,8 +5,10 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
+ "encoding/pem"
"fmt"
"os"
+ "path/filepath"
"testing"
"time"
@@ -21,6 +23,18 @@ import (
"github.com/stretchr/testify/require"
)
+const (
+ testDomain1 = "acme.localhost"
+ testDomain2 = "lego.localhost"
+ testDomain3 = "acme.lego.localhost"
+ testDomain4 = "légô.localhost"
+)
+
+const (
+ testEmail1 = "lego@example.com"
+ testEmail2 = "acme@example.com"
+)
+
var load = loader.EnvLoader{
PebbleOptions: &loader.CmdOption{
HealthCheckURL: "https://localhost:14000/dir",
@@ -29,6 +43,7 @@ var load = loader.EnvLoader{
},
LegoOptions: []string{
"LEGO_CA_CERTIFICATES=./fixtures/certs/pebble.minica.pem",
+ "LEGO_DEBUG_ACME_HTTP_CLIENT=1",
},
}
@@ -37,7 +52,7 @@ func TestMain(m *testing.M) {
}
func TestHelp(t *testing.T) {
- output, err := load.RunLego("-h")
+ output, err := load.RunLegoCombinedOutput("-h")
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", output)
t.Fatal(err)
@@ -49,18 +64,14 @@ func TestHelp(t *testing.T) {
func TestChallengeHTTP_Run(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ err := load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-d", "acme.wtf",
+ "-d", testDomain1,
"--http",
"--http.port", ":5002",
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -69,18 +80,14 @@ func TestChallengeHTTP_Run(t *testing.T) {
func TestChallengeTLS_Run_Domains(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ err := load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-d", "acme.wtf",
+ "-d", testDomain1,
"--tls",
"--tls.port", ":5001",
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -89,18 +96,14 @@ func TestChallengeTLS_Run_Domains(t *testing.T) {
func TestChallengeTLS_Run_IP(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ err := load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
"-d", "127.0.0.1",
"--tls",
"--tls.port", ":5001",
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -109,18 +112,16 @@ func TestChallengeTLS_Run_IP(t *testing.T) {
func TestChallengeTLS_Run_CSR(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ csrPath := createTestCSRFile(t, true)
+
+ err := load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-csr", "./fixtures/csr.raw",
+ "-csr", csrPath,
"--tls",
"--tls.port", ":5001",
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -129,18 +130,16 @@ func TestChallengeTLS_Run_CSR(t *testing.T) {
func TestChallengeTLS_Run_CSR_PEM(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ csrPath := createTestCSRFile(t, false)
+
+ err := load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-csr", "./fixtures/csr.cert",
+ "-csr", csrPath,
"--tls",
"--tls.port", ":5001",
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -149,35 +148,27 @@ func TestChallengeTLS_Run_CSR_PEM(t *testing.T) {
func TestChallengeTLS_Run_Revoke(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ err := load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-d", "lego.wtf",
- "-d", "acme.lego.wtf",
+ "-d", testDomain2,
+ "-d", testDomain3,
"--tls",
"--tls.port", ":5001",
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
- output, err = load.RunLego(
- "-m", "hubert@hubert.com",
+ err = load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-d", "lego.wtf",
+ "-d", testDomain2,
"--tls",
"--tls.port", ":5001",
"revoke")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -186,34 +177,26 @@ func TestChallengeTLS_Run_Revoke(t *testing.T) {
func TestChallengeTLS_Run_Revoke_Non_ASCII(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ err := load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-d", "légô.wtf",
+ "-d", testDomain4,
"--tls",
"--tls.port", ":5001",
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
- output, err = load.RunLego(
- "-m", "hubert@hubert.com",
+ err = load.RunLego(
+ "-m", testEmail1,
"--accept-tos",
"-s", "https://localhost:14000/dir",
- "-d", "légô.wtf",
+ "-d", testDomain4,
"--tls",
"--tls.port", ":5001",
"revoke")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -222,6 +205,7 @@ func TestChallengeTLS_Run_Revoke_Non_ASCII(t *testing.T) {
func TestChallengeHTTP_Client_Obtain(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -239,17 +223,100 @@ func TestChallengeHTTP_Client_Obtain(t *testing.T) {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
require.NoError(t, err)
+
user.registration = reg
request := certificate.ObtainRequest{
- Domains: []string{"acme.wtf"},
+ Domains: []string{testDomain1},
Bundle: true,
}
resource, err := client.Certificate.Obtain(request)
require.NoError(t, err)
require.NotNil(t, resource)
- assert.Equal(t, "acme.wtf", resource.Domain)
+ assert.Equal(t, testDomain1, resource.Domain)
+ assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
+ assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
+ assert.NotEmpty(t, resource.Certificate)
+ assert.NotEmpty(t, resource.IssuerCertificate)
+ assert.Empty(t, resource.CSR)
+}
+
+func TestChallengeHTTP_Client_Obtain_profile(t *testing.T) {
+ err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
+ require.NoError(t, err)
+
+ defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err, "Could not generate test key")
+
+ user := &fakeUser{privateKey: privateKey}
+ config := lego.NewConfig(user)
+ config.CADirURL = load.PebbleOptions.HealthCheckURL
+
+ client, err := lego.NewClient(config)
+ require.NoError(t, err)
+
+ err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002"))
+ require.NoError(t, err)
+
+ reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
+ require.NoError(t, err)
+
+ user.registration = reg
+
+ request := certificate.ObtainRequest{
+ Domains: []string{testDomain1},
+ Bundle: true,
+ Profile: "shortlived",
+ }
+ resource, err := client.Certificate.Obtain(request)
+ require.NoError(t, err)
+
+ require.NotNil(t, resource)
+ assert.Equal(t, testDomain1, resource.Domain)
+ assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
+ assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
+ assert.NotEmpty(t, resource.Certificate)
+ assert.NotEmpty(t, resource.IssuerCertificate)
+ assert.Empty(t, resource.CSR)
+}
+
+func TestChallengeHTTP_Client_Obtain_emails_csr(t *testing.T) {
+ err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
+ require.NoError(t, err)
+
+ defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err, "Could not generate test key")
+
+ user := &fakeUser{privateKey: privateKey}
+ config := lego.NewConfig(user)
+ config.CADirURL = load.PebbleOptions.HealthCheckURL
+
+ client, err := lego.NewClient(config)
+ require.NoError(t, err)
+
+ err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002"))
+ require.NoError(t, err)
+
+ reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
+ require.NoError(t, err)
+
+ user.registration = reg
+
+ request := certificate.ObtainRequest{
+ Domains: []string{testDomain1},
+ Bundle: true,
+ EmailAddresses: []string{testEmail1},
+ }
+ resource, err := client.Certificate.Obtain(request)
+ require.NoError(t, err)
+
+ require.NotNil(t, resource)
+ assert.Equal(t, testDomain1, resource.Domain)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
assert.NotEmpty(t, resource.Certificate)
@@ -260,6 +327,7 @@ func TestChallengeHTTP_Client_Obtain(t *testing.T) {
func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -277,12 +345,13 @@ func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
require.NoError(t, err)
+
user.registration = reg
now := time.Now().UTC()
request := certificate.ObtainRequest{
- Domains: []string{"acme.wtf"},
+ Domains: []string{testDomain1},
NotBefore: now.Add(1 * time.Hour),
NotAfter: now.Add(2 * time.Hour),
Bundle: true,
@@ -291,7 +360,7 @@ func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resource)
- assert.Equal(t, "acme.wtf", resource.Domain)
+ assert.Equal(t, testDomain1, resource.Domain)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
assert.NotEmpty(t, resource.Certificate)
@@ -307,6 +376,7 @@ func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) {
func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -324,6 +394,7 @@ func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
require.NoError(t, err)
+
user.registration = reg
resource, err := client.Registration.QueryRegistration()
@@ -339,6 +410,7 @@ func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) {
func TestChallengeTLS_Client_Obtain(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -356,6 +428,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
require.NoError(t, err)
+
user.registration = reg
// https://github.com/letsencrypt/pebble/issues/285
@@ -363,7 +436,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) {
require.NoError(t, err, "Could not generate test key")
request := certificate.ObtainRequest{
- Domains: []string{"acme.wtf"},
+ Domains: []string{testDomain1},
Bundle: true,
PrivateKey: privateKeyCSR,
}
@@ -371,7 +444,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resource)
- assert.Equal(t, "acme.wtf", resource.Domain)
+ assert.Equal(t, testDomain1, resource.Domain)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
assert.NotEmpty(t, resource.Certificate)
@@ -382,6 +455,7 @@ func TestChallengeTLS_Client_Obtain(t *testing.T) {
func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -399,12 +473,10 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
require.NoError(t, err)
+
user.registration = reg
- csrRaw, err := os.ReadFile("./fixtures/csr.raw")
- require.NoError(t, err)
-
- csr, err := x509.ParseCertificateRequest(csrRaw)
+ csr, err := x509.ParseCertificateRequest(createTestCSR(t))
require.NoError(t, err)
resource, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{
@@ -414,7 +486,50 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resource)
- assert.Equal(t, "acme.wtf", resource.Domain)
+ assert.Equal(t, testDomain1, resource.Domain)
+ assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
+ assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
+ assert.NotEmpty(t, resource.Certificate)
+ assert.NotEmpty(t, resource.IssuerCertificate)
+ assert.NotEmpty(t, resource.CSR)
+}
+
+func TestChallengeTLS_Client_ObtainForCSR_profile(t *testing.T) {
+ err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
+ require.NoError(t, err)
+
+ defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err, "Could not generate test key")
+
+ user := &fakeUser{privateKey: privateKey}
+ config := lego.NewConfig(user)
+ config.CADirURL = load.PebbleOptions.HealthCheckURL
+
+ client, err := lego.NewClient(config)
+ require.NoError(t, err)
+
+ err = client.Challenge.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "5001"))
+ require.NoError(t, err)
+
+ reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
+ require.NoError(t, err)
+
+ user.registration = reg
+
+ csr, err := x509.ParseCertificateRequest(createTestCSR(t))
+ require.NoError(t, err)
+
+ resource, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{
+ CSR: csr,
+ Bundle: true,
+ Profile: "shortlived",
+ })
+ require.NoError(t, err)
+
+ require.NotNil(t, resource)
+ assert.Equal(t, testDomain1, resource.Domain)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL)
assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL)
assert.NotEmpty(t, resource.Certificate)
@@ -425,6 +540,7 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) {
func TestRegistrar_UpdateAccount(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -432,7 +548,7 @@ func TestRegistrar_UpdateAccount(t *testing.T) {
user := &fakeUser{
privateKey: privateKey,
- email: "foo@example.com",
+ email: testEmail1,
}
config := lego.NewConfig(user)
config.CADirURL = load.PebbleOptions.HealthCheckURL
@@ -443,13 +559,13 @@ func TestRegistrar_UpdateAccount(t *testing.T) {
regOptions := registration.RegisterOptions{TermsOfServiceAgreed: true}
reg, err := client.Registration.Register(regOptions)
require.NoError(t, err)
- require.Equal(t, []string{"mailto:foo@example.com"}, reg.Body.Contact)
+ require.Equal(t, []string{"mailto:" + testEmail1}, reg.Body.Contact)
user.registration = reg
- user.email = "bar@example.com"
+ user.email = testEmail2
resource, err := client.Registration.UpdateRegistration(regOptions)
require.NoError(t, err)
- require.Equal(t, []string{"mailto:bar@example.com"}, resource.Body.Contact)
+ require.Equal(t, []string{"mailto:" + testEmail2}, resource.Body.Contact)
require.Equal(t, reg.URI, resource.URI)
}
@@ -462,3 +578,53 @@ type fakeUser struct {
func (f *fakeUser) GetEmail() string { return f.email }
func (f *fakeUser) GetRegistration() *registration.Resource { return f.registration }
func (f *fakeUser) GetPrivateKey() crypto.PrivateKey { return f.privateKey }
+
+func createTestCSRFile(t *testing.T, raw bool) string {
+ t.Helper()
+
+ csr := createTestCSR(t)
+
+ if raw {
+ filename := filepath.Join(t.TempDir(), "csr.raw")
+
+ fileRaw, err := os.Create(filename)
+ require.NoError(t, err)
+
+ defer fileRaw.Close()
+
+ _, err = fileRaw.Write(csr)
+ require.NoError(t, err)
+
+ return filename
+ }
+
+ filename := filepath.Join(t.TempDir(), "csr.cert")
+
+ file, err := os.Create(filename)
+ require.NoError(t, err)
+
+ defer file.Close()
+
+ _, err = file.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr}))
+ require.NoError(t, err)
+
+ return filename
+}
+
+func createTestCSR(t *testing.T) []byte {
+ t.Helper()
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
+ require.NoError(t, err)
+
+ csr, err := certcrypto.CreateCSR(privateKey, certcrypto.CSROptions{
+ Domain: testDomain1,
+ SAN: []string{
+ testDomain1,
+ testDomain2,
+ },
+ })
+ require.NoError(t, err)
+
+ return csr
+}
diff --git a/e2e/dnschallenge/dns_challenges_test.go b/e2e/dnschallenge/dns_challenges_test.go
index 605a77bd0..9dd9ab0d6 100644
--- a/e2e/dnschallenge/dns_challenges_test.go
+++ b/e2e/dnschallenge/dns_challenges_test.go
@@ -18,6 +18,11 @@ import (
"github.com/stretchr/testify/require"
)
+const (
+ testDomain1 = "légo.localhost"
+ testDomain2 = "*.légo.localhost"
+)
+
var load = loader.EnvLoader{
PebbleOptions: &loader.CmdOption{
HealthCheckURL: "https://localhost:15000/dir",
@@ -28,6 +33,7 @@ var load = loader.EnvLoader{
LegoOptions: []string{
"LEGO_CA_CERTIFICATES=../fixtures/certs/pebble.minica.pem",
"EXEC_PATH=../fixtures/update-dns.sh",
+ "LEGO_DEBUG_ACME_HTTP_CLIENT=1",
},
ChallSrv: &loader.CmdOption{
Args: []string{"-http01", ":5012", "-tlsalpn01", ":5011"},
@@ -39,7 +45,7 @@ func TestMain(m *testing.M) {
}
func TestDNSHelp(t *testing.T) {
- output, err := load.RunLego("dnshelp")
+ output, err := load.RunLegoCombinedOutput("dnshelp")
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", output)
t.Fatal(err)
@@ -51,20 +57,15 @@ func TestDNSHelp(t *testing.T) {
func TestChallengeDNS_Run(t *testing.T) {
loader.CleanLegoFiles()
- output, err := load.RunLego(
- "-m", "hubert@hubert.com",
+ err := load.RunLego(
"--accept-tos",
"--dns", "exec",
"--dns.resolvers", ":8053",
"--dns.disable-cp",
"-s", "https://localhost:15000/dir",
- "-d", "*.légo.acme",
- "-d", "légo.acme",
+ "-d", testDomain2,
+ "-d", testDomain1,
"run")
-
- if len(output) > 0 {
- fmt.Fprintf(os.Stdout, "%s\n", output)
- }
if err != nil {
t.Fatal(err)
}
@@ -73,10 +74,12 @@ func TestChallengeDNS_Run(t *testing.T) {
func TestChallengeDNS_Client_Obtain(t *testing.T) {
err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh")
require.NoError(t, err)
+
defer func() { _ = os.Unsetenv("EXEC_PATH") }()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -99,9 +102,10 @@ func TestChallengeDNS_Client_Obtain(t *testing.T) {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
require.NoError(t, err)
+
user.registration = reg
- domains := []string{"*.légo.acme", "légo.acme"}
+ domains := []string{testDomain2, testDomain1}
// https://github.com/letsencrypt/pebble/issues/285
privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -116,7 +120,65 @@ func TestChallengeDNS_Client_Obtain(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resource)
- assert.Equal(t, "*.xn--lgo-bma.acme", resource.Domain)
+ assert.Equal(t, "*.xn--lgo-bma.localhost", resource.Domain)
+ assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL)
+ assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL)
+ assert.NotEmpty(t, resource.Certificate)
+ assert.NotEmpty(t, resource.IssuerCertificate)
+ assert.Empty(t, resource.CSR)
+}
+
+func TestChallengeDNS_Client_Obtain_profile(t *testing.T) {
+ err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem")
+ require.NoError(t, err)
+
+ defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }()
+
+ err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh")
+ require.NoError(t, err)
+
+ defer func() { _ = os.Unsetenv("EXEC_PATH") }()
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err, "Could not generate test key")
+
+ user := &fakeUser{privateKey: privateKey}
+ config := lego.NewConfig(user)
+ config.CADirURL = "https://localhost:15000/dir"
+
+ client, err := lego.NewClient(config)
+ require.NoError(t, err)
+
+ provider, err := dns.NewDNSChallengeProviderByName("exec")
+ require.NoError(t, err)
+
+ err = client.Challenge.SetDNS01Provider(provider,
+ dns01.AddRecursiveNameservers([]string{":8053"}),
+ dns01.DisableAuthoritativeNssPropagationRequirement())
+ require.NoError(t, err)
+
+ reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
+ require.NoError(t, err)
+
+ user.registration = reg
+
+ domains := []string{testDomain2, testDomain1}
+
+ // https://github.com/letsencrypt/pebble/issues/285
+ privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err, "Could not generate test key")
+
+ request := certificate.ObtainRequest{
+ Domains: domains,
+ Bundle: true,
+ PrivateKey: privateKeyCSR,
+ Profile: "shortlived",
+ }
+ resource, err := client.Certificate.Obtain(request)
+ require.NoError(t, err)
+
+ require.NotNil(t, resource)
+ assert.Equal(t, "*.xn--lgo-bma.localhost", resource.Domain)
assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL)
assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL)
assert.NotEmpty(t, resource.Certificate)
diff --git a/e2e/fixtures/certs/localhost/cert.pem b/e2e/fixtures/certs/localhost/cert.pem
index 2866a2b48..d81d29e70 100644
--- a/e2e/fixtures/certs/localhost/cert.pem
+++ b/e2e/fixtures/certs/localhost/cert.pem
@@ -1,19 +1,20 @@
-----BEGIN CERTIFICATE-----
-MIIDGzCCAgOgAwIBAgIIbEfayDFsBtwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
-AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMDcx
-MjA2MTk0MjEwWjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
-AQUAA4IBDwAwggEKAoIBAQCbFMW3DXXdErvQf2lCZ0qz0DGEWadDoF0O2neM5mVa
-VQ7QGW0xc5Qwvn3Tl62C0JtwLpF0pG2BICIN+DHdVaIUwkf77iBS2doH1I3waE1I
-8GkV9JrYmFY+j0dA1SwBmqUZNXhLNwZGq1a91nFSI59DZNy/JciqxoPX2K++ojU2
-FPpuXe2t51NmXMsszpa+TDqF/IeskA9A/ws6UIh4Mzhghx7oay2/qqj2IIPjAmJj
-i73kdUvtEry3wmlkBvtVH50+FscS9WmPC5h3lDTk5nbzSAXKuFusotuqy3XTgY5B
-PiRAwkZbEY43JNfqenQPHo7mNTt29i+NVVrBsnAa5ovrAgMBAAGjYzBhMA4GA1Ud
-DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T
-AQH/BAIwADAiBgNVHREEGzAZgglsb2NhbGhvc3SCBnBlYmJsZYcEfwAAATANBgkq
-hkiG9w0BAQsFAAOCAQEAYIkXff8H28KS0KyLHtbbSOGU4sujHHVwiVXSATACsNAE
-D0Qa8hdtTQ6AUqA6/n8/u1tk0O4rPE/cTpsM3IJFX9S3rZMRsguBP7BSr1Lq/XAB
-7JP/CNHt+Z9aKCKcg11wIX9/B9F7pyKM3TdKgOpqXGV6TMuLjg5PlYWI/07lVGFW
-/mSJDRs8bSCFmbRtEqc4lpwlrpz+kTTnX6G7JDLfLWYw/xXVqwFfdengcDTHCc8K
-wtgGq/Gu6vcoBxIO3jaca+OIkMfxxXmGrcNdseuUCa3RMZ8Qy03DqGu6Y6XQyK4B
-W8zIG6H9SVKkAznM2yfYhW8v2ktcaZ95/OBHY97ZIw==
+MIIDMDCCAhigAwIBAgIILDt8c2fMw2IwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgNTM0NWU2MB4XDTI1MDkwMzIzNDAwNVoXDTI3MTAw
+MzIzNDAwNVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAmxTFtw113RK70H9pQmdKs9AxhFmnQ6BdDtp3jOZlWlUO
+0BltMXOUML5905etgtCbcC6RdKRtgSAiDfgx3VWiFMJH++4gUtnaB9SN8GhNSPBp
+FfSa2JhWPo9HQNUsAZqlGTV4SzcGRqtWvdZxUiOfQ2TcvyXIqsaD19ivvqI1NhT6
+bl3tredTZlzLLM6Wvkw6hfyHrJAPQP8LOlCIeDM4YIce6Gstv6qo9iCD4wJiY4u9
+5HVL7RK8t8JpZAb7VR+dPhbHEvVpjwuYd5Q05OZ280gFyrhbrKLbqst104GOQT4k
+QMJGWxGONyTX6np0Dx6O5jU7dvYvjVVawbJwGuaL6wIDAQABo3oweDAOBgNVHQ8B
+Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNV
+HSMEGDAWgBSu8RGpErgYUoYnQuwCq+/ggTiEjDAiBgNVHREEGzAZgglsb2NhbGhv
+c3SCBnBlYmJsZYcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAAB0gkekXCNOwqWmY
+vQ2lLJ8Zk2WzQ9B+VOC27IgxEEuskZyCpyXAbJB9sCGQWZhAARyaI4SPRGGagcug
+d1SwDWdPGeSJzF3aDnXDYoP9Zw2KqiqVZTngeoiw8Yn0F8PNriANwRLybouX7mMc
+4V7T5+2k4SUs7pFH4KO0a0XBCcjXDjdKuBljftRTXCHzJzfRtmieCCuZlpnp5sHx
+hKa/uxKGyyZB+4Y3MrzsiQSCBOr9G4TH9RofmNcawl+tsVe08zLV/XVhrbakKEs7
+Y7MGHSj3BkPFF32NObc0znqWzTaUD9hU+rXWGANM4sXd4dagdnxfrb7i0WYhcUFj
+9Try8Q==
-----END CERTIFICATE-----
diff --git a/e2e/fixtures/certs/pebble.minica.pem b/e2e/fixtures/certs/pebble.minica.pem
index a69a4c419..5578b5b55 100644
--- a/e2e/fixtures/certs/pebble.minica.pem
+++ b/e2e/fixtures/certs/pebble.minica.pem
@@ -1,19 +1,20 @@
-----BEGIN CERTIFICATE-----
-MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
-AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx
-MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi
+MIIDPzCCAiegAwIBAgIIU0Xm9UFdQxUwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgNTM0NWU2MCAXDTI1MDkwMzIzNDAwNVoYDzIxMjUw
+OTAzMjM0MDA1WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA1MzQ1ZTYwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
-AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
-BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v
-d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF
-WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll
-xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix
-Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82
-2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF
-p9BI7gVKtWSZYegicA==
+AAGjezB5MA4GA1UdDwEB/wQEAwIChDATBgNVHSUEDDAKBggrBgEFBQcDATASBgNV
+HRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSu8RGpErgYUoYnQuwCq+/ggTiEjDAf
+BgNVHSMEGDAWgBSu8RGpErgYUoYnQuwCq+/ggTiEjDANBgkqhkiG9w0BAQsFAAOC
+AQEAXDVYov1+f6EL7S41LhYQkEX/GyNNzsEvqxE9U0+3Iri5JfkcNOiA9O9L6Z+Y
+bqcsXV93s3vi4r4WSWuc//wHyJYrVe5+tK4nlFpbJOvfBUtnoBDyKNxXzZCxFJVh
+f9uc8UejRfQMFbDbhWY/x83y9BDufJHHq32OjCIN7gp2UR8rnfYvlz7Zg4qkJBsn
+DG4dwd+pRTCFWJOVIG0JoNhK3ZmE7oJ1N4H38XkZ31NPcMksKxpsLLIS9+mosZtg
+4olL7tMPJklx5ZaeMFaKRDq4Gdxkbw4+O4vRgNm3Z8AXWKknOdfgdpqLUPPhRcP4
+v1lhy71EhBuXXwRQJry0lTdF+w==
-----END CERTIFICATE-----
diff --git a/e2e/fixtures/csr.cert b/e2e/fixtures/csr.cert
deleted file mode 100644
index cece7ddec..000000000
--- a/e2e/fixtures/csr.cert
+++ /dev/null
@@ -1,16 +0,0 @@
------BEGIN CERTIFICATE REQUEST-----
-MIICfjCCAWYCAQAwEzERMA8GA1UEAxMIYWNtZS53dGYwggEiMA0GCSqGSIb3DQEB
-AQUAA4IBDwAwggEKAoIBAQDAhXnho1w9OPHWs4YSMahYbG4Ui1K6hsHytBZfhsz0
-09igSWzHMEFZYHZJVuSr60enuJSZRhgwDjfhQWSUgHgKItLPnlNVYM6RhVaW0WfT
-w6CpmE2AuH3WuQbrR2he1Nt0xfUJla+VWOFZuW7GhgBiV5iWBvdLv6Ztgh8eATjo
-2vG2R+KuSUzrm6h+sb3nUR28OYunZ3vESjNwnL3/D/1th2rFpe3EA3em1HArJdXN
-F4eclciun5Js17AS9tdoHEEZMMBWyViiuz3CQlh+YD2qAvqaubanWNa+r+iijMvd
-4HlDHC99LTk6TJoSKoL+E/OGKmntLqmBJ1UrCFgvnw3DAgMBAAGgJjAkBgkqhkiG
-9w0BCQ4xFzAVMBMGA1UdEQQMMAqCCGFjbWUud3RmMA0GCSqGSIb3DQEBCwUAA4IB
-AQAfBLR8njftxf15V49szNsgNaG7Y5UQFwgl8pyiIaanGvX1DE0BtU1RB/w7itzX
-wW5W/wjielEbs1XkI2uz3hkebvHVA1QpA7bbrX01WonS18xCkiRDj8ZqFEG4vEGa
-HswzGUfq2v0gCOIPpVGE+8Q2Y7In5zwEfev+5DkHox4/vgwMhyPMI+y7jKtdG/dV
-U58SFnt/F1raoSmR6vfDcAFXm/L8LXEkxqqefFbhiRHRqQar1Wr15BH//swmNzEW
-5SVCCHcyIqreSua8uPjBcJ8aYVLniX6DMRyYv4ij/PSvSQy9xJDewLqR235WfTd/
-tk4hhJaqizKDpsvB+UFod5o5
------END CERTIFICATE REQUEST-----
diff --git a/e2e/fixtures/csr.raw b/e2e/fixtures/csr.raw
deleted file mode 100644
index f4bb701cd..000000000
Binary files a/e2e/fixtures/csr.raw and /dev/null differ
diff --git a/e2e/fixtures/pebble-config-dns.json b/e2e/fixtures/pebble-config-dns.json
index 4834825a4..dd5b63142 100644
--- a/e2e/fixtures/pebble-config-dns.json
+++ b/e2e/fixtures/pebble-config-dns.json
@@ -4,6 +4,16 @@
"certificate": "fixtures/certs/localhost/cert.pem",
"privateKey": "fixtures/certs/localhost/key.pem",
"httpPort": 5004,
- "tlsPort": 5003
+ "tlsPort": 5003,
+ "profiles": {
+ "default": {
+ "description": "The profile you know and love",
+ "validityPeriod": 7776000
+ },
+ "shortlived": {
+ "description": "A short-lived cert profile, without actual enforcement",
+ "validityPeriod": 518400
+ }
+ }
}
}
diff --git a/e2e/fixtures/pebble-config.json b/e2e/fixtures/pebble-config.json
index f2abe6ab8..dcf659b4c 100644
--- a/e2e/fixtures/pebble-config.json
+++ b/e2e/fixtures/pebble-config.json
@@ -4,6 +4,16 @@
"certificate": "fixtures/certs/localhost/cert.pem",
"privateKey": "fixtures/certs/localhost/key.pem",
"httpPort": 5002,
- "tlsPort": 5001
+ "tlsPort": 5001,
+ "profiles": {
+ "default": {
+ "description": "The profile you know and love",
+ "validityPeriod": 7776000
+ },
+ "shortlived": {
+ "description": "A short-lived cert profile, without actual enforcement",
+ "validityPeriod": 518400
+ }
+ }
}
}
diff --git a/e2e/loader/loader.go b/e2e/loader/loader.go
index 7e8ff539f..3e63302a3 100644
--- a/e2e/loader/loader.go
+++ b/e2e/loader/loader.go
@@ -1,7 +1,9 @@
package loader
import (
+ "bufio"
"bytes"
+ "context"
"crypto/tls"
"errors"
"fmt"
@@ -15,6 +17,7 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/wait"
+ "github.com/ldez/grignotin/goenv"
)
const (
@@ -40,12 +43,14 @@ func (l *EnvLoader) MainTest(m *testing.M) int {
if _, e2e := os.LookupEnv("LEGO_E2E_TESTS"); !e2e {
fmt.Fprintln(os.Stderr, "skipping test: e2e tests are disabled. (no 'LEGO_E2E_TESTS' env var)")
fmt.Println("PASS")
+
return 0
}
if _, err := exec.LookPath("git"); err != nil {
fmt.Fprintln(os.Stderr, "skipping because git command not found")
fmt.Println("PASS")
+
return 0
}
@@ -53,6 +58,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int {
if _, err := exec.LookPath(cmdNamePebble); err != nil {
fmt.Fprintln(os.Stderr, "skipping because pebble binary not found")
fmt.Println("PASS")
+
return 0
}
}
@@ -61,6 +67,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int {
if _, err := exec.LookPath(cmdNameChallSrv); err != nil {
fmt.Fprintln(os.Stderr, "skipping because challtestsrv binary not found")
fmt.Println("PASS")
+
return 0
}
}
@@ -73,6 +80,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int {
legoBinary, tearDown, err := buildLego()
defer tearDown()
+
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
@@ -87,7 +95,7 @@ func (l *EnvLoader) MainTest(m *testing.M) int {
return m.Run()
}
-func (l *EnvLoader) RunLego(arg ...string) ([]byte, error) {
+func (l *EnvLoader) RunLegoCombinedOutput(arg ...string) ([]byte, error) {
cmd := exec.Command(l.lego, arg...)
cmd.Env = l.LegoOptions
@@ -96,12 +104,44 @@ func (l *EnvLoader) RunLego(arg ...string) ([]byte, error) {
return cmd.CombinedOutput()
}
+func (l *EnvLoader) RunLego(arg ...string) error {
+ cmd := exec.Command(l.lego, arg...)
+ cmd.Env = l.LegoOptions
+
+ fmt.Printf("$ %s\n", strings.Join(cmd.Args, " "))
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("create pipe: %w", err)
+ }
+
+ cmd.Stderr = cmd.Stdout
+
+ err = cmd.Start()
+ if err != nil {
+ return fmt.Errorf("start command: %w", err)
+ }
+
+ scanner := bufio.NewScanner(stdout)
+ for scanner.Scan() {
+ println(scanner.Text())
+ }
+
+ err = cmd.Wait()
+ if err != nil {
+ return fmt.Errorf("wait command: %w", err)
+ }
+
+ return nil
+}
+
func (l *EnvLoader) launchPebble() func() {
if l.PebbleOptions == nil {
return func() {}
}
pebble, outPebble := l.cmdPebble()
+
go func() {
err := pebble.Run()
if err != nil {
@@ -114,6 +154,7 @@ func (l *EnvLoader) launchPebble() func() {
if err != nil {
fmt.Println(err)
}
+
fmt.Println(outPebble.String())
}
}
@@ -126,11 +167,13 @@ func (l *EnvLoader) cmdPebble() (*exec.Cmd, *bytes.Buffer) {
if err != nil {
panic(err)
}
+
cmd.Dir = dir
fmt.Printf("$ %s\n", strings.Join(cmd.Args, " "))
var b bytes.Buffer
+
cmd.Stdout = &b
cmd.Stderr = &b
@@ -139,6 +182,7 @@ func (l *EnvLoader) cmdPebble() (*exec.Cmd, *bytes.Buffer) {
func pebbleHealthCheck(options *CmdOption) {
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
+
err := wait.For("pebble", 10*time.Second, 500*time.Millisecond, func() (bool, error) {
resp, err := client.Get(options.HealthCheckURL)
if err != nil {
@@ -162,6 +206,7 @@ func (l *EnvLoader) launchChallSrv() func() {
}
challtestsrv, outChalSrv := l.cmdChallSrv()
+
go func() {
err := challtestsrv.Run()
if err != nil {
@@ -174,6 +219,7 @@ func (l *EnvLoader) launchChallSrv() func() {
if err != nil {
fmt.Println(err)
}
+
fmt.Println(outChalSrv.String())
}
}
@@ -184,6 +230,7 @@ func (l *EnvLoader) cmdChallSrv() (*exec.Cmd, *bytes.Buffer) {
fmt.Printf("$ %s\n", strings.Join(cmd.Args, " "))
var b bytes.Buffer
+
cmd.Stdout = &b
cmd.Stderr = &b
@@ -195,6 +242,7 @@ func buildLego() (string, func(), error) {
if err != nil {
return "", func() {}, err
}
+
defer func() { _ = os.Chdir(here) }()
buildPath, err := os.MkdirTemp("", "lego_test")
@@ -228,6 +276,7 @@ func buildLego() (string, func(), error) {
return binary, func() {
_ = os.RemoveAll(buildPath)
+
CleanLegoFiles()
}, nil
}
@@ -249,6 +298,7 @@ func build(binary string) error {
if err != nil {
return err
}
+
cmd := exec.Command(toolPath, "build", "-o", binary)
output, err := cmd.CombinedOutput()
@@ -279,8 +329,13 @@ func goTool() (string, error) {
exeSuffix = ".exe"
}
- path := filepath.Join(runtime.GOROOT(), "bin", "go"+exeSuffix)
- if _, err := os.Stat(path); err == nil {
+ goRoot, err := goenv.GetOne(context.Background(), goenv.GOROOT)
+ if err != nil {
+ return "", fmt.Errorf("cannot find go root: %w", err)
+ }
+
+ path := filepath.Join(goRoot, "bin", "go"+exeSuffix)
+ if _, err = os.Stat(path); err == nil {
return path, nil
}
@@ -295,6 +350,7 @@ func goTool() (string, error) {
func CleanLegoFiles() {
cmd := exec.Command("rm", "-rf", ".lego")
fmt.Printf("$ %s\n", strings.Join(cmd.Args, " "))
+
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(string(output))
diff --git a/e2e/readme.md b/e2e/readme.md
index 746b9d726..171170507 100644
--- a/e2e/readme.md
+++ b/e2e/readme.md
@@ -1,20 +1,9 @@
# E2E tests
-How to run:
-
-- Add the following entries to your `/etc/hosts`:
-```
-127.0.0.1 acme.wtf
-127.0.0.1 lego.wtf
-127.0.0.1 acme.lego.wtf
-127.0.0.1 légô.wtf
-127.0.0.1 xn--lg-bja9b.wtf
-```
-
- Install [Pebble](https://github.com/letsencrypt/pebble):
```bash
-go install github.com/letsencrypt/pebble/v2/cmd/pebble@main
-go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@main
+go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.9.0
+go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0
```
- Launch tests:
diff --git a/go.mod b/go.mod
index ed000aac2..b8e88428e 100644
--- a/go.mod
+++ b/go.mod
@@ -1,210 +1,229 @@
module github.com/go-acme/lego/v4
-go 1.22.0
+go 1.24.0
require (
- cloud.google.com/go/compute/metadata v0.5.2
+ cloud.google.com/go/compute/metadata v0.9.0
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible
- github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0
- github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0
- github.com/Azure/go-autorest/autorest v0.11.29
+ github.com/Azure/go-autorest/autorest v0.11.30
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13
- github.com/Azure/go-autorest/autorest/to v0.4.0
- github.com/BurntSushi/toml v1.4.0
- github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87
- github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2
- github.com/aliyun/alibaba-cloud-sdk-go v1.63.47
- github.com/aws/aws-sdk-go-v2 v1.32.3
- github.com/aws/aws-sdk-go-v2/config v1.28.1
- github.com/aws/aws-sdk-go-v2/credentials v1.17.42
- github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.3
- github.com/aws/aws-sdk-go-v2/service/route53 v1.46.0
- github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2
- github.com/aws/aws-sdk-go-v2/service/sts v1.32.3
- github.com/cenkalti/backoff/v4 v4.3.0
- github.com/civo/civogo v0.3.11
- github.com/cloudflare/cloudflare-go v0.108.0
- github.com/cpu/goacmedns v0.1.1
- github.com/dnsimple/dnsimple-go v1.7.0
- github.com/exoscale/egoscale/v3 v3.1.7
- github.com/go-jose/go-jose/v4 v4.0.4
- github.com/go-viper/mapstructure/v2 v2.2.1
- github.com/google/go-querystring v1.1.0
+ github.com/Azure/go-autorest/autorest/to v0.4.1
+ github.com/BurntSushi/toml v1.6.0
+ github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0
+ github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15
+ github.com/alibabacloud-go/tea v1.4.0
+ github.com/aliyun/credentials-go v1.4.7
+ github.com/aws/aws-sdk-go-v2 v1.41.1
+ github.com/aws/aws-sdk-go-v2/config v1.32.8
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.8
+ github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11
+ github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6
+ github.com/aziontech/azionapi-go-sdk v0.144.0
+ github.com/baidubce/bce-sdk-go v0.9.260
+ github.com/cenkalti/backoff/v5 v5.0.3
+ github.com/dnsimple/dnsimple-go/v4 v4.0.0
+ github.com/exoscale/egoscale/v3 v3.1.33
+ github.com/go-acme/alidns-20150109/v4 v4.7.0
+ github.com/go-acme/esa-20240910/v2 v2.48.0
+ github.com/go-acme/jdcloud-sdk-go v1.64.0
+ github.com/go-acme/tencentclouddnspod v1.3.24
+ github.com/go-acme/tencentedgdeone v1.3.38
+ github.com/go-jose/go-jose/v4 v4.1.3
+ github.com/go-viper/mapstructure/v2 v2.5.0
+ github.com/google/go-cmp v0.7.0
+ github.com/google/go-querystring v1.2.0
+ github.com/google/uuid v1.6.0
github.com/gophercloud/gophercloud v1.14.1
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56
- github.com/hashicorp/go-retryablehttp v0.7.7
- github.com/hashicorp/go-version v1.7.0
- github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.120
+ github.com/hashicorp/go-retryablehttp v0.7.8
+ github.com/hashicorp/go-version v1.8.0
+ github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df
- github.com/infobloxopen/infoblox-go-client v1.1.1
+ github.com/infobloxopen/infoblox-go-client/v2 v2.10.0
github.com/labbsr0x/bindman-dns-webhook v1.0.2
- github.com/linode/linodego v1.42.0
+ github.com/ldez/grignotin v0.10.1
+ github.com/linode/linodego v1.65.0
github.com/liquidweb/liquidweb-go v1.6.4
github.com/mattn/go-isatty v0.0.20
- github.com/miekg/dns v1.1.62
+ github.com/miekg/dns v1.1.72
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/bunny-go v0.0.0-20240207213615-dde5bf4577a3
- github.com/nrdcg/desec v0.8.0
+ github.com/namedotcom/go/v4 v4.0.2
+ github.com/nrdcg/auroradns v1.2.0
+ github.com/nrdcg/bunny-go v0.1.0
+ github.com/nrdcg/desec v0.11.1
github.com/nrdcg/dnspod-go v0.4.0
- github.com/nrdcg/freemyip v0.2.0
- github.com/nrdcg/goinwx v0.10.0
- github.com/nrdcg/mailinabox v0.2.0
- github.com/nrdcg/namesilo v0.2.1
+ github.com/nrdcg/freemyip v0.3.0
+ github.com/nrdcg/goacmedns v0.2.0
+ github.com/nrdcg/goinwx v0.12.0
+ github.com/nrdcg/mailinabox v0.3.0
+ github.com/nrdcg/namesilo v0.5.0
github.com/nrdcg/nodion v0.1.0
+ github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2
+ github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2
github.com/nrdcg/porkbun v0.4.0
+ github.com/nrdcg/vegadns v0.3.0
github.com/nzdjb/go-metaname v1.0.0
- github.com/oracle/oci-go-sdk/v65 v65.77.1
- github.com/ovh/go-ovh v1.6.0
- github.com/pquerna/otp v1.4.0
+ github.com/ovh/go-ovh v1.9.0
+ github.com/pquerna/otp v1.5.0
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2
github.com/regfish/regfish-dnsapi-go v0.1.1
- github.com/sacloud/api-client-go v0.2.10
- github.com/sacloud/iaas-api-go v1.12.0
- github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30
+ github.com/sacloud/api-client-go v0.3.3
+ github.com/sacloud/iaas-api-go v1.23.1
+ github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36
github.com/selectel/domains-go v1.1.0
- github.com/selectel/go-selvpcclient/v3 v3.1.1
- github.com/softlayer/softlayer-go v1.1.7
- github.com/stretchr/testify v1.9.0
- github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1034
- github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1034
- github.com/transip/gotransip/v6 v6.26.0
- github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec
- github.com/urfave/cli/v2 v2.27.5
- github.com/vinyldns/go-vinyldns v0.9.16
- github.com/volcengine/volc-sdk-golang v1.0.183
- github.com/vultr/govultr/v3 v3.9.1
- github.com/yandex-cloud/go-genproto v0.0.0-20241101135610-76a0cfc1a773
- github.com/yandex-cloud/go-sdk v0.0.0-20241101143304-947cf519f6bd
- golang.org/x/crypto v0.28.0
- golang.org/x/net v0.30.0
- golang.org/x/oauth2 v0.23.0
- golang.org/x/time v0.7.0
- google.golang.org/api v0.204.0
- gopkg.in/ns1/ns1-go.v2 v2.12.2
+ github.com/selectel/go-selvpcclient/v4 v4.1.0
+ github.com/softlayer/softlayer-go v1.2.1
+ github.com/stretchr/testify v1.11.1
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48
+ github.com/transip/gotransip/v6 v6.26.1
+ github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419
+ github.com/urfave/cli/v2 v2.27.7
+ github.com/vinyldns/go-vinyldns v0.9.17
+ github.com/volcengine/volc-sdk-golang v1.0.237
+ github.com/vultr/govultr/v3 v3.27.0
+ github.com/yandex-cloud/go-genproto v0.54.0
+ github.com/yandex-cloud/go-sdk/services/dns v0.0.36
+ github.com/yandex-cloud/go-sdk/v2 v2.56.0
+ golang.org/x/crypto v0.48.0
+ golang.org/x/net v0.50.0
+ golang.org/x/oauth2 v0.35.0
+ golang.org/x/text v0.34.0
+ golang.org/x/time v0.14.0
+ google.golang.org/api v0.267.0
+ gopkg.in/ns1/ns1-go.v2 v2.17.2
gopkg.in/yaml.v2 v2.4.0
- software.sslmate.com/src/go-pkcs12 v0.5.0
+ software.sslmate.com/src/go-pkcs12 v0.7.0
)
require (
- cloud.google.com/go/auth v0.10.0 // indirect
- cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect
+ cloud.google.com/go/auth v0.18.1 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
- github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // 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.2.2 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 // indirect
- github.com/aws/smithy-go v1.22.0 // indirect
- github.com/benbjohnson/clock v1.3.0 // indirect
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
+ github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
+ github.com/alibabacloud-go/debug v1.0.1 // indirect
+ github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
+ github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
+ github.com/aws/smithy-go v1.24.0 // indirect
+ github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/clbanning/mxj/v2 v2.7.0 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
+ github.com/fatih/color v1.16.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/gabriel-vasile/mimetype v1.4.2 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect
- github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.16.0 // indirect
- github.com/go-resty/resty/v2 v2.13.1 // indirect
- github.com/goccy/go-json v0.10.3 // indirect
- github.com/gofrs/flock v0.12.1 // indirect
- github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
- github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/google/s2a-go v0.1.8 // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
- github.com/googleapis/gax-go/v2 v2.13.0 // indirect
- github.com/hashicorp/errwrap v1.0.0 // indirect
+ github.com/go-playground/validator/v10 v10.23.0 // indirect
+ github.com/go-resty/resty/v2 v2.17.1 // indirect
+ github.com/goccy/go-yaml v1.9.8 // indirect
+ github.com/gofrs/flock v0.13.0 // indirect
+ github.com/gofrs/uuid v4.4.0+incompatible // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
+ github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
- github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
- github.com/jmespath/go-jmespath v0.4.0 // indirect
- github.com/json-iterator/go v1.1.12 // indirect
+ github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // 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/leodido/go-urn v1.2.4 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-colorable v0.1.13 // 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/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
- github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
+ github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
- github.com/sacloud/go-http v0.1.8 // indirect
- github.com/sacloud/packages-go v0.0.10 // indirect
+ github.com/sacloud/go-http v0.1.9 // indirect
+ github.com/sacloud/packages-go v0.0.12 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
- github.com/shopspring/decimal v1.3.1 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/smartystreets/assertions v1.0.1 // 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/sony/gobreaker v0.5.0 // indirect
+ github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
- github.com/spf13/cast v1.6.0 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
+ github.com/spf13/cast v1.7.0 // indirect
+ github.com/spf13/pflag v1.0.7 // indirect
github.com/spf13/viper v1.18.2 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
- go.mongodb.org/mongo-driver v1.12.0 // indirect
- go.opencensus.io v0.24.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
- go.opentelemetry.io/otel v1.29.0 // indirect
- go.opentelemetry.io/otel/metric v1.29.0 // indirect
- go.opentelemetry.io/otel/trace v1.29.0 // indirect
- go.uber.org/atomic v1.9.0 // indirect
- go.uber.org/multierr v1.9.0 // indirect
- go.uber.org/ratelimit v0.3.0 // indirect
- golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect
- golang.org/x/mod v0.21.0 // indirect
- golang.org/x/sync v0.8.0 // indirect
- golang.org/x/sys v0.26.0 // indirect
- golang.org/x/text v0.19.0 // indirect
- golang.org/x/tools v0.25.0 // indirect
- google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
- google.golang.org/grpc v1.67.1 // indirect
- google.golang.org/protobuf v1.35.1 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
+ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
+ go.mongodb.org/mongo-driver v1.13.1 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
+ go.opentelemetry.io/otel v1.39.0 // indirect
+ go.opentelemetry.io/otel/metric v1.39.0 // indirect
+ go.opentelemetry.io/otel/trace v1.39.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/ratelimit v0.3.1 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
+ golang.org/x/mod v0.32.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/tools v0.41.0 // indirect
+ golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
+ google.golang.org/grpc v1.78.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+
+retract v4.30.0 // Problem related to misuse of sycalls by aliyun/credentials-go
diff --git a/go.sum b/go.sum
index 40bdeb669..f5b87c9fe 100644
--- a/go.sum
+++ b/go.sum
@@ -13,18 +13,18 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go/auth v0.10.0 h1:tWlkvFAh+wwTOzXIjrwM64karR1iTBZ/GRr0S/DULYo=
-cloud.google.com/go/auth v0.10.0/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
-cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk=
-cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
+cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
+cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
-cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
@@ -42,14 +42,14 @@ github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYs
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0/go.mod h1:YL1xnZ6QejvQHWJrX/AvhFl4WW4rqHVoKspWNVwFk0M=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvULkDNfdXOgrjtg6UYJPFBJyuEcRCAw=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
@@ -63,8 +63,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.
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.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA=
-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 v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE=
+github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs=
github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ=
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=
@@ -77,28 +77,25 @@ github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSY
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/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc=
+github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
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-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
-github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
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/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
@@ -106,69 +103,125 @@ github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
-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/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 h1:h/33OxYLqBk0BYmEbSUy7MlvgQR/m1w1/7OJFKoPL1I=
+github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0/go.mod h1:rvh3imDA6EaQi+oM/GQHkQAOHbXPKJ7EWJvfjuw141Q=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.47 h1:B8ApNodSpIM5ST9INmhMG4d0rRwNY/63/XjXUDO/XIo=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.47/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
+github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
+github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
+github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
+github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
+github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
+github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
+github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
+github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
+github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
+github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
+github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
+github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
+github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
+github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 h1:Mubp9hXZMTPWZK+WxrR+kKOVFp4Q/PDZrIIM7ByXI9Y=
+github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
+github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
+github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
+github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
+github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
+github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
+github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
+github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
+github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
+github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
+github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
+github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
+github.com/alibabacloud-go/openapi-util v0.1.1 h1:ujGErJjG8ncRW6XtBBMphzHTvCxn4DjrVw4m04HsS28=
+github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw=
+github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
+github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
+github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
+github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
+github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
+github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
+github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
+github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
+github.com/alibabacloud-go/tea v1.4.0 h1:MSKhu/kWLPX7mplWMngki8nNt+CyUZ+kfkzaR5VpMhA=
+github.com/alibabacloud-go/tea v1.4.0/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
+github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
+github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
+github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
+github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
+github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
+github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
+github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
+github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
+github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
+github.com/aliyun/credentials-go v1.4.7 h1:T17dLqEtPUFvjDRRb5giVvLh6dFT8IcNFJJb7MeyCxw=
+github.com/aliyun/credentials-go v1.4.7/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
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-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
-github.com/aws/aws-sdk-go-v2 v1.32.3 h1:T0dRlFBKcdaUPGNtkBSwHZxrtis8CQU17UpNBZYd0wk=
-github.com/aws/aws-sdk-go-v2 v1.32.3/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA=
-github.com/aws/aws-sdk-go-v2/config v1.28.1 h1:oxIvOUXy8x0U3fR//0eq+RdCKimWI900+SV+10xsCBw=
-github.com/aws/aws-sdk-go-v2/config v1.28.1/go.mod h1:bRQcttQJiARbd5JZxw6wG0yIK3eLeSCPdg6uqmmlIiI=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.42 h1:sBP0RPjBU4neGpIYyx8mkU2QqLPl5u9cmdTWVzIpHkM=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.42/go.mod h1:FwZBfU530dJ26rv9saAbxa9Ej3eF/AK0OAY86k13n4M=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 h1:68jFVtt3NulEzojFesM/WVarlFpCaXLKaBxDpzkQ9OQ=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18/go.mod h1:Fjnn5jQVIo6VyedMc0/EhPpfNlPl7dHV916O6B+49aE=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 h1:Jw50LwEkVjuVzE1NzkhNKkBf9cRN7MtE1F/b2cOKTUM=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22/go.mod h1:Y/SmAyPcOTmpeVaWSzSKiILfXTVJwrGmYZhcRbhWuEY=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 h1:981MHwBaRZM7+9QSR6XamDzF/o7ouUGxFzr+nVSIhrs=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22/go.mod h1:1RA1+aBEfn+CAB/Mh0MB6LsdCYCnjZm7tKXtnk499ZQ=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 h1:yV+hCAHZZYJQcwAaszoBNwLbPItHvApxT0kVIw6jRgs=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22/go.mod h1:kbR1TL8llqB1eGnVbybcA4/wgScxdylOdyAd51yxPdw=
+github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
+github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
+github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
+github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.3 h1:kT6BcZsmMtNkP/iYMcRG+mIEA/IbeiUimXtGmqF39y0=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.3/go.mod h1:Z8uGua2k4PPaGOYn66pK02rhMrot3Xk3tpBuUFPomZU=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 h1:qcxX0JYlgWH3hpPUnd6U0ikcl6LLA9sLkXE2w1fpMvY=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3/go.mod h1:cLSNEmI45soc+Ef8K/L+8sEA3A3pYFEYf5B5UI+6bH4=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3 h1:ZC7Y/XgKUxwqcdhO5LE8P6oGP1eh6xlQReWNKfhvJno=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3/go.mod h1:WqfO7M9l9yUAw0HcHaikwRd/H6gzYdz7vjejCA5e2oY=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.3 h1:lcsqV11EaB74iNKr/PaXV0Og1D/lCZIhIf+kPucTfPw=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.3/go.mod h1:IyYNP3fIP5/BvFKqQFj7wwQnKuH0wndcv6j4DyG9pRk=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.46.0 h1:AaOWmXBSDSIEsTzx8Y2nYAxckgmBPNiRU5mjn/a9ynI=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.46.0/go.mod h1:IN9bx4yLAa3a3J7A41skQefcYObNv6ARAd2i5WxvGKg=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2 h1:p9TNFL8bFUMd+38YIpTAXpoxyz0MxC7FlbFEH4P4E1U=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2/go.mod h1:fNjyo0Coen9QTwQLWeV6WO2Nytwiu+cCcWaTdKCAqqE=
-github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 h1:UTpsIf0loCIWEbrqdLb+0RxnTXfWh2vhw4nQmFi4nPc=
-github.com/aws/aws-sdk-go-v2/service/sso v1.24.3/go.mod h1:FZ9j3PFHHAR+w0BSEjK955w5YD2UwB/l/H0yAK3MJvI=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 h1:2YCmIXv3tmiItw0LlYf6v7gEHebLY45kBEnPezbUKyU=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3/go.mod h1:u19stRyNPxGhj6dRm+Cdgu6N75qnbW7+QN0q0dsAk58=
-github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 h1:wVnQ6tigGsRqSWDEEyH6lSAJ9OyFUsSnbaUWChuSGzs=
-github.com/aws/aws-sdk-go-v2/service/sts v1.32.3/go.mod h1:VZa9yTFyj4o10YGsmDO4gbQJUvvhY72fhumT8W4LqsE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
+github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 h1:VM5e5M39zRSs+aT0O9SoxHjUXqXxhbw3Yi0FdMQWPIc=
+github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11/go.mod h1:0jvzYPIQGCpnY/dmdaotTk2JH4QuBlnW0oeyrcGLWJ4=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
-github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
-github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
+github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
+github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
+github.com/aziontech/azionapi-go-sdk v0.144.0 h1:T+/w18o+FCiZsk3Z0ACBVVe7c/5EGLG15S3P8JfuPfo=
+github.com/aziontech/azionapi-go-sdk v0.144.0/go.mod h1:OKxP/R0iVXnJJakYwMhh2BGAXnud8Ruy55Ak9ANuWoU=
+github.com/baidubce/bce-sdk-go v0.9.260 h1:1v1+2GTP+NGK3L24rJ+bnoiTaDaIy2YoaUM+ot2GTcw=
+github.com/baidubce/bce-sdk-go v0.9.260/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
-github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
-github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
+github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
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=
@@ -182,8 +235,9 @@ github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq
github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -194,12 +248,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
-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/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
+github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
+github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cloudflare/cloudflare-go v0.108.0 h1:C4Skfjd8I8X3uEOGmQUT4/iGyZcWdkIU7HwvMoLkEE0=
-github.com/cloudflare/cloudflare-go v0.108.0/go.mod h1:m492eNahT/9MsN7Ppnoge8AaI7QhVFtEgVm3I9HJFeU=
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/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
@@ -209,25 +261,21 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/cpu/goacmedns v0.1.1 h1:DM3H2NiN2oam7QljgGY5ygy4yDXhK5Z4JUnqaugs2C4=
-github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
-github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
+github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
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 v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY=
-github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8=
+github.com/dnsimple/dnsimple-go/v4 v4.0.0 h1:nUCICZSyZDiiqimAAL+E8XL+0sKGks5VRki5S8XotRo=
+github.com/dnsimple/dnsimple-go/v4 v4.0.0/go.mod h1:AXT2yfAFOntJx6iMeo1J/zKBw0ggXFYBt4e97dqqPnc=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
@@ -241,10 +289,11 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/exoscale/egoscale/v3 v3.1.7 h1:Q6p9tOVY0IiOW0fUpaPQWY7ggGEuSPZLAGxFgDd2sCE=
-github.com/exoscale/egoscale/v3 v3.1.7/go.mod h1:GHKucK/J26v8PGWztGdhxWNMjrjG9PbelxKCJ4YI11Q=
+github.com/exoscale/egoscale/v3 v3.1.33 h1:5Lk/pwZ+K0sjNu9obS0VYPfhZQffRkvvO0BpdPoir4o=
+github.com/exoscale/egoscale/v3 v3.1.33/go.mod h1:0iY8OxgHJCS5TKqDNhwOW95JBKCnBZl3YGU4Yt+NqkU=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@@ -262,20 +311,30 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
-github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQLAuuSdLjjQaQ=
+github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0=
+github.com/go-acme/esa-20240910/v2 v2.48.0 h1:muSDyhjDTejxUGe3FTthCPCqRaEdYY9cG3N/AmU52Lc=
+github.com/go-acme/esa-20240910/v2 v2.48.0/go.mod h1:shPb6hzc1rJL15IJBY8HQ4GZk4E8RC52+52twutEwIg=
+github.com/go-acme/jdcloud-sdk-go v1.64.0 h1:AW9j5khk8tRYbpBJPxKmqdwIqgLs2Fz3HUK3hn2YXjs=
+github.com/go-acme/jdcloud-sdk-go v1.64.0/go.mod h1:qc/m8HNX1Zgd7GAv2DSEinup8fwy3Ted3/VVx7LB5bU=
+github.com/go-acme/tencentclouddnspod v1.3.24 h1:uCSiOW1EJttcnOON+MVVyVDJguFL/Q4NIGkq1CrT9p8=
+github.com/go-acme/tencentclouddnspod v1.3.24/go.mod h1:RKcB2wSoZncjBA0OEFj59s1ko1XDy+ZsAtk+9uMxUF0=
+github.com/go-acme/tencentedgdeone v1.3.38 h1:5YsVl0H4A+cwtiUqR1eZbKFdr4OWfYp2KYJopifzKyQ=
+github.com/go-acme/tencentedgdeone v1.3.38/go.mod h1:yyjTKVmGpMtFv5HqGODqehHnZJ4KWAbG6dAiwWDgCDY=
github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
-github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs=
@@ -286,53 +345,59 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
+github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
-github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
-github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
-github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
+github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
+github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
+github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
+github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
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 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
-github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
-github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
-github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
-github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.9.8 h1:5gMyLUeU1/6zl+WFfR1hN7D2kf+1/eRGa7DFtToiBvQ=
+github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
-github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
-github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
+github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
+github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
+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/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
-github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
-github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
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-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
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=
@@ -357,6 +422,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
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/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -376,12 +443,14 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
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=
+github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
+github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -392,28 +461,29 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
+github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
-github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
+github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
+github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
-github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
+github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
+github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw=
github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE=
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
@@ -425,13 +495,10 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmg
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
-github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
-github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
@@ -446,11 +513,9 @@ github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjh
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.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
-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.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
-github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
-github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
+github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
@@ -460,8 +525,8 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
-github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
+github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -476,8 +541,8 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.120 h1:i+rlH2xzkEMGbol86Fq/ioxgAaOnX2vkH4i/bLptc5s=
-github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.120/go.mod h1:JWz2ujO9X3oU5wb6kXp+DpR2UuDj2SldDbX8T0FSuhI=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -485,20 +550,18 @@ github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df h1:MZf03xP9WdakyXhOWuAD5
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
-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/infobloxopen/infoblox-go-client/v2 v2.10.0 h1:AKsihjFT/t6Y0keEv3p59DACcOuh0inWXdUB0ZOzYH0=
+github.com/infobloxopen/infoblox-go-client/v2 v2.10.0/go.mod h1:NeNJpz09efw/edzqkVivGv1bWqBXTomqYBRFbP+XBqg=
github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
-github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
-github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
+github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
+github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
-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=
-github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
@@ -509,8 +572,9 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
+github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
@@ -519,14 +583,13 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
-github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs=
-github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
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=
@@ -548,10 +611,13 @@ github.com/labbsr0x/bindman-dns-webhook v1.0.2 h1:I7ITbmQPAVwrDdhd6dHKi+MYJTJqPC
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=
github.com/labbsr0x/goh v1.0.1/go.mod h1:8K2UhVoaWXcCU7Lxoa2omWnC8gyW8px7/lmO61c027w=
-github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
-github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
-github.com/linode/linodego v1.42.0 h1:ZSbi4MtvwrfB9Y6bknesorvvueBGGilcmh2D5dq76RM=
-github.com/linode/linodego v1.42.0/go.mod h1:2yzmY6pegPBDgx2HDllmt0eIk2IlzqcgK6NR0wFCFRY=
+github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
+github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7k=
+github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8=
github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs=
github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM=
github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ=
@@ -574,6 +640,7 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
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.16/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=
@@ -586,8 +653,8 @@ github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3N
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
-github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
-github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34=
github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M=
github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
@@ -618,8 +685,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g=
-github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8=
+github.com/namedotcom/go/v4 v4.0.2 h1:4gNkPaPRG/2tqFNUUof7jAVsA6vDutFutEOd7ivnDwA=
+github.com/namedotcom/go/v4 v4.0.2/go.mod h1:J6sVueHMb0qbarPgdhrzEVhEaYp+R1SCaTGl2s6/J1Q=
github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q=
github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY=
github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g=
@@ -627,28 +694,35 @@ github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/
github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
-github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
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/bunny-go v0.0.0-20240207213615-dde5bf4577a3 h1:ouZ2JWDl8IW5k1qugYbmpbmW8hn85Ig6buSMBRlz3KI=
-github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3/go.mod h1:ZwadWt7mVhMHMbAQ1w8IhDqtWO3eWqWq72W7trnaiE8=
-github.com/nrdcg/desec v0.8.0 h1:FJbRWUAluTCUi9nHFnhqPhLSIHiNnB9elZVWYgFtIqA=
-github.com/nrdcg/desec v0.8.0/go.mod h1:BsnYPtSlBttJL3Gyzv0kDH7zkk60obwThlnqiiKzn+o=
+github.com/nrdcg/auroradns v1.2.0 h1:Jg407vTdXZvZKsART9CNWMp8rQOyhBk04q0MsOf0YR4=
+github.com/nrdcg/auroradns v1.2.0/go.mod h1:hnByA4Z7MOmV4EPRw5eOmEaNRFavcCIz6kONpNxp9LI=
+github.com/nrdcg/bunny-go v0.1.0 h1:GAHTRpHaG/TxfLZlqoJ8OJFzw8rI74+jOTkzxWh0uHA=
+github.com/nrdcg/bunny-go v0.1.0/go.mod h1:u+C9dgsspgtWVaAz6QkyV17s9fxD8viwwKoxb9XMz1A=
+github.com/nrdcg/desec v0.11.1 h1:ilpKmCr4gGsLcyq3RHfHNmlRzm9fzT2XbWxoVaUCS0s=
+github.com/nrdcg/desec v0.11.1/go.mod h1:2LuxHlOcwML/7cntu0eimONmA1U+ZxFDAonoSXr4igQ=
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.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/freemyip v0.3.0 h1:0D2rXgvLwe2RRaVIjyUcQ4S26+cIS2iFwnhzDsEuuwc=
+github.com/nrdcg/freemyip v0.3.0/go.mod h1:c1PscDvA0ukBF0dwelU/IwOakNKnVxetpAQ863RMJoM=
+github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
+github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
+github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
+github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
+github.com/nrdcg/mailinabox v0.3.0 h1:PHkC1elKXKAjEvdx2HHFMgcEGZFqudAl7aU3L2JDhM4=
+github.com/nrdcg/mailinabox v0.3.0/go.mod h1:1eFIGcM4lI+AfFOUpbs548SFGz1ZWoMOGbECBmkghw4=
+github.com/nrdcg/namesilo v0.5.0 h1:6QNxT/XxE+f5B+7QlfWorthNzOzcGlBLRQxqi6YeBrE=
+github.com/nrdcg/namesilo v0.5.0/go.mod h1:4UkwlwQfDt74kSGmhLaDylnBrD94IfflnpoEaj6T2qw=
github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw=
github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms=
+github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 h1:OWijzl3nHUApvTivl+3+78dbBwmyEHOnb+W9m6ixGbk=
+github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
+github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 h1:9LsjN/zaIN7H8JE61NHpbWhxF0UGY96+kMlk3g8OvGU=
+github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2/go.mod h1:32vZH06TuwZSn+IDMO1qcDvC2vHVlzUALCwXGWPA+dc=
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
+github.com/nrdcg/vegadns v0.3.0 h1:11FQMw7xVIRUWO9o5+Z/5YZhmPWlm4oxUUH3F6EVqQU=
+github.com/nrdcg/vegadns v0.3.0/go.mod h1:NqSyRKZuJlAsv8VI/7rSubfPXN68NwaJ0aG9KxQVFVo=
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=
@@ -663,35 +737,30 @@ github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
-github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY=
-github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM=
+github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0=
+github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
-github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
-github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo=
-github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0=
+github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
+github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
-github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
-github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE=
-github.com/oracle/oci-go-sdk/v65 v65.77.1 h1:gqjTXIUWvTihkn470AclxSAMcR1JecqjD2IUtp+sDIU=
-github.com/oracle/oci-go-sdk/v65 v65.77.1/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0=
-github.com/ovh/go-ovh v1.6.0 h1:ixLOwxQdzYDx296sXcgS35TOPEahJkpjMGtzPadCjQI=
-github.com/ovh/go-ovh v1.6.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
+github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
+github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/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/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM=
+github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c=
+github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -708,8 +777,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
-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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.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=
@@ -742,40 +811,38 @@ github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 h1:dq90+d51/hQR
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
-github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
-github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/regfish/regfish-dnsapi-go v0.1.1 h1:TJFtbePHkd47q5GZwYl1h3DIYXmoxdLjW/SBsPtB5IE=
github.com/regfish/regfish-dnsapi-go v0.1.1/go.mod h1:ubIgXSfqarSnl3XHSn8hIFwFF3h0yrq0ZiWD93Y2VjY=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/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.10 h1:+rv3jDohD+pkdYwOTBiB+jZsM0xK3AxadXRzhp3q66c=
-github.com/sacloud/api-client-go v0.2.10/go.mod h1:Jj3CTy2+O4bcMedVDXlbHuqqche85HEPuVXoQFhLaRc=
-github.com/sacloud/go-http v0.1.8 h1:ynreWA/vnM8G2ksbMlmefBHsXURKPz49qlPRqQ9IQdw=
-github.com/sacloud/go-http v0.1.8/go.mod h1:7TL7TN1fnPKHsMifIqURDkGujnKViCgEz5Ei/LQdFK8=
-github.com/sacloud/iaas-api-go v1.12.0 h1:kqXFn3HzCiawlX6hVJb1GVqcSJqcmiGHB4Zp14sxiI8=
-github.com/sacloud/iaas-api-go v1.12.0/go.mod h1:SZLXeWOdXk3WReIS557sbU1gkOgrE4rseIBQV1B3b7o=
-github.com/sacloud/packages-go v0.0.10 h1:UiQGjy8LretewkRhsuna1TBM9Vz/l9FoYpQx+D+AOck=
-github.com/sacloud/packages-go v0.0.10/go.mod h1:f8QITBh9z4IZc4yE9j21Q8b0sXEMwRlRmhhjWeDVTYs=
+github.com/sacloud/api-client-go v0.3.3 h1:ZpSAyGpITA8UFO3Hq4qMHZLGuNI1FgxAxo4sqBnCKDs=
+github.com/sacloud/api-client-go v0.3.3/go.mod h1:0p3ukcWYXRCc2AUWTl1aA+3sXLvurvvDqhRaLZRLBwo=
+github.com/sacloud/go-http v0.1.9 h1:Xa5PY8/pb7XWhwG9nAeXSrYXPbtfBWqawgzxD5co3VE=
+github.com/sacloud/go-http v0.1.9/go.mod h1:DpDG+MSyxYaBwPJ7l3aKLMzwYdTVtC5Bo63HActcgoE=
+github.com/sacloud/iaas-api-go v1.23.1 h1:rjYG0vVoxWyETiwc7R8YdD7CIzs9vVNEOzu7w6dgGzc=
+github.com/sacloud/iaas-api-go v1.23.1/go.mod h1:EGIHOWRB9azOv7HPCVM8WpOEl28WIV9TNRbnEVg+Q3U=
+github.com/sacloud/packages-go v0.0.12 h1:MKeZNN3FQn1heqUSRBrbZw89YusZA1n4kammjMFZYvQ=
+github.com/sacloud/packages-go v0.0.12/go.mod h1:XNF5MCTWcHo9NiqWnYctVbASSSZR3ZOmmQORIzcurJ8=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
-github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 h1:yoKAVkEVwAqbGbR8n87rHQ1dulL25rKloGadb3vm770=
-github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30/go.mod h1:sH0u6fq6x4R5M7WxkoQFY/o7UaiItec0o1LinLCJNq8=
+github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=
+github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/selectel/domains-go v1.1.0 h1:futG50J43ALLKQAnZk9H9yOtLGnSUh7c5hSvuC5gSHo=
github.com/selectel/domains-go v1.1.0/go.mod h1:SugRKfq4sTpnOHquslCpzda72wV8u0cMBHx0C0l+bzA=
-github.com/selectel/go-selvpcclient/v3 v3.1.1 h1:C1q2LqqosiapoLpnGITGmysg0YCSQYDo2Gh69CioevM=
-github.com/selectel/go-selvpcclient/v3 v3.1.1/go.mod h1:NM7IXhh1IzqZ88DOw1Qc5Ez3tULLViXo95l5+rKPuyQ=
-github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
-github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/selectel/go-selvpcclient/v4 v4.1.0 h1:22lBp+rzg9g2MP4iiGhpVAcCt0kMv7I7uV1W3taLSvQ=
+github.com/selectel/go-selvpcclient/v4 v4.1.0/go.mod h1:eFhL1KUW159KOJVeGO7k/Uxl0TYd/sBkWXjuF5WxmYk=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@@ -784,21 +851,16 @@ github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic
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/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
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/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ=
-github.com/softlayer/softlayer-go v1.1.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE=
-github.com/softlayer/softlayer-go v1.1.7/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw=
+github.com/softlayer/softlayer-go v1.2.1 h1:8ucHxn5laVsVPb0/aMGnr6tOMt1I9BgEtU5mn70OGKw=
+github.com/softlayer/softlayer-go v1.2.1/go.mod h1:Gz9/ktcmB7Z8EJlu+QEJJpkv8lAmnhYdB9Tc6gedjmo=
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=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
-github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg=
-github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
+github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
+github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
@@ -808,14 +870,15 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
-github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
+github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -825,6 +888,7 @@ github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1Sd
github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@@ -837,57 +901,55 @@ 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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1034 h1:T7ewuO2DD+5R2LRpD2kTRy25aCkVDVdYkmmyUS63i08=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1034/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1034 h1:hXxv58/eSlDj80n0P0ISXh91pC/2vqurJNwn5SpXFPI=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1034/go.mod h1:hwTIplwF9IYWz5HQcyw0+R8aqJB0lEZB8sI0pIA5Htw=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.24/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.38/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 h1:bCs+z6dxRaHWm/C1D/XkSOcCZ0+W2+/6HmIXjpAj+fY=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
+github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/transip/gotransip/v6 v6.26.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550=
-github.com/transip/gotransip/v6 v6.26.0/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
+github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA=
+github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
-github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
-github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
-github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
-github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
-github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec h1:2s/ghQ8wKE+UzD/hf3P4Gd1j0JI9ncbxv+nsypPoUYI=
-github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec/go.mod h1:BZr7Qs3ku1ckpqed8tCRSqTlp8NAeZfAVpfx4OzXMss=
+github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 h1:/VaznPrb/b68e3iMvkr27fU7JqPKU4j7tIITZnjQX1k=
+github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419/go.mod h1:QN0/PdenvYWB0GRMz6JJbPeZz2Lph2iys1p8AFVHm2c=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
-github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
-github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
-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/volcengine/volc-sdk-golang v1.0.183 h1:V6M/lhgnBxZS3pLDNwMXSLw+i4VowphNCfVzai6JjWE=
-github.com/volcengine/volc-sdk-golang v1.0.183/go.mod h1:u0VtPvlXWpXDTmc9IHkaW1q+5Jjwus4oAqRhNMDRInE=
-github.com/vultr/govultr/v3 v3.9.1 h1:uxSIb8Miel7tqTs3ee+z3t+JelZikwqBBsZzCOPBy/8=
-github.com/vultr/govultr/v3 v3.9.1/go.mod h1:Rd8ebpXm7jxH3MDmhnEs+zrlYW212ouhx+HeUMfHm2o=
+github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
+github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
+github.com/vinyldns/go-vinyldns v0.9.17 h1:hfPZfCaxcRBX6Gsgl42rLCeoal58/BH8kkvJShzjjdI=
+github.com/vinyldns/go-vinyldns v0.9.17/go.mod h1:pwWhE9K/leGDOIduVhRGvQ3ecVMHWRfEnKYUTEU3gB4=
+github.com/volcengine/volc-sdk-golang v1.0.237 h1:hpLKiS2BwDcSBtZWSz034foCbd0h3FrHTKlUMqHIdc4=
+github.com/volcengine/volc-sdk-golang v1.0.237/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM=
+github.com/vultr/govultr/v3 v3.27.0 h1:J8etMyu/Jh5+idMsu2YZpOWmDXXHeW4VZnkYXmJYHx8=
+github.com/vultr/govultr/v3 v3.27.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
-github.com/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=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
-github.com/yandex-cloud/go-genproto v0.0.0-20241101135610-76a0cfc1a773 h1:xkWrnYFWxiwCKVbmuOEMR030UCFklpglmOcPv9yJz2c=
-github.com/yandex-cloud/go-genproto v0.0.0-20241101135610-76a0cfc1a773/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
-github.com/yandex-cloud/go-sdk v0.0.0-20241101143304-947cf519f6bd h1:LcA5pQoWjS2hhG6bV2ZL9eBEV2wLSVbM2KcpDphYP/w=
-github.com/yandex-cloud/go-sdk v0.0.0-20241101143304-947cf519f6bd/go.mod h1:oku4OkbdLLOOpZEz2XxYGXI7rFhxBI5W0cLPmpStdqA=
+github.com/yandex-cloud/go-genproto v0.54.0 h1:LjEwDPBAtF39HvcPQe8I+ImCnFasCPCOVh2b2Sr2eAg=
+github.com/yandex-cloud/go-genproto v0.54.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
+github.com/yandex-cloud/go-sdk/services/dns v0.0.36 h1:sD622+baDvJ2ujhCfoFsCH0XeNsaZNW6loRqvRavjtE=
+github.com/yandex-cloud/go-sdk/services/dns v0.0.36/go.mod h1:Hh7IKJxULaRzmyM19lQZw+yUDyMM8M3Qrk1LbWqhCkc=
+github.com/yandex-cloud/go-sdk/v2 v2.56.0 h1:rihPAZbPbHU/BKTLuT64nU1uhbBrO20HhdlLR3Hyoz0=
+github.com/yandex-cloud/go-sdk/v2 v2.56.0/go.mod h1:jzVBQgamNHoiDsmjog2dPZHMXuGZqmxf/epH+Qb7Emc=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
+github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
+github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/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=
@@ -897,40 +959,50 @@ go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQc
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0=
-go.mongodb.org/mongo-driver v1.12.0 h1:aPx33jmn/rQuJXPQLZQ8NtfPQG8CaqgLThFtqRb0PiE=
-go.mongodb.org/mongo-driver v1.12.0/go.mod h1:AZkxhPnFJUoH7kZlFkVKucV20K387miPfm7oimrSmK0=
+go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk=
+go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
-go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
-go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
-go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
-go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
-go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
-go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
-go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
+go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
+go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
+go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
+go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
+go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
+go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
+go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
-go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
-go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
-go.uber.org/ratelimit v0.3.0 h1:IdZd9wqvFXnvLvSEBo0KPcGfkoBGNkpTHlrE3Rcjkjw=
-go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyBaI=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
+go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -939,7 +1011,9 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -952,13 +1026,17 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
-golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -972,8 +1050,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE=
-golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
+golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
+golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -999,8 +1077,11 @@ golang.org/x/mod v0.3.0/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/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
-golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1046,23 +1127,28 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
-golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
-golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1076,8 +1162,11 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
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/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -1119,6 +1208,7 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1143,30 +1233,43 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/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-20220406163625-3f8b81556e12/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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
-golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
-golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
+golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1181,19 +1284,20 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
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/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
-golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
-golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1234,6 +1338,7 @@ golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjs
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -1248,14 +1353,20 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
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/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
-golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
+golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
@@ -1274,8 +1385,8 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
-google.golang.org/api v0.204.0 h1:3PjmQQEDkR/ENVZZwIYB4W/KzYtN8OrqnNcHWpeR8E4=
-google.golang.org/api v0.204.0/go.mod h1:69y8QSoKIbL9F94bWgWAq6wGqGwyjBgi2y8rAK8zLag=
+google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
+google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1314,12 +1425,12 @@ google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU=
-google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4=
-google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 h1:fVoAXEKA4+yufmbdVYv+SE73+cPZbbbe8paLsHfkK+U=
-google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
+google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
+google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
+google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
+google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1337,8 +1448,8 @@ 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.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
-google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
+google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
+google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
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=
@@ -1353,8 +1464,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
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.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
-google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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=
@@ -1365,16 +1476,15 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
-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.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
+gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
-gopkg.in/ns1/ns1-go.v2 v2.12.2 h1:SPM5BTTMJ1zVBhMMiiPFdF7l6Y3fq5o7bKM7jDqsUfM=
-gopkg.in/ns1/ns1-go.v2 v2.12.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
+gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs=
+gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -1391,7 +1501,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1406,5 +1515,5 @@ rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
-software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M=
-software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
+software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
diff --git a/internal/clihelp/generator.go b/internal/clihelp/generator.go
index 2d256b4d7..fcabde015 100644
--- a/internal/clihelp/generator.go
+++ b/internal/clihelp/generator.go
@@ -50,6 +50,7 @@ func generate() error {
// collect output of various help pages
var help []commandHelp
+
for _, args := range [][]string{
{"lego", "help"},
{"lego", "help", "run"},
@@ -72,7 +73,9 @@ func generate() error {
}
err = outputTpl.Execute(f, help)
+
defer func() { _ = f.Close() }()
+
if err != nil {
return fmt.Errorf("failed to write cli_help.toml: %w", err)
}
@@ -98,9 +101,11 @@ func createStubApp() *cli.App {
func run(app *cli.App, args []string) (h commandHelp, err error) {
w := app.Writer
+
defer func() { app.Writer = w }()
var buf bytes.Buffer
+
app.Writer = &buf
if err := app.Run(args); err != nil {
diff --git a/internal/dns/docs/generator.go b/internal/dns/docs/generator.go
index a6b91b45d..9355d0d1b 100644
--- a/internal/dns/docs/generator.go
+++ b/internal/dns/docs/generator.go
@@ -48,6 +48,11 @@ func main() {
log.Fatal(err)
}
+ err = cleanDocumentation()
+ if err != nil {
+ log.Fatal(err)
+ }
+
for _, m := range models.Providers {
// generate documentation
err = generateDocumentation(m)
@@ -71,6 +76,22 @@ func main() {
fmt.Printf("Documentation for %d DNS providers has been generated.\n", len(models.Providers)+1)
}
+func cleanDocumentation() error {
+ paths, err := filepath.Glob(filepath.Join(docOutput, "zz_gen_*.md"))
+ if err != nil {
+ return err
+ }
+
+ for _, p := range paths {
+ err = os.RemoveAll(p)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
func generateDocumentation(m descriptors.Provider) error {
filename := filepath.Join(docOutput, "zz_gen_"+m.Code+".md")
@@ -95,8 +116,9 @@ func generateCLIHelp(models *descriptors.Providers) error {
defer func() { _ = file.Close() }()
b := &bytes.Buffer{}
+
err = template.Must(
- template.New(filepath.Base(cliTemplate)).Funcs(map[string]interface{}{
+ template.New(filepath.Base(cliTemplate)).Funcs(map[string]any{
"safe": func(src string) string {
return strings.ReplaceAll(src, "`", "'")
},
@@ -113,6 +135,7 @@ func generateCLIHelp(models *descriptors.Providers) error {
}
_, err = file.Write(source)
+
return err
}
@@ -140,6 +163,7 @@ func generateReadMe(models *descriptors.Providers) error {
if err = tpl.Execute(buffer, providers); err != nil {
return err
}
+
skip = true
}
@@ -166,31 +190,29 @@ func generateReadMe(models *descriptors.Providers) error {
}
func orderProviders(models *descriptors.Providers) [][]descriptors.Provider {
- providers := append(models.Providers, descriptors.Provider{
- Name: "Manual",
- Code: "manual",
- })
-
const nbCol = 4
- slices.SortFunc(providers, func(a, b descriptors.Provider) int {
+ slices.SortFunc(models.Providers, func(a, b descriptors.Provider) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
- var matrix [][]descriptors.Provider
- var row []descriptors.Provider
+ var (
+ matrix [][]descriptors.Provider
+ row []descriptors.Provider
+ )
- for i, p := range providers {
+ for i, p := range models.Providers {
switch {
case len(row) == nbCol:
matrix = append(matrix, row)
row = []descriptors.Provider{p}
- case i == len(providers)-1:
+ case i == len(models.Providers)-1:
row = append(row, p)
for j := len(row); j < nbCol; j++ {
row = append(row, descriptors.Provider{})
}
+
matrix = append(matrix, row)
default:
@@ -202,6 +224,7 @@ func orderProviders(models *descriptors.Providers) [][]descriptors.Provider {
for j := len(row); j < nbCol; j++ {
row = append(row, descriptors.Provider{})
}
+
matrix = append(matrix, row)
}
diff --git a/internal/dns/docs/templates/dns.go.tmpl b/internal/dns/docs/templates/dns.go.tmpl
index e8b336254..c1896c91a 100644
--- a/internal/dns/docs/templates/dns.go.tmpl
+++ b/internal/dns/docs/templates/dns.go.tmpl
@@ -12,7 +12,6 @@ import (
func allDNSCodes() string {
providers := []string{
- "manual",
{{- range $provider := .Providers }}
"{{ $provider.Code }}",
{{- end}}
@@ -48,8 +47,6 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/{{ $provider.Code }}`)
{{end}}
- case "manual":
- ew.writeln(`Solving the DNS-01 challenge using CLI prompt.`)
default:
return fmt.Errorf("%q is not yet supported", name)
}
diff --git a/internal/dns/providers/dns_providers.go.tmpl b/internal/dns/providers/dns_providers.go.tmpl
index 2030a3ed0..c974ef6a9 100644
--- a/internal/dns/providers/dns_providers.go.tmpl
+++ b/internal/dns/providers/dns_providers.go.tmpl
@@ -6,7 +6,6 @@ import (
"fmt"
"github.com/go-acme/lego/v4/challenge"
- "github.com/go-acme/lego/v4/challenge/dns01"
{{- range $provider := .Providers }}
"github.com/go-acme/lego/v4/providers/dns/{{ cleanName $provider.Code }}"
{{- end}}
@@ -15,8 +14,6 @@ import (
// NewDNSChallengeProviderByName Factory for DNS providers.
func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
switch name {
- case "manual":
- return dns01.NewDNSProviderManual()
{{- range $provider := .Providers }}
case "{{ $provider.Code }}"{{range $alias := $provider.Aliases }},"{{ $alias }}"{{end}}:
return {{ cleanName $provider.Code }}.NewDNSProvider()
diff --git a/internal/dns/providers/generator.go b/internal/dns/providers/generator.go
index bab31072d..df3f8a2e6 100644
--- a/internal/dns/providers/generator.go
+++ b/internal/dns/providers/generator.go
@@ -46,8 +46,9 @@ func generate() error {
defer func() { _ = file.Close() }()
b := &bytes.Buffer{}
+
err = template.Must(
- template.New("").Funcs(map[string]interface{}{
+ template.New("").Funcs(map[string]any{
"cleanName": func(src string) string {
return strings.ReplaceAll(src, "-", "")
},
diff --git a/internal/releaser/generator.go b/internal/releaser/generator.go
index d1b3e74e1..f24aea25f 100644
--- a/internal/releaser/generator.go
+++ b/internal/releaser/generator.go
@@ -33,7 +33,7 @@ type Generator struct {
targetFile string
}
-func NewGenerator(templatePath string, targetFile string) *Generator {
+func NewGenerator(templatePath, targetFile string) *Generator {
return &Generator{templatePath: templatePath, targetFile: targetFile}
}
diff --git a/internal/releaser/releaser.go b/internal/releaser/releaser.go
index 6047c427c..57b463933 100644
--- a/internal/releaser/releaser.go
+++ b/internal/releaser/releaser.go
@@ -108,6 +108,7 @@ func detach(_ *cli.Context) error {
func readCurrentVersion(filename string) (*hcversion.Version, error) {
fset := token.NewFileSet()
+
file, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
if err != nil {
return nil, err
@@ -141,6 +142,7 @@ func (v visitor) Visit(n ast.Node) ast.Visitor {
if !ok {
continue
}
+
if len(valueSpec.Names) != 1 || len(valueSpec.Values) != 1 {
continue
}
@@ -149,6 +151,7 @@ func (v visitor) Visit(n ast.Node) ast.Visitor {
if !ok {
continue
}
+
if va.Kind != token.STRING {
continue
}
@@ -164,6 +167,7 @@ func (v visitor) Visit(n ast.Node) ast.Visitor {
default:
// noop
}
+
return v
}
diff --git a/lego/client.go b/lego/client.go
index 1109e1224..d06956203 100644
--- a/lego/client.go
+++ b/lego/client.go
@@ -53,7 +53,15 @@ func NewClient(config *Config) (*Client, error) {
solversManager := resolver.NewSolversManager(core)
prober := resolver.NewProber(solversManager)
- certifier := certificate.NewCertifier(core, prober, certificate.CertifierOptions{KeyType: config.Certificate.KeyType, Timeout: config.Certificate.Timeout, OverallRequestLimit: config.Certificate.OverallRequestLimit})
+
+ options := certificate.CertifierOptions{
+ KeyType: config.Certificate.KeyType,
+ Timeout: config.Certificate.Timeout,
+ OverallRequestLimit: config.Certificate.OverallRequestLimit,
+ DisableCommonName: config.Certificate.DisableCommonName,
+ }
+
+ certifier := certificate.NewCertifier(core, prober, options)
return &Client{
Certificate: certifier,
diff --git a/lego/client_config.go b/lego/client_config.go
index fdf1a55f8..969135a13 100644
--- a/lego/client_config.go
+++ b/lego/client_config.go
@@ -64,6 +64,7 @@ type CertificateConfig struct {
KeyType certcrypto.KeyType
Timeout time.Duration
OverallRequestLimit int
+ DisableCommonName bool
}
// createDefaultHTTPClient Creates an HTTP client with a reasonable timeout value
diff --git a/lego/client_test.go b/lego/client_test.go
index 7d2f514dc..63d3b0ad1 100644
--- a/lego/client_test.go
+++ b/lego/client_test.go
@@ -13,10 +13,9 @@ import (
)
func TestNewClient(t *testing.T) {
- _, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().BuildHTTPS(t)
- keyBits := 32 // small value keeps test fast
- key, err := rsa.GenerateKey(rand.Reader, keyBits)
+ key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
user := mockUser{
@@ -26,7 +25,8 @@ func TestNewClient(t *testing.T) {
}
config := NewConfig(user)
- config.CADirURL = apiURL + "/dir"
+ config.CADirURL = server.URL + "/dir"
+ config.HTTPClient = server.Client()
client, err := NewClient(config)
require.NoError(t, err, "Could not create client")
diff --git a/log/logger.go b/log/logger.go
index 48a81fad0..2f700a359 100644
--- a/log/logger.go
+++ b/log/logger.go
@@ -10,50 +10,50 @@ var Logger StdLogger = log.New(os.Stderr, "", log.LstdFlags)
// StdLogger interface for Standard Logger.
type StdLogger interface {
- Fatal(args ...interface{})
- Fatalln(args ...interface{})
- Fatalf(format string, args ...interface{})
- Print(args ...interface{})
- Println(args ...interface{})
- Printf(format string, args ...interface{})
+ Fatal(args ...any)
+ Fatalln(args ...any)
+ Fatalf(format string, args ...any)
+ Print(args ...any)
+ Println(args ...any)
+ Printf(format string, args ...any)
}
// Fatal writes a log entry.
// It uses Logger if not nil, otherwise it uses the default log.Logger.
-func Fatal(args ...interface{}) {
+func Fatal(args ...any) {
Logger.Fatal(args...)
}
// Fatalf writes a log entry.
// It uses Logger if not nil, otherwise it uses the default log.Logger.
-func Fatalf(format string, args ...interface{}) {
+func Fatalf(format string, args ...any) {
Logger.Fatalf(format, args...)
}
// Print writes a log entry.
// It uses Logger if not nil, otherwise it uses the default log.Logger.
-func Print(args ...interface{}) {
+func Print(args ...any) {
Logger.Print(args...)
}
// Println writes a log entry.
// It uses Logger if not nil, otherwise it uses the default log.Logger.
-func Println(args ...interface{}) {
+func Println(args ...any) {
Logger.Println(args...)
}
// Printf writes a log entry.
// It uses Logger if not nil, otherwise it uses the default log.Logger.
-func Printf(format string, args ...interface{}) {
+func Printf(format string, args ...any) {
Logger.Printf(format, args...)
}
// Warnf writes a log entry.
-func Warnf(format string, args ...interface{}) {
+func Warnf(format string, args ...any) {
Printf("[WARN] "+format, args...)
}
// Infof writes a log entry.
-func Infof(format string, args ...interface{}) {
+func Infof(format string, args ...any) {
Printf("[INFO] "+format, args...)
}
diff --git a/platform/config/env/env.go b/platform/config/env/env.go
index 3fd1e3a1a..33a0d6caa 100644
--- a/platform/config/env/env.go
+++ b/platform/config/env/env.go
@@ -16,11 +16,13 @@ func Get(names ...string) (map[string]string, error) {
values := map[string]string{}
var missingEnvVars []string
+
for _, envVar := range names {
value := GetOrFile(envVar)
if value == "" {
missingEnvVars = append(missingEnvVars, envVar)
}
+
values[envVar] = value
}
@@ -58,6 +60,7 @@ func GetWithFallback(groups ...[]string) (map[string]string, error) {
values := map[string]string{}
var missingEnvVars []string
+
for _, names := range groups {
if len(names) == 0 {
return nil, errors.New("undefined environment variable names")
@@ -68,6 +71,7 @@ func GetWithFallback(groups ...[]string) (map[string]string, error) {
missingEnvVars = append(missingEnvVars, envVar)
continue
}
+
values[envVar] = value
}
@@ -107,7 +111,7 @@ func getOneWithFallback(main string, names ...string) (string, string) {
// GetOrDefaultString returns the given environment variable value as a string.
// Returns the default if the env var cannot be found.
-func GetOrDefaultString(envVar string, defaultValue string) string {
+func GetOrDefaultString(envVar, defaultValue string) string {
return getOrDefault(envVar, defaultValue, ParseString)
}
@@ -148,6 +152,7 @@ func GetOrFile(envVar string) string {
}
fileVar := envVar + "_FILE"
+
fileVarValue := os.Getenv(fileVar)
if fileVarValue == "" {
return envVarValue
@@ -184,3 +189,20 @@ func ParseString(s string) (string, error) {
return s, nil
}
+
+// ParsePairs parses a raw string of comma-separated key-value pairs into a map.
+// Keys and values are separated by a colon and are trimmed of whitespace.
+func ParsePairs(raw string) (map[string]string, error) {
+ result := make(map[string]string)
+
+ for pair := range strings.SplitSeq(strings.TrimSuffix(raw, ","), ",") {
+ data := strings.Split(pair, ":")
+ if len(data) != 2 {
+ return nil, fmt.Errorf("incorrect pair: %s", pair)
+ }
+
+ result[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1])
+ }
+
+ return result, nil
+}
diff --git a/platform/config/env/env_test.go b/platform/config/env/env_test.go
index 4a3d0a04c..b131d4d91 100644
--- a/platform/config/env/env_test.go
+++ b/platform/config/env/env_test.go
@@ -367,9 +367,10 @@ func TestGetOrFile_ReadsFiles(t *testing.T) {
err = os.Unsetenv(varEnvName)
require.NoError(t, err)
- file, err := os.CreateTemp("", "lego")
+ file, err := os.CreateTemp(t.TempDir(), "lego")
require.NoError(t, err)
- defer os.Remove(file.Name())
+
+ t.Cleanup(func() { _ = file.Close() })
err = os.WriteFile(file.Name(), []byte("lego_file\n"), 0o644)
require.NoError(t, err)
@@ -392,9 +393,10 @@ func TestGetOrFile_PrefersEnvVars(t *testing.T) {
err = os.Unsetenv(varEnvName)
require.NoError(t, err)
- file, err := os.CreateTemp("", "lego")
+ file, err := os.CreateTemp(t.TempDir(), "lego")
require.NoError(t, err)
- defer os.Remove(file.Name())
+
+ t.Cleanup(func() { _ = file.Close() })
err = os.WriteFile(file.Name(), []byte("lego_file"), 0o644)
require.NoError(t, err)
@@ -406,3 +408,77 @@ func TestGetOrFile_PrefersEnvVars(t *testing.T) {
assert.Equal(t, "lego_env", value)
}
+
+func TestParsePairs(t *testing.T) {
+ testCases := []struct {
+ desc string
+ value string
+ expected map[string]string
+ }{
+ {
+ desc: "one pair",
+ value: "foo:bar",
+ expected: map[string]string{"foo": "bar"},
+ },
+ {
+ desc: "multiple pairs",
+ value: "foo:bar,a:b,c:d",
+ expected: map[string]string{"a": "b", "c": "d", "foo": "bar"},
+ },
+ {
+ desc: "multiple pairs with spaces",
+ value: "foo:bar, a:b , c: d",
+ expected: map[string]string{"a": "b", "c": "d", "foo": "bar"},
+ },
+ {
+ desc: "empty value pair",
+ value: "foo:",
+ expected: map[string]string{"foo": ""},
+ },
+ {
+ desc: "empty key pair",
+ value: ":bar",
+ expected: map[string]string{"": "bar"},
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ pairs, err := ParsePairs(test.value)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expected, pairs)
+ })
+ }
+}
+
+func TestParsePairs_error(t *testing.T) {
+ testCases := []struct {
+ desc string
+ value string
+ }{
+ {
+ desc: "empty value",
+ value: "",
+ },
+ {
+ desc: "multiple colons",
+ value: "foo:bar:bir",
+ },
+ {
+ desc: "valid pair and multiple colons",
+ value: "a:b,foo:bar:bir",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := ParsePairs(test.value)
+ require.Error(t, err)
+ })
+ }
+}
diff --git a/platform/tester/api.go b/platform/tester/api.go
index 175530f96..8343b487f 100644
--- a/platform/tester/api.go
+++ b/platform/tester/api.go
@@ -2,63 +2,47 @@ package tester
import (
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
- "testing"
"github.com/go-acme/lego/v4/acme"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
)
-// SetupFakeAPI Minimal stub ACME server for validation.
-func SetupFakeAPI(t *testing.T) (*http.ServeMux, string) {
- t.Helper()
+// MockACMEServer Minimal stub ACME server for validation.
+func MockACMEServer() *servermock.Builder[*httptest.Server] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*httptest.Server, error) {
+ return server, nil
+ }).
+ Route("GET /dir", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ serverURL := fmt.Sprintf("https://%s", req.Context().Value(http.LocalAddrContextKey))
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/dir", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- err := WriteJSONResponse(w, acme.Directory{
- NewNonceURL: server.URL + "/nonce",
- NewAccountURL: server.URL + "/account",
- NewOrderURL: server.URL + "/newOrder",
- RevokeCertURL: server.URL + "/revokeCert",
- KeyChangeURL: server.URL + "/keyChange",
- RenewalInfo: server.URL + "/renewalInfo",
- })
-
- mux.HandleFunc("/nonce", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodHead {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- w.Header().Set("Replay-Nonce", "12345")
- w.Header().Set("Retry-After", "0")
- })
-
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- return mux, server.URL
+ servermock.JSONEncode(acme.Directory{
+ NewNonceURL: serverURL + "/nonce",
+ NewAccountURL: serverURL + "/account",
+ NewOrderURL: serverURL + "/newOrder",
+ RevokeCertURL: serverURL + "/revokeCert",
+ KeyChangeURL: serverURL + "/keyChange",
+ RenewalInfo: serverURL + "/renewalInfo",
+ }).ServeHTTP(rw, req)
+ })).
+ Route("HEAD /nonce", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ rw.Header().Set("Replay-Nonce", "12345")
+ rw.Header().Set("Retry-After", "0")
+ }))
}
// WriteJSONResponse marshals the body as JSON and writes it to the response.
-func WriteJSONResponse(w http.ResponseWriter, body interface{}) error {
+func WriteJSONResponse(w http.ResponseWriter, body any) error {
bs, err := json.Marshal(body)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
+
if _, err := w.Write(bs); err != nil {
return err
}
diff --git a/platform/tester/dnsmock/dnsmock.go b/platform/tester/dnsmock/dnsmock.go
new file mode 100644
index 000000000..6cb4f45b8
--- /dev/null
+++ b/platform/tester/dnsmock/dnsmock.go
@@ -0,0 +1,191 @@
+package dnsmock
+
+import (
+ "fmt"
+ "math"
+ "net"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/require"
+)
+
+const noType uint16 = math.MaxUint16
+
+type Option func(*dns.Server) error
+
+type Builder struct {
+ // domain -> op -> type
+ routes map[string]map[int]map[uint16]dns.Handler
+
+ stringToType map[string]uint16
+}
+
+func NewServer() *Builder {
+ stringToType := make(map[string]uint16)
+ for typ, str := range dns.TypeToString {
+ stringToType[str] = typ
+ }
+
+ return &Builder{
+ routes: make(map[string]map[int]map[uint16]dns.Handler),
+ stringToType: stringToType,
+ }
+}
+
+func (b *Builder) Query(pattern string, handler dns.HandlerFunc) *Builder {
+ route, err := b.route(pattern, dns.OpcodeQuery, handler)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ return route
+}
+
+func (b *Builder) Update(pattern string, handler dns.HandlerFunc) *Builder {
+ route, err := b.route(pattern, dns.OpcodeUpdate, handler)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ return route
+}
+
+func (b *Builder) route(pattern string, op int, handler dns.HandlerFunc) (*Builder, error) {
+ parts := strings.Fields(pattern)
+
+ domain := parts[0]
+
+ _, ok := dns.IsDomainName(domain)
+ if !ok {
+ return nil, fmt.Errorf("%s: invalid domain: %s", dns.OpcodeToString[op], domain)
+ }
+
+ if _, ok := b.routes[domain]; !ok {
+ b.routes[domain] = make(map[int]map[uint16]dns.Handler)
+ }
+
+ if _, ok := b.routes[domain][op]; !ok {
+ b.routes[domain][op] = make(map[uint16]dns.Handler)
+ }
+
+ if _, ok := b.routes[domain][op][noType]; ok {
+ return nil, fmt.Errorf("%s: a global route already exists for the domain: %s", dns.OpcodeToString[op], domain)
+ }
+
+ switch len(parts) {
+ case 1:
+ if len(b.routes[domain][op]) > 0 {
+ return nil, fmt.Errorf("%s: global route and specific routes cannot be mixed for the same domain: %s", dns.OpcodeToString[op], domain)
+ }
+
+ b.routes[domain][op][noType] = handler
+
+ return b, nil
+
+ case 2:
+ raw := parts[1]
+
+ qType, ok := b.stringToType[raw]
+ if !ok {
+ return nil, fmt.Errorf("%s: unknown type: %s", dns.OpcodeToString[op], raw)
+ }
+
+ if _, ok := b.routes[domain][op][qType]; ok {
+ return nil, fmt.Errorf("%s: duplicate route: %s", dns.OpcodeToString[op], pattern)
+ }
+
+ b.routes[domain][op][qType] = handler
+
+ return b, nil
+
+ default:
+ return nil, fmt.Errorf("%s: invalid pattern: %s", dns.OpcodeToString[op], pattern)
+ }
+}
+
+func (b *Builder) Build(t *testing.T, options ...Option) net.Addr {
+ t.Helper()
+
+ mux := dns.NewServeMux()
+
+ server := &dns.Server{
+ Addr: "127.0.0.1:0",
+ Net: "udp",
+ ReadTimeout: time.Hour,
+ WriteTimeout: time.Hour,
+ Handler: mux,
+ MsgAcceptFunc: func(dh dns.Header) dns.MsgAcceptAction {
+ // bypass defaultMsgAcceptFunc to allow dynamic update (https://github.com/miekg/dns/pull/830)
+ return dns.MsgAccept
+ },
+ }
+
+ for _, option := range options {
+ require.NoError(t, option(server))
+ }
+
+ for pattern, ops := range b.routes {
+ mux.HandleFunc(pattern, func(w dns.ResponseWriter, req *dns.Msg) {
+ mTypes, ok := ops[req.Opcode]
+ if !ok {
+ _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeNotImplemented))
+
+ return
+ }
+
+ if h, found := mTypes[noType]; found {
+ h.ServeDNS(w, req)
+
+ return
+ }
+
+ // For safety but it doesn't happen.
+ if len(req.Question) == 0 {
+ _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeRefused))
+
+ return
+ }
+
+ // For safety but it doesn't happen.
+ if req.Question[0].Qclass != dns.ClassINET {
+ _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeRefused))
+
+ return
+ }
+
+ // Works only for [Query].
+ h, ok := mTypes[req.Question[0].Qtype]
+ if !ok {
+ _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeNotImplemented))
+
+ return
+ }
+
+ h.ServeDNS(w, req)
+ })
+ }
+
+ t.Cleanup(func() {
+ _ = server.Shutdown()
+ })
+
+ waitLock := sync.Mutex{}
+ waitLock.Lock()
+
+ server.NotifyStartedFunc = waitLock.Unlock
+
+ go func() {
+ err := server.ListenAndServe()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+
+ waitLock.Lock()
+
+ return server.PacketConn.LocalAddr()
+}
diff --git a/platform/tester/dnsmock/dnsmock_test.go b/platform/tester/dnsmock/dnsmock_test.go
new file mode 100644
index 000000000..77a67a402
--- /dev/null
+++ b/platform/tester/dnsmock/dnsmock_test.go
@@ -0,0 +1,240 @@
+package dnsmock
+
+import (
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestServer_Query_matchType(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. SOA", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeSuccess, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Query_noType(t *testing.T) {
+ addr := NewServer().
+ Query("example.com.", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeSuccess, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Query_noMatch_domain(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. SOA", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.org.", dns.TypeSOA)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeRefused, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeRefused], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Query_noMatch_type(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. SOA", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeNotImplemented, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Query_noMatch_opType(t *testing.T) {
+ addr := NewServer().
+ Query("example.com.", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetUpdate("example.com.")
+ m.Insert([]dns.RR{
+ &dns.TXT{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1},
+ Txt: []string{"foo"},
+ },
+ })
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeNotImplemented, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Query_unknownType(t *testing.T) {
+ assert.PanicsWithValue(t, "QUERY: unknown type: ABC", func() {
+ NewServer().
+ Query("example.com. ABC", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Query_duplicate(t *testing.T) {
+ assert.PanicsWithValue(t, "QUERY: duplicate route: example.com. SOA", func() {
+ NewServer().
+ Query("example.com. SOA", Noop).
+ Query("example.com. SOA", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Query_duplicateGlobal(t *testing.T) {
+ assert.PanicsWithValue(t, "QUERY: a global route already exists for the domain: example.com.", func() {
+ NewServer().
+ Query("example.com.", Noop).
+ Query("example.com.", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Query_mixed(t *testing.T) {
+ assert.PanicsWithValue(t, "QUERY: global route and specific routes cannot be mixed for the same domain: example.com.", func() {
+ NewServer().
+ Query("example.com. SOA", Noop).
+ Query("example.com.", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Query_invalidDomain(t *testing.T) {
+ assert.PanicsWithValue(t, "QUERY: invalid domain: .example.com.", func() {
+ NewServer().
+ Query(".example.com. SOA", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Query_invalidPattern(t *testing.T) {
+ assert.PanicsWithValue(t, "QUERY: invalid pattern: example.com. SOA 13", func() {
+ NewServer().
+ Query("example.com. SOA 13", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Update(t *testing.T) {
+ addr := NewServer().
+ Update("example.com.", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetUpdate("example.com.")
+ m.Insert([]dns.RR{
+ &dns.TXT{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1},
+ Txt: []string{"foo"},
+ },
+ })
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeSuccess, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Update_noMatch_domain(t *testing.T) {
+ addr := NewServer().
+ Update("example.com.", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetUpdate("example.org.")
+ m.Insert([]dns.RR{
+ &dns.TXT{
+ Hdr: dns.RR_Header{Name: "example.org.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1},
+ Txt: []string{"foo"},
+ },
+ })
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeRefused, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeRefused], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Update_noMatch_opType(t *testing.T) {
+ addr := NewServer().
+ Update("example.com.", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeNotImplemented, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestServer_Update_duplicate(t *testing.T) {
+ assert.PanicsWithValue(t, "UPDATE: a global route already exists for the domain: example.com.", func() {
+ NewServer().
+ Update("example.com.", Noop).
+ Update("example.com.", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Update_invalidDomain(t *testing.T) {
+ assert.PanicsWithValue(t, "UPDATE: invalid domain: .example.com.", func() {
+ NewServer().
+ Update(".example.com.", Noop).
+ Build(t)
+ })
+}
+
+func TestServer_Update_invalidPattern(t *testing.T) {
+ assert.PanicsWithValue(t, "UPDATE: invalid pattern: example.com. SOA 13", func() {
+ NewServer().
+ Update("example.com. SOA 13", Noop).
+ Build(t)
+ })
+}
diff --git a/platform/tester/dnsmock/handlers.go b/platform/tester/dnsmock/handlers.go
new file mode 100644
index 000000000..e1b047318
--- /dev/null
+++ b/platform/tester/dnsmock/handlers.go
@@ -0,0 +1,76 @@
+package dnsmock
+
+import (
+ "fmt"
+
+ "github.com/miekg/dns"
+)
+
+func DumpRequest() dns.HandlerFunc {
+ return func(w dns.ResponseWriter, req *dns.Msg) {
+ fmt.Println(req)
+
+ Noop(w, req)
+ }
+}
+
+func SOA(name string) dns.HandlerFunc {
+ return func(w dns.ResponseWriter, req *dns.Msg) {
+ if name == "" {
+ name = req.Question[0].Name
+ }
+
+ // Handle TLD
+ base := name
+ if dns.CountLabel(req.Question[0].Name) == 1 {
+ base = "nic." + req.Question[0].Name
+ }
+
+ answer := &dns.SOA{
+ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120},
+ Ns: "ns1." + base,
+ Mbox: "admin." + base,
+ Serial: 2016022801,
+ Refresh: 28800,
+ Retry: 7200,
+ Expire: 2419200,
+ Minttl: 1200,
+ }
+
+ Answer(answer)(w, req)
+ }
+}
+
+func CNAME(target string) dns.HandlerFunc {
+ return func(w dns.ResponseWriter, req *dns.Msg) {
+ answer := &dns.CNAME{
+ Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 1},
+ Target: dns.Fqdn(target),
+ }
+
+ Answer(answer)(w, req)
+ }
+}
+
+func Noop(w dns.ResponseWriter, req *dns.Msg) {
+ _ = w.WriteMsg(new(dns.Msg).SetReply(req))
+}
+
+func Error(rcode int) dns.HandlerFunc {
+ return func(w dns.ResponseWriter, req *dns.Msg) {
+ _ = w.WriteMsg(new(dns.Msg).SetRcode(req, rcode))
+ }
+}
+
+func Answer(answer ...dns.RR) func(w dns.ResponseWriter, req *dns.Msg) {
+ return func(w dns.ResponseWriter, req *dns.Msg) {
+ m := new(dns.Msg).SetReply(req)
+
+ m.Answer = answer
+
+ err := w.WriteMsg(m)
+ if err != nil {
+ panic(err.Error())
+ }
+ }
+}
diff --git a/platform/tester/dnsmock/handlers_test.go b/platform/tester/dnsmock/handlers_test.go
new file mode 100644
index 000000000..13cdc0e2d
--- /dev/null
+++ b/platform/tester/dnsmock/handlers_test.go
@@ -0,0 +1,156 @@
+package dnsmock
+
+import (
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSOA_self(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. SOA", SOA("")).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ expectedSOA := []dns.RR{&dns.SOA{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 56},
+ Ns: "ns1.example.com.",
+ Mbox: "admin.example.com.",
+ Serial: 2016022801,
+ Refresh: 28800,
+ Retry: 7200,
+ Expire: 2419200,
+ Minttl: 1200,
+ }}
+
+ require.Equal(t, dns.RcodeSuccess, r.Rcode)
+ assert.Equal(t, expectedSOA, r.Answer)
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestSOA_differentDomain(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. SOA", SOA("example.org.")).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeSuccess, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode])
+
+ expectedSOA := []dns.RR{&dns.SOA{
+ Hdr: dns.RR_Header{Name: "example.org.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 56},
+ Ns: "ns1.example.org.",
+ Mbox: "admin.example.org.",
+ Serial: 2016022801,
+ Refresh: 28800,
+ Retry: 7200,
+ Expire: 2419200,
+ Minttl: 1200,
+ }}
+
+ assert.Equal(t, expectedSOA, r.Answer)
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestSOA_tld(t *testing.T) {
+ addr := NewServer().
+ Query("com. SOA", SOA("")).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("com.", dns.TypeSOA)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeSuccess, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode])
+
+ expectedSOA := []dns.RR{&dns.SOA{
+ Hdr: dns.RR_Header{Name: "com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 48},
+ Ns: "ns1.nic.com.",
+ Mbox: "admin.nic.com.",
+ Serial: 2016022801,
+ Refresh: 28800,
+ Retry: 7200,
+ Expire: 2419200,
+ Minttl: 1200,
+ }}
+
+ assert.Equal(t, expectedSOA, r.Answer)
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestCNAME(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. CNAME", CNAME("example.org.")).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeSuccess, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode])
+
+ expectedCNAME := []dns.RR{&dns.CNAME{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 1, Rdlength: 13},
+ Target: "example.org.",
+ }}
+
+ assert.Equal(t, expectedCNAME, r.Answer)
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestNoop(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. CNAME", Noop).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeSuccess, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
+
+func TestError(t *testing.T) {
+ addr := NewServer().
+ Query("example.com. CNAME", Error(dns.RcodeNameError)).
+ Build(t)
+
+ client := &dns.Client{Timeout: 1 * time.Second}
+
+ m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME)
+
+ r, _, err := client.Exchange(m, addr.String())
+ require.NoError(t, err)
+
+ require.Equalf(t, dns.RcodeNameError, r.Rcode,
+ "expected %s, got %s", dns.RcodeToString[dns.RcodeNameError], dns.RcodeToString[r.Rcode])
+ assert.Equal(t, m.Question, r.Question)
+}
diff --git a/platform/tester/env.go b/platform/tester/env.go
index 26788be3b..a12c32ef8 100644
--- a/platform/tester/env.go
+++ b/platform/tester/env.go
@@ -21,6 +21,7 @@ type EnvTest struct {
// NewEnvTest Creates an EnvTest.
func NewEnvTest(keys ...string) *EnvTest {
values := make(map[string]string)
+
for _, key := range keys {
value := os.Getenv(key)
if value != "" {
@@ -39,6 +40,7 @@ func NewEnvTest(keys ...string) *EnvTest {
func (e *EnvTest) WithDomain(key string) *EnvTest {
e.domainKey = key
e.domain = os.Getenv(key)
+
return e
}
diff --git a/platform/tester/env_test.go b/platform/tester/env_test.go
index 25748f8ff..4d9e4e7d1 100644
--- a/platform/tester/env_test.go
+++ b/platform/tester/env_test.go
@@ -18,6 +18,7 @@ const (
func TestMain(m *testing.M) {
exitCode := m.Run()
+
clearEnv()
os.Exit(exitCode)
}
@@ -39,6 +40,7 @@ func clearEnv() {
os.Unsetenv(strings.Split(key, "=")[0])
}
}
+
os.Unsetenv("EXTRA_LEGO_TEST")
}
@@ -62,7 +64,7 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -75,9 +77,9 @@ func TestEnvTest(t *testing.T) {
},
expected: func(t *testing.T, envTest *tester.EnvTest) {
assert.False(t, envTest.IsLiveTest())
- assert.Equal(t, "", envTest.GetValue(envVar01))
+ assert.Empty(t, envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -94,7 +96,7 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetValue(envVarDomain))
+ assert.Empty(t, envTest.GetValue(envVarDomain))
assert.Equal(t, "D", envTest.GetDomain())
},
},
@@ -110,8 +112,8 @@ func TestEnvTest(t *testing.T) {
expected: func(t *testing.T, envTest *tester.EnvTest) {
assert.False(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
- assert.Equal(t, "", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetValue(envVarDomain))
+ assert.Empty(t, envTest.GetValue(envVar02))
+ assert.Empty(t, envTest.GetValue(envVarDomain))
assert.Equal(t, "D", envTest.GetDomain())
},
},
@@ -128,8 +130,8 @@ func TestEnvTest(t *testing.T) {
assert.False(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetValue(envVarDomain))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetValue(envVarDomain))
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -145,7 +147,7 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -161,7 +163,7 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -174,9 +176,9 @@ func TestEnvTest(t *testing.T) {
},
expected: func(t *testing.T, envTest *tester.EnvTest) {
assert.True(t, envTest.IsLiveTest())
- assert.Equal(t, "", envTest.GetValue(envVar01))
+ assert.Empty(t, envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -190,8 +192,8 @@ func TestEnvTest(t *testing.T) {
expected: func(t *testing.T, envTest *tester.EnvTest) {
assert.False(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
- assert.Equal(t, "", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetValue(envVar02))
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -210,7 +212,7 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetValue(envVarDomain))
+ assert.Empty(t, envTest.GetValue(envVarDomain))
assert.Equal(t, "D", envTest.GetDomain())
},
},
@@ -229,8 +231,8 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetValue(envVarDomain))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetValue(envVarDomain))
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -247,7 +249,7 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -264,7 +266,7 @@ func TestEnvTest(t *testing.T) {
assert.False(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -282,7 +284,7 @@ func TestEnvTest(t *testing.T) {
assert.True(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -300,7 +302,7 @@ func TestEnvTest(t *testing.T) {
assert.False(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
assert.Equal(t, "B", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetDomain())
},
},
{
@@ -316,8 +318,8 @@ func TestEnvTest(t *testing.T) {
expected: func(t *testing.T, envTest *tester.EnvTest) {
assert.False(t, envTest.IsLiveTest())
assert.Equal(t, "A", envTest.GetValue(envVar01))
- assert.Equal(t, "", envTest.GetValue(envVar02))
- assert.Equal(t, "", envTest.GetDomain())
+ assert.Empty(t, envTest.GetValue(envVar02))
+ assert.Empty(t, envTest.GetDomain())
},
},
}
@@ -325,6 +327,7 @@ func TestEnvTest(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer clearEnv()
+
applyEnv(test.envVars)
envTest := test.envTestSetup()
@@ -357,7 +360,7 @@ func TestEnvTest_ClearEnv(t *testing.T) {
envTest.ClearEnv()
- assert.Equal(t, "", os.Getenv(envVar01))
- assert.Equal(t, "", os.Getenv(envVar02))
+ assert.Empty(t, os.Getenv(envVar01))
+ assert.Empty(t, os.Getenv(envVar02))
assert.Equal(t, "X", os.Getenv("EXTRA_LEGO_TEST"))
}
diff --git a/platform/tester/servermock/builder.go b/platform/tester/servermock/builder.go
new file mode 100644
index 000000000..b5a9d909b
--- /dev/null
+++ b/platform/tester/servermock/builder.go
@@ -0,0 +1,84 @@
+package servermock
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Link represents a middleware interface, enabling middleware chaining.
+type Link interface {
+ Bind(next http.Handler) http.Handler
+}
+
+// LinkFunc defines a function type [Link].
+type LinkFunc func(next http.Handler) http.Handler
+
+func (f LinkFunc) Bind(next http.Handler) http.Handler {
+ return f(next)
+}
+
+// ClientBuilder defines a function type for creating a client of type T based on a httptest.Server instance.
+type ClientBuilder[T any] func(server *httptest.Server) (T, error)
+
+// Builder is a type that facilitates the construction of testable HTTP clients and server.
+// It allows defining routes, attaching middleware, and creating custom HTTP clients.
+type Builder[T any] struct {
+ mux *http.ServeMux
+ chain []Link
+
+ clientBuilder ClientBuilder[T]
+}
+
+func NewBuilder[T any](clientBuilder ClientBuilder[T], chain ...Link) *Builder[T] {
+ return &Builder[T]{
+ mux: http.NewServeMux(),
+ chain: chain,
+ clientBuilder: clientBuilder,
+ }
+}
+
+func (b *Builder[T]) Route(pattern string, handler http.Handler, chain ...Link) *Builder[T] {
+ if handler == nil {
+ handler = Noop()
+ }
+
+ for _, link := range slices.Backward(b.chain) {
+ handler = link.Bind(handler)
+ }
+
+ for _, link := range slices.Backward(chain) {
+ handler = link.Bind(handler)
+ }
+
+ b.mux.Handle(pattern, handler)
+
+ return b
+}
+
+func (b *Builder[T]) Build(t *testing.T) T {
+ t.Helper()
+
+ server := httptest.NewServer(b.mux)
+ t.Cleanup(server.Close)
+
+ client, err := b.clientBuilder(server)
+ require.NoError(t, err)
+
+ return client
+}
+
+func (b *Builder[T]) BuildHTTPS(t *testing.T) T {
+ t.Helper()
+
+ server := httptest.NewTLSServer(b.mux)
+ t.Cleanup(server.Close)
+
+ client, err := b.clientBuilder(server)
+ require.NoError(t, err)
+
+ return client
+}
diff --git a/platform/tester/servermock/handler_dump.go b/platform/tester/servermock/handler_dump.go
new file mode 100644
index 000000000..83f902980
--- /dev/null
+++ b/platform/tester/servermock/handler_dump.go
@@ -0,0 +1,20 @@
+package servermock
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httputil"
+)
+
+// DumpRequest logs the full HTTP request to the console, including the body if present.
+func DumpRequest() http.HandlerFunc {
+ return func(rw http.ResponseWriter, req *http.Request) {
+ dump, err := httputil.DumpRequest(req, true)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ fmt.Println(string(dump))
+ }
+}
diff --git a/platform/tester/servermock/handler_file.go b/platform/tester/servermock/handler_file.go
new file mode 100644
index 000000000..c5a9b33e1
--- /dev/null
+++ b/platform/tester/servermock/handler_file.go
@@ -0,0 +1,84 @@
+package servermock
+
+import (
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+)
+
+// ResponseFromFileHandler handles HTTP responses using the content of a file.
+type ResponseFromFileHandler struct {
+ statusCode int
+ headers http.Header
+ filename string
+}
+
+// ResponseFromFile creates a [ResponseFromFileHandler] using a filename.
+func ResponseFromFile(filename string) *ResponseFromFileHandler {
+ return &ResponseFromFileHandler{
+ statusCode: http.StatusOK,
+ headers: http.Header{},
+ filename: filename,
+ }
+}
+
+// ResponseFromFixture creates a [ResponseFromFileHandler] using a filename from the `fixtures` directory.
+func ResponseFromFixture(filename string) *ResponseFromFileHandler {
+ return ResponseFromFile(filepath.Join("fixtures", filename))
+}
+
+// ResponseFromInternal creates a [ResponseFromFileHandler] using a filename from the `internal/fixtures` directory.
+func ResponseFromInternal(filename string) *ResponseFromFileHandler {
+ return ResponseFromFile(filepath.Join("internal", "fixtures", filename))
+}
+
+func (h *ResponseFromFileHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) {
+ for k, values := range h.headers {
+ for _, v := range values {
+ rw.Header().Add(k, v)
+ }
+ }
+
+ if h.filename == "" {
+ rw.WriteHeader(h.statusCode)
+ return
+ }
+
+ if filepath.Ext(h.filename) == ".json" {
+ rw.Header().Set(contentTypeHeader, applicationJSONMimeType)
+ }
+
+ file, err := os.Open(h.filename)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ defer func() { _ = file.Close() }()
+
+ rw.WriteHeader(h.statusCode)
+
+ _, err = io.Copy(rw, file)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (h *ResponseFromFileHandler) WithStatusCode(status int) *ResponseFromFileHandler {
+ if h.statusCode >= http.StatusContinue {
+ h.statusCode = status
+ }
+
+ return h
+}
+
+func (h *ResponseFromFileHandler) WithHeader(name, value string, values ...string) *ResponseFromFileHandler {
+ for _, v := range slices.Concat([]string{value}, values) {
+ h.headers.Add(name, v)
+ }
+
+ return h
+}
diff --git a/platform/tester/servermock/handler_json.go b/platform/tester/servermock/handler_json.go
new file mode 100644
index 000000000..f1c2aa9ce
--- /dev/null
+++ b/platform/tester/servermock/handler_json.go
@@ -0,0 +1,39 @@
+package servermock
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// JSONEncodeHandler is a handler that encodes data into JSON and writes it to an HTTP response.
+type JSONEncodeHandler struct {
+ data any
+ statusCode int
+}
+
+func JSONEncode(data any) *JSONEncodeHandler {
+ return &JSONEncodeHandler{
+ data: data,
+ statusCode: http.StatusOK,
+ }
+}
+
+func (h *JSONEncodeHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) {
+ rw.Header().Set(contentTypeHeader, applicationJSONMimeType)
+
+ rw.WriteHeader(h.statusCode)
+
+ err := json.NewEncoder(rw).Encode(h.data)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (h *JSONEncodeHandler) WithStatusCode(status int) *JSONEncodeHandler {
+ if h.statusCode >= http.StatusContinue {
+ h.statusCode = status
+ }
+
+ return h
+}
diff --git a/platform/tester/servermock/handler_noop.go b/platform/tester/servermock/handler_noop.go
new file mode 100644
index 000000000..6df5164e6
--- /dev/null
+++ b/platform/tester/servermock/handler_noop.go
@@ -0,0 +1,45 @@
+package servermock
+
+import (
+ "net/http"
+ "slices"
+)
+
+// NoopHandler is a simple HTTP handler that responds without processing requests.
+type NoopHandler struct {
+ statusCode int
+ headers http.Header
+}
+
+func Noop() *NoopHandler {
+ return &NoopHandler{
+ statusCode: http.StatusOK,
+ headers: http.Header{},
+ }
+}
+
+func (h *NoopHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+ for k, values := range h.headers {
+ for _, v := range values {
+ rw.Header().Add(k, v)
+ }
+ }
+
+ rw.WriteHeader(h.statusCode)
+}
+
+func (h *NoopHandler) WithStatusCode(status int) *NoopHandler {
+ if h.statusCode >= http.StatusContinue {
+ h.statusCode = status
+ }
+
+ return h
+}
+
+func (h *NoopHandler) WithHeader(name, value string, values ...string) *NoopHandler {
+ for _, v := range slices.Concat([]string{value}, values) {
+ h.headers.Add(name, v)
+ }
+
+ return h
+}
diff --git a/platform/tester/servermock/handler_raw.go b/platform/tester/servermock/handler_raw.go
new file mode 100644
index 000000000..d7c68f396
--- /dev/null
+++ b/platform/tester/servermock/handler_raw.go
@@ -0,0 +1,61 @@
+package servermock
+
+import (
+ "net/http"
+ "slices"
+)
+
+// RawResponseHandler is a custom HTTP handler that serves raw response data.
+type RawResponseHandler struct {
+ statusCode int
+ headers http.Header
+ data []byte
+}
+
+func RawResponse(data []byte) *RawResponseHandler {
+ return &RawResponseHandler{
+ statusCode: http.StatusOK,
+ headers: http.Header{},
+ data: data,
+ }
+}
+
+func RawStringResponse(data string) *RawResponseHandler {
+ return RawResponse([]byte(data))
+}
+
+func (h *RawResponseHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) {
+ for k, values := range h.headers {
+ for _, v := range values {
+ rw.Header().Add(k, v)
+ }
+ }
+
+ rw.WriteHeader(h.statusCode)
+
+ if len(h.data) == 0 {
+ return
+ }
+
+ _, err := rw.Write(h.data)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (h *RawResponseHandler) WithStatusCode(status int) *RawResponseHandler {
+ if h.statusCode >= http.StatusContinue {
+ h.statusCode = status
+ }
+
+ return h
+}
+
+func (h *RawResponseHandler) WithHeader(name, value string, values ...string) *RawResponseHandler {
+ for _, v := range slices.Concat([]string{value}, values) {
+ h.headers.Add(name, v)
+ }
+
+ return h
+}
diff --git a/platform/tester/servermock/link_form.go b/platform/tester/servermock/link_form.go
new file mode 100644
index 000000000..581e27d66
--- /dev/null
+++ b/platform/tester/servermock/link_form.go
@@ -0,0 +1,97 @@
+package servermock
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "slices"
+)
+
+// FormLink is a type used for validating and processing form data in HTTP requests.
+// It supports strict validation, predefined values, and regex-based checks to ensure form compliance.
+type FormLink struct {
+ values url.Values
+ regexes map[string]*regexp.Regexp
+ strict bool
+ usePostForm bool
+ statusCode int
+}
+
+func CheckForm() *FormLink {
+ return &FormLink{
+ values: url.Values{},
+ regexes: map[string]*regexp.Regexp{},
+ statusCode: http.StatusBadRequest,
+ }
+}
+
+func (l *FormLink) Bind(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ err := req.ParseForm()
+ if err != nil {
+ http.Error(rw, err.Error(), l.statusCode)
+ return
+ }
+
+ form := req.Form
+ if l.usePostForm {
+ form = req.PostForm
+ }
+
+ if l.strict {
+ if len(form) != len(l.values)+len(l.regexes) {
+ msg := fmt.Sprintf("invalid query parameters, got %v, want %v", req.Form, l.values)
+ http.Error(rw, msg, l.statusCode)
+
+ return
+ }
+ }
+
+ for k, v := range l.values {
+ value := form[k]
+ if !slices.Equal(v, value) {
+ msg := fmt.Sprintf("invalid %q form value, got %q, want %q", k, value, v)
+ http.Error(rw, msg, l.statusCode)
+
+ return
+ }
+ }
+
+ for k, exp := range l.regexes {
+ value := form.Get(k)
+ if !exp.MatchString(value) {
+ msg := fmt.Sprintf("invalid %q form value, %q doesn't match to %q", k, value, exp)
+ http.Error(rw, msg, l.statusCode)
+
+ return
+ }
+ }
+
+ next.ServeHTTP(rw, req)
+ })
+}
+
+func (l *FormLink) Strict() *FormLink {
+ l.strict = true
+
+ return l
+}
+
+func (l *FormLink) UsePostForm() *FormLink {
+ l.usePostForm = true
+
+ return l
+}
+
+func (l *FormLink) With(name, value string) *FormLink {
+ l.values.Set(name, value)
+
+ return l
+}
+
+func (l *FormLink) WithRegexp(name, exp string) *FormLink {
+ l.regexes[name] = regexp.MustCompile(exp)
+
+ return l
+}
diff --git a/platform/tester/servermock/link_headers.go b/platform/tester/servermock/link_headers.go
new file mode 100644
index 000000000..0ca519958
--- /dev/null
+++ b/platform/tester/servermock/link_headers.go
@@ -0,0 +1,178 @@
+package servermock
+
+import (
+ "fmt"
+ "net/http"
+ "regexp"
+ "slices"
+)
+
+const (
+ authorizationHeader = "Authorization"
+ contentTypeHeader = "Content-Type"
+ acceptHeader = "Accept"
+)
+
+const (
+ applicationJSONMimeType = "application/json"
+ applicationFormMimeType = "application/x-www-form-urlencoded"
+)
+
+type basicAuth struct {
+ username, password string
+}
+
+// HeaderLink validates HTTP request headers.
+type HeaderLink struct {
+ values http.Header
+ regexes map[string]*regexp.Regexp
+ json bool
+ basicAuth *basicAuth
+ statusCode int
+}
+
+func CheckHeader() *HeaderLink {
+ return &HeaderLink{
+ values: http.Header{},
+ regexes: map[string]*regexp.Regexp{},
+ statusCode: http.StatusBadRequest,
+ }
+}
+
+func (l *HeaderLink) Bind(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ for k, v := range l.values {
+ err := checkHeader(req, k, v)
+ if err != nil {
+ http.Error(rw, err.Error(), l.statusCode)
+ return
+ }
+ }
+
+ for k, exp := range l.regexes {
+ value := req.Header.Get(k)
+
+ if !exp.MatchString(value) {
+ msg := fmt.Sprintf("invalid %q header value, %q doesn't match to %q", k, value, exp)
+ http.Error(rw, msg, l.statusCode)
+
+ return
+ }
+ }
+
+ if l.json && !l.checkJSONHeaders(rw, req) {
+ return
+ }
+
+ if l.basicAuth != nil && !l.checkBasicAuth(rw, req) {
+ return
+ }
+
+ next.ServeHTTP(rw, req)
+ })
+}
+
+func (l *HeaderLink) With(name, value string, values ...string) *HeaderLink {
+ for _, v := range slices.Concat([]string{value}, values) {
+ l.values.Add(name, v)
+ }
+
+ return l
+}
+
+func (l *HeaderLink) WithRegexp(name, exp string) *HeaderLink {
+ l.regexes[name] = regexp.MustCompile(exp)
+
+ return l
+}
+
+func (l *HeaderLink) WithJSONHeaders() *HeaderLink {
+ l.json = true
+
+ return l
+}
+
+func (l *HeaderLink) WithContentTypeFromURLEncoded() *HeaderLink {
+ l.values.Set(contentTypeHeader, applicationFormMimeType)
+
+ return l
+}
+
+func (l *HeaderLink) WithContentType(value string) *HeaderLink {
+ l.values.Set(contentTypeHeader, value)
+
+ return l
+}
+
+func (l *HeaderLink) WithAccept(value string) *HeaderLink {
+ l.values.Set(acceptHeader, value)
+
+ return l
+}
+
+func (l *HeaderLink) WithAuthorization(value string) *HeaderLink {
+ l.values.Set(authorizationHeader, value)
+
+ return l
+}
+
+func (l *HeaderLink) WithStatusCode(status int) *HeaderLink {
+ if l.statusCode >= http.StatusContinue {
+ l.statusCode = status
+ }
+
+ return l
+}
+
+func (l *HeaderLink) WithBasicAuth(username, password string) *HeaderLink {
+ l.basicAuth = &basicAuth{username: username, password: password}
+
+ return l
+}
+
+func (l *HeaderLink) checkBasicAuth(rw http.ResponseWriter, req *http.Request) bool {
+ usr, pwd, ok := req.BasicAuth()
+ if !ok {
+ http.Error(rw, "missing Basic auth", l.statusCode)
+
+ return false
+ }
+
+ if usr != l.basicAuth.username || pwd != l.basicAuth.password {
+ msg := fmt.Sprintf("invalid credentials: got [username: %q, password: %q], want [username: %q, password: %q]",
+ usr, pwd, l.basicAuth.username, l.basicAuth.password)
+ http.Error(rw, msg, l.statusCode)
+
+ return false
+ }
+
+ return true
+}
+
+func (l *HeaderLink) checkJSONHeaders(rw http.ResponseWriter, req *http.Request) bool {
+ err := checkHeader(req, acceptHeader, []string{applicationJSONMimeType})
+ if err != nil {
+ http.Error(rw, err.Error(), l.statusCode)
+
+ return false
+ }
+
+ if req.ContentLength > 0 {
+ err = checkHeader(req, contentTypeHeader, []string{applicationJSONMimeType})
+ if err != nil {
+ http.Error(rw, err.Error(), l.statusCode)
+
+ return false
+ }
+ }
+
+ return true
+}
+
+func checkHeader(req *http.Request, k string, v []string) error {
+ if !slices.Equal(req.Header[k], v) {
+ return fmt.Errorf("invalid %q header value, got %q, want %q", k, req.Header[k], v)
+ }
+
+ return nil
+}
diff --git a/platform/tester/servermock/link_query.go b/platform/tester/servermock/link_query.go
new file mode 100644
index 000000000..14f776515
--- /dev/null
+++ b/platform/tester/servermock/link_query.go
@@ -0,0 +1,100 @@
+package servermock
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+)
+
+// QueryParameterLink validates query parameters in HTTP requests.
+// The strict flag enforces exact matches with specified query parameters.
+type QueryParameterLink struct {
+ values map[string]string
+ regexes map[string]*regexp.Regexp
+ strict bool
+ statusCode int
+}
+
+func CheckQueryParameter() *QueryParameterLink {
+ return &QueryParameterLink{
+ values: map[string]string{},
+ regexes: map[string]*regexp.Regexp{},
+ statusCode: http.StatusBadRequest,
+ }
+}
+
+func (l *QueryParameterLink) Bind(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ query := req.URL.Query()
+
+ if l.strict {
+ if len(query) != len(l.values)+len(l.regexes) {
+ msg := fmt.Sprintf("invalid query parameters, got %v, want %v", query, l.values)
+ http.Error(rw, msg, l.statusCode)
+
+ return
+ }
+ }
+
+ for k, v := range l.values {
+ p := query.Get(k)
+ if p != v {
+ msg := fmt.Sprintf("invalid %q query parameter value, got %q, want %q", k, p, v)
+ http.Error(rw, msg, l.statusCode)
+
+ return
+ }
+ }
+
+ for k, exp := range l.regexes {
+ value := query.Get(k)
+ if !exp.MatchString(value) {
+ msg := fmt.Sprintf("invalid %q query parameter value, %q doesn't match to %q", k, value, exp)
+ http.Error(rw, msg, l.statusCode)
+
+ return
+ }
+ }
+
+ next.ServeHTTP(rw, req)
+ })
+}
+
+func (l *QueryParameterLink) Strict() *QueryParameterLink {
+ l.strict = true
+
+ return l
+}
+
+func (l *QueryParameterLink) With(name, value string) *QueryParameterLink {
+ l.values[name] = value
+
+ return l
+}
+
+func (l *QueryParameterLink) WithRegexp(name, exp string) *QueryParameterLink {
+ l.regexes[name] = regexp.MustCompile(exp)
+
+ return l
+}
+
+func (l *QueryParameterLink) WithValues(values url.Values) *QueryParameterLink {
+ for k, v := range values {
+ if len(v) != 1 {
+ continue
+ }
+
+ l.values[k] = v[0]
+ }
+
+ return l
+}
+
+func (l *QueryParameterLink) WithStatusCode(status int) *QueryParameterLink {
+ if l.statusCode >= http.StatusContinue {
+ l.statusCode = status
+ }
+
+ return l
+}
diff --git a/platform/tester/servermock/link_request_body.go b/platform/tester/servermock/link_request_body.go
new file mode 100644
index 000000000..d6b2d9efd
--- /dev/null
+++ b/platform/tester/servermock/link_request_body.go
@@ -0,0 +1,100 @@
+package servermock
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+)
+
+// RequestBodyLink represents a handler utility to validate HTTP request bodies against a predefined byte slice.
+type RequestBodyLink struct {
+ body []byte
+ filename string
+ ignoreWhitespace bool
+}
+
+// CheckRequestBody creates a [RequestBodyLink] initialized with the provided request body string.
+func CheckRequestBody(body string) *RequestBodyLink {
+ return &RequestBodyLink{body: []byte(body)}
+}
+
+// CheckRequestBodyFromFile creates a [RequestBodyLink] initialized with the provided request body file.
+func CheckRequestBodyFromFile(filename string) *RequestBodyLink {
+ return &RequestBodyLink{filename: filename}
+}
+
+// CheckRequestBodyFromFixture creates a [RequestBodyLink] initialized with the provided request body file from the `fixtures` directory.
+func CheckRequestBodyFromFixture(filename string) *RequestBodyLink {
+ return CheckRequestBodyFromFile(filepath.Join("fixtures", filename))
+}
+
+// CheckRequestBodyFromInternal creates a [RequestBodyLink] initialized with the provided request body file from the `internal/fixtures directory.
+func CheckRequestBodyFromInternal(filename string) *RequestBodyLink {
+ return CheckRequestBodyFromFile(filepath.Join("internal", "fixtures", filename))
+}
+
+func (l *RequestBodyLink) Bind(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ if req.ContentLength == 0 {
+ http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest)
+ return
+ }
+
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ _ = req.Body.Close()
+
+ expectedRaw := slices.Clone(l.body)
+
+ if l.filename != "" {
+ expectedRaw, err = os.ReadFile(l.filename)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if len(expectedRaw) == 0 {
+ http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest)
+ return
+ }
+
+ if l.ignoreWhitespace {
+ body = trimLineSpace(body)
+ expectedRaw = trimLineSpace(expectedRaw)
+ }
+
+ if !bytes.Equal(bytes.TrimSpace(expectedRaw), bytes.TrimSpace(body)) {
+ msg := fmt.Sprintf("%s: request body differences: got: %s, want: %s", req.URL.Path,
+ string(bytes.TrimSpace(body)), string(bytes.TrimSpace(expectedRaw)))
+ http.Error(rw, msg, http.StatusBadRequest)
+
+ return
+ }
+
+ next.ServeHTTP(rw, req)
+ })
+}
+
+func (l *RequestBodyLink) IgnoreWhitespace() *RequestBodyLink {
+ l.ignoreWhitespace = true
+
+ return l
+}
+
+func trimLineSpace(body []byte) []byte {
+ buf := bytes.NewBuffer(nil)
+ for line := range bytes.Lines(body) {
+ buf.Write(bytes.TrimSpace(line))
+ }
+
+ return buf.Bytes()
+}
diff --git a/platform/tester/servermock/link_request_body_json.go b/platform/tester/servermock/link_request_body_json.go
new file mode 100644
index 000000000..ed5a117ba
--- /dev/null
+++ b/platform/tester/servermock/link_request_body_json.go
@@ -0,0 +1,114 @@
+package servermock
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+// RequestBodyJSONLink validates JSON request bodies.
+type RequestBodyJSONLink struct {
+ body []byte
+ filename string
+ data any
+}
+
+// CheckRequestJSONBody creates a [RequestBodyJSONLink] initialized with a string.
+func CheckRequestJSONBody(body string) *RequestBodyJSONLink {
+ return &RequestBodyJSONLink{body: []byte(body)}
+}
+
+// CheckRequestJSONBodyFromStruct creates a [RequestBodyJSONLink] initialized with a struct.
+func CheckRequestJSONBodyFromStruct(data any) *RequestBodyJSONLink {
+ return &RequestBodyJSONLink{data: data}
+}
+
+// CheckRequestJSONBodyFromFile creates a [RequestBodyJSONLink] initialized with the provided request body file.
+func CheckRequestJSONBodyFromFile(filename string) *RequestBodyJSONLink {
+ return &RequestBodyJSONLink{
+ filename: filename,
+ }
+}
+
+// CheckRequestJSONBodyFromFixture creates a [RequestBodyJSONLink] initialized with the provided request body file from the `fixtures` directory.
+func CheckRequestJSONBodyFromFixture(filename string) *RequestBodyJSONLink {
+ return CheckRequestJSONBodyFromFile(filepath.Join("fixtures", filename))
+}
+
+// CheckRequestJSONBodyFromInternal creates a [RequestBodyJSONLink] initialized with the provided request body file from the `internal/fixtures` directory.
+func CheckRequestJSONBodyFromInternal(filename string) *RequestBodyJSONLink {
+ return CheckRequestJSONBodyFromFile(filepath.Join("internal", "fixtures", filename))
+}
+
+func (l *RequestBodyJSONLink) Bind(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ if req.ContentLength == 0 {
+ http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest)
+ return
+ }
+
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ _ = req.Body.Close()
+
+ var expected, actual any
+
+ expectedRaw := slices.Clone(l.body)
+
+ switch {
+ case l.filename != "":
+ expectedRaw, err = os.ReadFile(l.filename)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ case l.data != nil:
+ expectedRaw, err = json.Marshal(l.data)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if len(expectedRaw) == 0 {
+ http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest)
+ return
+ }
+
+ err = json.Unmarshal(expectedRaw, &expected)
+ if err != nil {
+ msg := fmt.Sprintf("%s: the expected request body is not valid JSON: %v", req.URL.Path, err)
+ http.Error(rw, msg, http.StatusBadRequest)
+
+ return
+ }
+
+ err = json.Unmarshal(body, &actual)
+ if err != nil {
+ msg := fmt.Sprintf("%s: request body is not valid JSON: %v", req.URL.Path, err)
+ http.Error(rw, msg, http.StatusBadRequest)
+
+ return
+ }
+
+ if !cmp.Equal(actual, expected) {
+ msg := fmt.Sprintf("%s: request body differences: %s", req.URL.Path, cmp.Diff(actual, expected))
+ http.Error(rw, msg, http.StatusBadRequest)
+
+ return
+ }
+
+ next.ServeHTTP(rw, req)
+ })
+}
diff --git a/platform/wait/wait.go b/platform/wait/wait.go
index 6ad817b26..c66f57446 100644
--- a/platform/wait/wait.go
+++ b/platform/wait/wait.go
@@ -1,9 +1,11 @@
package wait
import (
+ "context"
"fmt"
"time"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/log"
)
@@ -12,21 +14,25 @@ func For(msg string, timeout, interval time.Duration, f func() (bool, error)) er
log.Infof("Wait for %s [timeout: %s, interval: %s]", msg, timeout, interval)
var lastErr error
+
timeUp := time.After(timeout)
+
for {
select {
case <-timeUp:
if lastErr == nil {
return fmt.Errorf("%s: time limit exceeded", msg)
}
+
return fmt.Errorf("%s: time limit exceeded: last error: %w", msg, lastErr)
default:
}
stop, err := f()
if stop {
- return nil
+ return err
}
+
if err != nil {
lastErr = err
}
@@ -34,3 +40,13 @@ func For(msg string, timeout, interval time.Duration, f func() (bool, error)) er
time.Sleep(interval)
}
}
+
+// Retry retries the given operation until it succeeds or the context is canceled.
+// Similar to [backoff.Retry] but with a different signature.
+func Retry(ctx context.Context, operation func() error, opts ...backoff.RetryOption) error {
+ _, err := backoff.Retry(ctx, func() (any, error) {
+ return nil, operation()
+ }, opts...)
+
+ return err
+}
diff --git a/platform/wait/wait_test.go b/platform/wait/wait_test.go
index 9722e6f2e..36dbffe69 100644
--- a/platform/wait/wait_test.go
+++ b/platform/wait/wait_test.go
@@ -1,26 +1,121 @@
package wait
import (
+ "errors"
+ "sync/atomic"
"testing"
"time"
+
+ "github.com/stretchr/testify/require"
)
-func TestForTimeout(t *testing.T) {
+// TODO(ldez): rewrite those tests when upgrading to go1.25 as minimum Go version.
+
+func TestFor_timeout(t *testing.T) {
+ var io atomic.Int64
+
c := make(chan error)
+
go func() {
- c <- For("", 3*time.Second, 1*time.Second, func() (bool, error) {
+ c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) {
+ io.Add(1)
+
+ if io.Load() == 1 {
+ return false, nil
+ }
+
return false, nil
})
}()
timeout := time.After(6 * time.Second)
+
select {
case <-timeout:
t.Fatal("timeout exceeded")
case err := <-c:
- if err == nil {
- t.Errorf("expected timeout error; got %v", err)
- }
- t.Logf("%v", err)
+ require.EqualError(t, err, "test: time limit exceeded")
}
+
+ require.EqualValues(t, 3, io.Load())
+}
+
+func TestFor_timeout_with_error(t *testing.T) {
+ var io atomic.Int64
+
+ c := make(chan error)
+
+ go func() {
+ c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) {
+ io.Add(1)
+
+ // This allows be sure that the latest previous error is returned.
+ if io.Load() == 1 {
+ return false, errors.New("oops")
+ }
+
+ return false, nil
+ })
+ }()
+
+ timeout := time.After(6 * time.Second)
+
+ select {
+ case <-timeout:
+ t.Fatal("timeout exceeded")
+ case err := <-c:
+ require.EqualError(t, err, "test: time limit exceeded: last error: oops")
+ }
+
+ require.EqualValues(t, 3, io.Load())
+}
+
+func TestFor_stop(t *testing.T) {
+ var io atomic.Int64
+
+ c := make(chan error)
+
+ go func() {
+ c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) {
+ io.Add(1)
+
+ return true, nil
+ })
+ }()
+
+ timeout := time.After(6 * time.Second)
+
+ select {
+ case <-timeout:
+ t.Fatal("timeout exceeded")
+ case err := <-c:
+ require.NoError(t, err)
+ }
+
+ require.EqualValues(t, 1, io.Load())
+}
+
+func TestFor_stop_with_error(t *testing.T) {
+ var io atomic.Int64
+
+ c := make(chan error)
+
+ go func() {
+ c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) {
+ io.Add(1)
+
+ return true, errors.New("oops")
+ })
+ }()
+
+ timeout := time.After(6 * time.Second)
+
+ select {
+ case <-timeout:
+ t.Fatal("timeout exceeded")
+ case err := <-c:
+ require.EqualError(t, err, "oops")
+ }
+
+ require.EqualValues(t, 1, io.Load())
}
diff --git a/providers/dns/acmedns/acmedns.go b/providers/dns/acmedns/acmedns.go
index 7ba7f08d0..8f1f16842 100644
--- a/providers/dns/acmedns/acmedns.go
+++ b/providers/dns/acmedns/acmedns.go
@@ -3,13 +3,17 @@
package acmedns
import (
+ "context"
"errors"
"fmt"
+ "strings"
- "github.com/cpu/goacmedns"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/acmedns/internal"
+ "github.com/nrdcg/goacmedns"
+ "github.com/nrdcg/goacmedns/storage"
)
const (
@@ -19,56 +23,112 @@ const (
// EnvAPIBase is the environment variable name for the ACME-DNS API address.
// (e.g. https://acmedns.your-domain.com).
EnvAPIBase = envNamespace + "API_BASE"
+
+ // EnvAllowList are source networks using CIDR notation,
+ // e.g. "192.168.100.1/24,1.2.3.4/32,2002:c0a8:2a00::0/40".
+ EnvAllowList = envNamespace + "ALLOWLIST"
+
// EnvStoragePath is the environment variable name for the ACME-DNS JSON account data file.
// A per-domain account will be registered/persisted to this file and used for TXT updates.
EnvStoragePath = envNamespace + "STORAGE_PATH"
+
+ // EnvStorageBaseURL is the environment variable name for the ACME-DNS JSON account data.
+ // The URL to the storage server.
+ EnvStorageBaseURL = envNamespace + "STORAGE_BASE_URL"
)
var _ challenge.Provider = (*DNSProvider)(nil)
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIBase string
+ AllowList []string
+ StoragePath string
+ StorageBaseURL string
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{}
+}
+
// acmeDNSClient is an interface describing the goacmedns.Client functions the DNSProvider uses.
// It makes it easier for tests to shim a mock Client into the DNSProvider.
type acmeDNSClient interface {
// UpdateTXTRecord updates the provided account's TXT record
// to the given value or returns an error.
- UpdateTXTRecord(account goacmedns.Account, value string) error
+ UpdateTXTRecord(ctx context.Context, account goacmedns.Account, value string) error
// RegisterAccount registers and returns a new account
// with the given allowFrom restriction or returns an error.
- RegisterAccount(allowFrom []string) (goacmedns.Account, error)
+ RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error)
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
+ config *Config
client acmeDNSClient
storage goacmedns.Storage
}
-// NewDNSProvider creates an ACME-DNS provider using file based account storage.
-// Its configuration is loaded from the environment by reading EnvAPIBase and EnvStoragePath.
+// NewDNSProvider returns a DNSProvider instance configured for Joohoi's acme-dns.
func NewDNSProvider() (*DNSProvider, error) {
- values, err := env.Get(EnvAPIBase, EnvStoragePath)
+ values, err := env.Get(EnvAPIBase)
if err != nil {
return nil, fmt.Errorf("acme-dns: %w", err)
}
- client := goacmedns.NewClient(values[EnvAPIBase])
- storage := goacmedns.NewFileStorage(values[EnvStoragePath], 0o600)
- return NewDNSProviderClient(client, storage)
-}
+ config := NewDefaultConfig()
+ config.APIBase = values[EnvAPIBase]
+ config.StoragePath = env.GetOrFile(EnvStoragePath)
+ config.StorageBaseURL = env.GetOrFile(EnvStorageBaseURL)
-// NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and goacmedns.Storage.
-func NewDNSProviderClient(client acmeDNSClient, storage goacmedns.Storage) (*DNSProvider, error) {
- if client == nil {
- return nil, errors.New("ACME-DNS Client must be not nil")
+ allowList := env.GetOrFile(EnvAllowList)
+ if allowList != "" {
+ config.AllowList = strings.Split(allowList, ",")
}
- if storage == nil {
- return nil, errors.New("ACME-DNS Storage must be not nil")
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Joohoi's acme-dns.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("acme-dns: the configuration of the DNS provider is nil")
+ }
+
+ st, err := getStorage(config)
+ if err != nil {
+ return nil, fmt.Errorf("acme-dns: %w", err)
+ }
+
+ client, err := goacmedns.NewClient(config.APIBase)
+ if err != nil {
+ return nil, fmt.Errorf("acme-dns: new client: %w", err)
}
return &DNSProvider{
+ config: config,
client: client,
- storage: storage,
+ storage: st,
+ }, nil
+}
+
+// NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and [goacmedns.Storage].
+//
+// Deprecated: use [NewDNSProviderConfig] instead.
+func NewDNSProviderClient(client acmeDNSClient, store goacmedns.Storage) (*DNSProvider, error) {
+ if client == nil {
+ return nil, errors.New("acme-dns: Client must be not nil")
+ }
+
+ if store == nil {
+ return nil, errors.New("acme-dns: Storage must be not nil")
+ }
+
+ return &DNSProvider{
+ config: NewDefaultConfig(),
+ client: client,
+ storage: store,
}, nil
}
@@ -105,24 +165,28 @@ func (e ErrCNAMERequired) Error() string {
// one will be created and registered with the ACME DNS server and an ErrCNAMERequired error is returned.
// This will halt issuance and indicate to the user that a one-time manual setup is required for the domain.
func (d *DNSProvider) Present(domain, _, keyAuth string) error {
+ ctx := context.Background()
+
// Compute the challenge response FQDN and TXT value for the domain based on the keyAuth.
info := dns01.GetChallengeInfo(domain, keyAuth)
// Check if credentials were previously saved for this domain.
- account, err := d.storage.Fetch(domain)
+ account, err := d.storage.Fetch(ctx, domain)
if err != nil {
- if errors.Is(err, goacmedns.ErrDomainNotFound) {
- // The account did not exist.
- // Create a new one and return an error indicating the required one-time manual CNAME setup.
- return d.register(domain, info.FQDN)
+ if !errors.Is(err, storage.ErrDomainNotFound) {
+ return err
}
- // Errors other than goacmedns.ErrDomainNotFound are unexpected.
- return err
+ // The account did not exist.
+ // Create a new one and return an error indicating the required one-time manual CNAME setup.
+ account, err = d.register(ctx, domain, info.FQDN)
+ if err != nil {
+ return err
+ }
}
// Update the acme-dns TXT record.
- return d.client.UpdateTXTRecord(account, info.Value)
+ return d.client.UpdateTXTRecord(ctx, account, info.Value)
}
// CleanUp removes the record matching the specified parameters. It is not
@@ -137,29 +201,59 @@ func (d *DNSProvider) CleanUp(_, _, _ string) error {
// If account creation works as expected a ErrCNAMERequired error is returned describing
// the one-time manual CNAME setup required to complete setup of the ACME-DNS hook for the domain.
// If any other error occurs it is returned as-is.
-func (d *DNSProvider) register(domain, fqdn string) error {
- // TODO(@cpu): Read CIDR whitelists from the environment
- newAcct, err := d.client.RegisterAccount(nil)
+func (d *DNSProvider) register(ctx context.Context, domain, fqdn string) (goacmedns.Account, error) {
+ newAcct, err := d.client.RegisterAccount(ctx, d.config.AllowList)
if err != nil {
- return err
+ return goacmedns.Account{}, err
}
+ var cnameCreated bool
+
// Store the new account in the storage and call save to persist the data.
- err = d.storage.Put(domain, newAcct)
+ err = d.storage.Put(ctx, domain, newAcct)
if err != nil {
- return err
+ cnameCreated = errors.Is(err, internal.ErrCNAMEAlreadyCreated)
+ if !cnameCreated {
+ return goacmedns.Account{}, err
+ }
}
- err = d.storage.Save()
+
+ err = d.storage.Save(ctx)
if err != nil {
- return err
+ return goacmedns.Account{}, err
+ }
+
+ if cnameCreated {
+ return newAcct, nil
}
// Stop issuance by returning an error.
// The user needs to perform a manual one-time CNAME setup in their DNS zone
// to complete the setup of the new account we created.
- return ErrCNAMERequired{
+ return goacmedns.Account{}, ErrCNAMERequired{
Domain: domain,
FQDN: fqdn,
Target: newAcct.FullDomain,
}
}
+
+func getStorage(config *Config) (goacmedns.Storage, error) {
+ if config.StoragePath == "" && config.StorageBaseURL == "" {
+ return nil, errors.New("storagePath or storageBaseURL is not set")
+ }
+
+ if config.StoragePath != "" && config.StorageBaseURL != "" {
+ return nil, errors.New("storagePath and storageBaseURL cannot be used at the same time")
+ }
+
+ if config.StoragePath != "" {
+ return storage.NewFile(config.StoragePath, 0o600), nil
+ }
+
+ st, err := internal.NewHTTPStorage(config.StorageBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("new HTTP storage: %w", err)
+ }
+
+ return st, nil
+}
diff --git a/providers/dns/acmedns/acmedns.toml b/providers/dns/acmedns/acmedns.toml
index f4632411b..e491569b0 100644
--- a/providers/dns/acmedns/acmedns.toml
+++ b/providers/dns/acmedns/acmedns.toml
@@ -8,14 +8,23 @@ Since = "v1.1.0"
Example = '''
ACME_DNS_API_BASE=http://10.0.0.8:4443 \
ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \
-lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run
+lego --dns "acme-dns" -d '*.example.com' -d example.com run
+
+# or
+
+ACME_DNS_API_BASE=http://10.0.0.8:4443 \
+ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \
+lego --dns "acme-dns" -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
ACME_DNS_API_BASE = "The ACME-DNS API address"
ACME_DNS_STORAGE_PATH = "The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates."
+ ACME_DNS_STORAGE_BASE_URL = "The ACME-DNS JSON account data server."
+ [Configuration.Additional]
+ ACME_DNS_ALLOWLIST = "Source networks using CIDR notation (multiple values should be separated with a comma)."
[Links]
API = "https://github.com/joohoi/acme-dns#api"
- GoClient = "https://github.com/cpu/goacmedns"
+ GoClient = "https://github.com/nrdcg/goacmedns"
diff --git a/providers/dns/acmedns/acmedns_test.go b/providers/dns/acmedns/acmedns_test.go
index 68e8f7406..a3ab59d59 100644
--- a/providers/dns/acmedns/acmedns_test.go
+++ b/providers/dns/acmedns/acmedns_test.go
@@ -1,170 +1,28 @@
package acmedns
import (
- "errors"
+ "net/http"
+ "net/http/httptest"
"testing"
- "github.com/cpu/goacmedns"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/nrdcg/goacmedns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-var (
- // errorClientErr is used by the Client mocks that return an error.
- errorClientErr = errors.New("errorClient always errors")
- // errorStorageErr is used by the Storage mocks that return an error.
- errorStorageErr = errors.New("errorStorage always errors")
-)
-
const (
- // Fixed test data for unit tests.
egDomain = "example.com"
egFQDN = "_acme-challenge." + egDomain + "."
egKeyAuth = "⚷"
)
-var egTestAccount = goacmedns.Account{
- FullDomain: "acme-dns." + egDomain,
- SubDomain: "random-looking-junk." + egDomain,
- Username: "spooky.mulder",
- Password: "trustno1",
-}
-
-// mockClient is a mock implementing the acmeDNSClient interface that always
-// returns a fixed goacmedns.Account from calls to Register.
-type mockClient struct {
- mockAccount goacmedns.Account
-}
-
-// UpdateTXTRecord does nothing.
-func (c mockClient) UpdateTXTRecord(_ goacmedns.Account, _ string) error {
- return nil
-}
-
-// RegisterAccount returns c.mockAccount and no errors.
-func (c mockClient) RegisterAccount(_ []string) (goacmedns.Account, error) {
- return c.mockAccount, nil
-}
-
-// mockUpdateClient is a mock implementing the acmeDNSClient interface that
-// tracks the calls to UpdateTXTRecord in the records map.
-type mockUpdateClient struct {
- mockClient
- records map[goacmedns.Account]string
-}
-
-// UpdateTXTRecord saves a record value to c.records for the given acct.
-func (c mockUpdateClient) UpdateTXTRecord(acct goacmedns.Account, value string) error {
- c.records[acct] = value
- return nil
-}
-
-// errorUpdateClient is a mock implementing the acmeDNSClient interface that always
-// returns errors from errorUpdateClient.
-type errorUpdateClient struct {
- mockClient
-}
-
-// UpdateTXTRecord always returns an error.
-func (c errorUpdateClient) UpdateTXTRecord(_ goacmedns.Account, _ string) error {
- return errorClientErr
-}
-
-// errorRegisterClient is a mock implementing the acmeDNSClient interface that always
-// returns errors from RegisterAccount.
-type errorRegisterClient struct {
- mockClient
-}
-
-// RegisterAccount always returns an error.
-func (c errorRegisterClient) RegisterAccount(_ []string) (goacmedns.Account, error) {
- return goacmedns.Account{}, errorClientErr
-}
-
-// mockStorage is a mock implementing the goacmedns.Storage interface that
-// returns static account data and ignores Save.
-type mockStorage struct {
- accounts map[string]goacmedns.Account
-}
-
-// Save does nothing.
-func (m mockStorage) Save() error {
- return nil
-}
-
-// Put stores an account for the given domain in m.accounts.
-func (m mockStorage) Put(domain string, acct goacmedns.Account) error {
- m.accounts[domain] = acct
- return nil
-}
-
-// Fetch retrieves an account for the given domain from m.accounts or returns
-// goacmedns.ErrDomainNotFound.
-func (m mockStorage) Fetch(domain string) (goacmedns.Account, error) {
- if acct, ok := m.accounts[domain]; ok {
- return acct, nil
- }
- return goacmedns.Account{}, goacmedns.ErrDomainNotFound
-}
-
-// FetchAll returns all of m.accounts.
-func (m mockStorage) FetchAll() map[string]goacmedns.Account {
- return m.accounts
-}
-
-// errorPutStorage is a mock implementing the goacmedns.Storage interface that
-// always returns errors from Put.
-type errorPutStorage struct {
- mockStorage
-}
-
-// Put always errors.
-func (e errorPutStorage) Put(_ string, _ goacmedns.Account) error {
- return errorStorageErr
-}
-
-// errorSaveStorage is a mock implementing the goacmedns.Storage interface that
-// always returns errors from Save.
-type errorSaveStorage struct {
- mockStorage
-}
-
-// Save always errors.
-func (e errorSaveStorage) Save() error {
- return errorStorageErr
-}
-
-// errorFetchStorage is a mock implementing the goacmedns.Storage interface that
-// always returns errors from Fetch.
-type errorFetchStorage struct {
- mockStorage
-}
-
-// Fetch always errors.
-func (e errorFetchStorage) Fetch(_ string) (goacmedns.Account, error) {
- return goacmedns.Account{}, errorStorageErr
-}
-
-// FetchAll is a nop for errorFetchStorage.
-func (e errorFetchStorage) FetchAll() map[string]goacmedns.Account {
- return nil
-}
-
-// TestPresent tests that the ACME-DNS Present function for updating a DNS-01
-// challenge response TXT record works as expected.
func TestPresent(t *testing.T) {
// validAccountStorage is a mockStorage configured to return the egTestAccount.
- validAccountStorage := mockStorage{
- map[string]goacmedns.Account{
- egDomain: egTestAccount,
- },
- }
- // validUpdateClient is a mockClient configured with the egTestAccount that will
- // track TXT updates in a map.
- validUpdateClient := mockUpdateClient{
- mockClient{egTestAccount},
- make(map[goacmedns.Account]string),
- }
+ validAccountStorage := newMockStorage().WithAccount(egDomain, egTestAccount)
+
+ // validUpdateClient is a mockClient configured with the egTestAccount that will track TXT updates in a map.
+ validUpdateClient := newMockClient()
testCases := []struct {
Name string
@@ -174,13 +32,13 @@ func TestPresent(t *testing.T) {
}{
{
Name: "present when client storage returns unexpected error",
- Client: mockClient{egTestAccount},
- Storage: errorFetchStorage{},
+ Client: newMockClient().WithRegisterAccount(egTestAccount),
+ Storage: newMockStorage().WithFetchError(errorStorageErr),
ExpectedError: errorStorageErr,
},
{
Name: "present when client storage returns ErrDomainNotFound",
- Client: mockClient{egTestAccount},
+ Client: newMockClient().WithRegisterAccount(egTestAccount),
ExpectedError: ErrCNAMERequired{
Domain: egDomain,
FQDN: egFQDN,
@@ -189,7 +47,7 @@ func TestPresent(t *testing.T) {
},
{
Name: "present when client UpdateTXTRecord returns unexpected error",
- Client: errorUpdateClient{},
+ Client: newMockClient().WithUpdateTXTRecordError(errorClientErr),
Storage: validAccountStorage,
ExpectedError: errorClientErr,
},
@@ -202,17 +60,17 @@ func TestPresent(t *testing.T) {
for _, test := range testCases {
t.Run(test.Name, func(t *testing.T) {
- dp, err := NewDNSProviderClient(test.Client, mockStorage{make(map[string]goacmedns.Account)})
- require.NoError(t, err)
-
- // override the storage mock if required by the test case.
- if test.Storage != nil {
- dp.storage = test.Storage
+ p := &DNSProvider{
+ config: NewDefaultConfig(),
+ client: test.Client,
+ storage: newMockStorage(),
}
- // call Present. The token argument can be garbage because the ACME-DNS
- // provider does not use it.
- err = dp.Present(egDomain, "foo", egKeyAuth)
+ if test.Storage != nil {
+ p.storage = test.Storage
+ }
+
+ err := p.Present(egDomain, "foo", egKeyAuth)
if test.ExpectedError != nil {
assert.Equal(t, test.ExpectedError, err)
} else {
@@ -228,36 +86,33 @@ func TestPresent(t *testing.T) {
assert.Len(t, validUpdateClient.records[egTestAccount], 43)
}
-// TestRegister tests that the ACME-DNS register function works correctly.
func TestRegister(t *testing.T) {
testCases := []struct {
Name string
Client acmeDNSClient
Storage goacmedns.Storage
- Domain string
- FQDN string
ExpectedError error
}{
{
Name: "register when acme-dns client returns an error",
- Client: errorRegisterClient{},
+ Client: newMockClient().WithRegisterAccountError(errorClientErr),
ExpectedError: errorClientErr,
},
{
Name: "register when acme-dns storage put returns an error",
- Client: mockClient{egTestAccount},
- Storage: errorPutStorage{mockStorage{make(map[string]goacmedns.Account)}},
+ Client: newMockClient().WithRegisterAccount(egTestAccount),
+ Storage: newMockStorage().WithPutError(errorStorageErr),
ExpectedError: errorStorageErr,
},
{
Name: "register when acme-dns storage save returns an error",
- Client: mockClient{egTestAccount},
- Storage: errorSaveStorage{mockStorage{make(map[string]goacmedns.Account)}},
+ Client: newMockClient().WithRegisterAccount(egTestAccount),
+ Storage: newMockStorage().WithSaveError(errorStorageErr),
ExpectedError: errorStorageErr,
},
{
Name: "register when everything works",
- Client: mockClient{egTestAccount},
+ Client: newMockClient().WithRegisterAccount(egTestAccount),
ExpectedError: ErrCNAMERequired{
Domain: egDomain,
FQDN: egFQDN,
@@ -268,21 +123,121 @@ func TestRegister(t *testing.T) {
for _, test := range testCases {
t.Run(test.Name, func(t *testing.T) {
- dp, err := NewDNSProviderClient(test.Client, mockStorage{make(map[string]goacmedns.Account)})
- require.NoError(t, err)
-
- // override the storage mock if required by the testcase.
- if test.Storage != nil {
- dp.storage = test.Storage
+ p := &DNSProvider{
+ config: NewDefaultConfig(),
+ client: test.Client,
+ storage: newMockStorage(),
}
- // Call register for the example domain/fqdn.
- err = dp.register(egDomain, egFQDN)
+ if test.Storage != nil {
+ p.storage = test.Storage
+ }
+
+ acc, err := p.register(t.Context(), egDomain, egFQDN)
if test.ExpectedError != nil {
assert.Equal(t, test.ExpectedError, err)
} else {
+ assert.Equal(t, goacmedns.Account{}, acc)
require.NoError(t, err)
}
})
}
}
+
+func TestPresent_httpStorage(t *testing.T) {
+ testCases := []struct {
+ desc string
+ StatusCode int
+ ExpectedError error
+ }{
+ {
+ desc: "the CNAME is not handled by the storage",
+ StatusCode: http.StatusOK,
+ ExpectedError: ErrCNAMERequired{
+ Domain: egDomain,
+ FQDN: egFQDN,
+ Target: egTestAccount.FullDomain,
+ },
+ },
+ {
+ desc: "the CNAME is handled by the storage",
+ StatusCode: http.StatusCreated,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ provider := servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.StorageBaseURL = server.URL
+
+ return NewDNSProviderConfig(config)
+ }).
+ // Fetch
+ Route("GET /example.com", servermock.Noop().WithStatusCode(http.StatusNotFound)).
+ // Put
+ Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)).
+ Build(t)
+
+ client := newMockClient().WithRegisterAccount(egTestAccount)
+ provider.client = client
+
+ err := provider.Present(egDomain, "foo", egKeyAuth)
+ if test.ExpectedError != nil {
+ assert.EqualError(t, err, test.ExpectedError.Error())
+ assert.True(t, client.registerAccountCalled)
+ assert.False(t, client.updateTXTRecordCalled)
+ } else {
+ require.NoError(t, err)
+ assert.True(t, client.registerAccountCalled)
+ assert.True(t, client.updateTXTRecordCalled)
+ }
+ })
+ }
+}
+
+func TestRegister_httpStorage(t *testing.T) {
+ testCases := []struct {
+ Name string
+ StatusCode int
+ ExpectedError error
+ }{
+ {
+ Name: "status code 200",
+ StatusCode: http.StatusOK,
+ ExpectedError: ErrCNAMERequired{
+ Domain: egDomain,
+ FQDN: egFQDN,
+ Target: egTestAccount.FullDomain,
+ },
+ },
+ {
+ Name: "status code 201",
+ StatusCode: http.StatusCreated,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.Name, func(t *testing.T) {
+ provider := servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.StorageBaseURL = server.URL
+
+ return NewDNSProviderConfig(config)
+ }).
+ // Put
+ Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)).
+ Build(t)
+
+ provider.client = newMockClient().WithRegisterAccount(egTestAccount)
+
+ acc, err := provider.register(t.Context(), egDomain, egFQDN)
+ if test.ExpectedError != nil {
+ assert.Equal(t, test.ExpectedError, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, egTestAccount, acc)
+ }
+ })
+ }
+}
diff --git a/providers/dns/acmedns/internal/fixtures/error.json b/providers/dns/acmedns/internal/fixtures/error.json
new file mode 100644
index 000000000..d1b2ba3be
--- /dev/null
+++ b/providers/dns/acmedns/internal/fixtures/error.json
@@ -0,0 +1,3 @@
+{
+ "message": "There is an error"
+}
diff --git a/providers/dns/acmedns/internal/fixtures/fetch-request.json b/providers/dns/acmedns/internal/fixtures/fetch-request.json
new file mode 100644
index 000000000..d29cebc5b
--- /dev/null
+++ b/providers/dns/acmedns/internal/fixtures/fetch-request.json
@@ -0,0 +1,7 @@
+{
+ "fulldomain": "foo.example.com",
+ "subdomain": "foo",
+ "username": "user",
+ "password": "secret",
+ "server_url": "https://example.com"
+}
diff --git a/providers/dns/acmedns/internal/fixtures/fetch.json b/providers/dns/acmedns/internal/fixtures/fetch.json
new file mode 100644
index 000000000..d29cebc5b
--- /dev/null
+++ b/providers/dns/acmedns/internal/fixtures/fetch.json
@@ -0,0 +1,7 @@
+{
+ "fulldomain": "foo.example.com",
+ "subdomain": "foo",
+ "username": "user",
+ "password": "secret",
+ "server_url": "https://example.com"
+}
diff --git a/providers/dns/acmedns/internal/fixtures/fetch_all.json b/providers/dns/acmedns/internal/fixtures/fetch_all.json
new file mode 100644
index 000000000..9ea557b38
--- /dev/null
+++ b/providers/dns/acmedns/internal/fixtures/fetch_all.json
@@ -0,0 +1,16 @@
+{
+ "a": {
+ "fulldomain": "foo.example.com",
+ "subdomain": "foo",
+ "username": "user",
+ "password": "secret",
+ "server_url": "https://example.com"
+ },
+ "b": {
+ "fulldomain": "bar.example.com",
+ "subdomain": "bar",
+ "username": "user",
+ "password": "secret",
+ "server_url": "https://example.com"
+ }
+}
diff --git a/providers/dns/acmedns/internal/http_storage.go b/providers/dns/acmedns/internal/http_storage.go
new file mode 100644
index 000000000..7a535eb20
--- /dev/null
+++ b/providers/dns/acmedns/internal/http_storage.go
@@ -0,0 +1,147 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/nrdcg/goacmedns"
+ "github.com/nrdcg/goacmedns/storage"
+)
+
+var _ goacmedns.Storage = (*HTTPStorage)(nil)
+
+var ErrCNAMEAlreadyCreated = errors.New("the CNAME has already been created")
+
+// HTTPStorage is an implementation of [acmedns.Storage] over HTTP.
+type HTTPStorage struct {
+ client *http.Client
+ baseURL *url.URL
+}
+
+// NewHTTPStorage created a new [HTTPStorage].
+func NewHTTPStorage(baseURL string) (*HTTPStorage, error) {
+ endpoint, err := url.Parse(baseURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &HTTPStorage{
+ client: &http.Client{Timeout: 2 * time.Minute},
+ baseURL: endpoint,
+ }, nil
+}
+
+func (s *HTTPStorage) Save(_ context.Context) error {
+ return nil
+}
+
+func (s *HTTPStorage) Put(ctx context.Context, domain string, account goacmedns.Account) error {
+ req, err := newJSONRequest(ctx, http.MethodPost, s.baseURL.JoinPath(domain), account)
+ if err != nil {
+ return fmt.Errorf("unable to create request: %w", err)
+ }
+
+ return s.do(req, nil)
+}
+
+func (s *HTTPStorage) Fetch(ctx context.Context, domain string) (goacmedns.Account, error) {
+ req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL.JoinPath(domain), nil)
+ if err != nil {
+ return goacmedns.Account{}, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ var account goacmedns.Account
+
+ err = s.do(req, &account)
+ if err != nil {
+ return goacmedns.Account{}, err
+ }
+
+ return account, nil
+}
+
+func (s *HTTPStorage) FetchAll(ctx context.Context) (map[string]goacmedns.Account, error) {
+ req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var mapping map[string]goacmedns.Account
+
+ err = s.do(req, &mapping)
+ if err != nil {
+ return nil, err
+ }
+
+ return mapping, nil
+}
+
+func (s *HTTPStorage) do(req *http.Request, result any) error {
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return storage.ErrDomainNotFound
+ }
+
+ if resp.StatusCode/100 != 2 {
+ return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+ }
+
+ if result == nil {
+ // Hack related to `Put`.
+ if resp.StatusCode == http.StatusCreated {
+ return ErrCNAMEAlreadyCreated
+ }
+
+ 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
+}
diff --git a/providers/dns/acmedns/internal/http_storage_test.go b/providers/dns/acmedns/internal/http_storage_test.go
new file mode 100644
index 000000000..5c166b47f
--- /dev/null
+++ b/providers/dns/acmedns/internal/http_storage_test.go
@@ -0,0 +1,153 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/nrdcg/goacmedns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*HTTPStorage] {
+ return servermock.NewBuilder[*HTTPStorage](
+ func(server *httptest.Server) (*HTTPStorage, error) {
+ storage, err := NewHTTPStorage(server.URL)
+ if err != nil {
+ return nil, err
+ }
+
+ storage.client = server.Client()
+
+ return storage, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders())
+}
+
+func TestHTTPStorage_Fetch(t *testing.T) {
+ storage := mockBuilder().
+ Route("GET /example.com", servermock.ResponseFromFixture("fetch.json")).
+ Build(t)
+
+ account, err := storage.Fetch(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := goacmedns.Account{
+ FullDomain: "foo.example.com",
+ SubDomain: "foo",
+ Username: "user",
+ Password: "secret",
+ ServerURL: "https://example.com",
+ }
+
+ assert.Equal(t, expected, account)
+}
+
+func TestHTTPStorage_Fetch_error(t *testing.T) {
+ storage := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ _, err := storage.Fetch(t.Context(), "example.com")
+ require.Error(t, err)
+}
+
+func TestHTTPStorage_FetchAll(t *testing.T) {
+ storage := mockBuilder().
+ Route("GET /", servermock.ResponseFromFixture("fetch_all.json")).
+ Build(t)
+
+ account, err := storage.FetchAll(t.Context())
+ require.NoError(t, err)
+
+ expected := map[string]goacmedns.Account{
+ "a": {
+ FullDomain: "foo.example.com",
+ SubDomain: "foo",
+ Username: "user",
+ Password: "secret",
+ ServerURL: "https://example.com",
+ },
+ "b": {
+ FullDomain: "bar.example.com",
+ SubDomain: "bar",
+ Username: "user",
+ Password: "secret",
+ ServerURL: "https://example.com",
+ },
+ }
+
+ assert.Equal(t, expected, account)
+}
+
+func TestHTTPStorage_FetchAll_error(t *testing.T) {
+ storage := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ _, err := storage.FetchAll(t.Context())
+ require.Error(t, err)
+}
+
+func TestHTTPStorage_Put(t *testing.T) {
+ storage := mockBuilder().
+ Route("POST /example.com", nil,
+ servermock.CheckRequestJSONBodyFromFixture("fetch-request.json")).
+ Build(t)
+
+ account := goacmedns.Account{
+ FullDomain: "foo.example.com",
+ SubDomain: "foo",
+ Username: "user",
+ Password: "secret",
+ ServerURL: "https://example.com",
+ }
+
+ err := storage.Put(t.Context(), "example.com", account)
+ require.NoError(t, err)
+}
+
+func TestHTTPStorage_Put_error(t *testing.T) {
+ storage := mockBuilder().
+ Route("POST /example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ account := goacmedns.Account{
+ FullDomain: "foo.example.com",
+ SubDomain: "foo",
+ Username: "user",
+ Password: "secret",
+ ServerURL: "https://example.com",
+ }
+
+ err := storage.Put(t.Context(), "example.com", account)
+ require.Error(t, err)
+}
+
+func TestHTTPStorage_Put_CNAME_created(t *testing.T) {
+ storage := mockBuilder().
+ Route("POST /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromFixture("fetch-request.json")).
+ Build(t)
+
+ account := goacmedns.Account{
+ FullDomain: "foo.example.com",
+ SubDomain: "foo",
+ Username: "user",
+ Password: "secret",
+ ServerURL: "https://example.com",
+ }
+
+ err := storage.Put(t.Context(), "example.com", account)
+ require.ErrorIs(t, err, ErrCNAMEAlreadyCreated)
+}
diff --git a/providers/dns/acmedns/internal/readme.md b/providers/dns/acmedns/internal/readme.md
new file mode 100644
index 000000000..b667d3d23
--- /dev/null
+++ b/providers/dns/acmedns/internal/readme.md
@@ -0,0 +1,72 @@
+# HTTP Storage
+
+## Fetch
+
+### Request
+
+Endpoint: `GET /`
+
+### Response
+
+Response status code 200.
+
+Response body (account):
+
+```json
+{
+ "fulldomain": "foo.example.com",
+ "subdomain": "foo",
+ "username": "user",
+ "password": "secret",
+ "server_url": "https://example.com"
+}
+```
+
+## Fetch All
+
+### Request
+
+Endpoint: `GET `
+
+### Response
+
+Response status code 200.
+
+Response body (domain/account mapping):
+
+```json
+{
+ "foo.example.com": {
+ "fulldomain": "foo.example.com",
+ "subdomain": "foo",
+ "username": "user",
+ "password": "secret",
+ "server_url": "https://example.com"
+ },
+ "bar.example.com": {
+ "fulldomain": "bar.example.com",
+ "subdomain": "bar",
+ "username": "user",
+ "password": "secret",
+ "server_url": "https://example.com"
+ }
+}
+```
+
+## Put
+
+### Request
+
+Endpoint: `POST /`
+
+### Response
+
+Response status code:
+- 200: the process will be stopped to allow the user to create the CNAME.
+- 201: the process will continue without error (the CNAME should be created by the server)
+
+No expected body.
+
+## Save
+
+No dedicated endpoint.
diff --git a/providers/dns/acmedns/mock_test.go b/providers/dns/acmedns/mock_test.go
new file mode 100644
index 000000000..a09a3ca91
--- /dev/null
+++ b/providers/dns/acmedns/mock_test.go
@@ -0,0 +1,161 @@
+package acmedns
+
+import (
+ "context"
+ "errors"
+
+ "github.com/nrdcg/goacmedns"
+ "github.com/nrdcg/goacmedns/storage"
+)
+
+var (
+ // errorClientErr is used by the Client mocks that return an error.
+ errorClientErr = errors.New("errorClient always errors")
+ // errorStorageErr is used by the Storage mocks that return an error.
+ errorStorageErr = errors.New("errorStorage always errors")
+)
+
+var egTestAccount = goacmedns.Account{
+ FullDomain: "acme-dns." + egDomain,
+ SubDomain: "random-looking-junk." + egDomain,
+ Username: "spooky.mulder",
+ Password: "trustno1",
+}
+
+type mockClient struct {
+ records map[goacmedns.Account]string
+
+ updateTXTRecordCalled bool
+ updateTXTRecord func(ctx context.Context, acct goacmedns.Account, value string) error
+
+ registerAccountCalled bool
+ registerAccount func(ctx context.Context, allowFrom []string) (goacmedns.Account, error)
+}
+
+func newMockClient() *mockClient {
+ return &mockClient{
+ records: make(map[goacmedns.Account]string),
+ updateTXTRecord: func(_ context.Context, _ goacmedns.Account, _ string) error {
+ return nil
+ },
+ registerAccount: func(_ context.Context, _ []string) (goacmedns.Account, error) {
+ return goacmedns.Account{}, nil
+ },
+ }
+}
+
+func (c *mockClient) UpdateTXTRecord(ctx context.Context, acct goacmedns.Account, value string) error {
+ c.updateTXTRecordCalled = true
+ c.records[acct] = value
+
+ return c.updateTXTRecord(ctx, acct, value)
+}
+
+func (c *mockClient) RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error) {
+ c.registerAccountCalled = true
+ return c.registerAccount(ctx, allowFrom)
+}
+
+func (c *mockClient) WithUpdateTXTRecordError(err error) *mockClient {
+ c.updateTXTRecord = func(_ context.Context, _ goacmedns.Account, _ string) error {
+ return err
+ }
+
+ return c
+}
+
+func (c *mockClient) WithRegisterAccount(acct goacmedns.Account) *mockClient {
+ c.registerAccount = func(_ context.Context, _ []string) (goacmedns.Account, error) {
+ return acct, nil
+ }
+
+ return c
+}
+
+func (c *mockClient) WithRegisterAccountError(err error) *mockClient {
+ c.registerAccount = func(_ context.Context, _ []string) (goacmedns.Account, error) {
+ return goacmedns.Account{}, err
+ }
+
+ return c
+}
+
+type mockStorage struct {
+ accounts map[string]goacmedns.Account
+ fetchAll func(ctx context.Context) (map[string]goacmedns.Account, error)
+ fetch func(ctx context.Context, domain string) (goacmedns.Account, error)
+ put func(ctx context.Context, domain string, acct goacmedns.Account) error
+ save func(ctx context.Context) error
+}
+
+func newMockStorage() *mockStorage {
+ m := &mockStorage{
+ accounts: make(map[string]goacmedns.Account),
+ put: func(_ context.Context, _ string, _ goacmedns.Account) error {
+ return nil
+ },
+ save: func(_ context.Context) error {
+ return nil
+ },
+ }
+
+ m.fetchAll = func(ctx context.Context) (map[string]goacmedns.Account, error) {
+ return m.accounts, nil
+ }
+
+ m.fetch = func(_ context.Context, domain string) (goacmedns.Account, error) {
+ if acct, ok := m.accounts[domain]; ok {
+ return acct, nil
+ }
+
+ return goacmedns.Account{}, storage.ErrDomainNotFound
+ }
+
+ return m
+}
+
+func (m *mockStorage) FetchAll(ctx context.Context) (map[string]goacmedns.Account, error) {
+ return m.fetchAll(ctx)
+}
+
+func (m *mockStorage) Fetch(ctx context.Context, domain string) (goacmedns.Account, error) {
+ return m.fetch(ctx, domain)
+}
+
+func (m *mockStorage) Put(ctx context.Context, domain string, account goacmedns.Account) error {
+ return m.put(ctx, domain, account)
+}
+
+func (m *mockStorage) Save(ctx context.Context) error {
+ return m.save(ctx)
+}
+
+func (m *mockStorage) WithAccount(domain string, acct goacmedns.Account) *mockStorage {
+ m.accounts[domain] = acct
+
+ return m
+}
+
+func (m *mockStorage) WithFetchError(err error) *mockStorage {
+ m.fetch = func(_ context.Context, _ string) (goacmedns.Account, error) {
+ return goacmedns.Account{}, err
+ }
+
+ return m
+}
+
+func (m *mockStorage) WithPutError(err error) *mockStorage {
+ m.put = func(_ context.Context, _ string, _ goacmedns.Account) error {
+ return err
+ }
+
+ return m
+}
+
+func (m *mockStorage) WithSaveError(err error) *mockStorage {
+ m.save = func(ctx context.Context) error {
+ return err
+ }
+
+ return m
+}
diff --git a/providers/dns/active24/active24.go b/providers/dns/active24/active24.go
new file mode 100644
index 000000000..0b925de6a
--- /dev/null
+++ b/providers/dns/active24/active24.go
@@ -0,0 +1,103 @@
+// Package active24 implements a DNS provider for solving the DNS-01 challenge using Active24.
+package active24
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/active24"
+)
+
+const baseAPIDomain = "active24.cz"
+
+// Environment variables names.
+const (
+ envNamespace = "ACTIVE24_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+ EnvSecret = envNamespace + "SECRET"
+
+ 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 = active24.Config
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ prv challenge.ProviderTimeout
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Active24.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey, EnvSecret)
+ if err != nil {
+ return nil, fmt.Errorf("active24: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+ config.Secret = values[EnvSecret]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Active24.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("active24: the configuration of the DNS provider is nil")
+ }
+
+ provider, err := active24.NewDNSProviderConfig(config, baseAPIDomain)
+ if err != nil {
+ return nil, fmt.Errorf("active24: %w", err)
+ }
+
+ return &DNSProvider{prv: provider}, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("active24: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("active24: %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.prv.Timeout()
+}
diff --git a/providers/dns/active24/active24.toml b/providers/dns/active24/active24.toml
new file mode 100644
index 000000000..b0eaabab8
--- /dev/null
+++ b/providers/dns/active24/active24.toml
@@ -0,0 +1,25 @@
+Name = "Active24"
+Description = ''''''
+URL = "https://www.active24.cz"
+Code = "active24"
+Since = "v4.23.0"
+
+Example = '''
+ACTIVE24_API_KEY="xxx" \
+ACTIVE24_SECRET="yyy" \
+lego --dns active24 -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ACTIVE24_API_KEY = "API key"
+ ACTIVE24_SECRET = "Secret"
+ [Configuration.Additional]
+ ACTIVE24_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ACTIVE24_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ACTIVE24_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ ACTIVE24_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://rest.active24.cz/v2/docs"
+ APIv1 = "https://rest.active24.cz/docs/v1.service#services"
diff --git a/providers/dns/active24/active24_test.go b/providers/dns/active24/active24_test.go
new file mode 100644
index 000000000..2987fb27b
--- /dev/null
+++ b/providers/dns/active24/active24_test.go
@@ -0,0 +1,146 @@
+package active24
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIKey: "user",
+ EnvSecret: "secret",
+ },
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{
+ EnvAPIKey: "",
+ EnvSecret: "secret",
+ },
+ expected: "active24: some credentials information are missing: ACTIVE24_API_KEY",
+ },
+ {
+ desc: "missing secret",
+ envVars: map[string]string{
+ EnvAPIKey: "user",
+ EnvSecret: "",
+ },
+ expected: "active24: some credentials information are missing: ACTIVE24_SECRET",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "active24: some credentials information are missing: ACTIVE24_API_KEY,ACTIVE24_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.prv)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ secret string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "user",
+ secret: "secret",
+ },
+ {
+ desc: "missing API key",
+ apiKey: "",
+ secret: "secret",
+ expected: "active24: credentials missing",
+ },
+ {
+ desc: "missing secret",
+ apiKey: "user",
+ secret: "",
+ expected: "active24: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "active24: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+ config.Secret = test.secret
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.prv)
+ } 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/dns/alidns/alidns.go b/providers/dns/alidns/alidns.go
index 9129eef09..cdd8e75e0 100644
--- a/providers/dns/alidns/alidns.go
+++ b/providers/dns/alidns/alidns.go
@@ -2,18 +2,19 @@
package alidns
import (
+ "context"
"errors"
"fmt"
"time"
- "github.com/aliyun/alibaba-cloud-sdk-go/sdk"
- "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth"
- "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials"
- "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
- "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns"
+ openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
+ "github.com/alibabacloud-go/tea/dara"
+ "github.com/aliyun/credentials-go/credentials"
+ alidns "github.com/go-acme/alidns-20150109/v4/client"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
"golang.org/x/net/idna"
)
@@ -26,6 +27,7 @@ const (
EnvSecretKey = envNamespace + "SECRET_KEY"
EnvSecurityToken = envNamespace + "SECURITY_TOKEN"
EnvRegionID = envNamespace + "REGION_ID"
+ EnvLine = envNamespace + "LINE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -44,6 +46,7 @@ type Config struct {
SecretKey string
SecurityToken string
RegionID string
+ Line string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@@ -73,6 +76,7 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.RegionID = env.GetOrFile(EnvRegionID)
+ config.Line = env.GetOrFile(EnvLine)
values, err := env.Get(EnvRAMRole)
if err == nil {
@@ -102,23 +106,42 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
config.RegionID = defaultRegionID
}
- var credential auth.Credential
+ cfg := new(openapi.Config).
+ SetRegionId(config.RegionID).
+ SetReadTimeout(int(config.HTTPTimeout.Milliseconds()))
+
switch {
case config.RAMRole != "":
- credential = credentials.NewEcsRamRoleCredential(config.RAMRole)
+ // https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance
+ credentialsCfg := new(credentials.Config).
+ SetType("ecs_ram_role").
+ SetRoleName(config.RAMRole)
+
+ credentialClient, err := credentials.NewCredential(credentialsCfg)
+ if err != nil {
+ return nil, fmt.Errorf("alicloud: new credential: %w", err)
+ }
+
+ cfg = cfg.SetCredential(credentialClient)
+
case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "":
- credential = credentials.NewStsTokenCredential(config.APIKey, config.SecretKey, config.SecurityToken)
+ cfg = cfg.
+ SetAccessKeyId(config.APIKey).
+ SetAccessKeySecret(config.SecretKey).
+ SetSecurityToken(config.SecurityToken)
+
case config.APIKey != "" && config.SecretKey != "":
- credential = credentials.NewAccessKeyCredential(config.APIKey, config.SecretKey)
+ cfg = cfg.
+ SetAccessKeyId(config.APIKey).
+ SetAccessKeySecret(config.SecretKey)
+
default:
return nil, errors.New("alicloud: ram role or credentials missing")
}
- conf := sdk.NewConfig().WithTimeout(config.HTTPTimeout)
-
- client, err := alidns.NewClientWithOptions(config.RegionID, conf, credential)
+ client, err := alidns.NewClient(cfg)
if err != nil {
- return nil, fmt.Errorf("alicloud: credentials failed: %w", err)
+ return nil, fmt.Errorf("alicloud: new client: %w", err)
}
return &DNSProvider{config: config, client: client}, nil
@@ -132,67 +155,76 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// 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)
- zoneName, err := d.getHostedZone(info.EffectiveFQDN)
+ zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("alicloud: %w", err)
}
- recordAttributes, err := d.newTxtRecord(zoneName, info.EffectiveFQDN, info.Value)
+ recordRequest, err := d.newTxtRecord(zoneName, info.EffectiveFQDN, info.Value)
if err != nil {
return err
}
- _, err = d.client.AddDomainRecord(recordAttributes)
+ _, err = alidns.AddDomainRecordWithContext(ctx, d.client, recordRequest, &dara.RuntimeOptions{})
if err != nil {
return fmt.Errorf("alicloud: API call failed: %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)
- records, err := d.findTxtRecords(info.EffectiveFQDN)
+ records, err := d.findTxtRecords(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("alicloud: %w", err)
}
- _, err = d.getHostedZone(info.EffectiveFQDN)
+ _, err = d.getHostedZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("alicloud: %w", err)
}
for _, rec := range records {
- request := alidns.CreateDeleteDomainRecordRequest()
- request.RecordId = rec.RecordId
- _, err = d.client.DeleteDomainRecord(request)
+ request := &alidns.DeleteDomainRecordRequest{
+ RecordId: rec.RecordId,
+ }
+
+ _, err = alidns.DeleteDomainRecordWithContext(ctx, d.client, request, &dara.RuntimeOptions{})
if err != nil {
return fmt.Errorf("alicloud: %w", err)
}
}
+
return nil
}
-func (d *DNSProvider) getHostedZone(domain string) (string, error) {
- request := alidns.CreateDescribeDomainsRequest()
+func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) {
+ request := new(alidns.DescribeDomainsRequest)
- var domains []alidns.DomainInDescribeDomains
- startPage := 1
+ var domains []*alidns.DescribeDomainsResponseBodyDomainsDomain
+
+ var startPage int64 = 1
for {
- request.PageNumber = requests.NewInteger(startPage)
+ request.SetPageNumber(startPage)
- response, err := d.client.DescribeDomains(request)
+ response, err := alidns.DescribeDomainsWithContext(ctx, d.client, request, &dara.RuntimeOptions{})
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}
- domains = append(domains, response.Domains.Domain...)
+ domains = append(domains, response.Body.Domains.Domain...)
- if response.PageNumber*response.PageSize >= response.TotalCount {
+ if ptr.Deref(response.Body.PageNumber)*ptr.Deref(response.Body.PageSize) >= ptr.Deref(response.Body.TotalCount) {
break
}
@@ -204,50 +236,54 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) {
return "", fmt.Errorf("could not find zone: %w", err)
}
- var hostedZone alidns.DomainInDescribeDomains
+ var hostedZone *alidns.DescribeDomainsResponseBodyDomainsDomain
+
for _, zone := range domains {
- if zone.DomainName == dns01.UnFqdn(authZone) || zone.PunyCode == dns01.UnFqdn(authZone) {
+ if ptr.Deref(zone.DomainName) == dns01.UnFqdn(authZone) || ptr.Deref(zone.PunyCode) == dns01.UnFqdn(authZone) {
hostedZone = zone
}
}
- if hostedZone.DomainId == "" {
+ if hostedZone == nil || ptr.Deref(hostedZone.DomainId) == "" {
return "", fmt.Errorf("zone %s not found in AliDNS for domain %s", authZone, domain)
}
- return hostedZone.DomainName, nil
+ return ptr.Deref(hostedZone.DomainName), nil
}
func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) (*alidns.AddDomainRecordRequest, error) {
- request := alidns.CreateAddDomainRecordRequest()
- request.Type = "TXT"
- request.DomainName = zone
-
- var err error
- request.RR, err = extractRecordName(fqdn, zone)
+ rr, err := extractRecordName(fqdn, zone)
if err != nil {
return nil, err
}
- request.Value = value
- request.TTL = requests.NewInteger(d.config.TTL)
+ adrr := new(alidns.AddDomainRecordRequest).
+ SetType("TXT").
+ SetDomainName(zone).
+ SetRR(rr).
+ SetValue(value).
+ SetTTL(int64(d.config.TTL))
- return request, nil
+ if d.config.Line != "" {
+ adrr.SetLine(d.config.Line)
+ }
+
+ return adrr, nil
}
-func (d *DNSProvider) findTxtRecords(fqdn string) ([]alidns.Record, error) {
- zoneName, err := d.getHostedZone(fqdn)
+func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord, error) {
+ zoneName, err := d.getHostedZone(ctx, fqdn)
if err != nil {
return nil, err
}
- request := alidns.CreateDescribeDomainRecordsRequest()
- request.DomainName = zoneName
- request.PageSize = requests.NewInteger(500)
+ request := new(alidns.DescribeDomainRecordsRequest).
+ SetDomainName(zoneName).
+ SetPageSize(500)
- var records []alidns.Record
+ var records []*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord
- result, err := d.client.DescribeDomainRecords(request)
+ result, err := alidns.DescribeDomainRecordsWithContext(ctx, d.client, request, &dara.RuntimeOptions{})
if err != nil {
return records, fmt.Errorf("API call has failed: %w", err)
}
@@ -257,11 +293,12 @@ func (d *DNSProvider) findTxtRecords(fqdn string) ([]alidns.Record, error) {
return nil, err
}
- for _, record := range result.DomainRecords.Record {
- if record.RR == recordName && record.Type == "TXT" {
+ for _, record := range result.Body.DomainRecords.Record {
+ if ptr.Deref(record.RR) == recordName && ptr.Deref(record.Type) == "TXT" {
records = append(records, record)
}
}
+
return records, nil
}
diff --git a/providers/dns/alidns/alidns.toml b/providers/dns/alidns/alidns.toml
index e2d5af8f8..b78e1859d 100644
--- a/providers/dns/alidns/alidns.toml
+++ b/providers/dns/alidns/alidns.toml
@@ -7,27 +7,30 @@ Since = "v1.1.0"
Example = '''
# Setup using instance RAM role
ALICLOUD_RAM_ROLE=lego \
-lego --email you@example.com --dns alidns -d '*.example.com' -d example.com run
+lego --dns alidns -d '*.example.com' -d example.com run
# Or, using credentials
ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx \
ALICLOUD_SECRET_KEY=your-secret-key \
ALICLOUD_SECURITY_TOKEN=your-sts-token \
-lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com run
+lego --dns alidns - -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
- ALICLOUD_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/doc-detail/54579.htm)"
+ ALICLOUD_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)"
ALICLOUD_ACCESS_KEY = "Access key ID"
ALICLOUD_SECRET_KEY = "Access Key secret"
ALICLOUD_SECURITY_TOKEN = "STS Security Token (optional)"
[Configuration.Additional]
- ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
- ALICLOUD_HTTP_TIMEOUT = "API request timeout"
+ ALICLOUD_REGION_ID = "Region ID (Default: cn-hangzhou)"
+ ALICLOUD_LINE = "Line (Default: default)"
+ ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ ALICLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://www.alibabacloud.com/help/en/alibaba-cloud-dns/latest/api-alidns-2015-01-09-dir-parsing-records"
- GoClient = "https://github.com/aliyun/alibaba-cloud-sdk-go"
+ GoClient = "https://github.com/alibabacloud-go/alidns-20150109"
+ GoClient2 = "https://github.com/aliyun/alibabacloud-go-sdk/tree/HEAD/alidns-20150109"
diff --git a/providers/dns/alidns/alidns_test.go b/providers/dns/alidns/alidns_test.go
index 487997813..b1e482d2d 100644
--- a/providers/dns/alidns/alidns_test.go
+++ b/providers/dns/alidns/alidns_test.go
@@ -64,6 +64,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -142,6 +143,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -155,6 +157,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/aliesa/aliesa.go b/providers/dns/aliesa/aliesa.go
new file mode 100644
index 000000000..2a38389be
--- /dev/null
+++ b/providers/dns/aliesa/aliesa.go
@@ -0,0 +1,255 @@
+// Package aliesa implements a DNS provider for solving the DNS-01 challenge using AlibabaCloud ESA.
+package aliesa
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
+ "github.com/alibabacloud-go/tea/dara"
+ "github.com/aliyun/credentials-go/credentials"
+ esa "github.com/go-acme/esa-20240910/v2/client"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ALIESA_"
+
+ EnvRAMRole = envNamespace + "RAM_ROLE"
+ EnvAccessKey = envNamespace + "ACCESS_KEY"
+ EnvSecretKey = envNamespace + "SECRET_KEY"
+ EnvSecurityToken = envNamespace + "SECURITY_TOKEN"
+ EnvRegionID = envNamespace + "REGION_ID"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultRegionID = "cn-hangzhou"
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ RAMRole string
+ APIKey string
+ SecretKey string
+ SecurityToken string
+ RegionID string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPTimeout time.Duration
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *esa.Client
+
+ recordIDs map[string]int64
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for AlibabaCloud ESA.
+func NewDNSProvider() (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.RegionID = env.GetOrFile(EnvRegionID)
+
+ values, err := env.Get(EnvRAMRole)
+ if err == nil {
+ config.RAMRole = values[EnvRAMRole]
+ return NewDNSProviderConfig(config)
+ }
+
+ values, err = env.Get(EnvAccessKey, EnvSecretKey)
+ if err != nil {
+ return nil, fmt.Errorf("aliesa: %w", err)
+ }
+
+ config.APIKey = values[EnvAccessKey]
+ config.SecretKey = values[EnvSecretKey]
+ config.SecurityToken = env.GetOrFile(EnvSecurityToken)
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for AlibabaCloud ESA.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("aliesa: the configuration of the DNS provider is nil")
+ }
+
+ if config.RegionID == "" {
+ config.RegionID = defaultRegionID
+ }
+
+ cfg := new(openapi.Config).
+ SetRegionId(config.RegionID).
+ SetReadTimeout(int(config.HTTPTimeout.Milliseconds()))
+
+ switch {
+ case config.RAMRole != "":
+ // https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance
+ credentialsCfg := new(credentials.Config).
+ SetType("ecs_ram_role").
+ SetRoleName(config.RAMRole)
+
+ credentialClient, err := credentials.NewCredential(credentialsCfg)
+ if err != nil {
+ return nil, fmt.Errorf("aliesa: new credential: %w", err)
+ }
+
+ cfg = cfg.SetCredential(credentialClient)
+
+ case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "":
+ cfg = cfg.
+ SetAccessKeyId(config.APIKey).
+ SetAccessKeySecret(config.SecretKey).
+ SetSecurityToken(config.SecurityToken)
+
+ case config.APIKey != "" && config.SecretKey != "":
+ cfg = cfg.
+ SetAccessKeyId(config.APIKey).
+ SetAccessKeySecret(config.SecretKey)
+
+ default:
+ return nil, errors.New("aliesa: ram role or credentials missing")
+ }
+
+ client, err := esa.NewClient(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("aliesa: new client: %w", err)
+ }
+
+ // Workaround to get a regional URL.
+ // https://github.com/alibabacloud-go/esa-20240910/blame/7660e3aab2045d4820e4b83427a154efe0c79319/client/client.go#L27
+ // The `EndpointRule` is hardcoded with an empty string, so the region is ignored.
+ client.Endpoint = nil
+ client.EndpointRule = ptr.Pointer("regional")
+
+ client.Endpoint, err = esa.GetEndpoint(client, dara.String("esa"), client.RegionId, client.EndpointRule, client.Network, client.Suffix, client.EndpointMap, client.Endpoint)
+ if err != nil {
+ return nil, fmt.Errorf("aliesa: get endpoint: %w", err)
+ }
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int64),
+ }, nil
+}
+
+// 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)
+
+ siteID, err := d.getSiteID(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("aliesa: %w", err)
+ }
+
+ crReq := new(esa.CreateRecordRequest).
+ SetSiteId(siteID).
+ SetType("TXT").
+ SetRecordName(dns01.UnFqdn(info.EffectiveFQDN)).
+ SetTtl(int32(d.config.TTL)).
+ SetData(new(esa.CreateRecordRequestData).SetValue(info.Value))
+
+ // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord
+ crResp, err := esa.CreateRecordWithContext(ctx, d.client, crReq, &dara.RuntimeOptions{})
+ if err != nil {
+ return fmt.Errorf("aliesa: create record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = ptr.Deref(crResp.Body.GetRecordId())
+ 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
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("aliesa: unknown record ID for '%s'", info.EffectiveFQDN)
+ }
+
+ drReq := new(esa.DeleteRecordRequest).
+ SetRecordId(recordID)
+
+ // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-deleterecord
+ _, err := esa.DeleteRecordWithContext(ctx, d.client, drReq, &dara.RuntimeOptions{})
+ if err != nil {
+ return fmt.Errorf("aliesa: delete 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
+}
+
+func (d *DNSProvider) getSiteID(ctx context.Context, fqdn string) (int64, error) {
+ authZone, err := dns01.FindZoneByFqdn(fqdn)
+ if err != nil {
+ return 0, fmt.Errorf("aliesa: could not find zone for domain %q: %w", fqdn, err)
+ }
+
+ lsReq := new(esa.ListSitesRequest).
+ SetSiteName(dns01.UnFqdn(authZone)).
+ SetSiteSearchType("suffix")
+
+ // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-listsites
+ lsResp, err := esa.ListSitesWithContext(ctx, d.client, lsReq, &dara.RuntimeOptions{})
+ if err != nil {
+ return 0, fmt.Errorf("list sites: %w", err)
+ }
+
+ for f := range dns01.UnFqdnDomainsSeq(fqdn) {
+ domain := dns01.UnFqdn(f)
+
+ for _, site := range lsResp.Body.GetSites() {
+ if ptr.Deref(site.GetSiteName()) == domain {
+ return ptr.Deref(site.GetSiteId()), nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("site not found (fqdn: %q)", fqdn)
+}
diff --git a/providers/dns/aliesa/aliesa.toml b/providers/dns/aliesa/aliesa.toml
new file mode 100644
index 000000000..5e7345e40
--- /dev/null
+++ b/providers/dns/aliesa/aliesa.toml
@@ -0,0 +1,33 @@
+Name = "AlibabaCloud ESA"
+Description = ''''''
+URL = "https://www.alibabacloud.com/en/product/esa"
+Code = "aliesa"
+Since = "v4.29.0"
+
+Example = '''
+# Setup using instance RAM role
+ALIESA_RAM_ROLE=lego \
+lego --dns aliesa -d '*.example.com' -d example.com run
+
+# Or, using credentials
+ALIESA_ACCESS_KEY=abcdefghijklmnopqrstuvwx \
+ALIESA_SECRET_KEY=your-secret-key \
+ALIESA_SECURITY_TOKEN=your-sts-token \
+lego --dns aliesa - -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ALIESA_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)"
+ ALIESA_ACCESS_KEY = "Access key ID"
+ ALIESA_SECRET_KEY = "Access Key secret"
+ ALIESA_SECURITY_TOKEN = "STS Security Token (optional)"
+ [Configuration.Additional]
+ ALIESA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ALIESA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ALIESA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ ALIESA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-overview?spm=a2c63.p38356.help-menu-2673927.d_6_0_0.20b224c28PSZDc#:~:text=DNS-,DNS%20records,-DNS%20records"
+ GoClient = "https://github.com/alibabacloud-go/esa-20240910"
diff --git a/providers/dns/aliesa/aliesa_test.go b/providers/dns/aliesa/aliesa_test.go
new file mode 100644
index 000000000..025529409
--- /dev/null
+++ b/providers/dns/aliesa/aliesa_test.go
@@ -0,0 +1,151 @@
+package aliesa
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvAccessKey,
+ EnvSecretKey,
+ EnvRAMRole).
+ WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAccessKey: "123",
+ EnvSecretKey: "456",
+ },
+ },
+ {
+ desc: "success (RAM role)",
+ envVars: map[string]string{
+ EnvRAMRole: "LegoInstanceRole",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvAccessKey: "",
+ EnvSecretKey: "",
+ },
+ expected: "aliesa: some credentials information are missing: ALIESA_ACCESS_KEY,ALIESA_SECRET_KEY",
+ },
+ {
+ desc: "missing access key",
+ envVars: map[string]string{
+ EnvAccessKey: "",
+ EnvSecretKey: "456",
+ },
+ expected: "aliesa: some credentials information are missing: ALIESA_ACCESS_KEY",
+ },
+ {
+ desc: "missing secret key",
+ envVars: map[string]string{
+ EnvAccessKey: "123",
+ EnvSecretKey: "",
+ },
+ expected: "aliesa: some credentials information are missing: ALIESA_SECRET_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ ramRole string
+ apiKey string
+ secretKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "123",
+ secretKey: "456",
+ },
+ {
+ desc: "success",
+ ramRole: "LegoInstanceRole",
+ },
+ {
+ desc: "missing credentials",
+ expected: "aliesa: ram role or credentials missing",
+ },
+ {
+ desc: "missing api key",
+ secretKey: "456",
+ expected: "aliesa: ram role or credentials missing",
+ },
+ {
+ desc: "missing secret key",
+ apiKey: "123",
+ expected: "aliesa: ram role or credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+ config.SecretKey = test.secretKey
+ config.RAMRole = test.ramRole
+
+ 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)
+}
diff --git a/providers/dns/allinkl/allinkl.go b/providers/dns/allinkl/allinkl.go
index b1a40ae64..376b0903c 100644
--- a/providers/dns/allinkl/allinkl.go
+++ b/providers/dns/allinkl/allinkl.go
@@ -11,8 +11,10 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/allinkl/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -92,12 +94,16 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
identifier.HTTPClient = config.HTTPClient
}
+ identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient)
+
client := internal.NewClient(config.Login)
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
identifier: identifier,
@@ -116,20 +122,20 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
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("allinkl: could not find zone for domain %q: %w", domain, err)
- }
-
ctx := context.Background()
credential, err := d.identifier.Authentication(ctx, 60, true)
if err != nil {
- return fmt.Errorf("allinkl: %w", err)
+ return fmt.Errorf("allinkl: authentication: %w", err)
}
ctx = internal.WithContext(ctx, credential)
+ authZone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("allinkl: %w", err)
+ }
+
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("allinkl: %w", err)
@@ -144,7 +150,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
recordID, err := d.client.AddDNSSettings(ctx, record)
if err != nil {
- return fmt.Errorf("allinkl: %w", err)
+ return fmt.Errorf("allinkl: add DNS settings: %w", err)
}
d.recordIDsMu.Lock()
@@ -162,7 +168,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
credential, err := d.identifier.Authentication(ctx, 60, true)
if err != nil {
- return fmt.Errorf("allinkl: %w", err)
+ return fmt.Errorf("allinkl: authentication: %w", err)
}
ctx = internal.WithContext(ctx, credential)
@@ -171,14 +177,33 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("allinkl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
_, err = d.client.DeleteDNSSettings(ctx, recordID)
if err != nil {
- return fmt.Errorf("allinkl: %w", err)
+ return fmt.Errorf("allinkl: delete DNS settings: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) {
+ for z := range dns01.DomainsSeq(fqdn) {
+ _, errG := d.client.GetDNSSettings(ctx, z, "")
+ if errG != nil {
+ log.Infof("get DNS settings zone[%q] %v", z, errG)
+ continue
+ }
+
+ return z, nil
+ }
+
+ return "", fmt.Errorf("unable to find auth zone for '%s'", fqdn)
+}
diff --git a/providers/dns/allinkl/allinkl.toml b/providers/dns/allinkl/allinkl.toml
index 4a308d653..774f8fb9f 100644
--- a/providers/dns/allinkl/allinkl.toml
+++ b/providers/dns/allinkl/allinkl.toml
@@ -7,7 +7,7 @@ Since = "v4.5.0"
Example = '''
ALL_INKL_LOGIN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
ALL_INKL_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \
-lego --email you@example.com --dns allinkl -d '*.example.com' -d example.com run
+lego --dns allinkl -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,9 +15,9 @@ lego --email you@example.com --dns allinkl -d '*.example.com' -d example.com run
ALL_INKL_LOGIN = "KAS login"
ALL_INKL_PASSWORD = "KAS password"
[Configuration.Additional]
- ALL_INKL_POLLING_INTERVAL = "Time between DNS propagation check"
- ALL_INKL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- ALL_INKL_HTTP_TIMEOUT = "API request timeout"
+ ALL_INKL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ALL_INKL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ALL_INKL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://kasapi.kasserver.com/dokumentation/phpdoc/index.html"
diff --git a/providers/dns/allinkl/allinkl_test.go b/providers/dns/allinkl/allinkl_test.go
index af85f8c54..7da47aee4 100644
--- a/providers/dns/allinkl/allinkl_test.go
+++ b/providers/dns/allinkl/allinkl_test.go
@@ -1,9 +1,18 @@
package allinkl
import (
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
"testing"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/allinkl/internal"
"github.com/stretchr/testify/require"
)
@@ -53,6 +62,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -121,6 +131,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -134,9 +145,115 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Login = "user"
+ config.Password = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+ p.identifier.BaseURL, _ = url.Parse(server.URL)
+
+ return p, err
+ },
+ ).Route("POST /KasAuth.php",
+ servermock.ResponseFromInternal("auth.xml"),
+ servermock.CheckRequestBodyFromInternal("auth-request.xml").
+ IgnoreWhitespace(),
+ )
+}
+
+func extractKasRequest(reader io.Reader) (*internal.KasRequest, error) {
+ type ReqEnvelope struct {
+ XMLName xml.Name `xml:"Envelope"`
+ Body struct {
+ KasAPI struct {
+ Params string `xml:"Params"`
+ } `xml:"KasApi"`
+ } `xml:"Body"`
+ }
+
+ raw, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, err
+ }
+
+ reqEnvelope := ReqEnvelope{}
+
+ err = xml.Unmarshal(raw, &reqEnvelope)
+ if err != nil {
+ return nil, err
+ }
+
+ var kReq internal.KasRequest
+
+ err = json.Unmarshal([]byte(reqEnvelope.Body.KasAPI.Params), &kReq)
+ if err != nil {
+ return nil, err
+ }
+
+ return &kReq, nil
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /KasApi.php",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ kReq, err := extractKasRequest(req.Body)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ switch kReq.Action {
+ case "get_dns_settings":
+ params := kReq.RequestParams.(map[string]any)
+
+ if params["zone_host"] == "_acme-challenge.example.com." {
+ servermock.ResponseFromInternal("get_dns_settings_not_found.xml").ServeHTTP(rw, req)
+ } else {
+ servermock.ResponseFromInternal("get_dns_settings.xml").ServeHTTP(rw, req)
+ }
+
+ case "add_dns_settings":
+ servermock.ResponseFromInternal("add_dns_settings.xml").ServeHTTP(rw, req)
+
+ default:
+ http.Error(rw, fmt.Sprintf("unknown action: %v", kReq.Action), http.StatusBadRequest)
+ }
+ }),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /KasApi.php",
+ servermock.ResponseFromInternal("delete_dns_settings.xml"),
+ servermock.CheckRequestBodyFromInternal("delete_dns_settings-request.xml").
+ IgnoreWhitespace()).
+ Build(t)
+
+ provider.recordIDs["abc"] = "57347450"
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/allinkl/internal/client.go b/providers/dns/allinkl/internal/client.go
index ab8cf9a38..d4403cac5 100644
--- a/providers/dns/allinkl/internal/client.go
+++ b/providers/dns/allinkl/internal/client.go
@@ -6,16 +6,21 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "net/url"
"strconv"
"strings"
"sync"
"time"
+ "github.com/cenkalti/backoff/v5"
+ "github.com/go-acme/lego/v4/platform/wait"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
"github.com/go-viper/mapstructure/v2"
)
-const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php"
+const defaultBaseURL = "https://kasapi.kasserver.com/soap/"
+
+const apiPath = "KasApi.php"
type Authentication interface {
Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error)
@@ -28,16 +33,21 @@ type Client struct {
floodTime time.Time
muFloodTime sync.Mutex
- baseURL string
+ maxElapsedTime time.Duration
+
+ BaseURL *url.URL
HTTPClient *http.Client
}
// NewClient creates a new Client.
func NewClient(login string) *Client {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
return &Client{
- login: login,
- baseURL: apiEndpoint,
- HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ login: login,
+ BaseURL: baseURL,
+ maxElapsedTime: 3 * time.Minute,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
@@ -51,13 +61,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R
requestParams["record_id"] = recordID
}
- req, err := c.newRequest(ctx, "get_dns_settings", requestParams)
- if err != nil {
- return nil, err
- }
+ var g APIResponse[GetDNSSettingsResponse]
- var g GetDNSSettingsAPIResponse
- err = c.do(req, &g)
+ err := c.doRequest(ctx, "get_dns_settings", requestParams, &g)
if err != nil {
return nil, err
}
@@ -69,13 +75,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R
// AddDNSSettings Creation of a DNS resource record.
func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) {
- req, err := c.newRequest(ctx, "add_dns_settings", record)
- if err != nil {
- return "", err
- }
+ var g APIResponse[AddDNSSettingsResponse]
- var g AddDNSSettingsAPIResponse
- err = c.do(req, &g)
+ err := c.doRequest(ctx, "add_dns_settings", record, &g)
if err != nil {
return "", err
}
@@ -86,23 +88,19 @@ func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string,
}
// DeleteDNSSettings Deleting a DNS Resource Record.
-func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (bool, error) {
+func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (string, error) {
requestParams := map[string]string{"record_id": recordID}
- req, err := c.newRequest(ctx, "delete_dns_settings", requestParams)
- if err != nil {
- return false, err
- }
+ var g APIResponse[DeleteDNSSettingsResponse]
- var g DeleteDNSSettingsAPIResponse
- err = c.do(req, &g)
+ err := c.doRequest(ctx, "delete_dns_settings", requestParams, &g)
if err != nil {
- return false, err
+ return "", err
}
c.updateFloodTime(g.Response.KasFloodDelay)
- return g.Response.ReturnInfo, nil
+ return g.Response.ReturnString, nil
}
func (c *Client) newRequest(ctx context.Context, action string, requestParams any) (*http.Request, error) {
@@ -121,7 +119,9 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an
payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body)))
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload))
+ endpoint := c.BaseURL.JoinPath(apiPath)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
@@ -129,6 +129,21 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an
return req, nil
}
+func (c *Client) doRequest(ctx context.Context, action string, requestParams, result any) error {
+ return wait.Retry(ctx,
+ func() error {
+ req, err := c.newRequest(ctx, action, requestParams)
+ if err != nil {
+ return backoff.Permanent(err)
+ }
+
+ return c.do(req, result)
+ },
+ backoff.WithBackOff(&backoff.ZeroBackOff{}),
+ backoff.WithMaxElapsedTime(c.maxElapsedTime),
+ )
+}
+
func (c *Client) do(req *http.Request, result any) error {
c.muFloodTime.Lock()
time.Sleep(time.Until(c.floodTime))
@@ -136,29 +151,40 @@ func (c *Client) do(req *http.Request, result any) error {
resp, err := c.HTTPClient.Do(req)
if err != nil {
- return errutils.NewHTTPDoError(req, err)
+ return backoff.Permanent(errutils.NewHTTPDoError(req, err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
- return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+ return backoff.Permanent(errutils.NewUnexpectedResponseStatusCodeError(req, resp))
}
envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body)
if err != nil {
- return err
+ return backoff.Permanent(err)
}
if envlp.Body.Fault != nil {
- return envlp.Body.Fault
+ if envlp.Body.Fault.Message == "flood_protection" {
+ ft, errP := strconv.ParseFloat(envlp.Body.Fault.Detail, 64)
+ if errP != nil {
+ return fmt.Errorf("parse flood protection delay: %w", envlp.Body.Fault)
+ }
+
+ c.updateFloodTime(ft)
+
+ return envlp.Body.Fault
+ }
+
+ return backoff.Permanent(envlp.Body.Fault)
}
raw := getValue(envlp.Body.KasAPIResponse.Return)
err = mapstructure.Decode(raw, result)
if err != nil {
- return fmt.Errorf("response struct decode: %w", err)
+ return backoff.Permanent(fmt.Errorf("response struct decode: %w", err))
}
return nil
diff --git a/providers/dns/allinkl/internal/client_test.go b/providers/dns/allinkl/internal/client_test.go
index 3eb7c21a9..949f45bf9 100644
--- a/providers/dns/allinkl/internal/client_test.go
+++ b/providers/dns/allinkl/internal/client_test.go
@@ -1,29 +1,34 @@
package internal
import (
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
+ "net/url"
"testing"
+ "time"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestClient_GetDNSSettings(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/", testHandler("get_dns_settings.xml"))
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("user")
- client.baseURL = server.URL
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- records, err := client.GetDNSSettings(mockContext(), "example.com", "")
+ client.maxElapsedTime = 1 * time.Second
+
+ return client, nil
+}
+
+func TestClient_GetDNSSettings(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /KasApi.php", servermock.ResponseFromFixture("get_dns_settings.xml"),
+ servermock.CheckRequestBodyFromFixture("get_dns_settings-request.xml").
+ IgnoreWhitespace()).
+ Build(t)
+
+ records, err := client.GetDNSSettings(mockContext(t), "example.com", "")
require.NoError(t, err)
expected := []ReturnInfo{
@@ -95,15 +100,27 @@ func TestClient_GetDNSSettings(t *testing.T) {
assert.Equal(t, expected, records)
}
+func TestClient_GetDNSSettings_error_flood_protection(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /KasApi.php",
+ servermock.ResponseFromFixture("flood_protection.xml"),
+ ).
+ Build(t)
+
+ assert.Zero(t, client.floodTime)
+
+ _, err := client.GetDNSSettings(mockContext(t), "example.com", "")
+ require.EqualError(t, err, "KasApi: SOAP-ENV:Server: flood_protection: 0.0688529014587")
+
+ assert.NotZero(t, client.floodTime)
+}
+
func TestClient_AddDNSSettings(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/", testHandler("add_dns_settings.xml"))
-
- client := NewClient("user")
- client.baseURL = server.URL
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /KasApi.php", servermock.ResponseFromFixture("add_dns_settings.xml"),
+ servermock.CheckRequestBodyFromFixture("add_dns_settings-request.xml").
+ IgnoreWhitespace()).
+ Build(t)
record := DNSRequest{
ZoneHost: "42cnc.de.",
@@ -112,47 +129,21 @@ func TestClient_AddDNSSettings(t *testing.T) {
RecordData: "abcdefgh",
}
- recordID, err := client.AddDNSSettings(mockContext(), record)
+ recordID, err := client.AddDNSSettings(mockContext(t), record)
require.NoError(t, err)
assert.Equal(t, "57347444", recordID)
}
func TestClient_DeleteDNSSettings(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /KasApi.php", servermock.ResponseFromFixture("delete_dns_settings.xml"),
+ servermock.CheckRequestBodyFromFixture("delete_dns_settings-request.xml").
+ IgnoreWhitespace()).
+ Build(t)
- mux.HandleFunc("/", testHandler("delete_dns_settings.xml"))
-
- client := NewClient("user")
- client.baseURL = server.URL
-
- r, err := client.DeleteDNSSettings(mockContext(), "57347450")
+ r, err := client.DeleteDNSSettings(mockContext(t), "57347450")
require.NoError(t, err)
- assert.True(t, r)
-}
-
-func testHandler(filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- file, err := os.Open(filepath.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
- }
- }
+ assert.Equal(t, "TRUE", r)
}
diff --git a/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml
new file mode 100644
index 000000000..e8cd12633
--- /dev/null
+++ b/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml
@@ -0,0 +1,7 @@
+
+
+
+ {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"add_dns_settings","KasRequestParams":{"zone_host":"42cnc.de.","record_type":"TXT","record_name":"lego","record_data":"abcdefgh","record_aux":0}}
+
+
+
diff --git a/providers/dns/allinkl/internal/fixtures/auth-request.xml b/providers/dns/allinkl/internal/fixtures/auth-request.xml
new file mode 100644
index 000000000..1cba86f10
--- /dev/null
+++ b/providers/dns/allinkl/internal/fixtures/auth-request.xml
@@ -0,0 +1,7 @@
+
+
+
+ {"kas_login":"user","kas_auth_data":"secret","kas_auth_type":"plain","session_lifetime":60,"session_update_lifetime":"Y"}
+
+
+
diff --git a/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml
new file mode 100644
index 000000000..a306a98a7
--- /dev/null
+++ b/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml
@@ -0,0 +1,7 @@
+
+
+
+ {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"delete_dns_settings","KasRequestParams":{"record_id":"57347450"}}
+
+
+
diff --git a/providers/dns/allinkl/internal/fixtures/flood_protection.xml b/providers/dns/allinkl/internal/fixtures/flood_protection.xml
new file mode 100644
index 000000000..b8da10fab
--- /dev/null
+++ b/providers/dns/allinkl/internal/fixtures/flood_protection.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ SOAP-ENV:Server
+ flood_protection
+ KasApi
+ 0.0688529014587
+
+
+
diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml
new file mode 100644
index 000000000..b44941d2b
--- /dev/null
+++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml
@@ -0,0 +1,7 @@
+
+
+
+ {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"get_dns_settings","KasRequestParams":{"zone_host":"example.com"}}
+
+
+
diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml
new file mode 100644
index 000000000..478d07a3a
--- /dev/null
+++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ SOAP-ENV:Server
+ zone_not_found
+ KasApi
+ example.com
+
+
+
diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml
new file mode 100644
index 000000000..c77d733db
--- /dev/null
+++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ SOAP-ENV:Server
+ zone_syntax_incorrect
+ KasApi
+ _acme-challenge.example.com
+
+
+
diff --git a/providers/dns/allinkl/internal/identity.go b/providers/dns/allinkl/internal/identity.go
index 4353ece31..e95e78899 100644
--- a/providers/dns/allinkl/internal/identity.go
+++ b/providers/dns/allinkl/internal/identity.go
@@ -6,14 +6,14 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "net/url"
"strings"
"time"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
-// authEndpoint represents the Identity API endpoint to call.
-const authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php"
+const authPath = "KasAuth.php"
type token string
@@ -24,17 +24,19 @@ type Identifier struct {
login string
password string
- authEndpoint string
- HTTPClient *http.Client
+ BaseURL *url.URL
+ HTTPClient *http.Client
}
// NewIdentifier creates a new Identifier.
-func NewIdentifier(login string, password string) *Identifier {
+func NewIdentifier(login, password string) *Identifier {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
return &Identifier{
- login: login,
- password: password,
- authEndpoint: authEndpoint,
- HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ login: login,
+ password: password,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
@@ -62,7 +64,9 @@ func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, se
payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body)))
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authEndpoint, bytes.NewReader(payload))
+ endpoint := c.BaseURL.JoinPath(authPath)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("unable to create request: %w", err)
}
diff --git a/providers/dns/allinkl/internal/identity_test.go b/providers/dns/allinkl/internal/identity_test.go
index 0753f3862..41d092b13 100644
--- a/providers/dns/allinkl/internal/identity_test.go
+++ b/providers/dns/allinkl/internal/identity_test.go
@@ -2,44 +2,48 @@ package internal
import (
"context"
- "net/http"
"net/http/httptest"
+ "net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func mockContext() context.Context {
- return context.WithValue(context.Background(), tokenKey, "593959ca04f0de9689b586c6a647d15d")
+func setupIdentifierClient(server *httptest.Server) (*Identifier, error) {
+ client := NewIdentifier("user", "secret")
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func mockContext(t *testing.T) context.Context {
+ t.Helper()
+
+ return context.WithValue(t.Context(), tokenKey, "593959ca04f0de9689b586c6a647d15d")
}
func TestIdentifier_Authentication(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client := servermock.NewBuilder[*Identifier](setupIdentifierClient).
+ Route("POST /KasAuth.php",
+ servermock.ResponseFromFixture("auth.xml"),
+ servermock.CheckRequestBodyFromFixture("auth-request.xml").
+ IgnoreWhitespace()).
+ Build(t)
- mux.HandleFunc("/", testHandler("auth.xml"))
-
- client := NewIdentifier("user", "secret")
- client.authEndpoint = server.URL
-
- credentialToken, err := client.Authentication(context.Background(), 60, false)
+ credentialToken, err := client.Authentication(t.Context(), 60, true)
require.NoError(t, err)
assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken)
}
func TestIdentifier_Authentication_error(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client := servermock.NewBuilder[*Identifier](setupIdentifierClient).
+ Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth_fault.xml")).
+ Build(t)
- mux.HandleFunc("/", testHandler("auth_fault.xml"))
-
- client := NewIdentifier("user", "secret")
- client.authEndpoint = server.URL
-
- _, err := client.Authentication(context.Background(), 60, false)
+ _, err := client.Authentication(t.Context(), 60, false)
require.Error(t, err)
}
diff --git a/providers/dns/allinkl/internal/types.go b/providers/dns/allinkl/internal/types.go
index b5c6ba0d1..51f7065b5 100644
--- a/providers/dns/allinkl/internal/types.go
+++ b/providers/dns/allinkl/internal/types.go
@@ -17,6 +17,7 @@ func (tr Trimmer) Token() (xml.Token, error) {
if cd, ok := t.(xml.CharData); ok {
t = xml.CharData(bytes.TrimSpace(cd))
}
+
return t, err
}
@@ -25,10 +26,11 @@ type Fault struct {
Code string `xml:"faultcode"`
Message string `xml:"faultstring"`
Actor string `xml:"faultactor"`
+ Detail string `xml:"detail"`
}
-func (f Fault) Error() string {
- return fmt.Sprintf("%s: %s: %s", f.Actor, f.Code, f.Message)
+func (f *Fault) Error() string {
+ return fmt.Sprintf("%s: %s: %s: %s", f.Actor, f.Code, f.Message, f.Detail)
}
// KasResponse a KAS SOAP response.
@@ -53,6 +55,7 @@ func decodeXML[T any](reader io.Reader) (*T, error) {
}
var result T
+
err = xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}).Decode(&result)
if err != nil {
return nil, fmt.Errorf("decode XML response: %w", err)
diff --git a/providers/dns/allinkl/internal/types_api.go b/providers/dns/allinkl/internal/types_api.go
index 145163cda..a11f3aac0 100644
--- a/providers/dns/allinkl/internal/types_api.go
+++ b/providers/dns/allinkl/internal/types_api.go
@@ -53,8 +53,8 @@ type DNSRequest struct {
// ---
-type GetDNSSettingsAPIResponse struct {
- Response GetDNSSettingsResponse `json:"Response" mapstructure:"Response"`
+type APIResponse[T any] struct {
+ Response T `json:"Response" mapstructure:"Response"`
}
type GetDNSSettingsResponse struct {
@@ -73,22 +73,14 @@ type ReturnInfo struct {
Aux int `json:"record_aux,omitempty" mapstructure:"record_aux"`
}
-type AddDNSSettingsAPIResponse struct {
- Response AddDNSSettingsResponse `json:"Response" mapstructure:"Response"`
-}
-
type AddDNSSettingsResponse struct {
KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"`
ReturnInfo string `json:"ReturnInfo" mapstructure:"ReturnInfo"`
ReturnString string `json:"ReturnString" mapstructure:"ReturnString"`
}
-type DeleteDNSSettingsAPIResponse struct {
- Response DeleteDNSSettingsResponse `json:"Response"`
-}
-
type DeleteDNSSettingsResponse struct {
KasFloodDelay float64 `json:"KasFloodDelay"`
- ReturnInfo bool `json:"ReturnInfo"`
ReturnString string `json:"ReturnString"`
+ // NOTE: ReturnInfo (!= ReturnString) doesn't seem to have a stable type
}
diff --git a/providers/dns/alwaysdata/alwaysdata.go b/providers/dns/alwaysdata/alwaysdata.go
new file mode 100644
index 000000000..b2e0f3957
--- /dev/null
+++ b/providers/dns/alwaysdata/alwaysdata.go
@@ -0,0 +1,185 @@
+// Package alwaysdata implements a DNS provider for solving the DNS-01 challenge using Alwaysdata.
+package alwaysdata
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/alwaysdata/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ALWAYSDATA_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+ EnvAccount = envNamespace + "ACCOUNT"
+
+ 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
+ Account string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Alwaysdata.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("alwaysdata: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+ config.Account = env.GetOrFile(EnvAccount)
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Alwaysdata.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("alwaysdata: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIKey, config.Account)
+ if err != nil {
+ return nil, fmt.Errorf("alwaysdata: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("alwaysdata: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name)
+ if err != nil {
+ return fmt.Errorf("alwaysdata: %w", err)
+ }
+
+ record := internal.RecordRequest{
+ DomainID: zone.ID,
+ Name: subDomain,
+ Type: "TXT",
+ Value: info.Value,
+ TTL: d.config.TTL,
+ Annotation: "lego",
+ }
+
+ err = d.client.AddRecord(ctx, record)
+ if err != nil {
+ return fmt.Errorf("alwaysdata: add TXT 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)
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("alwaysdata: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name)
+ if err != nil {
+ return fmt.Errorf("alwaysdata: %w", err)
+ }
+
+ records, err := d.client.ListRecords(ctx, zone.ID, subDomain)
+ if err != nil {
+ return fmt.Errorf("alwaysdata: list records: %w", err)
+ }
+
+ for _, record := range records {
+ if record.Type != "TXT" || record.Value != info.Value {
+ continue
+ }
+
+ err = d.client.DeleteRecord(ctx, record.ID)
+ if err != nil {
+ return fmt.Errorf("alwaysdata: delete TXT 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
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.Domain, error) {
+ domains, err := d.client.ListDomains(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list domains: %w", err)
+ }
+
+ for a := range dns01.UnFqdnDomainsSeq(fqdn) {
+ for _, domain := range domains {
+ if a == domain.Name {
+ return &domain, nil
+ }
+ }
+ }
+
+ return nil, errors.New("domain not found")
+}
diff --git a/providers/dns/alwaysdata/alwaysdata.toml b/providers/dns/alwaysdata/alwaysdata.toml
new file mode 100644
index 000000000..d00c6f032
--- /dev/null
+++ b/providers/dns/alwaysdata/alwaysdata.toml
@@ -0,0 +1,26 @@
+Name = "Alwaysdata"
+Description = ''''''
+URL = "https://alwaysdata.com/"
+Code = "alwaysdata"
+Since = "v4.31.0"
+
+Example = '''
+ALWAYSDATA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns alwaysdata -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ALWAYSDATA_API_KEY = "API Key"
+ [Configuration.Additional]
+ ALWAYSDATA_ACCOUNT = "Account name"
+ ALWAYSDATA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ALWAYSDATA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ALWAYSDATA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ ALWAYSDATA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://help.alwaysdata.com/en/api/resources/"
+ APIDocDomains = "https://api.alwaysdata.com/v1/domain/doc/"
+ APIDocRecords = "https://api.alwaysdata.com/v1/record/doc/"
+ APIExamples = "https://help.alwaysdata.com/en/api/examples/"
diff --git a/providers/dns/alwaysdata/alwaysdata_test.go b/providers/dns/alwaysdata/alwaysdata_test.go
new file mode 100644
index 000000000..6084c2ae4
--- /dev/null
+++ b/providers/dns/alwaysdata/alwaysdata_test.go
@@ -0,0 +1,187 @@
+package alwaysdata
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIKey, EnvAccount).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "success with an account",
+ envVars: map[string]string{
+ EnvAPIKey: "secret",
+ EnvAccount: "foo",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "alwaysdata: some credentials information are missing: ALWAYSDATA_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ account string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "secret",
+ },
+ {
+ desc: "success with an account",
+ apiKey: "secret",
+ account: "foo",
+ },
+ {
+ desc: "missing credentials",
+ expected: "alwaysdata: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+ config.Account = test.account
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithBasicAuth("secret", ""),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domain/",
+ servermock.ResponseFromInternal("domains.json")).
+ Route("POST /record/",
+ servermock.Noop().WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromInternal("record_add-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domain/",
+ servermock.ResponseFromInternal("domains.json")).
+ Route("GET /record/",
+ servermock.ResponseFromInternal("records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "132").
+ With("name", "_acme-challenge"),
+ ).
+ Route("DELETE /record/789/",
+ servermock.Noop()).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/alwaysdata/internal/client.go b/providers/dns/alwaysdata/internal/client.go
new file mode 100644
index 000000000..5db11dcd1
--- /dev/null
+++ b/providers/dns/alwaysdata/internal/client.go
@@ -0,0 +1,177 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.alwaysdata.com/v1"
+
+// Client the Alwaysdata API client.
+type Client struct {
+ apiKey string
+ account string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey, account string) (*Client, error) {
+ if apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ account: account,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
+ endpoint := c.BaseURL.JoinPath("domain", "/")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result []Domain
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, record RecordRequest) error {
+ endpoint := c.BaseURL.JoinPath("record", "/")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ err = c.do(req, nil)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, recordID int64) error {
+ endpoint := c.BaseURL.JoinPath("record", 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) ListRecords(ctx context.Context, domainID int64, name string) ([]Record, error) {
+ endpoint := c.BaseURL.JoinPath("record", "/")
+
+ query := endpoint.Query()
+ query.Set("domain", strconv.FormatInt(domainID, 10))
+ query.Set("name", name)
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ 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) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ user := c.apiKey
+
+ if c.account != "" {
+ user += "account=" + c.account
+ }
+
+ req.SetBasicAuth(user, "")
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ 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
+}
diff --git a/providers/dns/alwaysdata/internal/client_test.go b/providers/dns/alwaysdata/internal/client_test.go
new file mode 100644
index 000000000..e6a349662
--- /dev/null
+++ b/providers/dns/alwaysdata/internal/client_test.go
@@ -0,0 +1,124 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret", "")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = clientdebug.Wrap(server.Client())
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithBasicAuth("secret", ""),
+ )
+}
+
+func TestClient_ListDomains(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domain/",
+ servermock.ResponseFromFixture("domains.json")).
+ Build(t)
+
+ result, err := client.ListDomains(t.Context())
+ require.NoError(t, err)
+
+ expected := []Domain{
+ {ID: 132, Name: "example.com", Annotation: "test"},
+ {ID: 133, Name: "example.net", IsInternal: true},
+ {ID: 134, Name: "example.org"},
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true")
+
+ client := mockBuilder().
+ Route("POST /record/",
+ servermock.Noop().WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromFixture("record_add-request.json")).
+ Build(t)
+
+ record := RecordRequest{
+ DomainID: 132,
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ Annotation: "lego",
+ }
+
+ err := client.AddRecord(t.Context(), record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /record/789/",
+ servermock.Noop()).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), 789)
+ require.NoError(t, err)
+}
+
+func TestClient_ListRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /record/",
+ servermock.ResponseFromFixture("records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "132").
+ With("name", "_acme-challenge"),
+ ).
+ Build(t)
+
+ result, err := client.ListRecords(t.Context(), 132, "_acme-challenge")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {
+ ID: 789,
+ Domain: &Domain{
+ Href: "/v1/domain/132/",
+ },
+ Type: "TXT",
+ Name: "_acme-challenge",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ Annotation: "lego",
+ },
+ {
+ ID: 11619270,
+ Domain: &Domain{
+ Href: "/v1/domain/118935/",
+ },
+ Name: "home",
+ Type: "A",
+ Value: "149.202.90.65",
+ TTL: 300,
+ IsUserDefined: true,
+ IsActive: true,
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
diff --git a/providers/dns/alwaysdata/internal/fixtures/domains.json b/providers/dns/alwaysdata/internal/fixtures/domains.json
new file mode 100644
index 000000000..dc34a948f
--- /dev/null
+++ b/providers/dns/alwaysdata/internal/fixtures/domains.json
@@ -0,0 +1,16 @@
+[
+ {
+ "id": 132,
+ "name": "example.com",
+ "annotation": "test"
+ },
+ {
+ "id": 133,
+ "name": "example.net",
+ "is_internal": true
+ },
+ {
+ "id": 134,
+ "name": "example.org"
+ }
+]
diff --git a/providers/dns/alwaysdata/internal/fixtures/record_add-request.json b/providers/dns/alwaysdata/internal/fixtures/record_add-request.json
new file mode 100644
index 000000000..5b6db2646
--- /dev/null
+++ b/providers/dns/alwaysdata/internal/fixtures/record_add-request.json
@@ -0,0 +1,8 @@
+{
+ "domain": 132,
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "annotation": "lego"
+}
diff --git a/providers/dns/alwaysdata/internal/fixtures/records.json b/providers/dns/alwaysdata/internal/fixtures/records.json
new file mode 100644
index 000000000..fa207395a
--- /dev/null
+++ b/providers/dns/alwaysdata/internal/fixtures/records.json
@@ -0,0 +1,28 @@
+[
+ {
+ "id": 789,
+ "domain": {
+ "href": "/v1/domain/132/"
+ },
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "annotation": "lego"
+ },
+ {
+ "id": 11619270,
+ "domain": {
+ "href": "/v1/domain/118935/"
+ },
+ "type": "A",
+ "name": "home",
+ "value": "149.202.90.65",
+ "priority": null,
+ "ttl": 300,
+ "href": "/v1/record/11619270/",
+ "annotation": "",
+ "is_user_defined": true,
+ "is_active": true
+ }
+]
diff --git a/providers/dns/alwaysdata/internal/types.go b/providers/dns/alwaysdata/internal/types.go
new file mode 100644
index 000000000..b1e66fa5b
--- /dev/null
+++ b/providers/dns/alwaysdata/internal/types.go
@@ -0,0 +1,33 @@
+package internal
+
+type RecordRequest struct {
+ ID int64 `json:"id,omitempty"`
+ DomainID int64 `json:"domain,omitempty"`
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ Value string `json:"value,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Annotation string `json:"annotation,omitempty"`
+ IsUserDefined bool `json:"is_user_defined,omitempty"`
+ IsActive bool `json:"is_active,omitempty"`
+}
+
+type Record struct {
+ ID int64 `json:"id,omitempty"`
+ Domain *Domain `json:"domain,omitempty"`
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Value string `json:"value,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Annotation string `json:"annotation,omitempty"`
+ IsUserDefined bool `json:"is_user_defined,omitempty"`
+ IsActive bool `json:"is_active,omitempty"`
+}
+
+type Domain struct {
+ ID int64 `json:"id,omitempty"`
+ Href string `json:"href,omitempty"`
+ Name string `json:"name,omitempty"`
+ IsInternal bool `json:"is_internal,omitempty"`
+ Annotation string `json:"annotation,omitempty"`
+}
diff --git a/providers/dns/anexia/anexia.go b/providers/dns/anexia/anexia.go
new file mode 100644
index 000000000..3ce7e2208
--- /dev/null
+++ b/providers/dns/anexia/anexia.go
@@ -0,0 +1,237 @@
+// Package anexia implements a DNS provider for solving the DNS-01 challenge using Anexia CloudDNS.
+package anexia
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/cenkalti/backoff/v5"
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/anexia/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ANEXIA_"
+
+ EnvToken = envNamespace + "TOKEN"
+ EnvAPIURL = envNamespace + "API_URL"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultTTL = 300
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Token string
+ APIURL 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{
+ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Anexia CloudDNS.
+// Credentials must be passed in the environment variable: ANEXIA_TOKEN.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("anexia: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Token = values[EnvToken]
+ config.APIURL = env.GetOrFile(EnvAPIURL)
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Anexia CloudDNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("anexia: the configuration of the DNS provider is nil")
+ }
+
+ if config.Token == "" {
+ return nil, errors.New("anexia: incomplete credentials, missing token")
+ }
+
+ client, err := internal.NewClient(config.Token)
+ if err != nil {
+ return nil, fmt.Errorf("anexia: %w", err)
+ }
+
+ if config.APIURL != "" {
+ var err error
+
+ client.BaseURL, err = url.Parse(config.APIURL)
+ if err != nil {
+ return nil, fmt.Errorf("anexia: %w", err)
+ }
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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 {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err)
+ }
+
+ recordName, err := extractRecordName(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("anexia: %w", err)
+ }
+
+ zoneName := dns01.UnFqdn(authZone)
+
+ recordReq := internal.Record{
+ Name: recordName,
+ Type: "TXT",
+ RData: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ // Ignores returned zone, because of UUID unstability.
+ // https://github.com/go-acme/lego/pull/2675#issuecomment-3418678194
+ _, err = d.client.CreateRecord(ctx, zoneName, recordReq)
+ if err != nil {
+ return fmt.Errorf("anexia: new 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err)
+ }
+
+ recordName, err := extractRecordName(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("anexia: %w", err)
+ }
+
+ recordID, err := d.findRecordID(ctx, dns01.UnFqdn(authZone), recordName, info.Value)
+ if err != nil {
+ return fmt.Errorf("anexia: %w", err)
+ }
+
+ err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID)
+ if err != nil {
+ return fmt.Errorf("anexia: delete TXT record: %w", err)
+ }
+
+ return nil
+}
+
+// findRecordID attempts to find the record ID from the zone response.
+// If the record is not immediately available in the response, it retries by querying the zone.
+func (d *DNSProvider) findRecordID(ctx context.Context, zoneName, recordName, rdata string) (string, error) {
+ return backoff.Retry(ctx,
+ func() (string, error) {
+ currentZone, err := d.client.GetZone(ctx, zoneName)
+ if err != nil {
+ return "", backoff.Permanent(fmt.Errorf("get zone: %w", err))
+ }
+
+ recordID := findRecordIdentifier(currentZone, recordName, rdata)
+ if recordID == "" {
+ return "", fmt.Errorf("get record identifier: %w", err)
+ }
+
+ return recordID, nil
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(5*time.Second)),
+ backoff.WithMaxElapsedTime(300*time.Second),
+ )
+}
+
+func findRecordIdentifier(zone *internal.Zone, recordName, rdata string) string {
+ if len(zone.Revisions) == 0 {
+ return ""
+ }
+
+ // Check the first revision (index 0) which should be the current one
+
+ for _, record := range zone.Revisions[0].Records {
+ if record.Name != recordName || record.Type != "TXT" {
+ continue
+ }
+
+ if record.RData == rdata || record.RData == strconv.Quote(rdata) {
+ return record.Identifier
+ }
+ }
+
+ return ""
+}
+
+func extractRecordName(fqdn, authZone string) (string, error) {
+ if dns01.UnFqdn(fqdn) == dns01.UnFqdn(authZone) {
+ // "@" for the root domain instead of an empty string.
+ return "@", nil
+ }
+
+ return dns01.ExtractSubDomain(fqdn, authZone)
+}
diff --git a/providers/dns/anexia/anexia.toml b/providers/dns/anexia/anexia.toml
new file mode 100644
index 000000000..332f0b8b1
--- /dev/null
+++ b/providers/dns/anexia/anexia.toml
@@ -0,0 +1,31 @@
+Name = "Anexia CloudDNS"
+Description = ''''''
+URL = "https://www.anexia-it.com/"
+Code = "anexia"
+Since = "v4.28.0"
+
+Example = '''
+ANEXIA_TOKEN=xxx \
+lego --dns anexia -d '*.example.com' -d example.com run
+'''
+
+Additional = '''
+## Description
+
+You need to create an API token in the [Anexia Engine](https://engine.anexia-it.com/).
+
+The token must have permissions to manage DNS zones and records.
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ANEXIA_TOKEN = "API token for Anexia Engine"
+ [Configuration.Additional]
+ ANEXIA_API_URL = "API endpoint URL (default: https://engine.anexia-it.com)"
+ ANEXIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ANEXIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ ANEXIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ ANEXIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://engine.anexia-it.com/docs/en/module/clouddns/api"
diff --git a/providers/dns/anexia/anexia_test.go b/providers/dns/anexia/anexia_test.go
new file mode 100644
index 000000000..9960c14d1
--- /dev/null
+++ b/providers/dns/anexia/anexia_test.go
@@ -0,0 +1,168 @@
+package anexia
+
+import (
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvToken,
+ EnvAPIURL).
+ WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success with token",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ },
+ },
+ {
+ desc: "missing token",
+ envVars: map[string]string{
+ EnvToken: "",
+ },
+ expected: "anexia: some credentials information are missing: ANEXIA_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)
+ assert.NotNil(t, p.config)
+ assert.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ token string
+ expected string
+ }{
+ {
+ desc: "success with token",
+ token: "secret",
+ },
+ {
+ desc: "missing token",
+ token: "",
+ expected: "anexia: incomplete credentials, missing token",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Token = test.token
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ assert.NotNil(t, p.config)
+ assert.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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Token = "secret"
+ config.APIURL = server.URL
+ config.HTTPClient = server.Client()
+
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithAuthorization("Token secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /api/clouddns/v1/zone.json/example.com/records",
+ servermock.ResponseFromInternal("create_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/json; charset=utf-8"),
+ servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /api/clouddns/v1/zone.json/example.com",
+ servermock.ResponseFromInternal("get_zone.json")).
+ Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc",
+ servermock.Noop()).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/anexia/internal/client.go b/providers/dns/anexia/internal/client.go
new file mode 100644
index 000000000..1a4159be0
--- /dev/null
+++ b/providers/dns/anexia/internal/client.go
@@ -0,0 +1,158 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://engine.anexia-it.com"
+
+// Client the Anexia CloudDNS API client.
+type Client struct {
+ token string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(token string) (*Client, error) {
+ if token == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ token: token,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) CreateRecord(ctx context.Context, zoneName string, record Record) (*Zone, error) {
+ endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName, "records")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return nil, err
+ }
+
+ var zone Zone
+
+ err = c.do(req, &zone)
+ if err != nil {
+ return nil, err
+ }
+
+ return &zone, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, zoneName, recordID string) error {
+ endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName, "records", recordID)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) GetZone(ctx context.Context, zoneName string) (*Zone, error) {
+ endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName)
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var zone Zone
+
+ err = c.do(req, &zone)
+ if err != nil {
+ return nil, err
+ }
+
+ return &zone, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Add("Authorization", fmt.Sprintf("Token %s", 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)
+ }
+
+ 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; charset=utf-8")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/anexia/internal/client_test.go b/providers/dns/anexia/internal/client_test.go
new file mode 100644
index 000000000..be33d6f88
--- /dev/null
+++ b/providers/dns/anexia/internal/client_test.go
@@ -0,0 +1,133 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithAuthorization("Token secret"),
+ )
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/clouddns/v1/zone.json/example.com/records",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/json; charset=utf-8"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 300,
+ Type: "TXT",
+ }
+
+ zone, err := client.CreateRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Name: "example.com",
+ TTL: 86400,
+ ZoneName: "example.com",
+ Revisions: []Revision{{
+ Identifier: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ Records: []Record{{
+ Identifier: "12345678-1234-1234-1234-123456789abc",
+ Name: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 300,
+ Type: "TXT",
+ }},
+ State: "deployed",
+ }},
+ }
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc",
+ servermock.Noop()).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "example.com", "12345678-1234-1234-1234-123456789abc")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "example.com", "12345678-1234-1234-1234-123456789abc")
+ require.EqualError(t, err, "401: Unauthorized")
+}
+
+func TestClient_GetZone(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/clouddns/v1/zone.json/example.com",
+ servermock.ResponseFromFixture("get_zone.json")).
+ Build(t)
+
+ zone, err := client.GetZone(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Identifier: "fdb355ffd07c48aba3d4f6bf6a116296",
+ Name: "example.com",
+ TTL: 3600,
+ ZoneName: "",
+ Revisions: []Revision{{
+ Identifier: "eeed7e08-f1ad-442b-9e75-369a0958c7d8",
+ Records: []Record{
+ {
+ Identifier: "5ced498b-c89d-4487-824d-c03ded84f849",
+ Immutable: true,
+ Name: "@",
+ RData: "acns02.xaas.systems.",
+ Region: "9a1609af9dae4ce1a4ef63f51d305321",
+ TTL: 3600,
+ Type: "NS",
+ },
+ {
+ Identifier: "12345678-1234-1234-1234-123456789abc",
+ Immutable: false,
+ Name: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Region: "",
+ TTL: 300,
+ Type: "TXT",
+ },
+ },
+ State: "active",
+ }},
+ }
+
+ assert.Equal(t, expected, zone)
+}
diff --git a/providers/dns/anexia/internal/fixtures/create_record-request.json b/providers/dns/anexia/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..e82add260
--- /dev/null
+++ b/providers/dns/anexia/internal/fixtures/create_record-request.json
@@ -0,0 +1,7 @@
+{
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "region": "",
+ "ttl": 300
+}
diff --git a/providers/dns/anexia/internal/fixtures/create_record.json b/providers/dns/anexia/internal/fixtures/create_record.json
new file mode 100644
index 000000000..8c4f2c149
--- /dev/null
+++ b/providers/dns/anexia/internal/fixtures/create_record.json
@@ -0,0 +1,38 @@
+{
+ "name": "example.com",
+ "zone_name": "example.com",
+ "master": true,
+ "dnssec_mode": "managed",
+ "admin_email": "admin@example.com",
+ "refresh": 10800,
+ "retry": 3600,
+ "expire": 604800,
+ "ttl": 86400,
+ "customer": "ANX12345",
+ "created_at": "0001-01-01T00:00:00Z",
+ "updated_at": "0001-01-01T00:00:00Z",
+ "published_at": "0001-01-01T00:00:00Z",
+ "is_editable": true,
+ "validation_level": 0,
+ "deployment_level": 0,
+ "revisions": [
+ {
+ "created_at": "0001-01-01T00:00:00Z",
+ "identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "modified_at": "0001-01-01T00:00:00Z",
+ "records": [
+ {
+ "identifier": "12345678-1234-1234-1234-123456789abc",
+ "immutable": false,
+ "name": "_acme-challenge",
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "region": "",
+ "ttl": 300,
+ "type": "TXT"
+ }
+ ],
+ "serial": 1,
+ "state": "deployed"
+ }
+ ]
+}
diff --git a/providers/dns/anexia/internal/fixtures/create_record_incomplete.json b/providers/dns/anexia/internal/fixtures/create_record_incomplete.json
new file mode 100644
index 000000000..0515fcde3
--- /dev/null
+++ b/providers/dns/anexia/internal/fixtures/create_record_incomplete.json
@@ -0,0 +1,37 @@
+{
+ "name": "example.com",
+ "zone_name": "example.com",
+ "master": true,
+ "dnssec_mode": "managed",
+ "admin_email": "admin@example.com",
+ "refresh": 10800,
+ "retry": 3600,
+ "expire": 604800,
+ "ttl": 86400,
+ "customer": "ANX12345",
+ "created_at": "0001-01-01T00:00:00Z",
+ "updated_at": "0001-01-01T00:00:00Z",
+ "published_at": "0001-01-01T00:00:00Z",
+ "is_editable": true,
+ "validation_level": 0,
+ "deployment_level": 0,
+ "revisions": [
+ {
+ "created_at": "0001-01-01T00:00:00Z",
+ "identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "modified_at": "0001-01-01T00:00:00Z",
+ "records": [
+ {
+ "immutable": false,
+ "name": "_acme-challenge",
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "region": "",
+ "ttl": 300,
+ "type": "TXT"
+ }
+ ],
+ "serial": 1,
+ "state": "deployed"
+ }
+ ]
+}
diff --git a/providers/dns/anexia/internal/fixtures/error.json b/providers/dns/anexia/internal/fixtures/error.json
new file mode 100644
index 000000000..afed571fa
--- /dev/null
+++ b/providers/dns/anexia/internal/fixtures/error.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 401,
+ "message": "Unauthorized"
+ }
+}
diff --git a/providers/dns/anexia/internal/fixtures/get_zone.json b/providers/dns/anexia/internal/fixtures/get_zone.json
new file mode 100644
index 000000000..6e54594ff
--- /dev/null
+++ b/providers/dns/anexia/internal/fixtures/get_zone.json
@@ -0,0 +1,82 @@
+{
+ "identifier": "fdb355ffd07c48aba3d4f6bf6a116296",
+ "admin_email": "admin@example.com",
+ "created_at": "2019-02-06T10:02:07.000Z",
+ "current_revision": "eeed7e08-f1ad-442b-9e75-369a0958c7d8",
+ "deployment_level": 100,
+ "dns_servers": [
+ {
+ "server": "acns01.xaas.systems",
+ "alias": null
+ },
+ {
+ "server": "acns04.xaas.systems",
+ "alias": null
+ },
+ {
+ "server": "acns02.xaas.systems",
+ "alias": null
+ },
+ {
+ "server": "acns03.xaas.systems",
+ "alias": null
+ },
+ {
+ "server": "acns05.xaas.systems",
+ "alias": null
+ }
+ ],
+ "dnsCluster": null,
+ "dnssec_ksk": null,
+ "dnssec_mode": "unvalidated",
+ "dnssec_sig_expires_at": null,
+ "dnssec_zsk": null,
+ "expire": 604800,
+ "inherit_ns_from": null,
+ "nameserver_set": null,
+ "master": true,
+ "master_ns": "acns02.xaas.systems.",
+ "name": "example.com",
+ "notify_allowed_ips": [
+ "127.0.0.1"
+ ],
+ "published_at": "2023-06-20T08:41:06.000Z",
+ "refresh": 14400,
+ "revisions": [
+ {
+ "created_at": "2023-06-20T08:41:06.000000Z",
+ "identifier": "eeed7e08-f1ad-442b-9e75-369a0958c7d8",
+ "modified_at": "2023-06-20T08:41:06.000000Z",
+ "records": [
+ {
+ "identifier": "5ced498b-c89d-4487-824d-c03ded84f849",
+ "immutable": true,
+ "name": "@",
+ "rdata": "acns02.xaas.systems.",
+ "region": "9a1609af9dae4ce1a4ef63f51d305321",
+ "ttl": 3600,
+ "type": "NS",
+ "options": null
+ },
+ {
+ "identifier": "12345678-1234-1234-1234-123456789abc",
+ "immutable": false,
+ "name": "_acme-challenge",
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "region": "",
+ "ttl": 300,
+ "Type": "TXT"
+ }
+ ],
+ "serial": 14,
+ "state": "active"
+ }
+ ],
+ "retry": 3600,
+ "ttl": 3600,
+ "updated_at": "2020-06-04T18:34:22.000Z",
+ "validation_level": 100,
+ "whitelabel_config": null,
+ "is_editable": true,
+ "deploy_zone": "49459f420f614eb2a979fc7e961f83e6"
+}
diff --git a/providers/dns/anexia/internal/types.go b/providers/dns/anexia/internal/types.go
new file mode 100644
index 000000000..f5546ca98
--- /dev/null
+++ b/providers/dns/anexia/internal/types.go
@@ -0,0 +1,38 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+ Details struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%d: %s", a.Details.Code, a.Details.Message)
+}
+
+type Zone struct {
+ Identifier string `json:"identifier,omitempty"`
+ Name string `json:"name,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ ZoneName string `json:"zone_name,omitempty"`
+ Revisions []Revision `json:"revisions,omitempty"`
+}
+
+type Revision struct {
+ Identifier string `json:"identifier,omitempty"`
+ Records []Record `json:"records,omitempty"`
+ State string `json:"state,omitempty"`
+}
+
+type Record struct {
+ Identifier string `json:"identifier,omitempty"`
+ Immutable bool `json:"immutable,omitempty"`
+ Name string `json:"name,omitempty"`
+ RData string `json:"rdata,omitempty"`
+ Region string `json:"region"`
+ TTL int `json:"ttl,omitempty"`
+ Type string `json:"type,omitempty"`
+}
diff --git a/providers/dns/artfiles/artfiles.go b/providers/dns/artfiles/artfiles.go
new file mode 100644
index 000000000..c918d77f6
--- /dev/null
+++ b/providers/dns/artfiles/artfiles.go
@@ -0,0 +1,204 @@
+// Package artfiles implements a DNS provider for solving the DNS-01 challenge using ArtFiles.
+package artfiles
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "slices"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/artfiles/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ARTFILES_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Username string
+ Password string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for ArtFiles.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("artfiles: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ArtFiles.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("artfiles: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("artfiles: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("artfiles: %w", err)
+ }
+
+ records, err := d.client.GetRecords(ctx, zone)
+ if err != nil {
+ return fmt.Errorf("artfiles: get records: %w", err)
+ }
+
+ rv := internal.RecordValue{}
+
+ if len(records["TXT"]) > 0 {
+ var raw string
+
+ err = json.Unmarshal(records["TXT"], &raw)
+ if err != nil {
+ return fmt.Errorf("artfiles: unmarshal TXT records: %w", err)
+ }
+
+ rv = internal.ParseRecordValue(raw)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
+ if err != nil {
+ return fmt.Errorf("artfiles: %w", err)
+ }
+
+ rv.Add(subDomain, info.Value)
+
+ err = d.client.SetRecords(ctx, zone, "TXT", rv)
+ if err != nil {
+ return fmt.Errorf("artfiles: set TXT records: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("artfiles: %w", err)
+ }
+
+ records, err := d.client.GetRecords(ctx, zone)
+ if err != nil {
+ return fmt.Errorf("artfiles: get records: %w", err)
+ }
+
+ var raw string
+
+ err = json.Unmarshal(records["TXT"], &raw)
+ if err != nil {
+ return fmt.Errorf("artfiles: unmarshal TXT records: %w", err)
+ }
+
+ rv := internal.ParseRecordValue(raw)
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
+ if err != nil {
+ return fmt.Errorf("artfiles: %w", err)
+ }
+
+ rv.RemoveValue(subDomain, info.Value)
+
+ err = d.client.SetRecords(ctx, zone, "TXT", rv)
+ if err != nil {
+ return fmt.Errorf("artfiles: set TXT records: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) {
+ domains, err := d.client.GetDomains(ctx)
+ if err != nil {
+ return "", fmt.Errorf("artfiles: get domains: %w", err)
+ }
+
+ var zone string
+
+ for s := range dns01.UnFqdnDomainsSeq(fqdn) {
+ if slices.Contains(domains, s) {
+ zone = s
+ }
+ }
+
+ if zone == "" {
+ return "", fmt.Errorf("artfiles: could not find the zone for domain %q", fqdn)
+ }
+
+ return zone, nil
+}
diff --git a/providers/dns/artfiles/artfiles.toml b/providers/dns/artfiles/artfiles.toml
new file mode 100644
index 000000000..00ff12342
--- /dev/null
+++ b/providers/dns/artfiles/artfiles.toml
@@ -0,0 +1,24 @@
+Name = "ArtFiles"
+Description = ''''''
+URL = "https://www.artfiles.de/extras/domains/"
+Code = "artfiles"
+Since = "v4.32.0"
+
+Example = '''
+ARTFILES_USERNAME="xxx" \
+ARTFILES_PASSWORD="yyy" \
+lego --dns artfiles -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ARTFILES_USERNAME = "API username"
+ ARTFILES_PASSWORD = "API password"
+ [Configuration.Additional]
+ ARTFILES_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ARTFILES_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)"
+ ARTFILES_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ ARTFILES_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://support.artfiles.de/DCP-API#dns"
diff --git a/providers/dns/artfiles/artfiles_test.go b/providers/dns/artfiles/artfiles_test.go
new file mode 100644
index 000000000..42490f10d
--- /dev/null
+++ b/providers/dns/artfiles/artfiles_test.go
@@ -0,0 +1,228 @@
+package artfiles
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "secret",
+ },
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "secret",
+ },
+ expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "",
+ },
+ expected: "artfiles: some credentials information are missing: ARTFILES_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME,ARTFILES_PASSWORD",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing username",
+ password: "secret",
+ expected: "artfiles: credentials missing",
+ },
+ {
+ desc: "missing Example",
+ username: "user",
+ expected: "artfiles: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "artfiles: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Username = test.username
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Username = "user"
+ config.Password = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithBasicAuth("user", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domain/get_domains.html",
+ servermock.ResponseFromInternal("domains.txt"),
+ ).
+ Route("GET /dns/get_dns.html",
+ servermock.ResponseFromInternal("get_dns.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "example.com"),
+ ).
+ Route("POST /dns/set_dns.html",
+ servermock.ResponseFromInternal("set_dns.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("TXT", `@ "v=spf1 a mx ~all"
+_acme-challenge "TheAcmeChallenge"
+_acme-challenge "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
+_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
+_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
+selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
+selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`).
+ With("domain", "example.com"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domain/get_domains.html",
+ servermock.ResponseFromInternal("domains.txt"),
+ ).
+ Route("GET /dns/get_dns.html",
+ servermock.ResponseFromInternal("get_dns.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "example.com"),
+ ).
+ Route("POST /dns/set_dns.html",
+ servermock.ResponseFromInternal("set_dns.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("TXT", `@ "v=spf1 a mx ~all"
+_acme-challenge "TheAcmeChallenge"
+_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
+_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
+_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
+selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
+selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`).
+ With("domain", "example.com"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/artfiles/internal/client.go b/providers/dns/artfiles/internal/client.go
new file mode 100644
index 000000000..61b350511
--- /dev/null
+++ b/providers/dns/artfiles/internal/client.go
@@ -0,0 +1,133 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://dcp.c.artfiles.de/api/"
+
+// Client the ArtFiles API client.
+type Client struct {
+ username string
+ password string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(username, password string) (*Client, error) {
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ username: username,
+ password: password,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) GetDomains(ctx context.Context) ([]string, error) {
+ endpoint := c.BaseURL.JoinPath("domain", "get_domains.html")
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ raw, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ return parseDomains(string(raw))
+}
+
+func (c *Client) GetRecords(ctx context.Context, domain string) (map[string]json.RawMessage, error) {
+ endpoint := c.BaseURL.JoinPath("dns", "get_dns.html")
+
+ query := endpoint.Query()
+ query.Set("domain", domain)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ raw, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ var result Records
+
+ err = json.Unmarshal(raw, &result)
+ if err != nil {
+ return nil, errutils.NewUnmarshalError(req, http.StatusOK, raw, err)
+ }
+
+ return result.Data, nil
+}
+
+func (c *Client) SetRecords(ctx context.Context, domain, rType string, value RecordValue) error {
+ endpoint := c.BaseURL.JoinPath("dns", "set_dns.html")
+
+ query := endpoint.Query()
+ query.Set("domain", domain)
+ query.Set(rType, value.String())
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil)
+ if err != nil {
+ return fmt.Errorf("unable to create request: %w", err)
+ }
+
+ _, err = c.do(req)
+
+ return err
+}
+
+func (c *Client) do(req *http.Request) ([]byte, error) {
+ useragent.SetHeader(req.Header)
+
+ req.SetBasicAuth(c.username, c.password)
+
+ if req.Method == http.MethodPost {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ if resp.StatusCode/100 != 2 {
+ return nil, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return raw, nil
+}
diff --git a/providers/dns/artfiles/internal/client_test.go b/providers/dns/artfiles/internal/client_test.go
new file mode 100644
index 000000000..cc76f06f5
--- /dev/null
+++ b/providers/dns/artfiles/internal/client_test.go
@@ -0,0 +1,89 @@
+package internal
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithBasicAuth("user", "secret"),
+ )
+}
+
+func TestClient_GetDomains(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domain/get_domains.html",
+ servermock.ResponseFromFixture("domains.txt"),
+ ).
+ Build(t)
+
+ zones, err := client.GetDomains(t.Context())
+ require.NoError(t, err)
+
+ expected := []string{"example.com", "example.org", "example.net"}
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/get_dns.html",
+ servermock.ResponseFromFixture("get_dns.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "example.com"),
+ ).
+ Build(t)
+
+ records, err := client.GetRecords(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := map[string]json.RawMessage{
+ "A": json.RawMessage(strconv.Quote("sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4")),
+ "AAAA": json.RawMessage(strconv.Quote("")),
+ "CAA": json.RawMessage(strconv.Quote("@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"")),
+ "CName": json.RawMessage(strconv.Quote("some cname.to.example.tld.")),
+ "MX": json.RawMessage(strconv.Quote("10 mail.example.tld.")),
+ "SRV": json.RawMessage(strconv.Quote("_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .")),
+ "TLSA": json.RawMessage(strconv.Quote("_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2")),
+ "TXT": json.RawMessage(strconv.Quote("_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"")),
+ "TTL": json.RawMessage("3600"),
+ "comment": json.RawMessage(strconv.Quote("TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php")),
+ "nameserver": json.RawMessage(strconv.Quote("auth1.artfiles.de.\nauth2.artfiles.de.")),
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_SetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/set_dns.html",
+ servermock.ResponseFromFixture("set_dns.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("TXT", "a b\nc \"d\"").
+ With("domain", "example.com"),
+ ).
+ Build(t)
+
+ err := client.SetRecords(t.Context(), "example.com", "TXT", RecordValue{"c": []string{`"d"`}, "a": []string{"b"}})
+ require.NoError(t, err)
+}
diff --git a/providers/dns/artfiles/internal/fixtures/domains.txt b/providers/dns/artfiles/internal/fixtures/domains.txt
new file mode 100644
index 000000000..b8a1247d2
--- /dev/null
+++ b/providers/dns/artfiles/internal/fixtures/domains.txt
@@ -0,0 +1,3 @@
+example.com normal 2026-10-01 2017-09-18 163477
+example.org normal 2026-08-01 2016-07-07 156216
+example.net normal 2026-07-01 2017-06-06 162462
diff --git a/providers/dns/artfiles/internal/fixtures/get_dns.json b/providers/dns/artfiles/internal/fixtures/get_dns.json
new file mode 100644
index 000000000..fa672e0e1
--- /dev/null
+++ b/providers/dns/artfiles/internal/fixtures/get_dns.json
@@ -0,0 +1,16 @@
+{
+ "data": {
+ "SRV": "_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .",
+ "AAAA": "",
+ "MX": "10 mail.example.tld.",
+ "CAA": "@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"",
+ "TTL": 3600,
+ "comment": "TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php",
+ "TXT": "_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"",
+ "A": "sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4",
+ "nameserver": "auth1.artfiles.de.\nauth2.artfiles.de.",
+ "CName": "some cname.to.example.tld.",
+ "TLSA": "_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2"
+ },
+ "status": "OK"
+}
diff --git a/providers/dns/artfiles/internal/fixtures/set_dns.json b/providers/dns/artfiles/internal/fixtures/set_dns.json
new file mode 100644
index 000000000..7cacb33e5
--- /dev/null
+++ b/providers/dns/artfiles/internal/fixtures/set_dns.json
@@ -0,0 +1,4 @@
+{
+ "status": "OK",
+ "error": ""
+}
diff --git a/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt b/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt
new file mode 100644
index 000000000..461489c77
--- /dev/null
+++ b/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt
@@ -0,0 +1,8 @@
+_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
+_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
+_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
+@ "v=spf1 a mx ~all"
+selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
+selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"
+_acme-challenge "xxx"
+_acme-challenge "yyy"
diff --git a/providers/dns/artfiles/internal/fixtures/txt_record.txt b/providers/dns/artfiles/internal/fixtures/txt_record.txt
new file mode 100644
index 000000000..5a6259b14
--- /dev/null
+++ b/providers/dns/artfiles/internal/fixtures/txt_record.txt
@@ -0,0 +1,7 @@
+_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf"
+_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;"
+_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com"
+@ "v=spf1 a mx ~all"
+selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff"
+selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"
+_acme-challenge "TheAcmeChallenge"
diff --git a/providers/dns/artfiles/internal/types.go b/providers/dns/artfiles/internal/types.go
new file mode 100644
index 000000000..c70ab34da
--- /dev/null
+++ b/providers/dns/artfiles/internal/types.go
@@ -0,0 +1,109 @@
+package internal
+
+import (
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "io"
+ "maps"
+ "slices"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+type Records struct {
+ Data map[string]json.RawMessage `json:"data"`
+ Status string `json:"status"`
+}
+
+type RecordValue map[string][]string
+
+func (r RecordValue) Set(key, value string) {
+ r[key] = []string{strconv.Quote(value)}
+}
+
+func (r RecordValue) Add(key, value string) {
+ r[key] = append(r[key], strconv.Quote(value))
+}
+
+func (r RecordValue) Delete(key string) {
+ delete(r, key)
+}
+
+func (r RecordValue) RemoveValue(key, value string) {
+ if len(r[key]) == 0 {
+ return
+ }
+
+ quotedValue := strconv.Quote(value)
+
+ var data []string
+
+ for _, s := range r[key] {
+ if s != quotedValue {
+ data = append(data, s)
+ }
+ }
+
+ r[key] = data
+
+ if len(r[key]) == 0 {
+ r.Delete(key)
+ }
+}
+
+func (r RecordValue) String() string {
+ var parts []string
+
+ for _, key := range slices.Sorted(maps.Keys(r)) {
+ for _, s := range r[key] {
+ parts = append(parts, key+" "+s)
+ }
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+func ParseRecordValue(lines string) RecordValue {
+ data := make(RecordValue)
+
+ for line := range strings.Lines(lines) {
+ line = strings.TrimSpace(line)
+
+ idx := strings.IndexFunc(line, unicode.IsSpace)
+
+ data[line[:idx]] = append(data[line[:idx]], line[idx+1:])
+ }
+
+ return data
+}
+
+func parseDomains(input string) ([]string, error) {
+ reader := csv.NewReader(strings.NewReader(input))
+ reader.Comma = '\t'
+ reader.TrimLeadingSpace = true
+ reader.LazyQuotes = true
+
+ var data []string
+
+ for {
+ record, err := reader.Read()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ if len(record) < 1 {
+ // Malformed line
+ continue
+ }
+
+ data = append(data, record[0])
+ }
+
+ return data, nil
+}
diff --git a/providers/dns/artfiles/internal/types_test.go b/providers/dns/artfiles/internal/types_test.go
new file mode 100644
index 000000000..3b219f39f
--- /dev/null
+++ b/providers/dns/artfiles/internal/types_test.go
@@ -0,0 +1,183 @@
+package internal
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRecordValue_Set(t *testing.T) {
+ rv := make(RecordValue)
+
+ rv.Set("a", "1")
+ rv.Set("b", "2")
+ rv.Set("b", "3")
+
+ assert.Equal(t, "a \"1\"\nb \"3\"", rv.String())
+}
+
+func TestRecordValue_Add(t *testing.T) {
+ rv := make(RecordValue)
+
+ rv.Add("a", "1")
+ rv.Add("b", "2")
+ rv.Add("b", "3")
+
+ assert.Equal(t, "a \"1\"\nb \"2\"\nb \"3\"", rv.String())
+}
+
+func TestRecordValue_Delete(t *testing.T) {
+ rv := make(RecordValue)
+
+ rv.Set("a", "1")
+ rv.Add("b", "2")
+
+ rv.Delete("b")
+
+ assert.Equal(t, "a \"1\"", rv.String())
+}
+
+func TestRecordValue_RemoveValue(t *testing.T) {
+ testCases := []struct {
+ desc string
+ data map[string][]string
+ toRemove map[string][]string
+ expected string
+ }{
+ {
+ desc: "remove the only value",
+ data: map[string][]string{
+ "a": {"1"},
+ },
+ toRemove: map[string][]string{
+ "a": {"1"},
+ },
+ expected: ``,
+ },
+ {
+ desc: "remove value in the middle",
+ data: map[string][]string{
+ "a": {"1", "2", "3"},
+ },
+ toRemove: map[string][]string{
+ "a": {"2"},
+ },
+ expected: "a \"1\"\na \"3\"",
+ },
+ {
+ desc: "remove value at the beginning",
+ data: map[string][]string{
+ "a": {"1", "2", "3"},
+ },
+ toRemove: map[string][]string{
+ "a": {"1"},
+ },
+ expected: "a \"2\"\na \"3\"",
+ },
+ {
+ desc: "remove value at the end",
+ data: map[string][]string{
+ "a": {"1", "2", "3"},
+ },
+ toRemove: map[string][]string{
+ "a": {"3"},
+ },
+ expected: "a \"1\"\na \"2\"",
+ },
+ {
+ desc: "remove all (delete)",
+ data: map[string][]string{
+ "a": {"1", "2", "3"},
+ },
+ toRemove: map[string][]string{
+ "a": {"1", "2", "3"},
+ },
+ expected: ``,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ rv := make(RecordValue)
+
+ for k, values := range test.data {
+ for _, v := range values {
+ rv.Add(k, v)
+ }
+ }
+
+ for k, values := range test.toRemove {
+ for _, v := range values {
+ rv.RemoveValue(k, v)
+ }
+ }
+
+ assert.Equal(t, test.expected, rv.String())
+ })
+ }
+}
+
+func TestParseRecordValue(t *testing.T) {
+ testCases := []struct {
+ desc string
+ filename string
+ expected RecordValue
+ }{
+ {
+ desc: "simple",
+ filename: "txt_record.txt",
+ expected: RecordValue{
+ "@": []string{"\"v=spf1 a mx ~all\""},
+ "_acme-challenge": []string{"\"TheAcmeChallenge\""},
+ "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""},
+ "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""},
+ "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""},
+ "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""},
+ "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""},
+ },
+ },
+ {
+ desc: "multiple values with the same key",
+ filename: "txt_record-multiple.txt",
+ expected: RecordValue{
+ "@": []string{"\"v=spf1 a mx ~all\""},
+ "_acme-challenge": []string{"\"xxx\"", "\"yyy\""},
+ "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""},
+ "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""},
+ "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""},
+ "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""},
+ "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""},
+ },
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ file, err := os.ReadFile(filepath.Join("fixtures", test.filename))
+ require.NoError(t, err)
+
+ data := ParseRecordValue(string(file))
+
+ assert.Equal(t, test.expected, data)
+ })
+ }
+}
+
+func Test_parseDomains(t *testing.T) {
+ file, err := os.ReadFile(filepath.FromSlash("./fixtures/domains.txt"))
+ require.NoError(t, err)
+
+ domains, err := parseDomains(string(file))
+ require.NoError(t, err)
+
+ expected := []string{"example.com", "example.org", "example.net"}
+
+ assert.Equal(t, expected, domains)
+}
diff --git a/providers/dns/arvancloud/arvancloud.go b/providers/dns/arvancloud/arvancloud.go
index 3dd4eee70..ed1d5ff7a 100644
--- a/providers/dns/arvancloud/arvancloud.go
+++ b/providers/dns/arvancloud/arvancloud.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/arvancloud/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -95,6 +96,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -164,6 +167,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("arvancloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
diff --git a/providers/dns/arvancloud/arvancloud.toml b/providers/dns/arvancloud/arvancloud.toml
index 3c0fed4ac..aa5cafb51 100644
--- a/providers/dns/arvancloud/arvancloud.toml
+++ b/providers/dns/arvancloud/arvancloud.toml
@@ -6,17 +6,17 @@ Since = "v3.8.0"
Example = '''
ARVANCLOUD_API_KEY="Apikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
-lego --email you@example.com --dns arvancloud -d '*.example.com' -d example.com run
+lego --dns arvancloud -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
ARVANCLOUD_API_KEY = "API key"
[Configuration.Additional]
- ARVANCLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- ARVANCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- ARVANCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
- ARVANCLOUD_HTTP_TIMEOUT = "API request timeout"
+ ARVANCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ARVANCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ ARVANCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ ARVANCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.arvancloud.ir/docs/api/cdn/4.0"
diff --git a/providers/dns/arvancloud/arvancloud_test.go b/providers/dns/arvancloud/arvancloud_test.go
index c31edf021..24013c437 100644
--- a/providers/dns/arvancloud/arvancloud_test.go
+++ b/providers/dns/arvancloud/arvancloud_test.go
@@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -104,6 +105,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -117,6 +119,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/arvancloud/internal/client.go b/providers/dns/arvancloud/internal/client.go
index 3caff392a..b447d97c4 100644
--- a/providers/dns/arvancloud/internal/client.go
+++ b/providers/dns/arvancloud/internal/client.go
@@ -70,6 +70,7 @@ func (c *Client) getRecords(ctx context.Context, domain, search string) ([]DNSRe
}
response := &apiResponse[[]DNSRecord]{}
+
err = c.do(req, http.StatusOK, response)
if err != nil {
return nil, fmt.Errorf("could not get records %s: Domain: %s: %w", search, domain, err)
@@ -89,6 +90,7 @@ func (c *Client) CreateRecord(ctx context.Context, domain string, record DNSReco
}
response := &apiResponse[*DNSRecord]{}
+
err = c.do(req, http.StatusCreated, response)
if err != nil {
return nil, fmt.Errorf("could not create record; Domain: %s: %w", domain, err)
diff --git a/providers/dns/arvancloud/internal/client_test.go b/providers/dns/arvancloud/internal/client_test.go
index 5c9154c62..183a8acfd 100644
--- a/providers/dns/arvancloud/internal/client_test.go
+++ b/providers/dns/arvancloud/internal/client_test.go
@@ -1,103 +1,55 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder(apiKey string) *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(apiKey)
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(apiKey)
- client.baseURL, _ = url.Parse(server.URL)
- client.HTTPClient = server.Client()
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization(apiKey))
}
func TestClient_GetTxtRecord(t *testing.T) {
const apiKey = "myKeyA"
- client, mux := setupTest(t, apiKey)
-
const domain = "example.com"
- mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
+ client := mockBuilder(apiKey).
+ Route("GET /cdn/4.0/domains/"+domain+"/dns-records",
+ servermock.ResponseFromFixture("get_txt_record.json"),
+ servermock.CheckQueryParameter().With("search", "acme-challenge")).
+ Build(t)
- auth := req.Header.Get(authorizationHeader)
- if auth != apiKey {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
-
- file, err := os.Open("./fixtures/get_txt_record.json")
- 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
- }
- })
-
- _, err := client.GetTxtRecord(context.Background(), domain, "_acme-challenge", "txtxtxt")
+ _, err := client.GetTxtRecord(t.Context(), domain, "_acme-challenge", "txtxtxt")
require.NoError(t, err)
}
func TestClient_CreateRecord(t *testing.T) {
const apiKey = "myKeyB"
- client, mux := setupTest(t, apiKey)
-
const domain = "example.com"
- mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != apiKey {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
-
- file, err := os.Open("./fixtures/create_txt_record.json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- defer func() { _ = file.Close() }()
-
- rw.WriteHeader(http.StatusCreated)
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder(apiKey).
+ Route("POST /cdn/4.0/domains/"+domain+"/dns-records",
+ servermock.ResponseFromFixture("create_txt_record.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
record := DNSRecord{
Name: "_acme-challenge",
@@ -106,13 +58,13 @@ func TestClient_CreateRecord(t *testing.T) {
TTL: 600,
}
- newRecord, err := client.CreateRecord(context.Background(), domain, record)
+ newRecord, err := client.CreateRecord(t.Context(), domain, record)
require.NoError(t, err)
expected := &DNSRecord{
ID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
Type: "txt",
- Value: map[string]interface{}{"text": "txtxtxt"},
+ Value: map[string]any{"text": "txtxtxt"},
Name: "_acme-challenge",
TTL: 120,
UpstreamHTTPS: "default",
@@ -129,24 +81,15 @@ func TestClient_CreateRecord(t *testing.T) {
func TestClient_DeleteRecord(t *testing.T) {
const apiKey = "myKeyC"
- client, mux := setupTest(t, apiKey)
+ const (
+ domain = "example.com"
+ recordID = "recordId"
+ )
- const domain = "example.com"
- const recordID = "recordId"
+ client := mockBuilder(apiKey).
+ Route("DELETE /cdn/4.0/domains/"+domain+"/dns-records/"+recordID, nil).
+ Build(t)
- mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records/"+recordID, func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != apiKey {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
- })
-
- err := client.DeleteRecord(context.Background(), domain, recordID)
+ err := client.DeleteRecord(t.Context(), domain, recordID)
require.NoError(t, err)
}
diff --git a/providers/dns/arvancloud/internal/fixtures/create_record-request.json b/providers/dns/arvancloud/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..48a7124f6
--- /dev/null
+++ b/providers/dns/arvancloud/internal/fixtures/create_record-request.json
@@ -0,0 +1,8 @@
+{
+ "type": "txt",
+ "value": {
+ "text": "txtxtxt"
+ },
+ "name": "_acme-challenge",
+ "ttl": 600
+}
diff --git a/providers/dns/auroradns/auroradns.go b/providers/dns/auroradns/auroradns.go
index 8a497ffa4..50d2fbc25 100644
--- a/providers/dns/auroradns/auroradns.go
+++ b/providers/dns/auroradns/auroradns.go
@@ -10,6 +10,8 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/miekg/dns"
"github.com/nrdcg/auroradns"
)
@@ -51,10 +53,11 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
+ config *Config
+ client *auroradns.Client
+
recordIDs map[string]string
recordIDsMu sync.Mutex
- config *Config
- client *auroradns.Client
}
// NewDNSProvider returns a DNSProvider instance configured for AuroraDNS.
@@ -93,7 +96,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("aurora: %w", err)
}
- client, err := auroradns.NewClient(tr.Client(), auroradns.WithBaseURL(config.BaseURL))
+ client, err := auroradns.NewClient(clientdebug.Wrap(tr.Client()), auroradns.WithBaseURL(config.BaseURL))
if err != nil {
return nil, fmt.Errorf("aurora: %w", err)
}
@@ -161,7 +164,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("aurora: unknown recordID for %q", info.EffectiveFQDN)
}
- authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN))
+ authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN))
if err != nil {
return fmt.Errorf("aurora: could not find zone for domain %q: %w", domain, err)
}
diff --git a/providers/dns/auroradns/auroradns.toml b/providers/dns/auroradns/auroradns.toml
index 4ee8c0975..59b5e7ab1 100644
--- a/providers/dns/auroradns/auroradns.toml
+++ b/providers/dns/auroradns/auroradns.toml
@@ -7,7 +7,7 @@ Since = "v0.4.0"
Example = '''
AURORA_API_KEY=xxxxx \
AURORA_SECRET=yyyyyy \
-lego --email you@example.com --dns auroradns -d '*.example.com' -d example.com run
+lego --dns auroradns -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,9 +16,9 @@ lego --email you@example.com --dns auroradns -d '*.example.com' -d example.com r
AURORA_SECRET = "Secret password to be used"
[Configuration.Additional]
AURORA_ENDPOINT = "API endpoint URL"
- AURORA_POLLING_INTERVAL = "Time between DNS propagation check"
- AURORA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- AURORA_TTL = "The TTL of the TXT record used for the DNS challenge"
+ AURORA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ AURORA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ AURORA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
[Links]
API = "https://libcloud.readthedocs.io/en/latest/dns/drivers/auroradns.html#api-docs"
diff --git a/providers/dns/auroradns/auroradns_test.go b/providers/dns/auroradns/auroradns_test.go
index cbd51b830..8a9835d9c 100644
--- a/providers/dns/auroradns/auroradns_test.go
+++ b/providers/dns/auroradns/auroradns_test.go
@@ -1,35 +1,32 @@
package auroradns
import (
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-acme/lego/v4/platform/tester"
- "github.com/stretchr/testify/assert"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/nrdcg/auroradns"
"github.com/stretchr/testify/require"
)
var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret)
-func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "asdf1234"
+ config.Secret = "key"
+ config.BaseURL = server.URL
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- config := NewDefaultConfig()
- config.APIKey = "asdf1234"
- config.Secret = "key"
- config.BaseURL = server.URL
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- return provider, mux
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithContentType("application/json").
+ WithRegexp("Authorization", `AuroraDNSv1 .+`).
+ WithRegexp("X-Auroradns-Date", `[0-9TZ]+`))
}
func TestNewDNSProvider(t *testing.T) {
@@ -74,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -145,76 +143,51 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
func TestDNSProvider_Present(t *testing.T) {
- provider, mux := setupTest(t)
-
- mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodGet, r.Method, "method")
-
- w.WriteHeader(http.StatusCreated)
- fmt.Fprintf(w, `[{
- "id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
- "name": "example.com"
- }]`)
- })
-
- mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodPost, r.Method)
- assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
-
- reqBody, err := io.ReadAll(r.Body)
- require.NoError(t, err)
- assert.JSONEq(t, `{"type":"TXT","name":"_acme-challenge","content":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":300}`, string(reqBody))
-
- w.WriteHeader(http.StatusCreated)
- fmt.Fprintf(w, `{
- "id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
- "type": "TXT",
- "name": "_acme-challenge",
- "ttl": 300
- }`)
- })
+ provider := mockBuilder().
+ Route("GET /zones",
+ servermock.JSONEncode([]auroradns.Zone{{
+ ID: "c56a4180-65aa-42ec-a945-5fd21dec0538",
+ Name: "example.com",
+ }}).
+ WithStatusCode(http.StatusCreated)).
+ Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records",
+ servermock.JSONEncode(auroradns.Record{
+ ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538",
+ RecordType: "TXT",
+ Name: "_acme-challenge",
+ TTL: 300,
+ }).
+ WithStatusCode(http.StatusCreated)).
+ Build(t)
err := provider.Present("example.com", "", "foobar")
- require.NoError(t, err, "fail to create TXT record")
+ require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
- provider, mux := setupTest(t)
-
- mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodGet, r.Method)
-
- w.WriteHeader(http.StatusCreated)
- fmt.Fprintf(w, `[{
- "id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
- "name": "example.com"
- }]`)
- })
-
- mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodPost, r.Method)
-
- w.WriteHeader(http.StatusCreated)
- fmt.Fprintf(w, `{
- "id": "ec56a4180-65aa-42ec-a945-5fd21dec0538",
- "type": "TXT",
- "name": "_acme-challenge",
- "ttl": 300
- }`)
- })
-
- mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodDelete, r.Method)
-
- assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
-
- w.WriteHeader(http.StatusCreated)
- fmt.Fprintf(w, `{}`)
- })
+ provider := mockBuilder().
+ Route("GET /zones",
+ servermock.JSONEncode([]auroradns.Zone{{
+ ID: "c56a4180-65aa-42ec-a945-5fd21dec0538",
+ Name: "example.com",
+ }}).
+ WithStatusCode(http.StatusCreated)).
+ Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records",
+ servermock.JSONEncode(auroradns.Record{
+ ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538",
+ RecordType: "TXT",
+ Name: "_acme-challenge",
+ TTL: 300,
+ }).
+ WithStatusCode(http.StatusCreated)).
+ Route("DELETE /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538",
+ servermock.RawStringResponse("{}").
+ WithStatusCode(http.StatusCreated)).
+ Build(t)
err := provider.Present("example.com", "", "foobar")
- require.NoError(t, err, "fail to create TXT record")
+ require.NoError(t, err)
err = provider.CleanUp("example.com", "", "foobar")
- require.NoError(t, err, "fail to remove TXT record")
+ require.NoError(t, err)
}
diff --git a/providers/dns/autodns/autodns.go b/providers/dns/autodns/autodns.go
index 61f3005f1..8a9361bc0 100644
--- a/providers/dns/autodns/autodns.go
+++ b/providers/dns/autodns/autodns.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/autodns/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -105,6 +106,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -125,9 +128,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Value: info.Value,
}}
- _, err := d.client.AddTxtRecords(context.Background(), info.EffectiveFQDN, records)
+ _, err := d.client.AddRecords(context.Background(), info.EffectiveFQDN, records)
if err != nil {
- return fmt.Errorf("autodns: %w", err)
+ return fmt.Errorf("autodns: add record: %w", err)
}
return nil
@@ -144,8 +147,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
Value: info.Value,
}}
- if err := d.client.RemoveTXTRecords(context.Background(), info.EffectiveFQDN, records); err != nil {
- return fmt.Errorf("autodns: %w", err)
+ _, err := d.client.RemoveRecords(context.Background(), info.EffectiveFQDN, records)
+ if err != nil {
+ return fmt.Errorf("autodns: remove record: %w", err)
}
return nil
diff --git a/providers/dns/autodns/autodns.toml b/providers/dns/autodns/autodns.toml
index 353f223a9..2798d4cee 100644
--- a/providers/dns/autodns/autodns.toml
+++ b/providers/dns/autodns/autodns.toml
@@ -7,7 +7,7 @@ Since = "v3.2.0"
Example = '''
AUTODNS_API_USER=username \
AUTODNS_API_PASSWORD=supersecretpassword \
-lego --email you@example.com --dns autodns -d '*.example.com' -d example.com run
+lego --dns autodns -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,10 @@ lego --email you@example.com --dns autodns -d '*.example.com' -d example.com run
[Configuration.Additional]
AUTODNS_ENDPOINT = "API endpoint URL, defaults to https://api.autodns.com/v1/"
AUTODNS_CONTEXT = "API context (4 for production, 1 for testing. Defaults to 4)"
- AUTODNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- AUTODNS_POLLING_INTERVAL = "Time between DNS propagation check"
- AUTODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- AUTODNS_HTTP_TIMEOUT = "API request timeout, defaults to 30 seconds"
+ AUTODNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ AUTODNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ AUTODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ AUTODNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://help.internetx.com/display/APIJSONEN"
diff --git a/providers/dns/autodns/autodns_test.go b/providers/dns/autodns/autodns_test.go
index bc9f3067e..632d24705 100644
--- a/providers/dns/autodns/autodns_test.go
+++ b/providers/dns/autodns/autodns_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -131,6 +132,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -144,6 +146,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/autodns/internal/client.go b/providers/dns/autodns/internal/client.go
index 363250d0a..d92490a60 100644
--- a/providers/dns/autodns/internal/client.go
+++ b/providers/dns/autodns/internal/client.go
@@ -31,7 +31,7 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(username string, password string, clientContext int) *Client {
+func NewClient(username, password string, clientContext int) *Client {
baseURL, _ := url.Parse(DefaultEndpoint)
return &Client{
@@ -43,23 +43,22 @@ func NewClient(username string, password string, clientContext int) *Client {
}
}
-// AddTxtRecords adds TXT records.
-func (c *Client) AddTxtRecords(ctx context.Context, domain string, records []*ResourceRecord) (*Zone, error) {
+// AddRecords adds records.
+func (c *Client) AddRecords(ctx context.Context, domain string, records []*ResourceRecord) (*DataZoneResponse, error) {
zoneStream := &ZoneStream{Adds: records}
return c.updateZone(ctx, domain, zoneStream)
}
-// RemoveTXTRecords removes TXT records.
-func (c *Client) RemoveTXTRecords(ctx context.Context, domain string, records []*ResourceRecord) error {
+// RemoveRecords removes records.
+func (c *Client) RemoveRecords(ctx context.Context, domain string, records []*ResourceRecord) (*DataZoneResponse, error) {
zoneStream := &ZoneStream{Removes: records}
- _, err := c.updateZone(ctx, domain, zoneStream)
- return err
+ return c.updateZone(ctx, domain, zoneStream)
}
// https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L21090
-func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *ZoneStream) (*Zone, error) {
+func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *ZoneStream) (*DataZoneResponse, error) {
endpoint := c.BaseURL.JoinPath("zone", domain, "_stream")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zoneStream)
@@ -67,12 +66,12 @@ func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *Zone
return nil, err
}
- var zone *Zone
- if err := c.do(req, &zone); err != nil {
+ var resp *DataZoneResponse
+ if err := c.do(req, &resp); err != nil {
return nil, err
}
- return zone, nil
+ return resp, nil
}
func (c *Client) do(req *http.Request, result any) error {
@@ -87,7 +86,7 @@ func (c *Client) do(req *http.Request, result any) error {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
- return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+ return parseError(req, resp)
}
if result == nil {
@@ -130,3 +129,16 @@ func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, paylo
return req, nil
}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/autodns/internal/client_test.go b/providers/dns/autodns/internal/client_test.go
index f8743b24b..9b0968fdc 100644
--- a/providers/dns/autodns/internal/client_test.go
+++ b/providers/dns/autodns/internal/client_test.go
@@ -1,96 +1,174 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret", 123)
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
- 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
- }
-
- apiUser, apiKey, ok := req.BasicAuth()
- if apiUser != "user" || apiKey != "secret" || !ok {
- 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", 123)
- client.HTTPClient = server.Client()
- client.BaseURL, _ = url.Parse(server.URL)
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithBasicAuth("user", "secret").
+ WithJSONHeaders())
}
-func TestClient_AddTxtRecords(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json")
+func TestClient_AddRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zone/example.com/_stream",
+ servermock.ResponseFromFixture("add_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"),
+ servermock.CheckHeader().
+ With("X-Domainrobot-Context", "123")).
+ Build(t)
- records := []*ResourceRecord{{}}
+ records := []*ResourceRecord{{
+ Name: "example.com",
+ TTL: 600,
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ }}
- zone, err := client.AddTxtRecords(context.Background(), "example.com", records)
+ resp, err := client.AddRecords(t.Context(), "example.com", records)
require.NoError(t, err)
- expected := &Zone{
- Name: "example.com",
- ResourceRecords: []*ResourceRecord{{
- Name: "example.com",
- TTL: 120,
- Type: "TXT",
- Value: "txt",
- Pref: 1,
- }},
- Action: "xxx",
- VirtualNameServer: "yyy",
+ expected := &DataZoneResponse{
+ STID: "20251121-appf4923-126284",
+ CTID: "",
+ Messages: []ResponseMessage{
+ {
+ Text: "string",
+ Messages: []string{
+ "string",
+ },
+ Objects: []GenericObject{
+ {
+ Type: "string",
+ Value: "string",
+ },
+ },
+ Code: "string",
+ Status: "SUCCESS",
+ },
+ },
+ Status: &ResponseStatus{
+ Code: "S0301",
+ Text: "Zone was updated successfully on the name server.",
+ Type: "SUCCESS",
+ },
+ Object: nil,
+ Data: []Zone{
+ {
+ Name: "example.com",
+ ResourceRecords: []ResourceRecord{
+ {
+ Name: "example.com",
+ TTL: 120,
+ Type: "TXT",
+ Value: "txt",
+ Pref: 1,
+ },
+ },
+ Action: "xxx",
+ VirtualNameServer: "yyy",
+ },
+ },
}
- assert.Equal(t, expected, zone)
+ assert.Equal(t, expected, resp)
}
-func TestClient_RemoveTXTRecords(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json")
+func TestClient_AddRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zone/example.com/_stream",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- records := []*ResourceRecord{{}}
+ records := []*ResourceRecord{{
+ Name: "example.com",
+ TTL: 600,
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ }}
- err := client.RemoveTXTRecords(context.Background(), "example.com", records)
+ _, err := client.AddRecords(t.Context(), "example.com", records)
+ require.EqualError(t, err, `STID: 20251121-appf4923-126284, status: code: E0202002, text: Zone konnte auf dem Nameserver nicht aktualisiert werden., type: ERROR, message: code: EF02022, text: Der Zusatzeintrag wurde doppelt eingetragen., status: ERROR, object: OURDOMAIN.TLD@nsa7.schlundtech.de/rr[17]: _acme-challenge.www.whoami.int.OURDOMAIN.TLD TXT "rK2SJb_ZcrYefbfCKU6jZEANfEAJeOtSh1Fv8hkUoVc"`)
+}
+
+func TestClient_RemoveRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zone/example.com/_stream",
+ servermock.ResponseFromFixture("remove_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("remove_record-request.json"),
+ servermock.CheckHeader().
+ With("X-Domainrobot-Context", "123")).
+ Build(t)
+
+ records := []*ResourceRecord{{
+ Name: "example.com",
+ TTL: 600,
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ }}
+
+ resp, err := client.RemoveRecords(t.Context(), "example.com", records)
require.NoError(t, err)
+
+ expected := &DataZoneResponse{
+ STID: "20251121-appf4923-126284",
+ CTID: "",
+ Messages: []ResponseMessage{
+ {
+ Text: "string",
+ Messages: []string{
+ "string",
+ },
+ Objects: []GenericObject{
+ {
+ Type: "string",
+ Value: "string",
+ },
+ },
+ Code: "string",
+ Status: "SUCCESS",
+ },
+ },
+ Status: &ResponseStatus{
+ Code: "S0301",
+ Text: "Zone was updated successfully on the name server.",
+ Type: "SUCCESS",
+ },
+ Object: nil,
+ Data: []Zone{
+ {
+ Name: "example.com",
+ ResourceRecords: []ResourceRecord{
+ {
+ Name: "example.com",
+ TTL: 120,
+ Type: "TXT",
+ Value: "txt",
+ Pref: 1,
+ },
+ },
+ Action: "xxx",
+ VirtualNameServer: "yyy",
+ },
+ },
+ }
+
+ assert.Equal(t, expected, resp)
}
diff --git a/providers/dns/autodns/internal/fixtures/add-record.json b/providers/dns/autodns/internal/fixtures/add-record.json
deleted file mode 100644
index 4a95f0784..000000000
--- a/providers/dns/autodns/internal/fixtures/add-record.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "origin": "example.com",
- "resourceRecords": [
- {
- "name": "example.com",
- "ttl": 120,
- "type": "TXT",
- "value": "txt",
- "pref": 1
- }
- ],
- "action": "xxx",
- "virtualNameServer": "yyy"
-}
diff --git a/providers/dns/autodns/internal/fixtures/add_record-request.json b/providers/dns/autodns/internal/fixtures/add_record-request.json
new file mode 100644
index 000000000..6105c77ac
--- /dev/null
+++ b/providers/dns/autodns/internal/fixtures/add_record-request.json
@@ -0,0 +1,11 @@
+{
+ "adds": [
+ {
+ "name": "example.com",
+ "ttl": 600,
+ "type": "TXT",
+ "value": "txtTXTtxt"
+ }
+ ],
+ "rems": null
+}
diff --git a/providers/dns/autodns/internal/fixtures/add_record.json b/providers/dns/autodns/internal/fixtures/add_record.json
new file mode 100644
index 000000000..a0ce66ba6
--- /dev/null
+++ b/providers/dns/autodns/internal/fixtures/add_record.json
@@ -0,0 +1,41 @@
+{
+ "stid": "20251121-appf4923-126284",
+ "messages": [
+ {
+ "text": "string",
+ "notice": "string",
+ "messages": [
+ "string"
+ ],
+ "objects": [
+ {
+ "type": "string",
+ "value": "string"
+ }
+ ],
+ "code": "string",
+ "status": "SUCCESS"
+ }
+ ],
+ "status": {
+ "code": "S0301",
+ "text": "Zone was updated successfully on the name server.",
+ "type": "SUCCESS"
+ },
+ "data": [
+ {
+ "origin": "example.com",
+ "resourceRecords": [
+ {
+ "name": "example.com",
+ "ttl": 120,
+ "type": "TXT",
+ "value": "txt",
+ "pref": 1
+ }
+ ],
+ "action": "xxx",
+ "virtualNameServer": "yyy"
+ }
+ ]
+}
diff --git a/providers/dns/autodns/internal/fixtures/error.json b/providers/dns/autodns/internal/fixtures/error.json
new file mode 100644
index 000000000..2ed635d58
--- /dev/null
+++ b/providers/dns/autodns/internal/fixtures/error.json
@@ -0,0 +1,21 @@
+{
+ "stid": "20251121-appf4923-126284",
+ "messages": [
+ {
+ "text": "Der Zusatzeintrag wurde doppelt eingetragen.",
+ "objects": [
+ {
+ "type": "OURDOMAIN.TLD@nsa7.schlundtech.de/rr[17]",
+ "value": "_acme-challenge.www.whoami.int.OURDOMAIN.TLD TXT \"rK2SJb_ZcrYefbfCKU6jZEANfEAJeOtSh1Fv8hkUoVc\""
+ }
+ ],
+ "code": "EF02022",
+ "status": "ERROR"
+ }
+ ],
+ "status": {
+ "code": "E0202002",
+ "text": "Zone konnte auf dem Nameserver nicht aktualisiert werden.",
+ "type": "ERROR"
+ }
+}
diff --git a/providers/dns/autodns/internal/fixtures/remove-record.json b/providers/dns/autodns/internal/fixtures/remove-record.json
deleted file mode 100644
index 4a95f0784..000000000
--- a/providers/dns/autodns/internal/fixtures/remove-record.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "origin": "example.com",
- "resourceRecords": [
- {
- "name": "example.com",
- "ttl": 120,
- "type": "TXT",
- "value": "txt",
- "pref": 1
- }
- ],
- "action": "xxx",
- "virtualNameServer": "yyy"
-}
diff --git a/providers/dns/autodns/internal/fixtures/remove_record-request.json b/providers/dns/autodns/internal/fixtures/remove_record-request.json
new file mode 100644
index 000000000..92361403e
--- /dev/null
+++ b/providers/dns/autodns/internal/fixtures/remove_record-request.json
@@ -0,0 +1,11 @@
+{
+ "adds": null,
+ "rems": [
+ {
+ "name": "example.com",
+ "ttl": 600,
+ "type": "TXT",
+ "value": "txtTXTtxt"
+ }
+ ]
+}
diff --git a/providers/dns/autodns/internal/fixtures/remove_record.json b/providers/dns/autodns/internal/fixtures/remove_record.json
new file mode 100644
index 000000000..a0ce66ba6
--- /dev/null
+++ b/providers/dns/autodns/internal/fixtures/remove_record.json
@@ -0,0 +1,41 @@
+{
+ "stid": "20251121-appf4923-126284",
+ "messages": [
+ {
+ "text": "string",
+ "notice": "string",
+ "messages": [
+ "string"
+ ],
+ "objects": [
+ {
+ "type": "string",
+ "value": "string"
+ }
+ ],
+ "code": "string",
+ "status": "SUCCESS"
+ }
+ ],
+ "status": {
+ "code": "S0301",
+ "text": "Zone was updated successfully on the name server.",
+ "type": "SUCCESS"
+ },
+ "data": [
+ {
+ "origin": "example.com",
+ "resourceRecords": [
+ {
+ "name": "example.com",
+ "ttl": 120,
+ "type": "TXT",
+ "value": "txt",
+ "pref": 1
+ }
+ ],
+ "action": "xxx",
+ "virtualNameServer": "yyy"
+ }
+ ]
+}
diff --git a/providers/dns/autodns/internal/types.go b/providers/dns/autodns/internal/types.go
index 93fd678ca..8a06f4889 100644
--- a/providers/dns/autodns/internal/types.go
+++ b/providers/dns/autodns/internal/types.go
@@ -1,33 +1,133 @@
package internal
+import (
+ "fmt"
+ "strings"
+)
+
+type APIResponse[T any] struct {
+ STID string `json:"stid"`
+ CTID string `json:"ctid"`
+ Messages []ResponseMessage `json:"messages"`
+ Status *ResponseStatus `json:"status"`
+ Object *ResponseObject `json:"object"`
+ Data T `json:"data"`
+}
+
+type APIError APIResponse[any]
+
+func (a *APIError) Error() string {
+ var parts []string
+
+ if a.STID != "" {
+ parts = append(parts, fmt.Sprintf("STID: %s", a.STID))
+ }
+
+ if a.CTID != "" {
+ parts = append(parts, fmt.Sprintf("CTID: %s", a.CTID))
+ }
+
+ if a.Status != nil {
+ parts = append(parts, "status: "+a.Status.String())
+ }
+
+ for _, message := range a.Messages {
+ parts = append(parts, "message: "+message.String())
+ }
+
+ if a.Object != nil {
+ parts = append(parts, "object: "+a.Object.String())
+ }
+
+ return strings.Join(parts, ", ")
+}
+
+type DataZoneResponse APIResponse[[]Zone]
+
type ResponseMessage struct {
- Text string `json:"text"`
- Messages []string `json:"messages"`
- Objects []string `json:"objects"`
- Code string `json:"code"`
- Status string `json:"status"`
+ Text string `json:"text"`
+ Code string `json:"code"`
+ Status string `json:"status"`
+ Messages []string `json:"messages"`
+ Objects []GenericObject `json:"objects"`
+}
+
+func (r ResponseMessage) String() string {
+ var parts []string
+
+ if r.Code != "" {
+ parts = append(parts, "code: "+r.Code)
+ }
+
+ if r.Text != "" {
+ parts = append(parts, "text: "+r.Text)
+ }
+
+ if r.Status != "" {
+ parts = append(parts, "status: "+r.Status)
+ }
+
+ if len(r.Messages) > 0 {
+ parts = append(parts, "messages: "+strings.Join(r.Messages, ";"))
+ }
+
+ for _, object := range r.Objects {
+ parts = append(parts, fmt.Sprintf("object: %s", object))
+ }
+
+ return strings.Join(parts, ", ")
+}
+
+type GenericObject struct {
+ Type string `json:"type"`
+ Value string `json:"value"`
+}
+
+func (g GenericObject) String() string {
+ return g.Type + ": " + g.Value
}
type ResponseStatus struct {
Code string `json:"code"`
Text string `json:"text"`
- Type string `json:"type"`
+ Type string `json:"type"` // SUCCESS, ERROR, NOTIFY, NOTICE, NICCOM_NOTIFY
+}
+
+func (r ResponseStatus) String() string {
+ return fmt.Sprintf("code: %s, text: %s, type: %s", r.Code, r.Text, r.Type)
}
type ResponseObject struct {
- Type string `json:"type"`
- Value string `json:"value"`
- Summary int32 `json:"summary"`
- Data string
+ Type string `json:"type"`
+ Value string `json:"value"`
+ Summary int32 `json:"summary"`
+ Data *ResponseObjectData `json:"data"`
}
-type DataZoneResponse struct {
- STID string `json:"stid"`
- CTID string `json:"ctid"`
- Messages []*ResponseMessage `json:"messages"`
- Status *ResponseStatus `json:"status"`
- Object any `json:"object"`
- Data []*Zone `json:"data"`
+func (r ResponseObject) String() string {
+ var parts []string
+
+ if r.Type != "" {
+ parts = append(parts, fmt.Sprintf("type: %s", r.Type))
+ }
+
+ if r.Value != "" {
+ parts = append(parts, fmt.Sprintf("value: %s", r.Value))
+ }
+
+ if r.Summary != 0 {
+ parts = append(parts, fmt.Sprintf("summary: %d", r.Summary))
+ }
+
+ if r.Data != nil {
+ parts = append(parts, fmt.Sprintf("data: %s", r.Data.Description))
+ }
+
+ return strings.Join(parts, ", ")
+}
+
+type ResponseObjectData struct {
+ Description string `json:"description"`
}
// ResourceRecord holds a resource record.
@@ -43,10 +143,10 @@ type ResourceRecord struct {
// Zone is an autodns zone record with all for us relevant fields.
// https://help.internetx.com/display/APIXMLEN/Zone+Object
type Zone struct {
- Name string `json:"origin"`
- ResourceRecords []*ResourceRecord `json:"resourceRecords"`
- Action string `json:"action"`
- VirtualNameServer string `json:"virtualNameServer"`
+ Name string `json:"origin"`
+ ResourceRecords []ResourceRecord `json:"resourceRecords"`
+ Action string `json:"action"`
+ VirtualNameServer string `json:"virtualNameServer"`
}
// ZoneStream body of the requests.
diff --git a/providers/dns/axelname/axelname.go b/providers/dns/axelname/axelname.go
new file mode 100644
index 000000000..96d26236e
--- /dev/null
+++ b/providers/dns/axelname/axelname.go
@@ -0,0 +1,160 @@
+// Package axelname implements a DNS provider for solving the DNS-01 challenge using Axelname.
+package axelname
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/axelname/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "AXELNAME_"
+
+ EnvNickname = envNamespace + "NICKNAME"
+ EnvToken = envNamespace + "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 {
+ Nickname 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, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Axelname.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvNickname, EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("axelname: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Nickname = values[EnvNickname]
+ config.Token = values[EnvToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Axelname.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("axelname: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Nickname, config.Token)
+ if err != nil {
+ return nil, fmt.Errorf("axelname: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("axelname: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("axelname: %w", err)
+ }
+
+ record := internal.Record{
+ Name: subDomain,
+ Type: "TXT",
+ Value: info.Value,
+ }
+
+ err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("axelname: 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("axelname: could not find zone for domain %q: %w", domain, err)
+ }
+
+ records, err := d.client.ListRecords(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("axelname: list records: %w", err)
+ }
+
+ for _, record := range records {
+ if record.Type != "TXT" || record.Value != info.Value {
+ continue
+ }
+
+ err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("axelname: delete record: %w", err)
+ }
+
+ return nil
+ }
+
+ return errors.New("axelname: delete record: record not found")
+}
+
+// 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/dns/axelname/axelname.toml b/providers/dns/axelname/axelname.toml
new file mode 100644
index 000000000..1e2ad6e72
--- /dev/null
+++ b/providers/dns/axelname/axelname.toml
@@ -0,0 +1,24 @@
+Name = "Axelname"
+Description = ''''''
+URL = "https://axelname.ru"
+Code = "axelname"
+Since = "v4.23.0"
+
+Example = '''
+AXELNAME_NICKNAME="yyy" \
+AXELNAME_TOKEN="xxx" \
+lego --dns axelname -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ AXELNAME_NICKNAME = "Account nickname"
+ AXELNAME_TOKEN = "API token"
+ [Configuration.Additional]
+ AXELNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ AXELNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ AXELNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ AXELNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://axelname.ru/static/content/files/axelname_api_rest_lite.pdf"
diff --git a/providers/dns/axelname/axelname_test.go b/providers/dns/axelname/axelname_test.go
new file mode 100644
index 000000000..1a8bac971
--- /dev/null
+++ b/providers/dns/axelname/axelname_test.go
@@ -0,0 +1,144 @@
+package axelname
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvNickname, EnvToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvNickname: "user",
+ EnvToken: "secret",
+ },
+ },
+ {
+ desc: "missing nickname",
+ envVars: map[string]string{
+ EnvNickname: "",
+ EnvToken: "secret",
+ },
+ expected: "axelname: some credentials information are missing: AXELNAME_NICKNAME",
+ },
+ {
+ desc: "missing token",
+ envVars: map[string]string{
+ EnvNickname: "user",
+ EnvToken: "",
+ },
+ expected: "axelname: some credentials information are missing: AXELNAME_TOKEN",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "axelname: some credentials information are missing: AXELNAME_NICKNAME,AXELNAME_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
+ token string
+ nickname string
+ expected string
+ }{
+ {
+ desc: "success",
+ nickname: "user",
+ token: "secret",
+ },
+ {
+ desc: "missing nickname",
+ expected: "axelname: credentials missing",
+ },
+ {
+ desc: "missing token",
+ expected: "axelname: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "axelname: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Token = test.token
+ config.Nickname = test.nickname
+
+ 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/dns/axelname/internal/client.go b/providers/dns/axelname/internal/client.go
new file mode 100644
index 000000000..f2defec87
--- /dev/null
+++ b/providers/dns/axelname/internal/client.go
@@ -0,0 +1,184 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ querystring "github.com/google/go-querystring/query"
+)
+
+const statusSuccess = "success"
+
+const defaultBaseURL = "https://my.axelname.ru/rest/"
+
+// Client the Axelname API client.
+type Client struct {
+ nickname string
+ token string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(nickname, token string) (*Client, error) {
+ if token == "" || nickname == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ nickname: nickname,
+ token: token,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) ListRecords(ctx context.Context, domain string) ([]Record, error) {
+ endpoint := c.baseURL.JoinPath("dns_list")
+
+ query := endpoint.Query()
+ query.Set("domain", domain)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := c.newRequest(ctx, endpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ var results ListResponse
+
+ err = c.do(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ if results.Result != statusSuccess {
+ return nil, &results.APIError
+ }
+
+ return results.List, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error {
+ endpoint := c.baseURL.JoinPath("dns_delete")
+
+ values, err := querystring.Values(record)
+ if err != nil {
+ return err
+ }
+
+ values.Set("domain", domain)
+
+ endpoint.RawQuery = values.Encode()
+
+ req, err := c.newRequest(ctx, endpoint)
+ if err != nil {
+ return err
+ }
+
+ var results APIResponse
+
+ err = c.do(req, &results)
+ if err != nil {
+ return err
+ }
+
+ if results.Result != statusSuccess {
+ return &results.APIError
+ }
+
+ return nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error {
+ endpoint := c.baseURL.JoinPath("dns_add")
+
+ values, err := querystring.Values(record)
+ if err != nil {
+ return err
+ }
+
+ values.Set("domain", domain)
+
+ endpoint.RawQuery = values.Encode()
+
+ req, err := c.newRequest(ctx, endpoint)
+ if err != nil {
+ return err
+ }
+
+ var results APIResponse
+
+ err = c.do(req, &results)
+ if err != nil {
+ return err
+ }
+
+ if results.Result != statusSuccess {
+ return &results.APIError
+ }
+
+ return nil
+}
+
+func (c *Client) newRequest(ctx context.Context, endpoint *url.URL) (*http.Request, error) {
+ query := endpoint.Query()
+ query.Set("token", c.token)
+ query.Set("nichdl", c.nickname)
+
+ endpoint.RawQuery = query.Encode()
+
+ return http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ 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 parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/axelname/internal/client_test.go b/providers/dns/axelname/internal/client_test.go
new file mode 100644
index 000000000..7796f6047
--- /dev/null
+++ b/providers/dns/axelname/internal/client_test.go
@@ -0,0 +1,118 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupClient(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+}
+
+func TestClient_ListRecords(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /dns_list",
+ servermock.ResponseFromFixture("dns_list.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "example.com").
+ With("nichdl", "user").
+ With("token", "secret")).
+ Build(t)
+
+ records, err := client.ListRecords(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {ID: "74749", Name: "example.com", Type: "A", Value: "46.161.54.22"},
+ {ID: "417", Name: "example.com", Type: "MX", Value: "mx.yandex.ru.", Prio: "10"},
+ {ID: "419", Name: "mail.example.com", Type: "CNAME", Value: "mail.yandex.ru."},
+ {ID: "74750", Name: "www.example.com", Type: "A", Value: "46.161.54.22"},
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_ListRecords_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /dns_list",
+ servermock.ResponseFromFixture("dns_list_error.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
+
+ _, err := client.ListRecords(t.Context(), "example.com")
+ require.EqualError(t, err, "error: Domain not found (1)")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /dns_delete",
+ servermock.ResponseFromFixture("dns_delete.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("id", "74749").
+ With("domain", "example.com").
+ With("nichdl", "user").
+ With("token", "secret")).
+ Build(t)
+
+ record := Record{ID: "74749"}
+
+ err := client.DeleteRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /dns_delete",
+ servermock.ResponseFromFixture("dns_delete_error.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
+
+ record := Record{ID: "74749"}
+
+ err := client.DeleteRecord(t.Context(), "example.com", record)
+ require.EqualError(t, err, "error: Domain not found (1)")
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /dns_add",
+ servermock.ResponseFromFixture("dns_add.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("id", "74749").
+ With("domain", "example.com").
+ With("nichdl", "user").
+ With("token", "secret")).
+ Build(t)
+
+ record := Record{ID: "74749"}
+
+ err := client.AddRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /dns_add",
+ servermock.ResponseFromFixture("dns_add_error.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
+
+ record := Record{ID: "74749"}
+
+ err := client.AddRecord(t.Context(), "example.com", record)
+ require.EqualError(t, err, "error: Domain not found (1)")
+}
diff --git a/providers/dns/axelname/internal/fixtures/dns_add.json b/providers/dns/axelname/internal/fixtures/dns_add.json
new file mode 100644
index 000000000..628813579
--- /dev/null
+++ b/providers/dns/axelname/internal/fixtures/dns_add.json
@@ -0,0 +1,5 @@
+{
+ "code": "OK",
+ "message": "DNS record added",
+ "result": "success"
+}
diff --git a/providers/dns/axelname/internal/fixtures/dns_add_error.json b/providers/dns/axelname/internal/fixtures/dns_add_error.json
new file mode 100644
index 000000000..5fb9fd368
--- /dev/null
+++ b/providers/dns/axelname/internal/fixtures/dns_add_error.json
@@ -0,0 +1,5 @@
+{
+ "error_code": "1",
+ "error_text": "Domain not found",
+ "result": "error"
+}
diff --git a/providers/dns/axelname/internal/fixtures/dns_delete.json b/providers/dns/axelname/internal/fixtures/dns_delete.json
new file mode 100644
index 000000000..a7851fcc6
--- /dev/null
+++ b/providers/dns/axelname/internal/fixtures/dns_delete.json
@@ -0,0 +1,5 @@
+{
+ "code": "OK",
+ "message": "DNS record deleted",
+ "result": "success"
+}
diff --git a/providers/dns/axelname/internal/fixtures/dns_delete_error.json b/providers/dns/axelname/internal/fixtures/dns_delete_error.json
new file mode 100644
index 000000000..5fb9fd368
--- /dev/null
+++ b/providers/dns/axelname/internal/fixtures/dns_delete_error.json
@@ -0,0 +1,5 @@
+{
+ "error_code": "1",
+ "error_text": "Domain not found",
+ "result": "error"
+}
diff --git a/providers/dns/axelname/internal/fixtures/dns_list.json b/providers/dns/axelname/internal/fixtures/dns_list.json
new file mode 100644
index 000000000..ace11ba73
--- /dev/null
+++ b/providers/dns/axelname/internal/fixtures/dns_list.json
@@ -0,0 +1,33 @@
+{
+ "code": "OK",
+ "message": "DNS-records",
+ "count": 4,
+ "result": "success",
+ "list": [
+ {
+ "id": "74749",
+ "name": "example.com",
+ "type": "A",
+ "value": "46.161.54.22"
+ },
+ {
+ "id": "417",
+ "name": "example.com",
+ "type": "MX",
+ "value": "mx.yandex.ru.",
+ "prio": "10"
+ },
+ {
+ "id": "419",
+ "name": "mail.example.com",
+ "type": "CNAME",
+ "value": "mail.yandex.ru."
+ },
+ {
+ "id": "74750",
+ "name": "www.example.com",
+ "type": "A",
+ "value": "46.161.54.22"
+ }
+ ]
+}
diff --git a/providers/dns/axelname/internal/fixtures/dns_list_error.json b/providers/dns/axelname/internal/fixtures/dns_list_error.json
new file mode 100644
index 000000000..5fb9fd368
--- /dev/null
+++ b/providers/dns/axelname/internal/fixtures/dns_list_error.json
@@ -0,0 +1,5 @@
+{
+ "error_code": "1",
+ "error_text": "Domain not found",
+ "result": "error"
+}
diff --git a/providers/dns/axelname/internal/types.go b/providers/dns/axelname/internal/types.go
new file mode 100644
index 000000000..45583fb2e
--- /dev/null
+++ b/providers/dns/axelname/internal/types.go
@@ -0,0 +1,35 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorText string `json:"error_text,omitempty"`
+ Result string `json:"result,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%s: %s (%s)", a.Result, a.ErrorText, a.ErrorCode)
+}
+
+type APIResponse struct {
+ APIError
+
+ Code string `json:"code,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+type ListResponse struct {
+ APIResponse
+
+ Count int `json:"count,omitempty"`
+ List []Record `json:"list,omitempty"`
+}
+
+type Record struct {
+ ID string `json:"id,omitempty" url:"id,omitempty"`
+ Name string `json:"name,omitempty" url:"name,omitempty"`
+ Type string `json:"type,omitempty" url:"type,omitempty"`
+ Value string `json:"value,omitempty" url:"value,omitempty"`
+ Prio string `json:"prio,omitempty" url:"prio,omitempty"`
+}
diff --git a/providers/dns/azion/azion.go b/providers/dns/azion/azion.go
new file mode 100644
index 000000000..5584ece0b
--- /dev/null
+++ b/providers/dns/azion/azion.go
@@ -0,0 +1,307 @@
+// Package azion implements a DNS provider for solving the DNS-01 challenge using Azion Edge DNS.
+package azion
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/aziontech/azionapi-go-sdk/idns"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "AZION_"
+
+ EnvPersonalToken = envNamespace + "PERSONAL_TOKEN"
+ EnvPageSize = envNamespace + "PAGE_SIZE"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ PersonalToken string
+ PageSize int
+
+ PollingInterval time.Duration
+ PropagationTimeout time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ PageSize: env.GetOrDefaultInt(EnvPageSize, 50),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *idns.APIClient
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Azion.
+// Credentials must be passed in the environment variable: AZION_PERSONAL_TOKEN.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvPersonalToken)
+ if err != nil {
+ return nil, fmt.Errorf("azion: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.PersonalToken = values[EnvPersonalToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Azion.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("azion: the configuration of the DNS provider is nil")
+ }
+
+ if config.PersonalToken == "" {
+ return nil, errors.New("azion: missing credentials")
+ }
+
+ clientConfig := idns.NewConfiguration()
+ clientConfig.AddDefaultHeader("Accept", "application/json; version=3")
+ clientConfig.UserAgent = "lego-dns/azion"
+
+ if config.HTTPClient != nil {
+ clientConfig.HTTPClient = config.HTTPClient
+ }
+
+ clientConfig.HTTPClient = clientdebug.Wrap(clientConfig.HTTPClient)
+
+ client := idns.NewAPIClient(clientConfig)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+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 {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctxAuth := authContext(context.Background(), d.config.PersonalToken)
+
+ zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := extractSubDomain(info, zone)
+ if err != nil {
+ return fmt.Errorf("azion: %w", err)
+ }
+
+ // Check if a TXT record with the same name already exists
+ existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
+ if err != nil {
+ return fmt.Errorf("azion: check existing records: %w", err)
+ }
+
+ record := idns.NewRecordPostOrPut()
+ record.SetEntry(subDomain)
+ record.SetRecordType("TXT")
+ record.SetTtl(int32(d.config.TTL))
+
+ var resp *idns.PostOrPutRecordResponse
+
+ if existingRecord != nil {
+ // Update existing record by adding the new value to the existing ones
+ record.SetAnswersList(append(existingRecord.GetAnswersList(), info.Value))
+
+ // Use PUT to update the existing record
+ resp, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
+ if err != nil {
+ return fmt.Errorf("azion: update existing record: %w", err)
+ }
+ } else {
+ // Create a new record
+ record.SetAnswersList([]string{info.Value})
+
+ resp, _, err = d.client.RecordsAPI.PostZoneRecord(ctxAuth, zone.GetId()).RecordPostOrPut(*record).Execute()
+ if err != nil {
+ return fmt.Errorf("azion: create new zone record: %w", err)
+ }
+ }
+
+ if resp == nil || resp.Results == nil {
+ return errors.New("azion: create zone record error")
+ }
+
+ 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)
+
+ ctxAuth := authContext(context.Background(), d.config.PersonalToken)
+
+ zone, err := d.findZone(ctxAuth, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := extractSubDomain(info, zone)
+ if err != nil {
+ return fmt.Errorf("azion: %w", err)
+ }
+
+ existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
+ if err != nil {
+ return fmt.Errorf("azion: find existing record: %w", err)
+ }
+
+ if existingRecord == nil {
+ return nil
+ }
+
+ currentAnswers := existingRecord.GetAnswersList()
+
+ var updatedAnswers []string
+
+ for _, answer := range currentAnswers {
+ if answer != info.Value {
+ updatedAnswers = append(updatedAnswers, answer)
+ }
+ }
+
+ // If no answers remain, delete the entire record
+ if len(updatedAnswers) == 0 {
+ _, resp, errDelete := d.client.RecordsAPI.DeleteZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).Execute()
+ if errDelete != nil {
+ // If a record doesn't exist (404), consider cleanup successful
+ if resp != nil && resp.StatusCode == http.StatusNotFound {
+ return nil
+ }
+
+ return fmt.Errorf("azion: delete record: %w", errDelete)
+ }
+
+ return nil
+ }
+
+ // Update the record with remaining answers
+ record := idns.NewRecordPostOrPut()
+ record.SetEntry(subDomain)
+ record.SetRecordType("TXT")
+ record.SetAnswersList(updatedAnswers)
+ record.SetTtl(existingRecord.GetTtl())
+
+ _, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute()
+ if err != nil {
+ return fmt.Errorf("azion: update record: %w", err)
+ }
+
+ return nil
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*idns.Zone, error) {
+ resp, _, err := d.client.ZonesAPI.GetZones(ctx).Execute()
+ if err != nil {
+ return nil, fmt.Errorf("get zones: %w", err)
+ }
+
+ if resp == nil {
+ return nil, errors.New("get zones: no results")
+ }
+
+ for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
+ for _, zone := range resp.GetResults() {
+ if zone.GetDomain() == domain {
+ return &zone, nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("zone not found (fqdn: %q)", fqdn)
+}
+
+// findExistingTXTRecord searches for an existing TXT record with the given name in the specified zone.
+// It handles pagination to search through all pages of results.
+func (d *DNSProvider) findExistingTXTRecord(ctx context.Context, zoneID int32, recordName string) (*idns.RecordGet, error) {
+ var page int64 = 1
+
+ for {
+ resp, _, err := d.client.RecordsAPI.GetZoneRecords(ctx, zoneID).Page(page).PageSize(int64(d.config.PageSize)).Execute()
+ if err != nil {
+ return nil, fmt.Errorf("get zone records (page %d): %w", page, err)
+ }
+
+ if resp == nil {
+ return nil, errors.New("get zone records: no results")
+ }
+
+ results, ok := resp.GetResultsOk()
+ if !ok || results == nil {
+ return nil, errors.New("get zone records: empty")
+ }
+
+ // Search for existing TXT record with the same name in current page
+ for _, record := range results.GetRecords() {
+ if record.GetRecordType() == "TXT" && record.GetEntry() == recordName {
+ return &record, nil
+ }
+ }
+
+ // Check if there are more pages to search
+ if page >= int64(resp.GetTotalPages()) {
+ break
+ }
+
+ page++
+ }
+
+ // No existing record found in any page
+ return nil, nil
+}
+
+func authContext(ctx context.Context, key string) context.Context {
+ return context.WithValue(ctx, idns.ContextAPIKeys, map[string]idns.APIKey{
+ "tokenAuth": {
+ Key: key,
+ Prefix: "Token",
+ },
+ })
+}
+
+func extractSubDomain(info dns01.ChallengeInfo, zone *idns.Zone) (string, error) {
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.GetName())
+ if err != nil {
+ return "", err
+ }
+
+ if subDomain != "" {
+ return subDomain, nil
+ }
+
+ return "@", nil
+}
diff --git a/providers/dns/azion/azion.toml b/providers/dns/azion/azion.toml
new file mode 100644
index 000000000..52df20ab5
--- /dev/null
+++ b/providers/dns/azion/azion.toml
@@ -0,0 +1,24 @@
+Name = "Azion"
+Description = ''''''
+Code = "azion"
+Since = "v4.24.0"
+URL = "https://www.azion.com/en/products/edge-dns/"
+
+Example = '''
+AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
+lego --dns azion -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ AZION_PERSONAL_TOKEN = "Your Azion personal token."
+ [Configuration.Additional]
+ AZION_PAGE_SIZE = "The page size for the API request (Default: 50)"
+ AZION_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ AZION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ AZION_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ AZION_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://api.azion.com/"
+ GoClient = "https://github.com/aziontech/azionapi-go-sdk"
diff --git a/providers/dns/azion/azion_test.go b/providers/dns/azion/azion_test.go
new file mode 100644
index 000000000..517594cdc
--- /dev/null
+++ b/providers/dns/azion/azion_test.go
@@ -0,0 +1,235 @@
+package azion
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/aziontech/azionapi-go-sdk/idns"
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvPersonalToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvPersonalToken: "token",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvPersonalToken: "",
+ },
+ expected: "azion: some credentials information are missing: AZION_PERSONAL_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
+ token string
+ expected string
+ }{
+ {
+ desc: "success",
+ token: "token",
+ },
+ {
+ desc: "missing credentials",
+ expected: "azion: missing credentials",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.PersonalToken = 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)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_findZone(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /intelligent_dns", servermock.ResponseFromFixture("zones.json")).
+ Build(t)
+
+ testCases := []struct {
+ desc string
+ fqdn string
+ expected *idns.Zone
+ }{
+ {
+ desc: "apex",
+ fqdn: "example.com.",
+ expected: &idns.Zone{
+ Id: idns.PtrInt32(1),
+ Domain: idns.PtrString("example.com"),
+ },
+ },
+ {
+ desc: "sub domain",
+ fqdn: "sub.example.com.",
+ expected: &idns.Zone{
+ Id: idns.PtrInt32(2),
+ Domain: idns.PtrString("sub.example.com"),
+ },
+ },
+ {
+ desc: "long sub domain",
+ fqdn: "_acme-challenge.api.sub.example.com.",
+ expected: &idns.Zone{
+ Id: idns.PtrInt32(2),
+ Domain: idns.PtrString("sub.example.com"),
+ },
+ },
+ {
+ desc: "long sub domain, apex",
+ fqdn: "_acme-challenge.test.example.com.",
+ expected: &idns.Zone{
+ Id: idns.PtrInt32(1),
+ Domain: idns.PtrString("example.com"),
+ },
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ zone, err := provider.findZone(context.Background(), test.fqdn)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expected, zone)
+ })
+ }
+}
+
+func TestDNSProvider_findZone_error(t *testing.T) {
+ testCases := []struct {
+ desc string
+ fqdn string
+ response string
+ expected string
+ }{
+ {
+ desc: "no parent zone found",
+ fqdn: "_acme-challenge.example.org.",
+ response: "zones.json",
+ expected: `zone not found (fqdn: "_acme-challenge.example.org.")`,
+ },
+ {
+ desc: "empty zones list",
+ fqdn: "example.com.",
+ response: "zones_empty.json",
+ expected: `zone not found (fqdn: "example.com.")`,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /intelligent_dns", servermock.ResponseFromFixture(test.response)).
+ Build(t)
+
+ zone, err := provider.findZone(context.Background(), test.fqdn)
+ require.EqualError(t, err, test.expected)
+
+ assert.Nil(t, zone)
+ })
+ }
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.PersonalToken = "secret"
+
+ provider, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ clientConfig := provider.client.GetConfig()
+ clientConfig.HTTPClient = server.Client()
+ clientConfig.Servers = idns.ServerConfigurations{{
+ URL: server.URL,
+ Description: "Production",
+ }}
+
+ return provider, nil
+ },
+ )
+}
diff --git a/providers/dns/azion/fixtures/zones.json b/providers/dns/azion/fixtures/zones.json
new file mode 100644
index 000000000..7dccedf1a
--- /dev/null
+++ b/providers/dns/azion/fixtures/zones.json
@@ -0,0 +1,19 @@
+{
+ "count": 2,
+ "links": {
+ "previous": null,
+ "next": null
+ },
+ "total_pages": 1,
+ "results": [
+ {
+ "id": 1,
+ "domain": "example.com"
+ },
+ {
+ "id": 2,
+ "domain": "sub.example.com"
+ }
+ ],
+ "schema_version": 3
+}
diff --git a/providers/dns/azion/fixtures/zones_empty.json b/providers/dns/azion/fixtures/zones_empty.json
new file mode 100644
index 000000000..540063837
--- /dev/null
+++ b/providers/dns/azion/fixtures/zones_empty.json
@@ -0,0 +1,10 @@
+{
+ "count": 0,
+ "links": {
+ "previous": null,
+ "next": null
+ },
+ "total_pages": 0,
+ "results": null,
+ "schema_version": 3
+}
diff --git a/providers/dns/azure/azure.go b/providers/dns/azure/azure.go
index 5702acd8a..8bfc6cfe1 100644
--- a/providers/dns/azure/azure.go
+++ b/providers/dns/azure/azure.go
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
+ "strings"
"time"
"github.com/Azure/go-autorest/autorest"
@@ -37,6 +38,8 @@ const (
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
)
+const EnvLegoAzureBypassDeprecation = "LEGO_AZURE_BYPASS_DEPRECATION"
+
const defaultMetadataEndpoint = "http://169.254.169.254"
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
@@ -89,6 +92,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()
@@ -96,6 +100,7 @@ func NewDNSProvider() (*DNSProvider, error) {
environmentName := env.GetOrFile(EnvEnvironment)
if environmentName != "" {
var environment aazure.Environment
+
switch environmentName {
case "china":
environment = aazure.ChinaCloud
@@ -124,12 +129,25 @@ 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")
}
+ if !env.GetOrDefaultBool(EnvLegoAzureBypassDeprecation, false) {
+ var msg strings.Builder
+
+ msg.WriteString("azure: ")
+ msg.WriteString("The `azure` provider has been deprecated since 2023, and replaced by `azuredns` provider. ")
+ msg.WriteString("It can be TEMPORARILY reactivated by using the environment variable `LEGO_AZURE_BYPASS_DEPRECATION=true`. ")
+ msg.WriteString("The `azure` provider will be removed in a future release, please migrate to the `azuredns` provider. ")
+ msg.WriteString("The documentation of the `azuredns` provider can be found at https://go-acme.github.io/lego/dns/azuredns/")
+
+ return nil, errors.New(msg.String())
+ }
+
if config.HTTPClient == nil {
config.HTTPClient = &http.Client{Timeout: 5 * time.Second}
}
@@ -148,6 +166,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if subsID == "" {
return nil, errors.New("azure: SubscriptionID is missing")
}
+
config.SubscriptionID = subsID
}
@@ -160,6 +179,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if resGroup == "" {
return nil, errors.New("azure: ResourceGroup is missing")
}
+
config.ResourceGroup = resGroup
}
diff --git a/providers/dns/azure/azure.toml b/providers/dns/azure/azure.toml
index c4e3b674a..a38ed55ab 100644
--- a/providers/dns/azure/azure.toml
+++ b/providers/dns/azure/azure.toml
@@ -19,9 +19,9 @@ Example = ''''''
AZURE_METADATA_ENDPOINT = "Metadata Service endpoint URL"
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_POLLING_INTERVAL = "Time between DNS propagation check"
- AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- AZURE_TTL = "The TTL of the TXT record used for the DNS challenge"
+ AZURE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ AZURE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
[Links]
API = "https://docs.microsoft.com/en-us/go/azure/"
diff --git a/providers/dns/azure/azure_test.go b/providers/dns/azure/azure_test.go
index 496168362..c4fec4359 100644
--- a/providers/dns/azure/azure_test.go
+++ b/providers/dns/azure/azure_test.go
@@ -14,6 +14,7 @@ import (
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(
+ EnvLegoAzureBypassDeprecation,
EnvEnvironment,
EnvClientID,
EnvClientSecret,
@@ -54,8 +55,11 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
+ test.envVars[EnvLegoAzureBypassDeprecation] = "true"
+
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
@@ -139,6 +143,11 @@ func TestNewDNSProviderConfig(t *testing.T) {
},
}
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+ envTest.Apply(map[string]string{EnvLegoAzureBypassDeprecation: "true"})
+
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
@@ -158,6 +167,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
} else {
mux.HandleFunc("/", test.handler)
}
+
config.MetadataEndpoint = server.URL
p, err := NewDNSProviderConfig(config)
@@ -186,6 +196,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -199,6 +210,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/azure/private.go b/providers/dns/azure/private.go
index d6c9fc7bd..f7c6a75b7 100644
--- a/providers/dns/azure/private.go
+++ b/providers/dns/azure/private.go
@@ -54,6 +54,7 @@ func (d *dnsProviderPrivate) Present(domain, token, keyAuth string) error {
// Construct unique TXT records using map
uniqRecords := map[string]struct{}{info.Value: {}}
+
if rset.RecordSetProperties != nil && rset.TxtRecords != nil {
for _, txtRecord := range *rset.TxtRecords {
// Assume Value doesn't contain multiple strings
@@ -81,6 +82,7 @@ func (d *dnsProviderPrivate) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("azure: %w", err)
}
+
return nil
}
@@ -106,6 +108,7 @@ func (d *dnsProviderPrivate) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("azure: %w", err)
}
+
return nil
}
diff --git a/providers/dns/azure/public.go b/providers/dns/azure/public.go
index 8e6fa182a..194956c9c 100644
--- a/providers/dns/azure/public.go
+++ b/providers/dns/azure/public.go
@@ -54,6 +54,7 @@ func (d *dnsProviderPublic) Present(domain, token, keyAuth string) error {
// Construct unique TXT records using map
uniqRecords := map[string]struct{}{info.Value: {}}
+
if rset.RecordSetProperties != nil && rset.TxtRecords != nil {
for _, txtRecord := range *rset.TxtRecords {
// Assume Value doesn't contain multiple strings
@@ -81,6 +82,7 @@ func (d *dnsProviderPublic) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("azure: %w", err)
}
+
return nil
}
@@ -106,6 +108,7 @@ func (d *dnsProviderPublic) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("azure: %w", err)
}
+
return nil
}
diff --git a/providers/dns/azuredns/azuredns.go b/providers/dns/azuredns/azuredns.go
index dd591d92b..b8effadea 100644
--- a/providers/dns/azuredns/azuredns.go
+++ b/providers/dns/azuredns/azuredns.go
@@ -3,20 +3,15 @@
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/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -33,10 +28,21 @@ const (
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"
+ EnvOIDCToken = envNamespace + "OIDC_TOKEN"
+ EnvOIDCTokenFilePath = envNamespace + "OIDC_TOKEN_FILE_PATH"
+ EnvOIDCRequestURL = envNamespace + "OIDC_REQUEST_URL"
+ EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL"
+ altEnvArmOIDCRequestURL = "ARM_OIDC_REQUEST_URL"
+ EnvOIDCRequestToken = envNamespace + "OIDC_REQUEST_TOKEN"
+ EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"
+ altEnvArmOIDCRequestToken = "ARM_OIDC_REQUEST_TOKEN"
+
+ EnvServiceConnectionID = envNamespace + "SERVICE_CONNECTION_ID"
+ altEnvServiceConnectionID = "SERVICE_CONNECTION_ID"
+ altEnvArmAdoPipelineServiceConnectionID = "ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID"
+ altEnvArmOIDCAzureServiceConnectionID = "ARM_OIDC_AZURE_SERVICE_CONNECTION_ID"
+ EnvSystemAccessToken = envNamespace + "SYSTEM_ACCESS_TOKEN"
+ altEnvSystemAccessToken = "SYSTEM_ACCESSTOKEN"
EnvAuthMethod = envNamespace + "AUTH_METHOD"
EnvAuthMSITimeout = envNamespace + "AUTH_MSI_TIMEOUT"
@@ -46,9 +52,6 @@ const (
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
-
- EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL"
- EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
@@ -73,6 +76,9 @@ type Config struct {
OIDCRequestURL string
OIDCRequestToken string
+ ServiceConnectionID string
+ SystemAccessToken string
+
AuthMethod string
AuthMSITimeout time.Duration
@@ -134,13 +140,22 @@ func NewDNSProvider() (*DNSProvider, error) {
config.ServiceDiscoveryFilter = env.GetOrFile(EnvServiceDiscoveryFilter)
oidcValues, _ := env.GetWithFallback(
- []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL},
- []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken},
+ []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL, altEnvArmOIDCRequestURL},
+ []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken, altEnvArmOIDCRequestToken},
)
config.OIDCRequestURL = oidcValues[EnvOIDCRequestURL]
config.OIDCRequestToken = oidcValues[EnvOIDCRequestToken]
+ // https://registry.terraform.io/providers/hashicorp/Azurerm/latest/docs/guides/service_principal_oidc
+ pipelineValues, _ := env.GetWithFallback(
+ []string{EnvServiceConnectionID, altEnvServiceConnectionID, altEnvArmAdoPipelineServiceConnectionID, altEnvArmOIDCAzureServiceConnectionID},
+ []string{EnvSystemAccessToken, altEnvArmOIDCRequestToken, altEnvSystemAccessToken},
+ )
+
+ config.ServiceConnectionID = pipelineValues[EnvServiceConnectionID]
+ config.SystemAccessToken = pipelineValues[EnvSystemAccessToken]
+
config.AuthMethod = env.GetOrFile(EnvAuthMethod)
config.AuthMSITimeout = env.GetOrDefaultSecond(EnvAuthMSITimeout, 2*time.Second)
@@ -157,6 +172,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
config.HTTPClient = &http.Client{Timeout: 5 * time.Second}
}
+ config.HTTPClient = clientdebug.Wrap(config.HTTPClient)
+
credentials, err := getCredentials(config)
if err != nil {
return nil, fmt.Errorf("azuredns: Unable to retrieve valid credentials: %w", err)
@@ -193,97 +210,3 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
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":
- var credOptions *azidentity.AzureCLICredentialOptions
- if config.TenantID != "" {
- credOptions = &azidentity.AzureCLICredentialOptions{TenantID: config.TenantID}
- }
- return azidentity.NewAzureCLICredential(credOptions)
-
- 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 getZoneName(config *Config, fqdn string) (string, error) {
- if config.ZoneName != "" {
- return config.ZoneName, nil
- }
-
- authZone, err := dns01.FindZoneByFqdn(fqdn)
- if err != nil {
- return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err)
- }
-
- if authZone == "" {
- return "", errors.New("empty zone name")
- }
-
- return authZone, nil
-}
-
-func deref[T any](v *T) T {
- if v == nil {
- var zero T
- return zero
- }
-
- return *v
-}
diff --git a/providers/dns/azuredns/azuredns.toml b/providers/dns/azuredns/azuredns.toml
index 1f160a856..7c800ce7e 100644
--- a/providers/dns/azuredns/azuredns.toml
+++ b/providers/dns/azuredns/azuredns.toml
@@ -10,32 +10,32 @@ Example = '''
AZURE_CLIENT_ID= \
AZURE_TENANT_ID= \
AZURE_CLIENT_SECRET= \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using client certificate
AZURE_CLIENT_ID= \
AZURE_TENANT_ID= \
AZURE_CLIENT_CERTIFICATE_PATH= \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using Azure CLI
az login \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using Managed Identity (Azure VM)
AZURE_TENANT_ID= \
AZURE_RESOURCE_GROUP= \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
### Using Managed Identity (Azure Arc)
AZURE_TENANT_ID= \
IMDS_ENDPOINT=http://localhost:40342 \
IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token \
-lego --email you@example.com --dns azuredns -d '*.example.com' -d example.com run
+lego --dns azuredns -d '*.example.com' -d example.com run
'''
@@ -174,6 +174,10 @@ This authentication method can be specifically used by setting the `AZURE_AUTH_M
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`.
+### Azure DevOps Pipelines
+
+It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`.
+
'''
[Configuration]
@@ -191,9 +195,9 @@ It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oi
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"
+ AZURE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ AZURE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
[Links]
API = "https://docs.microsoft.com/en-us/go/azure/"
diff --git a/providers/dns/azuredns/azuredns_test.go b/providers/dns/azuredns/azuredns_test.go
index 7ddb4de45..594a0d6a3 100644
--- a/providers/dns/azuredns/azuredns_test.go
+++ b/providers/dns/azuredns/azuredns_test.go
@@ -35,6 +35,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -61,6 +62,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -74,6 +76,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/azuredns/credentials.go b/providers/dns/azuredns/credentials.go
new file mode 100644
index 000000000..a38b3f7dd
--- /dev/null
+++ b/providers/dns/azuredns/credentials.go
@@ -0,0 +1,136 @@
+package azuredns
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+ "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/dns01"
+)
+
+const (
+ authMethodEnv = "env"
+ authMethodWLI = "wli"
+ authMethodMSI = "msi"
+ authMethodCLI = "cli"
+ authMethodOIDC = "oidc"
+ authMethodPipeline = "pipeline"
+)
+
+//nolint:gocyclo // The complexity is related to the number of possible configurations.
+func getCredentials(config *Config) (azcore.TokenCredential, error) {
+ clientOptions := azcore.ClientOptions{Cloud: config.Environment}
+
+ switch strings.ToLower(config.AuthMethod) {
+ case authMethodEnv:
+ 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 authMethodWLI:
+ return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: clientOptions})
+
+ case authMethodMSI:
+ cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ClientOptions: clientOptions})
+ if err != nil {
+ return nil, err
+ }
+
+ return &timeoutTokenCredential{cred: cred, timeout: config.AuthMSITimeout}, nil
+
+ case authMethodCLI:
+ var credOptions *azidentity.AzureCLICredentialOptions
+ if config.TenantID != "" {
+ credOptions = &azidentity.AzureCLICredentialOptions{TenantID: config.TenantID}
+ }
+
+ return azidentity.NewAzureCLICredential(credOptions)
+
+ case authMethodOIDC:
+ err := checkOIDCConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return azidentity.NewClientAssertionCredential(config.TenantID, config.ClientID, getOIDCAssertion(config), &azidentity.ClientAssertionCredentialOptions{ClientOptions: clientOptions})
+
+ case authMethodPipeline:
+ err := checkPipelineConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ // Uses the env var `SYSTEM_OIDCREQUESTURI`,
+ // but the constant is not exported,
+ // and there is no way to set it programmatically.
+ // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L22
+ // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L79
+
+ return azidentity.NewAzurePipelinesCredential(config.TenantID, config.ClientID, config.ServiceConnectionID, config.SystemAccessToken, &azidentity.AzurePipelinesCredentialOptions{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 getZoneName(config *Config, fqdn string) (string, error) {
+ if config.ZoneName != "" {
+ return config.ZoneName, nil
+ }
+
+ authZone, err := dns01.FindZoneByFqdn(fqdn)
+ if err != nil {
+ return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err)
+ }
+
+ if authZone == "" {
+ return "", errors.New("empty zone name")
+ }
+
+ return authZone, nil
+}
+
+func checkPipelineConfig(config *Config) error {
+ if config.ServiceConnectionID == "" {
+ return errors.New("azuredns: ServiceConnectionID is missing")
+ }
+
+ if config.SystemAccessToken == "" {
+ return errors.New("azuredns: SystemAccessToken is missing")
+ }
+
+ return nil
+}
diff --git a/providers/dns/azuredns/private.go b/providers/dns/azuredns/private.go
index c3d6cf354..43b39ed14 100644
--- a/providers/dns/azuredns/private.go
+++ b/providers/dns/azuredns/private.go
@@ -14,6 +14,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
)
var _ challenge.ProviderTimeout = (*DNSProviderPrivate)(nil)
@@ -180,11 +181,12 @@ func (c privateZoneClient) Delete(ctx context.Context, subDomain string) (armpri
func privateUniqueRecords(recordSet armprivatedns.RecordSet, value string) map[string]struct{} {
uniqRecords := map[string]struct{}{value: {}}
+
if recordSet.Properties != nil && recordSet.Properties.TxtRecords != nil {
for _, txtRecord := range recordSet.Properties.TxtRecords {
// Assume Value doesn't contain multiple strings
if len(txtRecord.Value) > 0 {
- uniqRecords[deref(txtRecord.Value[0])] = struct{}{}
+ uniqRecords[ptr.Deref(txtRecord.Value[0])] = struct{}{}
}
}
}
diff --git a/providers/dns/azuredns/public.go b/providers/dns/azuredns/public.go
index f6c00b2a7..79b6e783f 100644
--- a/providers/dns/azuredns/public.go
+++ b/providers/dns/azuredns/public.go
@@ -14,6 +14,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
)
var _ challenge.ProviderTimeout = (*DNSProviderPublic)(nil)
@@ -178,11 +179,12 @@ func (c publicZoneClient) Delete(ctx context.Context, subDomain string) (armdns.
func publicUniqueRecords(recordSet armdns.RecordSet, value string) map[string]struct{} {
uniqRecords := map[string]struct{}{value: {}}
+
if recordSet.Properties != nil && recordSet.Properties.TxtRecords != nil {
for _, txtRecord := range recordSet.Properties.TxtRecords {
// Assume Value doesn't contain multiple strings
if len(txtRecord.Value) > 0 {
- uniqRecords[deref(txtRecord.Value[0])] = struct{}{}
+ uniqRecords[ptr.Deref(txtRecord.Value[0])] = struct{}{}
}
}
}
diff --git a/providers/dns/azuredns/servicediscovery.go b/providers/dns/azuredns/servicediscovery.go
index 62dfd6623..50a41da37 100644
--- a/providers/dns/azuredns/servicediscovery.go
+++ b/providers/dns/azuredns/servicediscovery.go
@@ -9,6 +9,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
)
type ServiceDiscoveryZone struct {
@@ -45,6 +46,7 @@ func discoverDNSZones(ctx context.Context, config *Config, credentials azcore.To
}
zones := map[string]ServiceDiscoveryZone{}
+
for {
// create the query request
request := armresourcegraph.QueryRequest{
@@ -88,7 +90,7 @@ func discoverDNSZones(ctx context.Context, config *Config, credentials azcore.To
*requestOptions.Skip += ResourceGraphQueryOptionsTop
if result.TotalRecords != nil {
- if int64(deref(requestOptions.Skip)) >= deref(result.TotalRecords) {
+ if int64(ptr.Deref(requestOptions.Skip)) >= ptr.Deref(result.TotalRecords) {
break
}
}
diff --git a/providers/dns/baiducloud/baiducloud.go b/providers/dns/baiducloud/baiducloud.go
new file mode 100644
index 000000000..1dc8d90ed
--- /dev/null
+++ b/providers/dns/baiducloud/baiducloud.go
@@ -0,0 +1,171 @@
+// Package baiducloud implements a DNS provider for solving the DNS-01 challenge using Baidu Cloud.
+package baiducloud
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ baidudns "github.com/baidubce/bce-sdk-go/services/dns"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "BAIDUCLOUD_"
+
+ EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID"
+ EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+)
+
+// 300 is the minimum TTL for free users.
+const defaultTTL = 300
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ AccessKeyID string
+ SecretAccessKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *baidudns.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Baidu Cloud.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey)
+ if err != nil {
+ return nil, fmt.Errorf("baiducloud: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.AccessKeyID = values[EnvAccessKeyID]
+ config.SecretAccessKey = values[EnvSecretAccessKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Baidu Cloud.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("baiducloud: the configuration of the DNS provider is nil")
+ }
+
+ if config.AccessKeyID == "" && config.SecretAccessKey == "" {
+ return nil, errors.New("baiducloud: credentials missing")
+ }
+
+ client, err := baidudns.NewClient(config.AccessKeyID, config.SecretAccessKey, "")
+ if err != nil {
+ return nil, fmt.Errorf("baiducloud: %w", err)
+ }
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, 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("baiducloud: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("baiducloud: %w", err)
+ }
+
+ crr := &baidudns.CreateRecordRequest{
+ Description: ptr.Pointer("lego"),
+ Rr: subDomain,
+ Type: "TXT",
+ Value: info.Value,
+ Ttl: ptr.Pointer(int32(d.config.TTL)),
+ }
+
+ err = d.client.CreateRecord(dns01.UnFqdn(authZone), crr, "")
+ if err != nil {
+ return fmt.Errorf("baiducloud: create record: %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err)
+ }
+
+ recordID, err := d.findRecordID(dns01.UnFqdn(authZone), info.Value)
+ if err != nil {
+ return fmt.Errorf("baiducloud: find record: %w", err)
+ }
+
+ err = d.client.DeleteRecord(dns01.UnFqdn(authZone), recordID, "")
+ if err != nil {
+ return fmt.Errorf("baiducloud: delete record: %w", err)
+ }
+
+ return nil
+}
+
+func (d *DNSProvider) findRecordID(zoneName, tokenValue string) (string, error) {
+ lrr := &baidudns.ListRecordRequest{}
+
+ for {
+ recordResponse, err := d.client.ListRecord(zoneName, lrr)
+ if err != nil {
+ return "", fmt.Errorf("baiducloud: list record: %w", err)
+ }
+
+ for _, record := range recordResponse.Records {
+ if record.Type == "TXT" && record.Value == tokenValue {
+ return record.Id, nil
+ }
+ }
+
+ if !recordResponse.IsTruncated {
+ break
+ }
+
+ lrr.Marker = recordResponse.NextMarker
+ }
+
+ return "", errors.New("record not found")
+}
+
+// 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/dns/baiducloud/baiducloud.toml b/providers/dns/baiducloud/baiducloud.toml
new file mode 100644
index 000000000..54f1f6312
--- /dev/null
+++ b/providers/dns/baiducloud/baiducloud.toml
@@ -0,0 +1,24 @@
+Name = "Baidu Cloud"
+Description = ''''''
+URL = "https://cloud.baidu.com"
+Code = "baiducloud"
+Since = "v4.23.0"
+
+Example = '''
+BAIDUCLOUD_ACCESS_KEY_ID="xxx" \
+BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \
+lego --dns baiducloud -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ BAIDUCLOUD_ACCESS_KEY_ID = "Access key"
+ BAIDUCLOUD_SECRET_ACCESS_KEY = "Secret access key"
+ [Configuration.Additional]
+ BAIDUCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BAIDUCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ BAIDUCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+
+[Links]
+ API = "https://cloud.baidu.com/doc/DNS/s/El4s7lssr"
+ GoClient = "https://github.com/baidubce/bce-sdk-go"
diff --git a/providers/dns/baiducloud/baiducloud_test.go b/providers/dns/baiducloud/baiducloud_test.go
new file mode 100644
index 000000000..483bfaf5e
--- /dev/null
+++ b/providers/dns/baiducloud/baiducloud_test.go
@@ -0,0 +1,146 @@
+package baiducloud
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAccessKeyID, EnvSecretAccessKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAccessKeyID: "key",
+ EnvSecretAccessKey: "secret",
+ },
+ },
+ {
+ desc: "missing access key ID",
+ envVars: map[string]string{
+ EnvAccessKeyID: "key",
+ },
+ expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_SECRET_ACCESS_KEY",
+ },
+ {
+ desc: "missing secret access key",
+ envVars: map[string]string{
+ EnvSecretAccessKey: "secret",
+ },
+ expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_ACCESS_KEY_ID",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_ACCESS_KEY_ID,BAIDUCLOUD_SECRET_ACCESS_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ accessKeyID string
+ secretAccessKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ accessKeyID: "key",
+ secretAccessKey: "secret",
+ },
+ {
+ desc: "missing access key ID",
+ accessKeyID: "",
+ secretAccessKey: "secret",
+ expected: "baiducloud: accessKeyId should not be empty",
+ },
+ {
+ desc: "missing secret access key",
+ accessKeyID: "key",
+ secretAccessKey: "",
+ expected: "baiducloud: secretKey should not be empty",
+ },
+ {
+ desc: "missing credentials",
+ expected: "baiducloud: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.AccessKeyID = test.accessKeyID
+ config.SecretAccessKey = test.secretAccessKey
+
+ 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/dns/beget/beget.go b/providers/dns/beget/beget.go
new file mode 100644
index 000000000..d4449deb8
--- /dev/null
+++ b/providers/dns/beget/beget.go
@@ -0,0 +1,164 @@
+// Package beget implements a DNS provider for solving the DNS-01 challenge using beget.com DNS.
+package beget
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/beget/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "BEGET_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Username string
+ Password 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, 300),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*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 beget.com.
+// Credentials must be passed in the environment variables:
+// BEGET_USERNAME and BEGET_PASSWORD.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("beget: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for beget.com.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("beget: the configuration of the DNS provider is nil")
+ }
+
+ if config.Username == "" || config.Password == "" {
+ return nil, errors.New("beget: incomplete credentials, missing username and/or password")
+ }
+
+ client := internal.NewClient(config.Username, config.Password)
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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 using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ records, err := d.client.GetTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("beget: get TXT records: %w", err)
+ }
+
+ records = append(records, internal.Record{
+ Value: info.Value,
+ Data: "", // NOTE: there are 2 fields in the API for the same thing.
+ Priority: 10,
+ TTL: d.config.TTL,
+ })
+
+ err = d.client.ChangeTXTRecord(ctx, dns01.UnFqdn(info.EffectiveFQDN), records)
+ if err != nil {
+ return fmt.Errorf("beget: failed to create TXT records [domain: %s]: %w",
+ dns01.UnFqdn(info.EffectiveFQDN), 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)
+
+ records, err := d.client.GetTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("beget: get TXT records: %w", err)
+ }
+
+ if len(records) == 0 {
+ return nil
+ }
+
+ var updatedRecords []internal.Record
+
+ for _, record := range records {
+ if record.Data == info.Value {
+ continue
+ }
+
+ updatedRecords = append(updatedRecords, record)
+ }
+
+ err = d.client.ChangeTXTRecord(ctx, dns01.UnFqdn(info.EffectiveFQDN), updatedRecords)
+ if err != nil {
+ return fmt.Errorf("beget: failed to remove TXT records [domain: %s]: %w",
+ dns01.UnFqdn(info.EffectiveFQDN), err)
+ }
+
+ return nil
+}
diff --git a/providers/dns/beget/beget.toml b/providers/dns/beget/beget.toml
new file mode 100644
index 000000000..4ed26d850
--- /dev/null
+++ b/providers/dns/beget/beget.toml
@@ -0,0 +1,24 @@
+Name = "Beget.com"
+Description = ''''''
+URL = "https://beget.com/"
+Code = "beget"
+Since = "v4.27.0"
+
+Example = '''
+BEGET_USERNAME=xxxxxx \
+BEGET_PASSWORD=yyyyyy \
+lego --dns beget -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ BEGET_USERNAME = "API username"
+ BEGET_PASSWORD = "API password"
+ [Configuration.Additional]
+ BEGET_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)"
+ BEGET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ BEGET_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ BEGET_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://beget.com/ru/kb/api/funkczii-upravleniya-dns"
diff --git a/providers/dns/beget/beget_test.go b/providers/dns/beget/beget_test.go
new file mode 100644
index 000000000..3cfb3c0b4
--- /dev/null
+++ b/providers/dns/beget/beget_test.go
@@ -0,0 +1,232 @@
+package beget
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvUsername: "123",
+ EnvPassword: "456",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "",
+ },
+ expected: "beget: some credentials information are missing: BEGET_USERNAME,BEGET_PASSWORD",
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "456",
+ },
+ expected: "beget: some credentials information are missing: BEGET_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvUsername: "123",
+ EnvPassword: "",
+ },
+ expected: "beget: some credentials information are missing: BEGET_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
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "123",
+ password: "456",
+ },
+ {
+ desc: "missing credentials",
+ username: "",
+ password: "",
+ expected: "beget: incomplete credentials, missing username and/or password",
+ },
+ {
+ desc: "missing username",
+ username: "",
+ password: "456",
+ expected: "beget: incomplete credentials, missing username and/or password",
+ },
+ {
+ desc: "missing password",
+ username: "123",
+ password: "",
+ expected: "beget: incomplete credentials, missing username and/or password",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Username = test.username
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ } 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()
+ assert.NoError(t, err)
+
+ err = provider.Present(envTest.GetDomain(), "", "123d==")
+ assert.NoError(t, err)
+}
+
+func TestLiveCleanUp(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ assert.NoError(t, err)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ assert.NoError(t, err)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Username = "user"
+ config.Password = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckQueryParameter().
+ With("login", "user").
+ With("passwd", "secret").
+ With("input_format", "json").
+ With("output_format", "json"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromInternal("getData-real.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com"}`),
+ ).
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromInternal("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"v=spf1 redirect=beget.com","ttl":300},{"value":"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY","priority":10,"ttl":300}]}}`),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromInternal("getData.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com"}`),
+ ).
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromInternal("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"foo","ttl":300}]}}`),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_empty(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromInternal("getData_empty.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com"}`),
+ ).
+ Route("/",
+ servermock.Noop().WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/beget/internal/client.go b/providers/dns/beget/internal/client.go
new file mode 100644
index 000000000..9b9746ba2
--- /dev/null
+++ b/providers/dns/beget/internal/client.go
@@ -0,0 +1,137 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://api.beget.com/api/"
+
+// Client the beget.com client.
+type Client struct {
+ login string
+ password string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient Creates a beget.com client.
+func NewClient(login, password string) *Client {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ login: login,
+ password: password,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 5 * time.Second},
+ }
+}
+
+// GetTXTRecords returns TXT records.
+// https://beget.com/ru/kb/api/funkczii-upravleniya-dns#getdata
+func (c *Client) GetTXTRecords(ctx context.Context, domain string) ([]Record, error) {
+ request := GetRecordsRequest{Fqdn: domain}
+
+ resp, err := c.doRequest(ctx, request, "dns", "getData")
+ if err != nil {
+ return nil, err
+ }
+
+ err = resp.HasError()
+ if err != nil {
+ return nil, err
+ }
+
+ result := GetRecordsResult{}
+
+ err = json.Unmarshal(resp.Answer.Result, &result)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal result: %s: %w", string(resp.Answer.Result), err)
+ }
+
+ return result.Records.TXT, nil
+}
+
+// ChangeTXTRecord changes TXT records.
+// https://beget.com/ru/kb/api/funkczii-upravleniya-dns#changerecords
+func (c *Client) ChangeTXTRecord(ctx context.Context, domain string, records []Record) error {
+ request := ChangeRecordsRequest{
+ Fqdn: domain,
+ Records: RecordList{TXT: records},
+ }
+
+ resp, err := c.doRequest(ctx, request, "dns", "changeRecords")
+ if err != nil {
+ return err
+ }
+
+ return resp.HasError()
+}
+
+func (c *Client) doRequest(ctx context.Context, data any, fragments ...string) (*APIResponse, error) {
+ endpoint := c.BaseURL.JoinPath(fragments...)
+
+ inputData, err := json.Marshal(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to mashall input data: %w", err)
+ }
+
+ query := endpoint.Query()
+ query.Add("input_data", string(inputData))
+ query.Add("login", c.login)
+ query.Add("passwd", c.password)
+ query.Add("input_format", "json")
+ query.Add("output_format", "json")
+ 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)
+ }
+
+ 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)
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ var apiResp APIResponse
+
+ err = json.Unmarshal(raw, &apiResp)
+ if err != nil {
+ return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return &apiResp, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var apiResp APIResponse
+
+ err := json.Unmarshal(raw, &apiResp)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return fmt.Errorf("[status code %d] %w", resp.StatusCode, apiResp)
+}
diff --git a/providers/dns/beget/internal/client_test.go b/providers/dns/beget/internal/client_test.go
new file mode 100644
index 000000000..4c127abf1
--- /dev/null
+++ b/providers/dns/beget/internal/client_test.go
@@ -0,0 +1,103 @@
+package internal
+
+import (
+ "context"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckQueryParameter().
+ With("login", "user").
+ With("passwd", "secret").
+ With("input_format", "json").
+ With("output_format", "json"),
+ )
+}
+
+func TestClient_GetTXTRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromFixture("getData-real.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"example.com"}`),
+ ).
+ Build(t)
+
+ data, err := client.GetTXTRecords(context.Background(), "example.com")
+ require.NoError(t, err)
+
+ expected := []Record{{Data: "v=spf1 redirect=beget.com", TTL: 300}}
+
+ assert.Equal(t, expected, data)
+}
+
+func TestClient_ChangeTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"sub.example.com","records":{"TXT":[{"value":"txtTXTtxt","priority":10,"ttl":300}]}}`),
+ ).
+ Build(t)
+
+ records := []Record{{Value: "txtTXTtxt", TTL: 300, Priority: 10}}
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records)
+ require.NoError(t, err)
+}
+
+func TestClient_ChangeTXTRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
+
+ records := []Record{{Data: "txtTXTtxt", TTL: 300}}
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "API error: NO_SUCH_METHOD: No such method")
+}
+
+func TestClient_ChangeTXTRecord_answer_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("answer_error.json")).
+ Build(t)
+
+ records := []Record{{Data: "txtTXTtxt", TTL: 300}}
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "API answer error: INVALID_DATA: Login length cannot be greater than 12 characters")
+}
+
+func TestClient_ChangeTXTRecord_remove(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"sub.example.com","records":{}}`),
+ ).
+ Build(t)
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", nil)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/beget/internal/fixtures/answer_error.json b/providers/dns/beget/internal/fixtures/answer_error.json
new file mode 100644
index 000000000..12f5fdda7
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/answer_error.json
@@ -0,0 +1,12 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "error",
+ "errors": [
+ {
+ "error_code": "INVALID_DATA",
+ "error_text": "Login length cannot be greater than 12 characters"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/beget/internal/fixtures/changeRecords-doc.json b/providers/dns/beget/internal/fixtures/changeRecords-doc.json
new file mode 100644
index 000000000..4c182d4e6
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/changeRecords-doc.json
@@ -0,0 +1,31 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "A": [
+ {
+ "priority": 10,
+ "value": "127.0.0.1"
+ }
+ ],
+ "MX": [
+ {
+ "priority": 10,
+ "value": "mx1.beget.ru"
+ },
+ {
+ "priority": 20,
+ "value": "mx2.beget.ru"
+ }
+ ],
+ "TXT": [
+ {
+ "priority": 10,
+ "value": "TXT record"
+ }
+ ]
+ }
+ }
+}
+
diff --git a/providers/dns/beget/internal/fixtures/error.json b/providers/dns/beget/internal/fixtures/error.json
new file mode 100644
index 000000000..1dd2a111e
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/error.json
@@ -0,0 +1,5 @@
+{
+ "status": "error",
+ "error_text": "No such method",
+ "error_code": "NO_SUCH_METHOD"
+}
diff --git a/providers/dns/beget/internal/fixtures/getData-doc.json b/providers/dns/beget/internal/fixtures/getData-doc.json
new file mode 100644
index 000000000..bed5b7461
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData-doc.json
@@ -0,0 +1,58 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": 1,
+ "is_beget_dns": 1,
+ "is_subdomain": 0,
+ "fqdn": "beget.ru",
+ "records": {
+ "DNS": [
+ {
+ "value": "ns1.beget.ru",
+ "priority": 10
+ },
+ {
+ "value": "ns2.beget.ru",
+ "priority": 20
+ }
+ ],
+ "DNS_IP": [
+ {
+ "value": null,
+ "priority": 10
+ },
+ {
+ "value": null,
+ "priority": 20
+ }
+ ],
+ "A": [
+ {
+ "value": "91.106.201.65",
+ "priority": "0"
+ }
+ ],
+ "MX": [
+ {
+ "value": "mx1.beget.ru",
+ "priority": "10"
+ },
+ {
+ "value": "mx2.beget.ru",
+ "priority": "20"
+ }
+ ],
+ "TXT": [
+ {
+ "value": "",
+ "priority": 0
+ }
+ ]
+ },
+ "set_type": 1
+ }
+ }
+}
+
diff --git a/providers/dns/beget/internal/fixtures/getData-real.json b/providers/dns/beget/internal/fixtures/getData-real.json
new file mode 100644
index 000000000..700c756e8
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData-real.json
@@ -0,0 +1,67 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": true,
+ "is_beget_dns": true,
+ "is_subdomain": false,
+ "fqdn": "example.com",
+ "records": {
+ "MX": [
+ {
+ "ttl": 300,
+ "exchange": "mx2.beget.com.",
+ "preference": 20
+ },
+ {
+ "ttl": 300,
+ "exchange": "mx1.beget.com.",
+ "preference": 10
+ }
+ ],
+ "TXT": [
+ {
+ "ttl": 300,
+ "txtdata": "v=spf1 redirect=beget.com"
+ }
+ ],
+ "A": [
+ {
+ "ttl": 300,
+ "address": "1.2.3.4"
+ }
+ ],
+ "DNS": [
+ {
+ "value": "ns1.beget.pro"
+ },
+ {
+ "value": "ns2.beget.pro"
+ },
+ {
+ "value": "ns1.beget.com"
+ },
+ {
+ "value": "ns2.beget.com"
+ }
+ ],
+ "DNS_IP": [
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ }
+ ]
+ },
+ "set_type": 1
+ }
+ }
+}
diff --git a/providers/dns/beget/internal/fixtures/getData.json b/providers/dns/beget/internal/fixtures/getData.json
new file mode 100644
index 000000000..571b6ac31
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData.json
@@ -0,0 +1,67 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": true,
+ "is_beget_dns": true,
+ "is_subdomain": false,
+ "fqdn": "_acme-challenge.example.com",
+ "records": {
+ "MX": [
+ {
+ "ttl": 300,
+ "exchange": "mx2.beget.com.",
+ "preference": 20
+ },
+ {
+ "ttl": 300,
+ "exchange": "mx1.beget.com.",
+ "preference": 10
+ }
+ ],
+ "TXT": [
+ {
+ "ttl": 300,
+ "txtdata": "foo"
+ }
+ ],
+ "A": [
+ {
+ "ttl": 300,
+ "address": "1.2.3.4"
+ }
+ ],
+ "DNS": [
+ {
+ "value": "ns1.beget.pro"
+ },
+ {
+ "value": "ns2.beget.pro"
+ },
+ {
+ "value": "ns1.beget.com"
+ },
+ {
+ "value": "ns2.beget.com"
+ }
+ ],
+ "DNS_IP": [
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ }
+ ]
+ },
+ "set_type": 1
+ }
+ }
+}
diff --git a/providers/dns/beget/internal/fixtures/getData_empty.json b/providers/dns/beget/internal/fixtures/getData_empty.json
new file mode 100644
index 000000000..ea819eeca
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData_empty.json
@@ -0,0 +1,13 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": true,
+ "is_beget_dns": true,
+ "is_subdomain": false,
+ "fqdn": "_acme-challenge.example.com",
+ "set_type": 1
+ }
+ }
+}
diff --git a/providers/dns/beget/internal/types.go b/providers/dns/beget/internal/types.go
new file mode 100644
index 000000000..f453bf628
--- /dev/null
+++ b/providers/dns/beget/internal/types.go
@@ -0,0 +1,100 @@
+package internal
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+const successResult = "success"
+
+// APIResponse is the representation of an API response.
+type APIResponse struct {
+ Status string `json:"status"`
+
+ Answer *Answer `json:"answer,omitempty"`
+
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorText string `json:"error_text,omitempty"`
+}
+
+func (a APIResponse) Error() string {
+ return fmt.Sprintf("API %s: %s: %s", a.Status, a.ErrorCode, a.ErrorText)
+}
+
+// HasError returns an error is the response contains an error.
+func (a APIResponse) HasError() error {
+ if a.Status != successResult {
+ return a
+ }
+
+ if a.Answer == nil || a.Status != successResult || a.Answer.Status != successResult {
+ return a.Answer
+ }
+
+ return nil
+}
+
+// Answer is the representation of an API response answer.
+type Answer struct {
+ Status string `json:"status,omitempty"`
+ Result json.RawMessage `json:"result,omitempty"`
+
+ Errors []AnswerError `json:"errors,omitempty"`
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorText string `json:"error_text,omitempty"`
+}
+
+type AnswerError struct {
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorText string `json:"error_text,omitempty"`
+}
+
+func (a Answer) Error() string {
+ parts := []string{fmt.Sprintf("API answer %s", a.Status)}
+
+ if a.ErrorCode != "" {
+ parts = append(parts, a.ErrorCode)
+ }
+
+ if a.ErrorText != "" {
+ parts = append(parts, a.ErrorText)
+ }
+
+ if len(a.Errors) > 0 {
+ for _, e := range a.Errors {
+ parts = append(parts, e.ErrorCode, e.ErrorText)
+ }
+ }
+
+ return strings.Join(parts, ": ")
+}
+
+// GetRecordsRequest data representation for data get request.
+type GetRecordsRequest struct {
+ Fqdn string `json:"fqdn,omitempty"`
+}
+
+// ChangeRecordsRequest data representation for data change request.
+type ChangeRecordsRequest struct {
+ Fqdn string `json:"fqdn,omitempty"`
+ Records RecordList `json:"records"`
+}
+
+// RecordList List of entries (in this case only described TXT).
+type RecordList struct {
+ TXT []Record `json:"TXT,omitempty"`
+}
+
+// Record data representation for TXT record.
+type Record struct {
+ Value string `json:"value,omitempty"`
+ Data string `json:"txtdata,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+}
+
+type GetRecordsResult struct {
+ Fqdn string `json:"fqdn"`
+ Records RecordList `json:"records"`
+}
diff --git a/providers/dns/binarylane/binarylane.go b/providers/dns/binarylane/binarylane.go
new file mode 100644
index 000000000..5bbb7a16a
--- /dev/null
+++ b/providers/dns/binarylane/binarylane.go
@@ -0,0 +1,165 @@
+// Package binarylane implements a DNS provider for solving the DNS-01 challenge using Binary Lane.
+package binarylane
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/binarylane/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "BINARYLANE_"
+
+ 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, 3600),
+ 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
+
+ recordIDs map[string]int64
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Binary Lane.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIToken)
+ if err != nil {
+ return nil, fmt.Errorf("binarylane: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIToken = values[EnvAPIToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Binary Lane.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("binarylane: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIToken)
+ if err != nil {
+ return nil, fmt.Errorf("binarylane: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int64),
+ }, 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("binarylane: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("binarylane: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: subDomain,
+ Data: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ response, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("binarylane: create record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = response.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(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("binarylane: could not find zone for domain %q: %w", domain, err)
+ }
+
+ // get the record's unique ID from when we created it
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("binarylane: unknown record ID for '%s'", info.EffectiveFQDN)
+ }
+
+ err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
+ if err != nil {
+ return fmt.Errorf("binarylane: delete 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/dns/binarylane/binarylane.toml b/providers/dns/binarylane/binarylane.toml
new file mode 100644
index 000000000..8b382f3b2
--- /dev/null
+++ b/providers/dns/binarylane/binarylane.toml
@@ -0,0 +1,22 @@
+Name = "Binary Lane"
+Description = ''''''
+URL = "https://www.binarylane.com.au/"
+Code = "binarylane"
+Since = "v4.26.0"
+
+Example = '''
+BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns binarylane -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ BINARYLANE_API_TOKEN = "API token"
+ [Configuration.Additional]
+ BINARYLANE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BINARYLANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ BINARYLANE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ BINARYLANE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://api.binarylane.com.au/reference/#tag/Domains"
diff --git a/providers/dns/binarylane/binarylane_test.go b/providers/dns/binarylane/binarylane_test.go
new file mode 100644
index 000000000..4f2cfd230
--- /dev/null
+++ b/providers/dns/binarylane/binarylane_test.go
@@ -0,0 +1,118 @@
+package binarylane
+
+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: "secret",
+ },
+ },
+ {
+ desc: "missing API token",
+ envVars: map[string]string{
+ EnvAPIToken: "",
+ },
+ expected: "binarylane: some credentials information are missing: BINARYLANE_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)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "secret",
+ },
+ {
+ desc: "missing API token",
+ expected: "binarylane: credentials 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)
+ 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/dns/binarylane/internal/client.go b/providers/dns/binarylane/internal/client.go
new file mode 100644
index 000000000..3f10e9f8b
--- /dev/null
+++ b/providers/dns/binarylane/internal/client.go
@@ -0,0 +1,148 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://api.binarylane.com.au/v2/"
+
+const authorizationHeader = "Authorization"
+
+// Client the Binary Lane API client.
+type Client struct {
+ apiToken string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiToken string) (*Client, error) {
+ if apiToken == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiToken: apiToken,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// CreateRecord Creates a new domain record.
+// https://api.binarylane.com.au/reference/#tag/Domains/paths/~1v2~1domains~1%7Bdomain_name%7D~1records/post
+func (c *Client) CreateRecord(ctx context.Context, domain string, record Record) (*Record, error) {
+ endpoint := c.baseURL.JoinPath("domains", domain, "records")
+
+ if record.Name == "" {
+ record.Name = "@"
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.DomainRecord, nil
+}
+
+// DeleteRecord Deletes an existing domain record.
+// https://api.binarylane.com.au/reference/#tag/Domains/paths/~1v2~1domains~1%7Bdomain_name%7D~1records~1%7Brecord_id%7D/delete
+func (c *Client) DeleteRecord(ctx context.Context, domainName string, recordID int64) error {
+ endpoint := c.baseURL.JoinPath("domains", domainName, "records", 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(authorizationHeader, "Bearer "+c.apiToken)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/binarylane/internal/client_test.go b/providers/dns/binarylane/internal/client_test.go
new file mode 100644
index 000000000..0398d5adf
--- /dev/null
+++ b/providers/dns/binarylane/internal/client_test.go
@@ -0,0 +1,97 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/example.com/records",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "foo",
+ Data: "txtTXTtxt",
+ TTL: 300,
+ }
+
+ rec, err := client.CreateRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: 123,
+ Type: "TXT",
+ Name: "foo",
+ Data: "txtTXTtxt",
+ TTL: 300,
+ }
+
+ require.Equal(t, expected, rec)
+}
+
+func TestClient_CreateRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/example.com/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "foo",
+ Data: "txtTXTtxt",
+ TTL: 300,
+ }
+
+ _, err := client.CreateRecord(t.Context(), "example.com", record)
+ require.EqualError(t, err, "400: type: title: detail: instance: property1: a")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/records/123",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "example.com", 123)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/records/123",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "example.com", 123)
+ require.EqualError(t, err, "400: type: title: detail: instance: property1: a")
+}
diff --git a/providers/dns/binarylane/internal/fixtures/create_record-request.json b/providers/dns/binarylane/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..98a349650
--- /dev/null
+++ b/providers/dns/binarylane/internal/fixtures/create_record-request.json
@@ -0,0 +1,6 @@
+{
+ "type": "TXT",
+ "name": "foo",
+ "data": "txtTXTtxt",
+ "ttl": 300
+}
diff --git a/providers/dns/binarylane/internal/fixtures/create_record.json b/providers/dns/binarylane/internal/fixtures/create_record.json
new file mode 100644
index 000000000..709bef23e
--- /dev/null
+++ b/providers/dns/binarylane/internal/fixtures/create_record.json
@@ -0,0 +1,9 @@
+{
+ "domain_record": {
+ "id": 123,
+ "type": "TXT",
+ "name": "foo",
+ "data": "txtTXTtxt",
+ "ttl": 300
+ }
+}
diff --git a/providers/dns/binarylane/internal/fixtures/error.json b/providers/dns/binarylane/internal/fixtures/error.json
new file mode 100644
index 000000000..79d115f74
--- /dev/null
+++ b/providers/dns/binarylane/internal/fixtures/error.json
@@ -0,0 +1,14 @@
+{
+ "type": "type",
+ "title": "title",
+ "status": 400,
+ "detail": "detail",
+ "instance": "instance",
+ "errors": {
+ "property1": [
+ "a"
+ ]
+ },
+ "property1": null,
+ "property2": null
+}
diff --git a/providers/dns/binarylane/internal/types.go b/providers/dns/binarylane/internal/types.go
new file mode 100644
index 000000000..06d4be5c0
--- /dev/null
+++ b/providers/dns/binarylane/internal/types.go
@@ -0,0 +1,44 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Status int `json:"status"`
+ Detail string `json:"detail"`
+ Instance string `json:"instance"`
+ Errors map[string][]string `json:"errors"`
+}
+
+func (a *APIError) Error() string {
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance)
+
+ for s, values := range a.Errors {
+ _, _ = fmt.Fprintf(msg, ": %s: %s", s, strings.Join(values, ", "))
+ }
+
+ return msg.String()
+}
+
+type Record struct {
+ ID int64 `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Data string `json:"data,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Port int `json:"port,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ Flags int `json:"flags,omitempty"`
+ Tag string `json:"tag,omitempty"`
+}
+
+type APIResponse struct {
+ DomainRecord *Record `json:"domain_record"`
+}
diff --git a/providers/dns/bindman/bindman.go b/providers/dns/bindman/bindman.go
index fbaddcbec..c529cb63c 100644
--- a/providers/dns/bindman/bindman.go
+++ b/providers/dns/bindman/bindman.go
@@ -10,7 +10,8 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/labbsr0x/bindman-dns-webhook/src/client"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ bindman "github.com/labbsr0x/bindman-dns-webhook/src/client"
)
// Environment variables names.
@@ -48,7 +49,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
- client *client.DNSWebhookClient
+ client *bindman.DNSWebhookClient
}
// NewDNSProvider returns a DNSProvider instance configured for Bindman.
@@ -75,12 +76,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("bindman: bindman manager address missing")
}
- bClient, err := client.New(config.BaseURL, config.HTTPClient)
+ // Because the client.New uses the http.DefaultClient.
+ if config.HTTPClient == nil {
+ config.HTTPClient = &http.Client{Timeout: time.Minute}
+ }
+
+ client, err := bindman.New(config.BaseURL, clientdebug.Wrap(config.HTTPClient))
if err != nil {
return nil, fmt.Errorf("bindman: %w", err)
}
- return &DNSProvider{config: config, client: bClient}, nil
+ return &DNSProvider{config: config, client: client}, nil
}
// Present creates a TXT record using the specified parameters.
@@ -92,6 +98,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err := d.client.AddRecord(info.EffectiveFQDN, "TXT", info.Value); err != nil {
return fmt.Errorf("bindman: %w", err)
}
+
return nil
}
@@ -102,6 +109,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err := d.client.RemoveRecord(info.EffectiveFQDN, "TXT"); err != nil {
return fmt.Errorf("bindman: %w", err)
}
+
return nil
}
diff --git a/providers/dns/bindman/bindman.toml b/providers/dns/bindman/bindman.toml
index 4befe9e9d..768601588 100644
--- a/providers/dns/bindman/bindman.toml
+++ b/providers/dns/bindman/bindman.toml
@@ -6,16 +6,16 @@ Since = "v2.6.0"
Example = '''
BINDMAN_MANAGER_ADDRESS= \
-lego --email you@example.com --dns bindman -d '*.example.com' -d example.com run
+lego --dns bindman -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
BINDMAN_MANAGER_ADDRESS = "The server URL, should have scheme, hostname, and port (if required) of the Bindman-DNS Manager server"
[Configuration.Additional]
- BINDMAN_POLLING_INTERVAL = "Time between DNS propagation check"
- BINDMAN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- BINDMAN_HTTP_TIMEOUT = "API request timeout"
+ BINDMAN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BINDMAN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ BINDMAN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
[Links]
API = "https://gitlab.isc.org/isc-projects/bind9"
diff --git a/providers/dns/bindman/bindman_test.go b/providers/dns/bindman/bindman_test.go
index a0db025e7..978a1d006 100644
--- a/providers/dns/bindman/bindman_test.go
+++ b/providers/dns/bindman/bindman_test.go
@@ -1,14 +1,13 @@
-// Package bindman implements a DNS provider for solving the DNS-01 challenge.
package bindman
import (
- "errors"
"net/http"
+ "net/http/httptest"
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
- bindmanClient "github.com/labbsr0x/bindman-dns-webhook/src/client"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
@@ -47,6 +46,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -106,10 +106,24 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
}
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.BaseURL = server.URL
+ config.HTTPClient = server.Client()
+
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("User-Agent", "bindman-dns-webhook-client"))
+}
+
func TestDNSProvider_Present(t *testing.T) {
testCases := []struct {
name string
- client *bindmanClient.DNSWebhookClient
+ mock *servermock.Builder[*DNSProvider]
domain string
token string
keyAuth string
@@ -117,28 +131,31 @@ func TestDNSProvider_Present(t *testing.T) {
}{
{
name: "success when add record function return no error",
- client: &bindmanClient.DNSWebhookClient{
- ClientAPI: &MockHTTPClientAPI{Status: http.StatusNoContent},
- },
- domain: "hello.test.com",
+ mock: mockBuilder().
+ Route("POST /records",
+ servermock.Noop().WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"),
+ ),
+ domain: "example.com",
keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw",
expectError: false,
},
{
name: "error when add record function return an error",
- client: &bindmanClient.DNSWebhookClient{
- ClientAPI: &MockHTTPClientAPI{Error: errors.New("error adding record")},
- },
- domain: "hello.test.com",
+ mock: mockBuilder().
+ Route("POST /records",
+ servermock.ResponseFromFixture("error.json"),
+ ),
+ domain: "example.com",
keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw",
expectError: true,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
- d := &DNSProvider{client: test.client}
+ provider := test.mock.Build(t)
- err := d.Present(test.domain, test.token, test.keyAuth)
+ err := provider.Present(test.domain, test.token, test.keyAuth)
if test.expectError {
require.Error(t, err)
} else {
@@ -151,7 +168,7 @@ func TestDNSProvider_Present(t *testing.T) {
func TestDNSProvider_CleanUp(t *testing.T) {
testCases := []struct {
name string
- client *bindmanClient.DNSWebhookClient
+ mock *servermock.Builder[*DNSProvider]
domain string
token string
keyAuth string
@@ -159,30 +176,33 @@ func TestDNSProvider_CleanUp(t *testing.T) {
}{
{
name: "success when remove record function return no error",
- client: &bindmanClient.DNSWebhookClient{
- ClientAPI: &MockHTTPClientAPI{Status: http.StatusNoContent},
- },
- domain: "hello.test.com",
+ mock: mockBuilder().
+ Route("DELETE /records/_acme-challenge.example.com./TXT",
+ servermock.Noop().WithStatusCode(http.StatusNoContent),
+ ),
+ domain: "example.com",
keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw",
expectError: false,
},
{
name: "error when remove record function return an error",
- client: &bindmanClient.DNSWebhookClient{
- ClientAPI: &MockHTTPClientAPI{Error: errors.New("error adding record")},
- },
- domain: "hello.test.com",
+ mock: mockBuilder().
+ Route("DELETE /records/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromFixture("error.json"),
+ ),
+ domain: "example.com",
keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw",
expectError: true,
},
}
+
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
- d := &DNSProvider{client: test.client}
+ provider := test.mock.Build(t)
- err := d.CleanUp(test.domain, test.token, test.keyAuth)
+ err := provider.CleanUp(test.domain, test.token, test.keyAuth)
if test.expectError {
- require.Error(t, err)
+ require.ErrorContains(t, err, "bindman: ERROR (400): bar; ")
} else {
require.NoError(t, err)
}
@@ -196,6 +216,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -209,6 +230,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -217,25 +239,3 @@ func TestLiveCleanUp(t *testing.T) {
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
-
-type MockHTTPClientAPI struct {
- Data []byte
- Status int
- Error error
-}
-
-func (m *MockHTTPClientAPI) Put(url string, data []byte) (*http.Response, []byte, error) {
- return &http.Response{StatusCode: m.Status}, m.Data, m.Error
-}
-
-func (m *MockHTTPClientAPI) Post(url string, data []byte) (*http.Response, []byte, error) {
- return &http.Response{StatusCode: m.Status}, m.Data, m.Error
-}
-
-func (m *MockHTTPClientAPI) Get(url string) (*http.Response, []byte, error) {
- return &http.Response{StatusCode: m.Status}, m.Data, m.Error
-}
-
-func (m *MockHTTPClientAPI) Delete(url string) (*http.Response, []byte, error) {
- return &http.Response{StatusCode: m.Status}, m.Data, m.Error
-}
diff --git a/providers/dns/bindman/fixtures/add_record-request.json b/providers/dns/bindman/fixtures/add_record-request.json
new file mode 100644
index 000000000..9585565b8
--- /dev/null
+++ b/providers/dns/bindman/fixtures/add_record-request.json
@@ -0,0 +1,5 @@
+{
+ "name": "_acme-challenge.example.com.",
+ "value": "_EYMkjukXEMcXbnvpT6WLESzfYhxH190NKTBo3cpu-E",
+ "type": "TXT"
+}
diff --git a/providers/dns/bindman/fixtures/error.json b/providers/dns/bindman/fixtures/error.json
new file mode 100644
index 000000000..c8a014510
--- /dev/null
+++ b/providers/dns/bindman/fixtures/error.json
@@ -0,0 +1,5 @@
+{
+ "message": "bar",
+ "code": 400,
+ "details": ["foo"]
+}
diff --git a/providers/dns/bluecat/bluecat.go b/providers/dns/bluecat/bluecat.go
index 8ba026f49..b26fab8be 100644
--- a/providers/dns/bluecat/bluecat.go
+++ b/providers/dns/bluecat/bluecat.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/bluecat/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -110,6 +111,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/bluecat/bluecat.toml b/providers/dns/bluecat/bluecat.toml
index e7eb45664..15df6ed34 100644
--- a/providers/dns/bluecat/bluecat.toml
+++ b/providers/dns/bluecat/bluecat.toml
@@ -11,7 +11,7 @@ BLUECAT_USER_NAME=myusername \
BLUECAT_CONFIG_NAME=myconfig \
BLUECAT_SERVER_URL=https://bam.example.com \
BLUECAT_TTL=30 \
-lego --email you@example.com --dns bluecat -d '*.example.com' -d example.com run
+lego --dns bluecat -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -22,10 +22,10 @@ lego --email you@example.com --dns bluecat -d '*.example.com' -d example.com run
BLUECAT_CONFIG_NAME = "Configuration name"
BLUECAT_DNS_VIEW = "External DNS View Name"
[Configuration.Additional]
- BLUECAT_POLLING_INTERVAL = "Time between DNS propagation check"
- BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge"
- BLUECAT_HTTP_TIMEOUT = "API request timeout"
+ BLUECAT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ BLUECAT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
BLUECAT_SKIP_DEPLOY = "Skip deployements"
[Links]
diff --git a/providers/dns/bluecat/bluecat_test.go b/providers/dns/bluecat/bluecat_test.go
index 5a3670e3a..38b110470 100644
--- a/providers/dns/bluecat/bluecat_test.go
+++ b/providers/dns/bluecat/bluecat_test.go
@@ -105,6 +105,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -219,6 +220,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -232,6 +234,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/bluecat/internal/client.go b/providers/dns/bluecat/internal/client.go
index a2649a455..d517ea857 100644
--- a/providers/dns/bluecat/internal/client.go
+++ b/providers/dns/bluecat/internal/client.go
@@ -36,7 +36,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(baseURL string, username, password string) *Client {
+func NewClient(baseURL, username, password string) *Client {
bu, _ := url.Parse(baseURL)
return &Client{
@@ -106,6 +106,7 @@ func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (u
// addEntity responds only with body text containing the ID of the created record
addTxtResp := string(raw)
+
id, err := strconv.ParseUint(addTxtResp, 10, 64)
if err != nil {
return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp)
@@ -147,6 +148,7 @@ func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objTy
}
var entity EntityResponse
+
err = json.Unmarshal(raw, &entity)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/bluecat/internal/client_test.go b/providers/dns/bluecat/internal/client_test.go
index 206d7d1a4..d4776b8a1 100644
--- a/providers/dns/bluecat/internal/client_test.go
+++ b/providers/dns/bluecat/internal/client_test.go
@@ -1,41 +1,45 @@
package internal
import (
- "context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestClient_LookupParentZoneID(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient(server.URL, "user", "secret")
client.HTTPClient = server.Client()
- mux.HandleFunc("/Services/REST/v1/getEntityByName", func(rw http.ResponseWriter, req *http.Request) {
- query := req.URL.Query()
+ return client, nil
+}
- if query.Get("name") == "com" {
- _ = json.NewEncoder(rw).Encode(EntityResponse{
- ID: 2,
- Name: "com",
- Type: ZoneType,
- Properties: "test",
- })
- return
- }
+func TestClient_LookupParentZoneID(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /Services/REST/v1/getEntityByName",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ query := req.URL.Query()
- http.Error(rw, "{}", http.StatusOK)
- })
+ if query.Get("name") == "com" {
+ _ = json.NewEncoder(rw).Encode(EntityResponse{
+ ID: 2,
+ Name: "com",
+ Type: ZoneType,
+ Properties: "test",
+ })
- parentID, name, err := client.LookupParentZoneID(context.Background(), 2, "foo.example.com")
+ return
+ }
+
+ _, _ = rw.Write([]byte(`{}`))
+ })).
+ Build(t)
+
+ parentID, name, err := client.LookupParentZoneID(t.Context(), 2, "foo.example.com")
require.NoError(t, err)
assert.EqualValues(t, 2, parentID)
diff --git a/providers/dns/bluecat/internal/identity_test.go b/providers/dns/bluecat/internal/identity_test.go
index 378f6ab38..9ad4c18e6 100644
--- a/providers/dns/bluecat/internal/identity_test.go
+++ b/providers/dns/bluecat/internal/identity_test.go
@@ -1,12 +1,9 @@
package internal
import (
- "context"
- "fmt"
- "net/http"
- "net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -14,41 +11,18 @@ import (
const fakeToken = "BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM="
func TestClient_CreateAuthenticatedContext(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /Services/REST/v1/login",
+ servermock.RawStringResponse(fakeToken),
+ servermock.CheckQueryParameter().
+ With("username", "user").
+ With("password", "secret")).
+ Route("DELETE /Services/REST/v1/delete", nil,
+ servermock.CheckHeader().
+ WithAuthorization(fakeToken)).
+ Build(t)
- client := NewClient(server.URL, "user", "secret")
- client.HTTPClient = server.Client()
-
- mux.HandleFunc("/Services/REST/v1/login", 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
- }
-
- query := req.URL.Query()
- if query.Get("username") != "user" {
- http.Error(rw, fmt.Sprintf("invalid username %s", query.Get("username")), http.StatusUnauthorized)
- return
- }
-
- if query.Get("password") != "secret" {
- http.Error(rw, fmt.Sprintf("invalid password %s", query.Get("password")), http.StatusUnauthorized)
- return
- }
-
- _, _ = fmt.Fprint(rw, fakeToken)
- })
- mux.HandleFunc("/Services/REST/v1/delete", func(rw http.ResponseWriter, req *http.Request) {
- authorization := req.Header.Get(authorizationHeader)
- if authorization != fakeToken {
- http.Error(rw, fmt.Sprintf("invalid credential: %s", authorization), http.StatusUnauthorized)
- return
- }
- })
-
- ctx, err := client.CreateAuthenticatedContext(context.Background())
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
at := getToken(ctx)
diff --git a/providers/dns/bluecatv2/bluecatv2.go b/providers/dns/bluecatv2/bluecatv2.go
new file mode 100644
index 000000000..0efe99661
--- /dev/null
+++ b/providers/dns/bluecatv2/bluecatv2.go
@@ -0,0 +1,249 @@
+// Package bluecatv2 implements a DNS provider for solving the DNS-01 challenge using Bluecat v2.
+package bluecatv2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "BLUECATV2_"
+
+ EnvServerURL = envNamespace + "SERVER_URL"
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+ EnvConfigName = envNamespace + "CONFIG_NAME"
+ EnvViewName = envNamespace + "VIEW_NAME"
+ EnvSkipDeploy = envNamespace + "SKIP_DEPLOY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ ServerURL string
+ Username string
+ Password string
+ ConfigName string
+ ViewName string
+ SkipDeploy bool
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ SkipDeploy: env.GetOrDefaultBool(EnvSkipDeploy, false),
+
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ zoneIDs map[string]int64
+ recordIDs map[string]int64
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Bluecat v2.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword, EnvConfigName, EnvViewName)
+ if err != nil {
+ return nil, fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.ServerURL = values[EnvServerURL]
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+ config.ConfigName = values[EnvConfigName]
+ config.ViewName = values[EnvViewName]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Bluecat v2.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("bluecatv2: the configuration of the DNS provider is nil")
+ }
+
+ if config.ServerURL == "" {
+ return nil, errors.New("bluecatv2: missing server URL")
+ }
+
+ if config.ConfigName == "" {
+ return nil, errors.New("bluecatv2: missing configuration name")
+ }
+
+ if config.ViewName == "" {
+ return nil, errors.New("bluecatv2: missing view name")
+ }
+
+ client, err := internal.NewClient(config.ServerURL, config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int64),
+ zoneIDs: make(map[string]int64),
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx, err := d.client.CreateAuthenticatedContext(context.Background())
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.AbsoluteName)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ record := internal.RecordTXT{
+ CommonResource: internal.CommonResource{
+ Type: "TXTRecord",
+ Name: subDomain,
+ },
+ Text: info.Value,
+ TTL: d.config.TTL,
+ RecordType: "TXT",
+ }
+
+ newRecord, err := d.client.CreateZoneResourceRecord(ctx, zone.ID, record)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: create resource record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.zoneIDs[token] = zone.ID
+ d.recordIDs[token] = newRecord.ID
+ d.recordIDsMu.Unlock()
+
+ if d.config.SkipDeploy {
+ return nil
+ }
+
+ _, err = d.client.CreateZoneDeployment(ctx, zone.ID)
+ if err != nil {
+ return fmt.Errorf("bluecat: deploy zone: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ d.recordIDsMu.Lock()
+ recordID, recordOK := d.recordIDs[token]
+ zoneID, zoneOK := d.zoneIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !recordOK {
+ return fmt.Errorf("bluecatv2: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ if !zoneOK {
+ return fmt.Errorf("bluecatv2: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ ctx, err := d.client.CreateAuthenticatedContext(context.Background())
+ if err != nil {
+ return fmt.Errorf("bluecatv2: %w", err)
+ }
+
+ err = d.client.DeleteResourceRecord(ctx, recordID)
+ if err != nil {
+ return fmt.Errorf("bluecatv2: delete resource record: %w", err)
+ }
+
+ if d.config.SkipDeploy {
+ return nil
+ }
+
+ _, err = d.client.CreateZoneDeployment(ctx, zoneID)
+ if err != nil {
+ return fmt.Errorf("bluecat: deploy zone: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.ZoneResource, error) {
+ for name := range dns01.UnFqdnDomainsSeq(fqdn) {
+ opts := &internal.CollectionOptions{
+ Fields: "id,absoluteName,configuration.id,configuration.name,view.id,view.name",
+ Filter: internal.And(
+ internal.Eq("absoluteName", name),
+ internal.Eq("configuration.name", d.config.ConfigName),
+ internal.Eq("view.name", d.config.ViewName),
+ ).String(),
+ }
+
+ zones, err := d.client.RetrieveZones(ctx, opts)
+ if err != nil {
+ // TODO(ldez) maybe add a log in v5.
+ continue
+ }
+
+ for _, zone := range zones {
+ if zone.AbsoluteName == name {
+ return &zone, nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("no zone found for fqdn: %s", fqdn)
+}
diff --git a/providers/dns/bluecatv2/bluecatv2.toml b/providers/dns/bluecatv2/bluecatv2.toml
new file mode 100644
index 000000000..6ec3781c6
--- /dev/null
+++ b/providers/dns/bluecatv2/bluecatv2.toml
@@ -0,0 +1,33 @@
+Name = "Bluecat v2"
+Description = ''''''
+URL = "https://www.bluecatnetworks.com"
+Code = "bluecatv2"
+Since = "v4.32.0"
+
+Example = '''
+BLUECATV2_SERVER_URL="https://example.com" \
+BLUECATV2_USERNAME="xxx" \
+BLUECATV2_PASSWORD="yyy" \
+BLUECATV2_CONFIG_NAME="myConfiguration" \
+BLUECATV2_VIEW_NAME="myView" \
+lego --dns bluecatv2 -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ BLUECAT_SERVER_URL = "The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve"
+ BLUECATV2_USERNAME = "API username"
+ BLUECATV2_PASSWORD = "API password"
+ BLUECATV2_CONFIG_NAME = "Configuration name"
+ BLUECATV2_VIEW_NAME = "DNS View Name"
+ [Configuration.Additional]
+ BLUECATV2_SKIP_DEPLOY = "Skip quick deployements"
+ BLUECATV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BLUECATV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ BLUECATV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ BLUECATV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0"
+ Swagger = "http://{Address_Manager_IP}/api/openapi.json"
+ SwaggerDump = "https://github.com/go-acme/lego/discussions/2218#discussioncomment-13060545"
diff --git a/providers/dns/bluecatv2/bluecatv2_test.go b/providers/dns/bluecatv2/bluecatv2_test.go
new file mode 100644
index 000000000..d852f0e18
--- /dev/null
+++ b/providers/dns/bluecatv2/bluecatv2_test.go
@@ -0,0 +1,414 @@
+package bluecatv2
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvServerURL,
+ EnvUsername,
+ EnvPassword,
+ EnvConfigName,
+ EnvViewName,
+ EnvSkipDeploy,
+).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ },
+ {
+ desc: "missing server URL",
+ envVars: map[string]string{
+ EnvServerURL: "",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL",
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "",
+ EnvConfigName: "myConfig",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_PASSWORD",
+ },
+ {
+ desc: "missing configuration name",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "",
+ EnvViewName: "myView",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_CONFIG_NAME",
+ },
+ {
+ desc: "missing view name",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com/",
+ EnvUsername: "userA",
+ EnvPassword: "secret",
+ EnvConfigName: "myConfig",
+ EnvViewName: "",
+ },
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_VIEW_NAME",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL,BLUECATV2_USERNAME,BLUECATV2_PASSWORD,BLUECATV2_CONFIG_NAME,BLUECATV2_VIEW_NAME",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ serverURL string
+ username string
+ password string
+ configName string
+ viewName string
+ expected string
+ }{
+ {
+ desc: "success",
+ serverURL: "https://example.com/",
+ username: "userA",
+ password: "secret",
+ configName: "myConfig",
+ viewName: "myView",
+ },
+ {
+ desc: "missing server URL",
+ username: "userA",
+ password: "secret",
+ configName: "myConfig",
+ viewName: "myView",
+ expected: "bluecatv2: missing server URL",
+ },
+ {
+ desc: "missing username",
+ serverURL: "https://example.com/",
+ password: "secret",
+ configName: "myConfig",
+ viewName: "myView",
+ expected: "bluecatv2: credentials missing",
+ },
+ {
+ desc: "missing password",
+ serverURL: "https://example.com/",
+ username: "userA",
+ configName: "myConfig",
+ viewName: "myView",
+ expected: "bluecatv2: credentials missing",
+ },
+ {
+ desc: "missing configuration name",
+ serverURL: "https://example.com/",
+ username: "userA",
+ password: "secret",
+ viewName: "myView",
+ expected: "bluecatv2: missing configuration name",
+ },
+ {
+ desc: "missing view name",
+ serverURL: "https://example.com/",
+ username: "userA",
+ password: "secret",
+ configName: "myConfig",
+ expected: "bluecatv2: missing view name",
+ },
+ {
+ desc: "missing credentials",
+ expected: "bluecatv2: missing server URL",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.ServerURL = test.serverURL
+ config.Username = test.username
+ config.Password = test.password
+ config.ConfigName = test.configName
+ config.ViewName = test.viewName
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+
+ config.ServerURL = server.URL
+ config.Username = "userA"
+ config.Password = "secret"
+ config.ConfigName = "myConfiguration"
+ config.ViewName = "myView"
+
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("GET /api/v2/configurations",
+ servermock.ResponseFromInternal("configurations.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myConfiguration')"),
+ ).
+ Route("GET /api/v2/configurations/12345/views",
+ servermock.ResponseFromInternal("views.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myView')"),
+ ).
+ Route("GET /api/v2/zones",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ filter := req.URL.Query().Get("filter")
+
+ if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) {
+ servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req)
+
+ return
+ }
+
+ servermock.ResponseFromInternal("error.json").
+ WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req)
+ }),
+ ).
+ Route("POST /api/v2/zones/12345/resourceRecords",
+ servermock.ResponseFromInternal("postZoneResourceRecord.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"),
+ ).
+ Route("POST /api/v2/zones/12345/deployments",
+ servermock.ResponseFromInternal("postZoneDeployment.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_Present_skipDeploy(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(map[string]string{
+ EnvSkipDeploy: "true",
+ })
+
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("GET /api/v2/configurations",
+ servermock.ResponseFromInternal("configurations.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myConfiguration')"),
+ ).
+ Route("GET /api/v2/configurations/12345/views",
+ servermock.ResponseFromInternal("views.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "name:eq('myView')"),
+ ).
+ Route("GET /api/v2/zones",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ filter := req.URL.Query().Get("filter")
+
+ if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) {
+ servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req)
+
+ return
+ }
+
+ servermock.ResponseFromInternal("error.json").
+ WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req)
+ }),
+ ).
+ Route("POST /api/v2/zones/12345/resourceRecords",
+ servermock.ResponseFromInternal("postZoneResourceRecord.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"),
+ ).
+ Route("POST /api/v2/zones/456789/deployments",
+ servermock.Noop().
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("DELETE /api/v2/resourceRecords/12345",
+ servermock.ResponseFromInternal("deleteResourceRecord.json"),
+ ).
+ Route("POST /api/v2/zones/456789/deployments",
+ servermock.ResponseFromInternal("postZoneDeployment.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"),
+ ).
+ Build(t)
+
+ provider.zoneIDs["abc"] = 456789
+ provider.recordIDs["abc"] = 12345
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_skipDeploy(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(map[string]string{
+ EnvSkipDeploy: "true",
+ })
+
+ provider := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromInternal("postSession.json"),
+ servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"),
+ ).
+ Route("DELETE /api/v2/resourceRecords/12345",
+ servermock.ResponseFromInternal("deleteResourceRecord.json"),
+ ).
+ Route("POST /api/v2/zones/456789/deployments",
+ servermock.Noop().
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ provider.zoneIDs["abc"] = 456789
+ provider.recordIDs["abc"] = 12345
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/bluecatv2/internal/client.go b/providers/dns/bluecatv2/internal/client.go
new file mode 100644
index 000000000..d3c801154
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/client.go
@@ -0,0 +1,221 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ querystring "github.com/google/go-querystring/query"
+)
+
+// Client the Bluecat v2 API client.
+type Client struct {
+ username string
+ password string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(serverURL, username, password string) (*Client, error) {
+ if serverURL == "" {
+ return nil, errors.New("server URL missing")
+ }
+
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, err := url.Parse(serverURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ username: username,
+ password: password,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// RetrieveZones retrieves all zones.
+func (c *Client) RetrieveZones(ctx context.Context, opts *CollectionOptions) ([]ZoneResource, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones")
+
+ collection, err := retrieveCollection[ZoneResource](ctx, c, endpoint, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return collection.Data, nil
+}
+
+// RetrieveZoneDeployments retrieves all deployments for a zone.
+func (c *Client) RetrieveZoneDeployments(ctx context.Context, zoneID int64, opts *CollectionOptions) ([]QuickDeployment, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments")
+
+ collection, err := retrieveCollection[QuickDeployment](ctx, c, endpoint, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return collection.Data, nil
+}
+
+// CreateZoneDeployment creates a new deployment for a zone.
+func (c *Client) CreateZoneDeployment(ctx context.Context, zoneID int64) (*QuickDeployment, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments")
+
+ payload := CommonResource{
+ Type: "QuickDeployment",
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(QuickDeployment)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// CreateZoneResourceRecord creates a new TXT record in a zone.
+func (c *Client) CreateZoneResourceRecord(ctx context.Context, zoneID int64, record RecordTXT) (*RecordTXT, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "resourceRecords")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(RecordTXT)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteResourceRecord deletes a resource record.
+func (c *Client) DeleteResourceRecord(ctx context.Context, recordID int64) error {
+ endpoint := c.baseURL.JoinPath("api", "v2", "resourceRecords", strconv.FormatInt(recordID, 10))
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.doAuthenticated(ctx, req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func retrieveCollection[T any](ctx context.Context, client *Client, endpoint *url.URL, opts *CollectionOptions) (*Collection[T], error) {
+ if opts != nil {
+ values, err := querystring.Values(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint.RawQuery = values.Encode()
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &Collection[T]{}
+
+ err = client.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/bluecatv2/internal/client_test.go b/providers/dns/bluecatv2/internal/client_test.go
new file mode 100644
index 000000000..2559af66e
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/client_test.go
@@ -0,0 +1,208 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilderAuthenticated() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "userA", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ servermock.CheckHeader().
+ WithAuthorization("Basic secretToken"),
+ )
+}
+
+func TestClient_RetrieveZones(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("GET /api/v2/zones",
+ servermock.ResponseFromFixture("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With(
+ "filter",
+ "absoluteName:eq('example.com') and configuration.name:eq('myConfiguration') and view.name:eq('myView')",
+ ),
+ ).
+ Build(t)
+
+ opts := &CollectionOptions{
+ Filter: And(
+ Eq("absoluteName", "example.com"),
+ Eq("configuration.name", "myConfiguration"),
+ Eq("view.name", "myView"),
+ ).String(),
+ }
+
+ result, err := client.RetrieveZones(mockToken(t.Context()), opts)
+ require.NoError(t, err)
+
+ expected := []ZoneResource{
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "ENUMZone", Name: "5678"},
+ AbsoluteName: "string",
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "ExternalHostsZone", Name: "name"},
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "InternalRootZone", Name: "name"},
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "ResponsePolicyZone", Name: "name"},
+ },
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "Zone", Name: "example.com"},
+ AbsoluteName: "example.com",
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_RetrieveZones_error(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("GET /api/v2/zones",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ opts := &CollectionOptions{
+ Filter: And(
+ Eq("absoluteName", "example.com"),
+ Eq("configuration.name", "myConfiguration"),
+ Eq("view.name", "myView"),
+ ).String(),
+ }
+
+ _, err := client.RetrieveZones(mockToken(t.Context()), opts)
+ require.EqualError(t, err, "401: Unauthorized: InvalidAuthorizationToken: The provided authorization token is invalid")
+}
+
+func TestClient_RetrieveZoneDeployments(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("GET /api/v2/zones/456789/deployments",
+ servermock.ResponseFromFixture("getZoneDeployments.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter", "id:eq('12345')"),
+ ).
+ Build(t)
+
+ opts := &CollectionOptions{
+ Filter: Eq("id", "12345").String(),
+ }
+
+ result, err := client.RetrieveZoneDeployments(mockToken(t.Context()), 456789, opts)
+ require.NoError(t, err)
+
+ expected := []QuickDeployment{
+ {
+ CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment", Name: ""},
+ State: "PENDING",
+ Status: "CANCEL",
+ Message: "string",
+ PercentComplete: 50,
+ CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC),
+ StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC),
+ CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC),
+ Method: "SCHEDULED",
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_CreateZoneDeployment(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("POST /api/v2/zones/12345/deployments",
+ servermock.ResponseFromFixture("postZoneDeployment.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromFixture("postZoneDeployment-request.json"),
+ ).
+ Build(t)
+
+ quickDeployment, err := client.CreateZoneDeployment(mockToken(t.Context()), 12345)
+ require.NoError(t, err)
+
+ expected := &QuickDeployment{
+ CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment"},
+ State: "PENDING",
+ Status: "CANCEL",
+ Message: "string",
+ PercentComplete: 50,
+ CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC),
+ StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC),
+ CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC),
+ Method: "SCHEDULED",
+ }
+
+ assert.Equal(t, expected, quickDeployment)
+}
+
+func TestClient_CreateZoneResourceRecord(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("POST /api/v2/zones/12345/resourceRecords",
+ servermock.ResponseFromFixture("postZoneResourceRecord.json"),
+ servermock.CheckRequestJSONBodyFromFixture("postZoneResourceRecord-request.json"),
+ ).
+ Build(t)
+
+ record := RecordTXT{
+ CommonResource: CommonResource{
+ Type: "TXTRecord",
+ Name: "_acme-challenge",
+ },
+ Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ RecordType: "TXT",
+ }
+
+ result, err := client.CreateZoneResourceRecord(mockToken(t.Context()), 12345, record)
+ require.NoError(t, err)
+
+ expected := &RecordTXT{
+ CommonResource: CommonResource{
+ ID: 12345,
+ Type: "ResourceRecord",
+ Name: "name",
+ },
+ TTL: 3600,
+ AbsoluteName: "host1.example.com",
+ Comment: "Sample comment.",
+ Dynamic: true,
+ RecordType: "CNAME",
+ Text: "",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_DeleteResourceRecord(t *testing.T) {
+ client := mockBuilderAuthenticated().
+ Route("DELETE /api/v2/resourceRecords/12345",
+ servermock.ResponseFromFixture("deleteResourceRecord.json"),
+ ).
+ Build(t)
+
+ err := client.DeleteResourceRecord(mockToken(t.Context()), 12345)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json
new file mode 100644
index 000000000..38ae2db6e
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json
@@ -0,0 +1,75 @@
+{
+ "id": 12345,
+ "type": "WorkflowRequest",
+ "state": "APPROVED",
+ "operation": "ADD_ALIAS_RECORD",
+ "creator": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "resourceId": 0,
+ "resourceType": "ACL",
+ "fieldUpdates": [
+ {
+ "name": "string",
+ "value": {},
+ "previousValue": {}
+ }
+ ],
+ "dependentRequest": "string",
+ "modifier": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "creationDateTime": "2022-10-17T19:11:45Z",
+ "modificationDateTime": "2022-10-18T19:11:45Z",
+ "comment": "Sample comment."
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/error.json b/providers/dns/bluecatv2/internal/fixtures/error.json
new file mode 100644
index 000000000..d3d2b8b5f
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/error.json
@@ -0,0 +1,6 @@
+{
+ "status": 401,
+ "reason": "Unauthorized",
+ "code": "InvalidAuthorizationToken",
+ "message": "The provided authorization token is invalid"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json
new file mode 100644
index 000000000..b1a4938ad
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json
@@ -0,0 +1,46 @@
+{
+ "count": 0,
+ "totalCount": 0,
+ "data": [
+ {
+ "id": 12345,
+ "type": "QuickDeployment",
+ "state": "PENDING",
+ "status": "CANCEL",
+ "message": "string",
+ "percentComplete": 50,
+ "creationDateTime": "2022-11-23T02:53:00Z",
+ "startDateTime": "2022-11-23T02:53:03Z",
+ "completionDateTime": "2022-11-23T02:54:05Z",
+ "user": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "method": "SCHEDULED"
+ }
+ ]
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession-request.json b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json
new file mode 100644
index 000000000..e62048eb9
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json
@@ -0,0 +1,4 @@
+{
+ "username": "userA",
+ "password": "secret"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession.json b/providers/dns/bluecatv2/internal/fixtures/postSession.json
new file mode 100644
index 000000000..4599ad0ad
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postSession.json
@@ -0,0 +1,50 @@
+{
+ "id": 12345,
+ "type": "UserSession",
+ "apiToken": "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez",
+ "apiTokenExpirationDateTime": "2022-09-15T17:52:07Z",
+ "basicAuthenticationCredentials": "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=",
+ "remoteAddress": "192.168.1.1",
+ "readOnly": true,
+ "loginDateTime": "2022-09-14T17:45:03Z",
+ "logoutDateTime": "2022-09-14T19:45:03Z",
+ "state": "LOGGED_IN",
+ "response": "Authentication Error: Ensure that your username and password are correct.",
+ "user": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ }
+ }
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json
new file mode 100644
index 000000000..099573a84
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json
@@ -0,0 +1,3 @@
+{
+ "type": "QuickDeployment"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json
new file mode 100644
index 000000000..fd26781fb
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json
@@ -0,0 +1,40 @@
+{
+ "id": 12345,
+ "type": "QuickDeployment",
+ "state": "PENDING",
+ "status": "CANCEL",
+ "message": "string",
+ "percentComplete": 50,
+ "creationDateTime": "2022-11-23T02:53:00Z",
+ "startDateTime": "2022-11-23T02:53:03Z",
+ "completionDateTime": "2022-11-23T02:54:05Z",
+ "user": {
+ "id": 103307,
+ "type": "User",
+ "name": "admin",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "authenticator": {
+ "id": 12345,
+ "type": "Authenticator",
+ "name": "LDAP authenticator"
+ },
+ "email": "user@example.com",
+ "phoneNumber": "555-1234",
+ "securityPrivilege": "NO_ACCESS",
+ "historyPrivilege": "HIDE",
+ "accessType": "GUI",
+ "passwordResetRequired": true,
+ "accountLocked": true,
+ "x509Required": true,
+ "administrativeAccessRights": [
+ {
+ "resourceType": "Event",
+ "accessLevel": "HIDE"
+ }
+ ]
+ },
+ "method": "SCHEDULED"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json
new file mode 100644
index 000000000..2de733c71
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json
@@ -0,0 +1,7 @@
+{
+ "type": "TXTRecord",
+ "name": "_acme-challenge",
+ "ttl": 120,
+ "recordType": "TXT",
+ "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json
new file mode 100644
index 000000000..78d028ee3
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json
@@ -0,0 +1,25 @@
+{
+ "id": 12345,
+ "type": "ResourceRecord",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "ttl": 3600,
+ "absoluteName": "host1.example.com",
+ "comment": "Sample comment.",
+ "dynamic": true,
+ "recordType": "CNAME",
+ "linkedRecord": {
+ "id": 12345,
+ "type": "ResourceRecord",
+ "name": "name",
+ "absoluteName": "host1.example.com"
+ }
+}
diff --git a/providers/dns/bluecatv2/internal/fixtures/zones.json b/providers/dns/bluecatv2/internal/fixtures/zones.json
new file mode 100644
index 000000000..b9f2dfa8f
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/fixtures/zones.json
@@ -0,0 +1,185 @@
+{
+ "count": 0,
+ "totalCount": 0,
+ "data": [
+ {
+ "id": 12345,
+ "type": "ENUMZone",
+ "name": "5678",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "deploymentEnabled": true,
+ "absoluteName": "string"
+ },
+ {
+ "id": 12345,
+ "type": "ExternalHostsZone",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ }
+ },
+ {
+ "id": 12345,
+ "type": "InternalRootZone",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "deploymentEnabled": true
+ },
+ {
+ "id": 12345,
+ "type": "ResponsePolicyZone",
+ "name": "name",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "responsePolicyZoneType": "LOCAL",
+ "responsePolicy": {
+ "id": 12345,
+ "type": "ResponsePolicy",
+ "name": "Block Response Policy"
+ },
+ "overridePolicyType": "ALLOWLIST",
+ "overrideRefreshTime": "string",
+ "redirectTarget": "string",
+ "feedCategories": [
+ "string"
+ ]
+ },
+ {
+ "id": 12345,
+ "type": "Zone",
+ "name": "example.com",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "view": {
+ "id": 12345,
+ "type": "View",
+ "name": "default",
+ "userDefinedFields": {
+ "udf1": "value1",
+ "udf2": "value2"
+ },
+ "configuration": {
+ "id": 12345,
+ "type": "Configuration",
+ "name": "name"
+ },
+ "deviceRegistrationEnabled": true,
+ "deviceRegistrationPortalAddress": "10.10.10.10"
+ },
+ "deploymentEnabled": true,
+ "dynamicUpdateEnabled": true,
+ "template": {
+ "id": 12345,
+ "type": "ZoneTemplate",
+ "name": "name"
+ },
+ "signed": true,
+ "signingPolicy": {
+ "id": 12345,
+ "type": "DNSSECSigningPolicy",
+ "name": "name"
+ },
+ "absoluteName": "example.com"
+ }
+ ]
+}
diff --git a/providers/dns/bluecatv2/internal/identity.go b/providers/dns/bluecatv2/internal/identity.go
new file mode 100644
index 000000000..af9355ab2
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/identity.go
@@ -0,0 +1,60 @@
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+)
+
+type token string
+
+const tokenKey token = "token"
+
+const authorizationHeader = "Authorization"
+
+// CreateSession creates a new session.
+func (c *Client) CreateSession(ctx context.Context, info LoginInfo) (*Session, error) {
+ endpoint := c.baseURL.JoinPath("api", "v2", "sessions")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, info)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(Session)
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// CreateAuthenticatedContext creates a new authenticated context.
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+ tok, err := c.CreateSession(ctx, LoginInfo{Username: c.username, Password: c.password})
+ if err != nil {
+ return nil, fmt.Errorf("create session: %w", err)
+ }
+
+ return context.WithValue(ctx, tokenKey, tok.BasicAuthenticationCredentials), nil
+}
+
+func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result any) error {
+ tok := getToken(ctx)
+ if tok != "" {
+ req.Header.Set(authorizationHeader, "Basic "+tok)
+ }
+
+ return c.do(req, result)
+}
+
+func getToken(ctx context.Context) string {
+ tok, ok := ctx.Value(tokenKey).(string)
+ if !ok {
+ return ""
+ }
+
+ return tok
+}
diff --git a/providers/dns/bluecatv2/internal/identity_test.go b/providers/dns/bluecatv2/internal/identity_test.go
new file mode 100644
index 000000000..3a1c4d2a2
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/identity_test.go
@@ -0,0 +1,82 @@
+package internal
+
+import (
+ "context"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "userA", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func mockToken(ctx context.Context) context.Context {
+ return context.WithValue(ctx, tokenKey, "secretToken")
+}
+
+func TestClient_CreateSession(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromFixture("postSession.json"),
+ servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"),
+ ).
+ Build(t)
+
+ info := LoginInfo{
+ Username: "userA",
+ Password: "secret",
+ }
+
+ result, err := client.CreateSession(mockToken(t.Context()), info)
+ require.NoError(t, err)
+
+ expected := &Session{
+ ID: 12345,
+ Type: "UserSession",
+ APIToken: "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez",
+ APITokenExpirationDateTime: time.Date(2022, time.September, 15, 17, 52, 7, 0, time.UTC),
+ BasicAuthenticationCredentials: "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=",
+ RemoteAddress: "192.168.1.1",
+ ReadOnly: true,
+ LoginDateTime: time.Date(2022, time.September, 14, 17, 45, 3, 0, time.UTC),
+ LogoutDateTime: time.Date(2022, time.September, 14, 19, 45, 3, 0, time.UTC),
+ State: "LOGGED_IN",
+ Response: "Authentication Error: Ensure that your username and password are correct.",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_CreateAuthenticatedContext(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/v2/sessions",
+ servermock.ResponseFromFixture("postSession.json"),
+ servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"),
+ ).
+ Build(t)
+
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", getToken(ctx))
+}
diff --git a/providers/dns/bluecatv2/internal/predicates.go b/providers/dns/bluecatv2/internal/predicates.go
new file mode 100644
index 000000000..8ed6f714b
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/predicates.go
@@ -0,0 +1,64 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Predicate struct {
+ field string
+ operator string
+ values []string
+}
+
+func (p *Predicate) String() string {
+ var values []string
+ for _, v := range p.values {
+ values = append(values, fmt.Sprintf("'%s'", v))
+ }
+
+ return fmt.Sprintf("%s:%s(%s)", p.field, p.operator, strings.Join(values, ", "))
+}
+
+func Eq(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "eq", values: []string{value}}
+}
+
+func Contains(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "contains", values: []string{value}}
+}
+
+func StartsWith(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "startsWith", values: []string{value}}
+}
+
+func EndsWith(field, value string) *Predicate {
+ return &Predicate{field: field, operator: "endsWith", values: []string{value}}
+}
+
+func In(field string, values ...string) *Predicate {
+ return &Predicate{field: field, operator: "in", values: values}
+}
+
+type Combined struct {
+ predicates []*Predicate
+ operator string
+}
+
+func (o *Combined) String() string {
+ var parts []string
+
+ for _, predicate := range o.predicates {
+ parts = append(parts, predicate.String())
+ }
+
+ return strings.Join(parts, " "+o.operator+" ")
+}
+
+func And(predicates ...*Predicate) *Combined {
+ return &Combined{predicates: predicates, operator: "and"}
+}
+
+func Or(predicates ...*Predicate) *Combined {
+ return &Combined{predicates: predicates, operator: "or"}
+}
diff --git a/providers/dns/bluecatv2/internal/predicates_test.go b/providers/dns/bluecatv2/internal/predicates_test.go
new file mode 100644
index 000000000..6913e8729
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/predicates_test.go
@@ -0,0 +1,78 @@
+package internal
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPredicate(t *testing.T) {
+ testCases := []struct {
+ desc string
+ predicate fmt.Stringer
+ expected string
+ }{
+ {
+ desc: "Equals",
+ predicate: Eq("foo", "bar"),
+ expected: "foo:eq('bar')",
+ },
+ {
+ desc: "Contains",
+ predicate: Contains("foo", "bar"),
+ expected: "foo:contains('bar')",
+ },
+ {
+ desc: "Starts with",
+ predicate: StartsWith("foo", "bar"),
+ expected: "foo:startsWith('bar')",
+ },
+ {
+ desc: "Ends with",
+ predicate: EndsWith("foo", "bar"),
+ expected: "foo:endsWith('bar')",
+ },
+ {
+ desc: "Match a list of values",
+ predicate: In("foo", "bar", "bir"),
+ expected: "foo:in('bar', 'bir')",
+ },
+ {
+ desc: "Combined: and",
+ predicate: And(Eq("foo", "bar"), Eq("fii", "bir")),
+ expected: "foo:eq('bar') and fii:eq('bir')",
+ },
+ {
+ desc: "Combined: multiple and",
+ predicate: And(
+ Eq("foo", "bar"),
+ Eq("fii", "bir"),
+ Eq("fuu", "bur"),
+ ),
+ expected: "foo:eq('bar') and fii:eq('bir') and fuu:eq('bur')",
+ },
+ {
+ desc: "Combined: or",
+ predicate: Or(Eq("foo", "bar"), Eq("foo", "bir")),
+ expected: "foo:eq('bar') or foo:eq('bir')",
+ },
+ {
+ desc: "Combined: multiple or",
+ predicate: Or(
+ Eq("foo", "bar"),
+ Eq("foo", "bir"),
+ Eq("foo", "bur"),
+ ),
+ expected: "foo:eq('bar') or foo:eq('bir') or foo:eq('bur')",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, test.expected, test.predicate.String())
+ })
+ }
+}
diff --git a/providers/dns/bluecatv2/internal/types.go b/providers/dns/bluecatv2/internal/types.go
new file mode 100644
index 000000000..562fd60b0
--- /dev/null
+++ b/providers/dns/bluecatv2/internal/types.go
@@ -0,0 +1,122 @@
+package internal
+
+import (
+ "fmt"
+ "time"
+)
+
+// Quick deployment states.
+//
+//nolint:misspell // US vs UK
+const (
+ QDStatePending = "PENDING"
+ QDStateQueued = "QUEUED"
+ QDStateRunning = "RUNNING"
+ QDStateCancelled = "CANCELLED"
+ QDStateCancelling = "CANCELLING"
+ QDStateCompleted = "COMPLETED"
+ QDStateCompletedWithErrors = "COMPLETED_WITH_ERRORS"
+ QDStateCompletedWithWarnings = "COMPLETED_WITH_WARNINGS"
+ QDStateFailed = "FAILED"
+ QDStateUnknown = "UNKNOWN"
+)
+
+// APIError represents an error.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Errors/9.6.0
+type APIError struct {
+ Status int `json:"status"`
+ Reason string `json:"reason"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%d: %s: %s: %s", a.Status, a.Reason, a.Code, a.Message)
+}
+
+// CommonResource represents the common resource fields.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Resources/9.6.0
+type CommonResource struct {
+ ID int64 `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+// Collection represents a collection of resources.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Collections/9.6.0
+type Collection[T any] struct {
+ Count int64 `json:"count"`
+ TotalCount int64 `json:"totalCount"`
+ Data []T `json:"data"`
+}
+
+type CollectionOptions struct {
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Fields/9.6.0
+ Fields string `url:"fields,omitempty"`
+
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Pagination/9.6.0
+ Limit int `url:"limit,omitempty"`
+ Offset int `url:"offset,omitempty"`
+
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Filter/9.6.0
+ Filter string `url:"filter,omitempty"`
+
+ // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Ordering/9.6.0
+ OrderBy string `url:"orderBy,omitempty"`
+
+ // Should return or not the total number of resources matching the query.
+ Total bool `url:"total,omitempty"`
+}
+
+type RecordTXT struct {
+ CommonResource
+
+ TTL int `json:"ttl,omitempty"`
+ AbsoluteName string `json:"absoluteName,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Dynamic bool `json:"dynamic,omitempty"`
+ RecordType string `json:"recordType,omitempty"`
+ Text string `json:"text,omitempty"`
+}
+
+type ZoneResource struct {
+ CommonResource
+
+ AbsoluteName string `json:"absoluteName,omitempty"`
+}
+
+type QuickDeployment struct {
+ CommonResource
+
+ State string `json:"state,omitempty"`
+ Status string `json:"status,omitempty"`
+ Message string `json:"message,omitempty"`
+ PercentComplete int `json:"percentComplete,omitempty"`
+ CreationDateTime time.Time `json:"creationDateTime,omitzero"`
+ StartDateTime time.Time `json:"startDateTime,omitzero"`
+ CompletionDateTime time.Time `json:"completionDateTime,omitzero"`
+ Method string `json:"method,omitempty"`
+}
+
+// LoginInfo represents the login information.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0
+type LoginInfo struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+// Session represents the session.
+// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0
+type Session struct {
+ ID int `json:"id"`
+ Type string `json:"type"`
+ APIToken string `json:"apiToken"`
+ APITokenExpirationDateTime time.Time `json:"apiTokenExpirationDateTime"`
+ BasicAuthenticationCredentials string `json:"basicAuthenticationCredentials"`
+ RemoteAddress string `json:"remoteAddress"`
+ ReadOnly bool `json:"readOnly"`
+ LoginDateTime time.Time `json:"loginDateTime"`
+ LogoutDateTime time.Time `json:"logoutDateTime"`
+ State string `json:"state"`
+ Response string `json:"response"`
+}
diff --git a/providers/dns/bookmyname/bookmyname.go b/providers/dns/bookmyname/bookmyname.go
new file mode 100644
index 000000000..6f42dfd78
--- /dev/null
+++ b/providers/dns/bookmyname/bookmyname.go
@@ -0,0 +1,141 @@
+// Package bookmyname implements a DNS provider for solving the DNS-01 challenge using BookMyName.
+package bookmyname
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/bookmyname/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "BOOKMYNAME_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Username string
+ Password string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for BookMyName.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("bookmyname: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for BookMyName.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("bookmyname: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("bookmyname: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ record := internal.Record{
+ Hostname: dns01.UnFqdn(info.EffectiveFQDN),
+ Type: "txt",
+ TTL: d.config.TTL,
+ Value: info.Value,
+ }
+
+ err := d.client.AddRecord(context.Background(), record)
+ if err != nil {
+ return fmt.Errorf("bookmyname: add record: %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)
+
+ record := internal.Record{
+ Hostname: dns01.UnFqdn(info.EffectiveFQDN),
+ Type: "txt",
+ TTL: d.config.TTL,
+ Value: info.Value,
+ }
+
+ err := d.client.RemoveRecord(context.Background(), record)
+ if err != nil {
+ return fmt.Errorf("bookmyname: add 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/dns/bookmyname/bookmyname.toml b/providers/dns/bookmyname/bookmyname.toml
new file mode 100644
index 000000000..76fcb85e7
--- /dev/null
+++ b/providers/dns/bookmyname/bookmyname.toml
@@ -0,0 +1,24 @@
+Name = "BookMyName"
+Description = ''''''
+URL = "https://www.bookmyname.com/"
+Code = "bookmyname"
+Since = "v4.23.0"
+
+Example = '''
+BOOKMYNAME_USERNAME="xxx" \
+BOOKMYNAME_PASSWORD="yyy" \
+lego --dns bookmyname -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ BOOKMYNAME_USERNAME = "Username"
+ BOOKMYNAME_PASSWORD = "Password"
+ [Configuration.Additional]
+ BOOKMYNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BOOKMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ BOOKMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ BOOKMYNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://fr.faqs.bookmyname.com/frfaqs/dyndns"
diff --git a/providers/dns/iwantmyname/iwantmyname_test.go b/providers/dns/bookmyname/bookmyname_test.go
similarity index 80%
rename from providers/dns/iwantmyname/iwantmyname_test.go
rename to providers/dns/bookmyname/bookmyname_test.go
index 7ae4545b2..8b3fa21e6 100644
--- a/providers/dns/iwantmyname/iwantmyname_test.go
+++ b/providers/dns/bookmyname/bookmyname_test.go
@@ -1,4 +1,4 @@
-package iwantmyname
+package bookmyname
import (
"testing"
@@ -9,8 +9,7 @@ import (
const envDomain = envNamespace + "DOMAIN"
-var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).
- WithDomain(envDomain)
+var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
@@ -25,30 +24,33 @@ func TestNewDNSProvider(t *testing.T) {
EnvPassword: "secret",
},
},
- {
- desc: "missing credentials",
- envVars: map[string]string{},
- expected: "iwantmyname: some credentials information are missing: IWANTMYNAME_USERNAME,IWANTMYNAME_PASSWORD",
- },
{
desc: "missing username",
envVars: map[string]string{
+ EnvUsername: "",
EnvPassword: "secret",
},
- expected: "iwantmyname: some credentials information are missing: IWANTMYNAME_USERNAME",
+ expected: "bookmyname: some credentials information are missing: BOOKMYNAME_USERNAME",
},
{
- desc: "missing password",
+ desc: "missing paswword",
envVars: map[string]string{
EnvUsername: "user",
+ EnvPassword: "",
},
- expected: "iwantmyname: some credentials information are missing: IWANTMYNAME_PASSWORD",
+ expected: "bookmyname: some credentials information are missing: BOOKMYNAME_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "bookmyname: some credentials information are missing: BOOKMYNAME_USERNAME,BOOKMYNAME_PASSWORD",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -79,19 +81,19 @@ func TestNewDNSProviderConfig(t *testing.T) {
username: "user",
password: "secret",
},
- {
- desc: "missing credentials",
- expected: "iwantmyname: credentials missing",
- },
{
desc: "missing username",
password: "secret",
- expected: "iwantmyname: credentials missing",
+ expected: "bookmyname: credentials missing",
},
{
desc: "missing password",
username: "user",
- expected: "iwantmyname: credentials missing",
+ expected: "bookmyname: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "bookmyname: credentials missing",
},
}
@@ -121,6 +123,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -134,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/bookmyname/internal/client.go b/providers/dns/bookmyname/internal/client.go
new file mode 100644
index 000000000..08d4cccce
--- /dev/null
+++ b/providers/dns/bookmyname/internal/client.go
@@ -0,0 +1,118 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ querystring "github.com/google/go-querystring/query"
+)
+
+const defaultBaseURL = "https://www.bookmyname.com/dyndns/"
+
+// Client the BookMyName API client.
+type Client struct {
+ username string
+ password string
+
+ baseURL string
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(username, password string) (*Client, error) {
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ return &Client{
+ username: username,
+ password: password,
+ baseURL: defaultBaseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, record Record) error {
+ endpoint, err := c.createEndpoint(record, "add")
+ if err != nil {
+ return err
+ }
+
+ err = c.do(ctx, endpoint)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) RemoveRecord(ctx context.Context, record Record) error {
+ endpoint, err := c.createEndpoint(record, "remove")
+ if err != nil {
+ return err
+ }
+
+ err = c.do(ctx, endpoint)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) createEndpoint(record Record, action string) (*url.URL, error) {
+ endpoint, err := url.Parse(c.baseURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse URL: %w", err)
+ }
+
+ values, err := querystring.Values(record)
+ if err != nil {
+ return nil, fmt.Errorf("query parameters: %w", err)
+ }
+
+ values.Set("do", action)
+
+ endpoint.RawQuery = values.Encode()
+
+ return endpoint, nil
+}
+
+func (c *Client) do(ctx context.Context, endpoint *url.URL) error {
+ endpoint.User = url.UserPassword(c.username, c.password)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
+ if err != nil {
+ return fmt.Errorf("unable to create request: %w", err)
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ if resp.StatusCode/100 != 2 {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ if !strings.HasPrefix(string(raw), "good: update done") && !strings.HasPrefix(string(raw), "good: remove done") {
+ return fmt.Errorf("unexpected response: %s", string(bytes.TrimSpace(raw)))
+ }
+
+ return nil
+}
diff --git a/providers/dns/bookmyname/internal/client_test.go b/providers/dns/bookmyname/internal/client_test.go
new file mode 100644
index 000000000..900d62fef
--- /dev/null
+++ b/providers/dns/bookmyname/internal/client_test.go
@@ -0,0 +1,116 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL = server.URL
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithBasicAuth("user", "secret"))
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("add_success.txt"),
+ servermock.CheckQueryParameter().Strict().
+ With("do", "add").
+ With("hostname", "_acme-challenge.sub.example.com.").
+ With("type", "txt").
+ With("value", "test").
+ With("ttl", "300"),
+ ).
+ Build(t)
+
+ record := Record{
+ Hostname: "_acme-challenge.sub.example.com.",
+ Type: "txt",
+ TTL: 300,
+ Value: "test",
+ }
+
+ err := client.AddRecord(t.Context(), record)
+ require.NoError(t, err)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("error.txt"),
+ servermock.CheckQueryParameter().
+ With("do", "add")).
+ Build(t)
+
+ record := Record{
+ Hostname: "_acme-challenge.sub.example.com.",
+ Type: "txt",
+ TTL: 300,
+ Value: "test",
+ }
+
+ err := client.AddRecord(t.Context(), record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "unexpected response: notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn")
+}
+
+func TestClient_RemoveRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("remove_success.txt"),
+ servermock.CheckQueryParameter().Strict().
+ With("do", "remove").
+ With("hostname", "_acme-challenge.sub.example.com.").
+ With("type", "txt").
+ With("value", "test").
+ With("ttl", "300"),
+ ).
+ Build(t)
+
+ record := Record{
+ Hostname: "_acme-challenge.sub.example.com.",
+ Type: "txt",
+ TTL: 300,
+ Value: "test",
+ }
+
+ err := client.RemoveRecord(t.Context(), record)
+ require.NoError(t, err)
+}
+
+func TestClient_RemoveRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("error.txt"),
+ servermock.CheckQueryParameter().
+ With("do", "remove")).
+ Build(t)
+
+ record := Record{
+ Hostname: "_acme-challenge.sub.example.com.",
+ Type: "txt",
+ TTL: 300,
+ Value: "test",
+ }
+
+ err := client.RemoveRecord(t.Context(), record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "unexpected response: notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn")
+}
diff --git a/providers/dns/bookmyname/internal/fixtures/add_success.txt b/providers/dns/bookmyname/internal/fixtures/add_success.txt
new file mode 100644
index 000000000..76304fc24
--- /dev/null
+++ b/providers/dns/bookmyname/internal/fixtures/add_success.txt
@@ -0,0 +1 @@
+good: update done, cid 123, domain id 456, type txt, ip xxx
diff --git a/providers/dns/bookmyname/internal/fixtures/error.txt b/providers/dns/bookmyname/internal/fixtures/error.txt
new file mode 100644
index 000000000..3c62ede60
--- /dev/null
+++ b/providers/dns/bookmyname/internal/fixtures/error.txt
@@ -0,0 +1 @@
+notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn
diff --git a/providers/dns/bookmyname/internal/fixtures/remove_success.txt b/providers/dns/bookmyname/internal/fixtures/remove_success.txt
new file mode 100644
index 000000000..1e83c6dcc
--- /dev/null
+++ b/providers/dns/bookmyname/internal/fixtures/remove_success.txt
@@ -0,0 +1 @@
+good: remove done 1, cid 123, domain id 456, ttl 300, type txt, ip xxx
diff --git a/providers/dns/bookmyname/internal/types.go b/providers/dns/bookmyname/internal/types.go
new file mode 100644
index 000000000..96dab064a
--- /dev/null
+++ b/providers/dns/bookmyname/internal/types.go
@@ -0,0 +1,8 @@
+package internal
+
+type Record struct {
+ Hostname string `url:"hostname"`
+ Type string `url:"type"`
+ TTL int `url:"ttl"`
+ Value string `url:"value"`
+}
diff --git a/providers/dns/brandit/brandit.go b/providers/dns/brandit/brandit.go
index 437d1642a..fe3b52239 100644
--- a/providers/dns/brandit/brandit.go
+++ b/providers/dns/brandit/brandit.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/brandit/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -92,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -165,6 +168,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordsMu.Lock()
dnsRecord, ok := d.records[token]
d.recordsMu.Unlock()
+
if !ok {
return fmt.Errorf("brandit: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
@@ -183,6 +187,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
var recordID int
+
for i, r := range records.RR {
if r == dnsRecord {
recordID = i
diff --git a/providers/dns/brandit/brandit.toml b/providers/dns/brandit/brandit.toml
index 1c70eb1ca..4c43e27a9 100644
--- a/providers/dns/brandit/brandit.toml
+++ b/providers/dns/brandit/brandit.toml
@@ -12,7 +12,7 @@ Since = "v4.11.0"
Example = '''
BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \
BRANDIT_API_USERNAME=yyyyyyyyyyyyyyyyyyyy \
-lego --email you@example.com --dns brandit -d '*.example.com' -d example.com run
+lego --dns brandit -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -20,10 +20,10 @@ lego --email you@example.com --dns brandit -d '*.example.com' -d example.com run
BRANDIT_API_KEY = "The API key"
BRANDIT_API_USERNAME = "The API username"
[Configuration.Additional]
- BRANDIT_POLLING_INTERVAL = "Time between DNS propagation check"
- BRANDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- BRANDIT_TTL = "The TTL of the TXT record used for the DNS challenge"
- BRANDIT_HTTP_TIMEOUT = "API request timeout"
+ BRANDIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BRANDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)"
+ BRANDIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ BRANDIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://portal.brandit.com/apidocv3"
diff --git a/providers/dns/brandit/brandit_test.go b/providers/dns/brandit/brandit_test.go
index 156e7c3f4..40abdd3d0 100644
--- a/providers/dns/brandit/brandit_test.go
+++ b/providers/dns/brandit/brandit_test.go
@@ -48,6 +48,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -120,6 +121,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -133,6 +135,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/brandit/internal/client.go b/providers/dns/brandit/internal/client.go
index 59c57419a..cda3be5a2 100644
--- a/providers/dns/brandit/internal/client.go
+++ b/providers/dns/brandit/internal/client.go
@@ -62,6 +62,7 @@ func (c *Client) ListRecords(ctx context.Context, account, dnsZone string) (*Lis
query.Add("first", strconv.Itoa(result.Response.Last[0]+1))
tmp := &Response[*ListRecordsResponse]{}
+
err := c.do(ctx, query, tmp)
if err != nil {
return nil, err
@@ -156,6 +157,7 @@ func (c *Client) do(ctx context.Context, query url.Values, result any) error {
// Unmarshal the error response, because the API returns a 200 OK even if there is an error.
var apiError APIError
+
err = json.Unmarshal(raw, &apiError)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -183,6 +185,7 @@ func sign(apiUsername, apiKey string, query url.Values) (url.Values, error) {
canonicalRequest := fmt.Sprintf("%s%s%s", apiUsername, timestamp, defaultBaseURL)
mac := hmac.New(sha256.New, []byte(apiKey))
+
_, err := mac.Write([]byte(canonicalRequest))
if err != nil {
return nil, err
diff --git a/providers/dns/brandit/internal/client_test.go b/providers/dns/brandit/internal/client_test.go
index a37e51a29..cb779ef68 100644
--- a/providers/dns/brandit/internal/client_test.go
+++ b/providers/dns/brandit/internal/client_test.go
@@ -1,52 +1,44 @@
package internal
import (
- "context"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, filename string) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
- server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
+ client.HTTPClient = server.Client()
+ client.baseURL = server.URL
- defer func() { _ = file.Close() }()
-
- rw.WriteHeader(http.StatusOK)
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- }))
- t.Cleanup(server.Close)
-
- client, err := NewClient("test_user", "apiKey")
- require.NoError(t, err)
-
- client.HTTPClient = server.Client()
- client.baseURL = server.URL
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
}
func TestClient_StatusDomain(t *testing.T) {
- client := setupTest(t, "status-domain.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("status-domain.json"),
+ servermock.CheckForm().Strict().
+ WithRegexp("signature", "[a-z0-9]+").
+ WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`).
+ With("command", "statusDomain").
+ With("user", "user").
+ With("domain", "example.com"),
+ ).
+ Build(t)
- domain, err := client.StatusDomain(context.Background(), "example.com")
+ domain, err := client.StatusDomain(t.Context(), "example.com")
require.NoError(t, err)
expected := &StatusResponse{
@@ -80,16 +72,28 @@ func TestClient_StatusDomain(t *testing.T) {
}
func TestClient_StatusDomain_error(t *testing.T) {
- client := setupTest(t, "error.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- _, err := client.StatusDomain(context.Background(), "example.com")
+ _, err := client.StatusDomain(t.Context(), "example.com")
require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
}
func TestClient_ListRecords(t *testing.T) {
- client := setupTest(t, "list-records.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("list-records.json"),
+ servermock.CheckForm().Strict().
+ WithRegexp("signature", "[a-z0-9]+").
+ WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`).
+ With("account", "example").
+ With("command", "listDNSRR").
+ With("user", "user").
+ With("dnszone", "example.com"),
+ ).
+ Build(t)
- resp, err := client.ListRecords(context.Background(), "example", "example.com")
+ resp, err := client.ListRecords(t.Context(), "example", "example.com")
require.NoError(t, err)
expected := &ListRecordsResponse{
@@ -106,14 +110,28 @@ func TestClient_ListRecords(t *testing.T) {
}
func TestClient_ListRecords_error(t *testing.T) {
- client := setupTest(t, "error.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- _, err := client.ListRecords(context.Background(), "example", "example.com")
+ _, err := client.ListRecords(t.Context(), "example", "example.com")
require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "add-record.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("add-record.json"),
+ servermock.CheckForm().Strict().
+ WithRegexp("signature", "[a-z0-9]+").
+ WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`).
+ With("account", "test").
+ With("command", "addDNSRR").
+ With("key", "2565").
+ With("user", "user").
+ With("rrdata", "example.com 600 IN TXT txttxttxt").
+ With("dnszone", "example.com"),
+ ).
+ Build(t)
testRecord := Record{
ID: 2565,
@@ -122,7 +140,7 @@ func TestClient_AddRecord(t *testing.T) {
Content: "txttxttxt",
TTL: 600,
}
- resp, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord)
+ resp, err := client.AddRecord(t.Context(), "example.com", "test", "2565", testRecord)
require.NoError(t, err)
expected := &AddRecord{
@@ -140,7 +158,9 @@ func TestClient_AddRecord(t *testing.T) {
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "error.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("error.json")).
+ Build(t)
testRecord := Record{
ID: 2565,
@@ -150,20 +170,34 @@ func TestClient_AddRecord_error(t *testing.T) {
TTL: 600,
}
- _, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord)
+ _, err := client.AddRecord(t.Context(), "example.com", "test", "2565", testRecord)
require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, "delete-record.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("delete-record.json"),
+ servermock.CheckForm().Strict().
+ WithRegexp("signature", "[a-z0-9]+").
+ WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`).
+ With("account", "test").
+ With("command", "deleteDNSRR").
+ With("key", "2374").
+ With("user", "user").
+ With("rrdata", "example.com 600 IN TXT txttxttxt").
+ With("dnszone", "example.com"),
+ ).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374")
+ err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374")
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, "error.json")
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374")
+ err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374")
require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
}
diff --git a/providers/dns/bunny/bunny.go b/providers/dns/bunny/bunny.go
index 63a5a01e9..29949608b 100644
--- a/providers/dns/bunny/bunny.go
+++ b/providers/dns/bunny/bunny.go
@@ -5,12 +5,18 @@ import (
"context"
"errors"
"fmt"
+ "net/http"
+ "slices"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
"github.com/nrdcg/bunny-go"
+ "golang.org/x/net/publicsuffix"
)
// Environment variables names.
@@ -22,6 +28,7 @@ const (
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
const minTTL = 60
@@ -30,10 +37,12 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- APIKey string
+ APIKey string
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
+ HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -41,7 +50,10 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
}
}
@@ -79,9 +91,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("bunny: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
}
- client := bunny.NewClient(config.APIKey)
+ if config.HTTPClient == nil {
+ config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
+ }
- return &DNSProvider{config: config, client: client}, nil
+ config.HTTPClient = clientdebug.Wrap(config.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: bunny.NewClient(config.APIKey,
+ bunny.WithUserAgent(useragent.Get()),
+ bunny.WithHTTPClient(config.HTTPClient),
+ ),
+ }, nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -94,32 +116,27 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- authZone, err := getZoneName(info.EffectiveFQDN)
- if err != nil {
- return fmt.Errorf("bunny: could not find zone for domain %q: %w", domain, err)
- }
-
ctx := context.Background()
- zone, err := d.findZone(ctx, authZone)
+ zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN))
if err != nil {
return fmt.Errorf("bunny: %w", err)
}
- subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.Domain))
if err != nil {
return fmt.Errorf("bunny: %w", err)
}
record := &bunny.AddOrUpdateDNSRecordOptions{
- Type: pointer(bunny.DNSRecordTypeTXT),
- Name: pointer(subDomain),
- Value: pointer(info.Value),
- TTL: pointer(int32(d.config.TTL)),
+ Type: ptr.Pointer(bunny.DNSRecordTypeTXT),
+ Name: ptr.Pointer(subDomain),
+ Value: ptr.Pointer(info.Value),
+ TTL: ptr.Pointer(int32(d.config.TTL)),
}
- if _, err := d.client.DNSZone.AddDNSRecord(ctx, deref(zone.ID), record); err != nil {
- return fmt.Errorf("bunny: failed to add TXT record: fqdn=%s, zoneID=%d: %w", info.EffectiveFQDN, deref(zone.ID), err)
+ if _, err := d.client.DNSZone.AddDNSRecord(ctx, ptr.Deref(zone.ID), record); err != nil {
+ return fmt.Errorf("bunny: failed to add TXT record: fqdn=%s, zoneID=%d: %w", info.EffectiveFQDN, ptr.Deref(zone.ID), err)
}
return nil
@@ -129,38 +146,35 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- authZone, err := getZoneName(info.EffectiveFQDN)
- if err != nil {
- return fmt.Errorf("bunny: could not find zone for domain %q: %w", domain, err)
- }
-
ctx := context.Background()
- zone, err := d.findZone(ctx, authZone)
+ zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN))
if err != nil {
return fmt.Errorf("bunny: %w", err)
}
- subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.Domain))
if err != nil {
return fmt.Errorf("bunny: %w", err)
}
var record *bunny.DNSRecord
+
for _, r := range zone.Records {
- if deref(r.Name) == subDomain && deref(r.Type) == bunny.DNSRecordTypeTXT {
+ if ptr.Deref(r.Name) == subDomain && ptr.Deref(r.Type) == bunny.DNSRecordTypeTXT {
r := r
record = &r
+
break
}
}
if record == nil {
- return fmt.Errorf("bunny: could not find TXT record zone=%d, subdomain=%s", deref(zone.ID), subDomain)
+ return fmt.Errorf("bunny: could not find TXT record zone=%d, subdomain=%s", ptr.Deref(zone.ID), subDomain)
}
- if err := d.client.DNSZone.DeleteDNSRecord(ctx, deref(zone.ID), deref(record.ID)); err != nil {
- return fmt.Errorf("bunny: failed to delete TXT record: id=%d, name=%s: %w", deref(record.ID), deref(record.Name), err)
+ if err := d.client.DNSZone.DeleteDNSRecord(ctx, ptr.Deref(zone.ID), ptr.Deref(record.ID)); err != nil {
+ return fmt.Errorf("bunny: failed to delete TXT record: id=%d, name=%s: %w", ptr.Deref(record.ID), ptr.Deref(record.Name), err)
}
return nil
@@ -172,37 +186,50 @@ func (d *DNSProvider) findZone(ctx context.Context, authZone string) (*bunny.DNS
return nil, err
}
- var zone *bunny.DNSZone
- for _, item := range zones.Items {
- if item != nil && deref(item.Domain) == authZone {
- zone = item
- break
- }
- }
-
+ zone := findZone(zones, authZone)
if zone == nil {
- return nil, fmt.Errorf("could not find DNSZone zone=%s", authZone)
+ return nil, fmt.Errorf("could not find DNSZone domain=%s", authZone)
}
return zone, nil
}
-func getZoneName(fqdn string) (string, error) {
- authZone, err := dns01.FindZoneByFqdn(fqdn)
- if err != nil {
- return "", err
+func findZone(zones *bunny.DNSZones, domain string) *bunny.DNSZone {
+ domains := possibleDomains(domain)
+
+ var domainLength int
+
+ var zone *bunny.DNSZone
+
+ for _, item := range zones.Items {
+ if item == nil {
+ continue
+ }
+
+ curr := ptr.Deref(item.Domain)
+
+ if slices.Contains(domains, curr) && domainLength < len(curr) {
+ domainLength = len(curr)
+
+ zone = item
+ }
}
- return dns01.UnFqdn(authZone), nil
+ return zone
}
-func pointer[T string | int | int32 | int64](v T) *T { return &v }
+func possibleDomains(domain string) []string {
+ var domains []string
-func deref[T string | int | int32 | int64](v *T) T {
- if v == nil {
- var zero T
- return zero
+ tld, _ := publicsuffix.PublicSuffix(domain)
+ for d := range dns01.DomainsSeq(domain) {
+ if tld == d {
+ // skip the TLD
+ break
+ }
+
+ domains = append(domains, dns01.UnFqdn(d))
}
- return *v
+ return domains
}
diff --git a/providers/dns/bunny/bunny.toml b/providers/dns/bunny/bunny.toml
index 22b119bbb..758c4f202 100644
--- a/providers/dns/bunny/bunny.toml
+++ b/providers/dns/bunny/bunny.toml
@@ -6,16 +6,17 @@ Since = "v4.11.0"
Example = '''
BUNNY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-lego --email you@example.com --dns bunny -d '*.example.com' -d example.com run
+lego --dns bunny -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
BUNNY_API_KEY = "API key"
[Configuration.Additional]
- BUNNY_POLLING_INTERVAL = "Time between DNS propagation check"
- BUNNY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- BUNNY_TTL = "The TTL of the TXT record used for the DNS challenge"
+ BUNNY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ BUNNY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ BUNNY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ BUNNY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://docs.bunny.net/reference/dnszonepublic_index"
diff --git a/providers/dns/bunny/bunny_test.go b/providers/dns/bunny/bunny_test.go
index e5724bcd2..ca4e821e0 100644
--- a/providers/dns/bunny/bunny_test.go
+++ b/providers/dns/bunny/bunny_test.go
@@ -4,6 +4,9 @@ import (
"testing"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+ "github.com/nrdcg/bunny-go"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -37,6 +40,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -104,6 +108,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -117,9 +122,124 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
+
+func Test_findZone(t *testing.T) {
+ testCases := []struct {
+ desc string
+ domain string
+ items []*bunny.DNSZone
+ expected *bunny.DNSZone
+ }{
+ {
+ desc: "found subdomain",
+ domain: "_acme-challenge.foo.bar.example.com",
+ items: []*bunny.DNSZone{
+ {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")},
+ {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")},
+ {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")},
+ {ID: ptr.Pointer[int64](5), Domain: ptr.Pointer("bar.example.com")},
+ {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")},
+ },
+ expected: &bunny.DNSZone{
+ ID: ptr.Pointer[int64](5),
+ Domain: ptr.Pointer("bar.example.com"),
+ },
+ },
+ {
+ desc: "found the longest subdomain",
+ domain: "_acme-challenge.foo.bar.example.com",
+ items: []*bunny.DNSZone{
+ {ID: ptr.Pointer[int64](7), Domain: ptr.Pointer("foo.bar.example.com")},
+ {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")},
+ {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")},
+ {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")},
+ {ID: ptr.Pointer[int64](5), Domain: ptr.Pointer("bar.example.com")},
+ {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")},
+ },
+ expected: &bunny.DNSZone{
+ ID: ptr.Pointer[int64](7),
+ Domain: ptr.Pointer("foo.bar.example.com"),
+ },
+ },
+ {
+ desc: "found apex",
+ domain: "_acme-challenge.foo.bar.example.com",
+ items: []*bunny.DNSZone{
+ {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")},
+ {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")},
+ {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")},
+ {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")},
+ },
+ expected: &bunny.DNSZone{
+ ID: ptr.Pointer[int64](1),
+ Domain: ptr.Pointer("example.com"),
+ },
+ },
+ {
+ desc: "not found",
+ domain: "_acme-challenge.foo.bar.example.com",
+ items: []*bunny.DNSZone{
+ {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")},
+ {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")},
+ {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")},
+ },
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ zones := &bunny.DNSZones{Items: test.items}
+
+ zone := findZone(zones, test.domain)
+
+ assert.Equal(t, test.expected, zone)
+ })
+ }
+}
+
+func Test_possibleDomains(t *testing.T) {
+ testCases := []struct {
+ desc string
+ domain string
+ expected []string
+ }{
+ {
+ desc: "apex",
+ domain: "example.com",
+ expected: []string{"example.com"},
+ },
+ {
+ desc: "CCTLD",
+ domain: "example.co.uk",
+ expected: []string{"example.co.uk"},
+ },
+ {
+ desc: "long domain",
+ domain: "_acme-challenge.foo.bar.example.com",
+ expected: []string{"_acme-challenge.foo.bar.example.com", "foo.bar.example.com", "bar.example.com", "example.com"},
+ },
+ {
+ desc: "empty",
+ domain: "",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ domains := possibleDomains(test.domain)
+
+ assert.Equal(t, test.expected, domains)
+ })
+ }
+}
diff --git a/providers/dns/checkdomain/checkdomain.go b/providers/dns/checkdomain/checkdomain.go
index e2d7a05aa..4bc926ed9 100644
--- a/providers/dns/checkdomain/checkdomain.go
+++ b/providers/dns/checkdomain/checkdomain.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/checkdomain/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -72,6 +73,7 @@ func NewDNSProvider() (*DNSProvider, error) {
if err != nil {
return nil, fmt.Errorf("checkdomain: invalid %s: %w", EnvEndpoint, err)
}
+
config.Endpoint = endpoint
return NewDNSProviderConfig(config)
@@ -86,7 +88,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("checkdomain: missing token")
}
- client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token))
+ client := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.Token),
+ ),
+ )
if config.Endpoint != nil {
client.BaseURL = config.Endpoint
diff --git a/providers/dns/checkdomain/checkdomain.toml b/providers/dns/checkdomain/checkdomain.toml
index 309b1dfa1..0b93058ba 100644
--- a/providers/dns/checkdomain/checkdomain.toml
+++ b/providers/dns/checkdomain/checkdomain.toml
@@ -6,7 +6,7 @@ Since = "v3.3.0"
Example = '''
CHECKDOMAIN_TOKEN=yoursecrettoken \
-lego --email you@example.com --dns checkdomain -d '*.example.com' -d example.com run
+lego --dns checkdomain -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns checkdomain -d '*.example.com' -d example.com
CHECKDOMAIN_TOKEN = "API token"
[Configuration.Additional]
CHECKDOMAIN_ENDPOINT = "API endpoint URL, defaults to https://api.checkdomain.de"
- CHECKDOMAIN_TTL = "The TTL of the TXT record used for the DNS challenge"
- CHECKDOMAIN_POLLING_INTERVAL = "Time between DNS propagation check"
- CHECKDOMAIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CHECKDOMAIN_HTTP_TIMEOUT = "API request timeout, defaults to 30 seconds"
+ CHECKDOMAIN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ CHECKDOMAIN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 300)"
+ CHECKDOMAIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 7)"
+ CHECKDOMAIN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developer.checkdomain.de/reference/"
diff --git a/providers/dns/checkdomain/checkdomain_test.go b/providers/dns/checkdomain/checkdomain_test.go
index d9d0b62a6..b2c940f7a 100644
--- a/providers/dns/checkdomain/checkdomain_test.go
+++ b/providers/dns/checkdomain/checkdomain_test.go
@@ -46,6 +46,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -108,6 +109,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -121,6 +123,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/checkdomain/internal/client.go b/providers/dns/checkdomain/internal/client.go
index 74189dee4..68d090755 100644
--- a/providers/dns/checkdomain/internal/client.go
+++ b/providers/dns/checkdomain/internal/client.go
@@ -36,11 +36,11 @@ const maxInt = int((^uint(0)) >> 1)
// Client the Autodns API client.
type Client struct {
- domainIDMapping map[string]int
- domainIDMu sync.Mutex
-
BaseURL *url.URL
httpClient *http.Client
+
+ domainIDMapping map[string]int
+ domainIDMu sync.Mutex
}
// NewClient creates a new Client.
@@ -63,6 +63,7 @@ func (c *Client) GetDomainIDByName(ctx context.Context, name string) (int, error
c.domainIDMu.Lock()
id, ok := c.domainIDMapping[name]
c.domainIDMu.Unlock()
+
if ok {
return id, nil
}
@@ -100,6 +101,7 @@ func (c *Client) listDomains(ctx context.Context) ([]*Domain, error) {
totalPages := maxInt
var domainList []*Domain
+
for currentPage <= totalPages {
q.Set("page", strconv.Itoa(currentPage))
endpoint.RawQuery = q.Encode()
@@ -151,6 +153,7 @@ func (c *Client) CheckNameservers(ctx context.Context, domainID int) error {
}
var found1, found2 bool
+
for _, item := range info.Nameservers {
switch item.Name {
case ns1:
@@ -229,6 +232,7 @@ func (c *Client) getDomainInfo(ctx context.Context, domainID int) (*DomainRespon
}
var res DomainResponse
+
err = c.do(req, &res)
if err != nil {
return nil, err
@@ -242,6 +246,7 @@ func (c *Client) listRecords(ctx context.Context, domainID int, recordType strin
q := endpoint.Query()
q.Set("limit", strconv.Itoa(maxLimit))
+
if recordType != "" {
q.Set("type", recordType)
}
@@ -250,6 +255,7 @@ func (c *Client) listRecords(ctx context.Context, domainID int, recordType strin
totalPages := maxInt
var recordList []*Record
+
for currentPage <= totalPages {
q.Set("page", strconv.Itoa(currentPage))
endpoint.RawQuery = q.Encode()
diff --git a/providers/dns/checkdomain/internal/client_test.go b/providers/dns/checkdomain/internal/client_test.go
index 3f6a7e7a7..68e4f1244 100644
--- a/providers/dns/checkdomain/internal/client_test.go
+++ b/providers/dns/checkdomain/internal/client_test.go
@@ -1,138 +1,66 @@
package internal
import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "reflect"
"testing"
"github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
+ client.BaseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
- client.BaseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func checkAuthorizationHeader(req *http.Request) error {
- val := req.Header.Get("Authorization")
- if val != "Bearer secret" {
- return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret")
- }
- return nil
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer secret"))
}
func TestClient_GetDomainIDByName(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains",
+ servermock.JSONEncode(DomainListingResponse{
+ Embedded: EmbeddedDomainList{Domains: []*Domain{
+ {ID: 1, Name: "test.com"},
+ {ID: 2, Name: "test.org"},
+ }},
+ })).
+ Build(t)
- mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- err := checkAuthorizationHeader(req)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- domainList := DomainListingResponse{
- Embedded: EmbeddedDomainList{Domains: []*Domain{
- {ID: 1, Name: "test.com"},
- {ID: 2, Name: "test.org"},
- }},
- }
-
- err = json.NewEncoder(rw).Encode(domainList)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- id, err := client.GetDomainIDByName(context.Background(), "test.com")
+ id, err := client.GetDomainIDByName(t.Context(), "test.com")
require.NoError(t, err)
assert.Equal(t, 1, id)
}
func TestClient_CheckNameservers(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/1/nameservers",
+ servermock.JSONEncode(NameserverResponse{
+ Nameservers: []*Nameserver{
+ {Name: ns1},
+ {Name: ns2},
+ // {Name: "ns.fake.de"},
+ },
+ })).
+ Build(t)
- mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- err := checkAuthorizationHeader(req)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- nsResp := NameserverResponse{
- Nameservers: []*Nameserver{
- {Name: ns1},
- {Name: ns2},
- // {Name: "ns.fake.de"},
- },
- }
-
- err = json.NewEncoder(rw).Encode(nsResp)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- err := client.CheckNameservers(context.Background(), 1)
+ err := client.CheckNameservers(t.Context(), 1)
require.NoError(t, err)
}
func TestClient_CreateRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- err := checkAuthorizationHeader(req)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- content, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- if string(bytes.TrimSpace(content)) != `{"name":"test.com","value":"value","ttl":300,"priority":0,"type":"TXT"}` {
- http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /v1/domains/1/nameservers/records", nil,
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
record := &Record{
Name: "test.com",
@@ -141,121 +69,51 @@ func TestClient_CreateRecord(t *testing.T) {
Value: "value",
}
- err := client.CreateRecord(context.Background(), 1, record)
+ err := client.CreateRecord(t.Context(), 1, record)
require.NoError(t, err)
}
func TestClient_DeleteTXTRecord(t *testing.T) {
- client, mux := setupTest(t)
-
domainName := "lego.test"
recordValue := "test"
- records := []*Record{
- {
- Name: "_acme-challenge",
- Value: recordValue,
- Type: "TXT",
- },
- {
- Name: "_acme-challenge",
- Value: recordValue,
- Type: "A",
- },
- {
- Name: "foobar",
- Value: recordValue,
- Type: "TXT",
- },
- }
-
- expectedRecords := []*Record{
- {
- Name: "_acme-challenge",
- Value: recordValue,
- Type: "A",
- },
- {
- Name: "foobar",
- Value: recordValue,
- Type: "TXT",
- },
- }
-
- mux.HandleFunc("/v1/domains/1", func(rw http.ResponseWriter, req *http.Request) {
- err := checkAuthorizationHeader(req)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- resp := DomainResponse{
- ID: 1,
- Name: domainName,
- }
-
- err = json.NewEncoder(rw).Encode(resp)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- nsResp := NameserverResponse{
- Nameservers: []*Nameserver{{Name: ns1}, {Name: ns2}},
- }
-
- err := json.NewEncoder(rw).Encode(nsResp)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) {
- switch req.Method {
- case http.MethodGet:
- resp := RecordListingResponse{
+ client := mockBuilder().
+ Route("GET /v1/domains/",
+ servermock.JSONEncode(DomainResponse{
+ ID: 1,
+ Name: domainName,
+ })).
+ Route("GET /v1/domains/1/nameservers",
+ servermock.JSONEncode(NameserverResponse{
+ Nameservers: []*Nameserver{{Name: ns1}, {Name: ns2}},
+ })).
+ Route("GET /v1/domains/1/nameservers/records",
+ servermock.JSONEncode(RecordListingResponse{
Embedded: EmbeddedRecordList{
- Records: records,
+ Records: []*Record{
+ {
+ Name: "_acme-challenge",
+ Value: recordValue,
+ Type: "TXT",
+ },
+ {
+ Name: "_acme-challenge",
+ Value: recordValue,
+ Type: "A",
+ },
+ {
+ Name: "foobar",
+ Value: recordValue,
+ Type: "TXT",
+ },
+ },
},
- }
-
- err := json.NewEncoder(rw).Encode(resp)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- case http.MethodPut:
- var records []*Record
- err := json.NewDecoder(req.Body).Decode(&records)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- if len(records) == 0 {
- http.Error(rw, "empty request body", http.StatusBadRequest)
- return
- }
-
- if !reflect.DeepEqual(expectedRecords, records) {
- http.Error(rw, fmt.Sprintf("invalid records: %v", records), http.StatusBadRequest)
- return
- }
- default:
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- }
- })
+ })).
+ Route("PUT /v1/domains/1/nameservers/records", nil,
+ servermock.CheckRequestJSONBodyFromFixture("delete_txt_record-request.json")).
+ Build(t)
info := dns01.GetChallengeInfo(domainName, "abc")
- err := client.DeleteTXTRecord(context.Background(), 1, info.EffectiveFQDN, recordValue)
+ err := client.DeleteTXTRecord(t.Context(), 1, info.EffectiveFQDN, recordValue)
require.NoError(t, err)
}
diff --git a/providers/dns/checkdomain/internal/fixtures/create_record-request.json b/providers/dns/checkdomain/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..af1d50625
--- /dev/null
+++ b/providers/dns/checkdomain/internal/fixtures/create_record-request.json
@@ -0,0 +1,7 @@
+{
+ "name": "test.com",
+ "value": "value",
+ "ttl": 300,
+ "priority": 0,
+ "type": "TXT"
+}
diff --git a/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json b/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json
new file mode 100644
index 000000000..67cb2570c
--- /dev/null
+++ b/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json
@@ -0,0 +1,16 @@
+[
+ {
+ "name": "_acme-challenge",
+ "value": "test",
+ "ttl": 0,
+ "priority": 0,
+ "type": "A"
+ },
+ {
+ "name": "foobar",
+ "value": "test",
+ "ttl": 0,
+ "priority": 0,
+ "type": "TXT"
+ }
+]
diff --git a/providers/dns/civo/civo.go b/providers/dns/civo/civo.go
index e2ee41bd4..dfb7c307f 100644
--- a/providers/dns/civo/civo.go
+++ b/providers/dns/civo/civo.go
@@ -2,14 +2,17 @@
package civo
import (
+ "context"
"errors"
"fmt"
+ "net/http"
"time"
- "github.com/civo/civogo"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/civo/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -21,6 +24,7 @@ const (
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
const (
@@ -33,11 +37,12 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- ProjectID string
- Token string
+ Token string
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
+ HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -46,13 +51,16 @@ func NewDefaultConfig() *Config {
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
- client *civogo.Client
+ client *internal.Client
}
// NewDNSProvider returns a DNSProvider instance configured for CIVO.
@@ -84,7 +92,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
// Create a Civo client - DNS is region independent, we can use any region
- client, err := civogo.NewClient(config.Token, "LON1")
+ client, err := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.Token),
+ ),
+ "LON1")
if err != nil {
return nil, fmt.Errorf("civo: %w", err)
}
@@ -96,6 +108,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
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("civo: could not find zone for domain %q: %w", domain, err)
@@ -103,7 +117,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
zone := dns01.UnFqdn(authZone)
- dnsDomain, err := d.client.GetDNSDomain(zone)
+ domainID, err := d.getDomainIDByName(ctx, zone)
if err != nil {
return fmt.Errorf("civo: %w", err)
}
@@ -113,10 +127,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("civo: %w", err)
}
- _, err = d.client.CreateDNSRecord(dnsDomain.ID, &civogo.DNSRecordConfig{
+ _, err = d.client.CreateDNSRecord(ctx, domainID, internal.Record{
Name: subDomain,
Value: info.Value,
- Type: civogo.DNSRecordTypeTXT,
+ Type: "TXT",
TTL: d.config.TTL,
})
if err != nil {
@@ -130,6 +144,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
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("civo: could not find zone for domain %q: %w", domain, err)
@@ -137,12 +153,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
zone := dns01.UnFqdn(authZone)
- dnsDomain, err := d.client.GetDNSDomain(zone)
+ domainID, err := d.getDomainIDByName(ctx, zone)
if err != nil {
return fmt.Errorf("civo: %w", err)
}
- dnsRecords, err := d.client.ListDNSRecords(dnsDomain.ID)
+ dnsRecords, err := d.client.ListDNSRecords(ctx, domainID)
if err != nil {
return fmt.Errorf("civo: %w", err)
}
@@ -152,7 +168,8 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("civo: %w", err)
}
- var dnsRecord civogo.DNSRecord
+ var dnsRecord internal.Record
+
for _, entry := range dnsRecords {
if entry.Name == subDomain && entry.Value == info.Value {
dnsRecord = entry
@@ -160,7 +177,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
}
- _, err = d.client.DeleteDNSRecord(&dnsRecord)
+ err = d.client.DeleteDNSRecord(ctx, dnsRecord)
if err != nil {
return fmt.Errorf("civo: %w", err)
}
@@ -173,3 +190,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
+
+func (d *DNSProvider) getDomainIDByName(ctx context.Context, domain string) (string, error) {
+ domains, err := d.client.ListDomains(ctx)
+ if err != nil {
+ return "", fmt.Errorf("list domains: %w", err)
+ }
+
+ for _, d := range domains {
+ if d.Name == domain {
+ return d.ID, nil
+ }
+ }
+
+ return "", fmt.Errorf("domain %q not found", domain)
+}
diff --git a/providers/dns/civo/civo.toml b/providers/dns/civo/civo.toml
index fe29364a4..b525712c8 100644
--- a/providers/dns/civo/civo.toml
+++ b/providers/dns/civo/civo.toml
@@ -6,16 +6,16 @@ Since = "v4.9.0"
Example = '''
CIVO_TOKEN=xxxxxx \
-lego --email you@example.com --dns civo -d '*.example.com' -d example.com run
+lego --dns civo -d '*.example.com' -d example.com run
'''
[Configuration]
- [Configuration.Credentials]
- CIVO_TOKEN = "Authentication token"
- [Configuration.Additional]
- CIVO_POLLING_INTERVAL = "Time between DNS propagation check"
- CIVO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CIVO_TTL = "The TTL of the TXT record used for the DNS challenge"
+ [Configuration.Credentials]
+ CIVO_TOKEN = "Authentication token"
+ [Configuration.Additional]
+ CIVO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)"
+ CIVO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ CIVO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
[Links]
API = "https://www.civo.com/api/dns"
diff --git a/providers/dns/civo/civo_test.go b/providers/dns/civo/civo_test.go
index 333cf0b1f..416dbac1d 100644
--- a/providers/dns/civo/civo_test.go
+++ b/providers/dns/civo/civo_test.go
@@ -2,10 +2,13 @@ package civo
import (
"fmt"
+ "net/http/httptest"
+ "net/url"
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -39,6 +42,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -103,6 +107,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -116,6 +121,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -124,3 +130,66 @@ func TestLiveCleanUp(t *testing.T) {
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.Token = "secret"
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("Authorization", "Bearer secret").
+ WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ // https://www.civo.com/api/dns#list-domain-names
+ Route("GET /dns",
+ servermock.ResponseFromInternal("list_domain_names.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("region", "LON1")).
+ // https://www.civo.com/api/dns#create-a-new-dns-record
+ Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+ servermock.ResponseFromInternal("create_dns_record.json"),
+ servermock.CheckRequestJSONBodyFromInternal("create_dns_record-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abd", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ // https://www.civo.com/api/dns#list-domain-names
+ Route("GET /dns",
+ servermock.ResponseFromInternal("list_domain_names.json"),
+ servermock.CheckQueryParameter().
+ With("region", "LON1")).
+ // https://www.civo.com/api/dns#list-dns-records
+ Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+ servermock.ResponseFromInternal("list_dns_records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("region", "LON1")).
+ // https://www.civo.com/api/dns#deleting-a-dns-record
+ Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+ servermock.ResponseFromInternal("delete_dns_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("region", "LON1")).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abd", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/civo/internal/client.go b/providers/dns/civo/internal/client.go
new file mode 100644
index 000000000..dc1d57793
--- /dev/null
+++ b/providers/dns/civo/internal/client.go
@@ -0,0 +1,213 @@
+/*
+Package internal Civo API client.
+
+Because the dependencies on k8s, the official client cannot be used.
+- https://github.com/civo/civogo/blob/v0.2.99/go.mod -> k8s.io/client-go
+- https://github.com/civo/civogo/blob/v0.3.34/go.mod -> k8s.io/api
+- https://github.com/civo/civogo/blob/v0.3.38/go.mod -> k8s.io/api + k8s.io/apimachinery
+- Current version -> https://github.com/civo/civogo/blob/v0.6.1/go.mod
+*/
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ "golang.org/x/oauth2"
+)
+
+const defaultBaseURL = "https://api.civo.com/v2"
+
+// Client the Civo API client.
+type Client struct {
+ region string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(hc *http.Client, region string) (*Client, error) {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ if hc == nil {
+ hc = &http.Client{Timeout: 10 * time.Second}
+ }
+
+ return &Client{
+ region: region,
+ BaseURL: baseURL,
+ HTTPClient: hc,
+ }, nil
+}
+
+// ListDomains a list of all domain names within the account.
+// https://www.civo.com/api/dns#list-domain-names
+func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
+ endpoint := c.BaseURL.JoinPath("dns")
+
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result []Domain
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// ListDNSRecords a list of all DNS records in the specified domain.
+// https://www.civo.com/api/dns#list-dns-records
+func (c *Client) ListDNSRecords(ctx context.Context, domainID string) ([]Record, error) {
+ endpoint := c.BaseURL.JoinPath("dns", domainID, "records")
+
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result []Record
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// CreateDNSRecord creates DNS records for a specific domain.
+// https://www.civo.com/api/dns#create-a-new-dns-record
+func (c *Client) CreateDNSRecord(ctx context.Context, domainID string, record Record) (*Record, error) {
+ endpoint := c.BaseURL.JoinPath("dns", domainID, "records")
+
+ req, err := c.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
+}
+
+// DeleteDNSRecord remove a DNS record from a domain.
+// https://www.civo.com/api/dns#deleting-a-dns-record
+func (c *Client) DeleteDNSRecord(ctx context.Context, record Record) error {
+ endpoint := c.BaseURL.JoinPath("dns", record.DomainID, "records", record.ID)
+
+ req, err := c.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 {
+ 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 (c *Client) 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)
+ }
+ }
+
+ if method == http.MethodGet || method == http.MethodDelete {
+ query := endpoint.Query()
+ query.Set("region", c.region)
+
+ endpoint.RawQuery = query.Encode()
+ }
+
+ 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")
+ }
+
+ useragent.SetHeader(req.Header)
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
+
+// OAuthStaticAccessToken Authorization header.
+// https://www.civo.com/api#authentication
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+ if client == nil {
+ client = &http.Client{Timeout: 5 * time.Second}
+ }
+
+ client.Transport = &oauth2.Transport{
+ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+ Base: client.Transport,
+ }
+
+ return client
+}
diff --git a/providers/dns/civo/internal/client_test.go b/providers/dns/civo/internal/client_test.go
new file mode 100644
index 000000000..ad56b75de
--- /dev/null
+++ b/providers/dns/civo/internal/client_test.go
@@ -0,0 +1,154 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "LON1")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("Authorization", "Bearer secret").
+ WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`),
+ )
+}
+
+func TestClient_ListDomains(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns",
+ servermock.ResponseFromFixture("list_domain_names.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("region", "LON1")).
+ Build(t)
+
+ domains, err := client.ListDomains(t.Context())
+ require.NoError(t, err)
+
+ expected := []Domain{{
+ ID: "7088fcea-7658-43e6-97fa-273f901978fd",
+ AccountID: "e7e8386e-434e-482f-95e0-c406e5d564c2",
+ Name: "example.com",
+ }}
+
+ assert.Equal(t, expected, domains)
+}
+
+func TestClient_ListDNSRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+ servermock.ResponseFromFixture("list_dns_records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("region", "LON1")).
+ Build(t)
+
+ records, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {
+ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+ DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+ Name: "_acme-challenge",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Type: "txt",
+ TTL: 600,
+ },
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_ListDNSRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd")
+ require.EqualError(t, err, "database_account_not_found: Failed to find the account within the internal database")
+}
+
+func TestClient_ListDNSRecords_error_raw(t *testing.T) {
+ // the API says:
+ // > 4xx/5xx status may not be JSON, unless it's obvious that the response should be parsed for a specific reason.
+ // > So, for example, 404 Not Found pages are a standard page of text
+ // > but 403 Unauthorized requests may have a reason attribute available in the JSON object.
+ // https://www.civo.com/api#parameters-and-responses
+ client := mockBuilder().
+ Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+ servermock.RawStringResponse(http.StatusText(http.StatusNotFound)).
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
+
+ _, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd")
+ require.EqualError(t, err, "unexpected status code: [status code: 404] body: Not Found")
+}
+
+func TestClient_CreateDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+ servermock.ResponseFromFixture("create_dns_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_dns_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Type: "TXT",
+ TTL: 600,
+ }
+
+ newRecord, err := client.CreateDNSRecord(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd", record)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+ DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+ Name: "_acme-challenge",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Type: "txt",
+ TTL: 600,
+ }
+
+ assert.Equal(t, expected, newRecord)
+}
+
+func TestClient_DeleteDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+ servermock.ResponseFromFixture("delete_dns_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("region", "LON1")).
+ Build(t)
+
+ record := Record{
+ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+ DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+ Name: "_acme-challenge",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Type: "TXT",
+ TTL: 600,
+ }
+
+ err := client.DeleteDNSRecord(t.Context(), record)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/civo/internal/fixtures/create_dns_record-request.json b/providers/dns/civo/internal/fixtures/create_dns_record-request.json
new file mode 100644
index 000000000..ec881e142
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/create_dns_record-request.json
@@ -0,0 +1,6 @@
+{
+ "type": "TXT",
+ "name": "_acme-challenge",
+ "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 600
+}
diff --git a/providers/dns/civo/internal/fixtures/create_dns_record.json b/providers/dns/civo/internal/fixtures/create_dns_record.json
new file mode 100644
index 000000000..d9557cf23
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/create_dns_record.json
@@ -0,0 +1,11 @@
+{
+ "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+ "created_at": "2019-04-11T12:47:56.000+01:00",
+ "updated_at": "2019-04-11T12:47:56.000+01:00",
+ "account_id": null,
+ "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+ "name": "_acme-challenge",
+ "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "type": "txt",
+ "ttl": 600
+}
diff --git a/providers/dns/civo/internal/fixtures/delete_dns_record.json b/providers/dns/civo/internal/fixtures/delete_dns_record.json
new file mode 100644
index 000000000..80bf76ad5
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/delete_dns_record.json
@@ -0,0 +1,3 @@
+{
+ "result": "success"
+}
diff --git a/providers/dns/civo/internal/fixtures/error.json b/providers/dns/civo/internal/fixtures/error.json
new file mode 100644
index 000000000..0a55e079f
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+ "code": "database_account_not_found",
+ "reason": "Failed to find the account within the internal database"
+}
diff --git a/providers/dns/civo/internal/fixtures/list_dns_records.json b/providers/dns/civo/internal/fixtures/list_dns_records.json
new file mode 100644
index 000000000..0c4e54737
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/list_dns_records.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+ "created_at": "2019-04-11T12:47:56.000+01:00",
+ "updated_at": "2019-04-11T12:47:56.000+01:00",
+ "account_id": null,
+ "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+ "name": "_acme-challenge",
+ "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "type": "txt",
+ "ttl": 600
+ }
+]
diff --git a/providers/dns/civo/internal/fixtures/list_domain_names.json b/providers/dns/civo/internal/fixtures/list_domain_names.json
new file mode 100644
index 000000000..909cdca04
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/list_domain_names.json
@@ -0,0 +1,7 @@
+[
+ {
+ "id": "7088fcea-7658-43e6-97fa-273f901978fd",
+ "account_id": "e7e8386e-434e-482f-95e0-c406e5d564c2",
+ "name": "example.com"
+ }
+]
diff --git a/providers/dns/civo/internal/types.go b/providers/dns/civo/internal/types.go
new file mode 100644
index 000000000..d173e2fcd
--- /dev/null
+++ b/providers/dns/civo/internal/types.go
@@ -0,0 +1,28 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+ Code string `json:"code"`
+ Reason string `json:"reason"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%s: %s", a.Code, a.Reason)
+}
+
+type Record struct {
+ ID string `json:"id,omitempty"`
+ AccountID string `json:"account_id,omitempty"`
+ DomainID string `json:"domain_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Value string `json:"value,omitempty"`
+ Type string `json:"type,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+}
+
+type Domain struct {
+ ID string `json:"id,omitempty"`
+ AccountID string `json:"account_id,omitempty"`
+ Name string `json:"name,omitempty"`
+}
diff --git a/providers/dns/clouddns/clouddns.go b/providers/dns/clouddns/clouddns.go
index 379dd3cf2..77b673738 100644
--- a/providers/dns/clouddns/clouddns.go
+++ b/providers/dns/clouddns/clouddns.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/clouddns/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -93,6 +94,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{client: client, config: config}, nil
}
diff --git a/providers/dns/clouddns/clouddns.toml b/providers/dns/clouddns/clouddns.toml
index 1927e21b5..6f516e834 100644
--- a/providers/dns/clouddns/clouddns.toml
+++ b/providers/dns/clouddns/clouddns.toml
@@ -8,7 +8,7 @@ Example = '''
CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \
CLOUDDNS_EMAIL=you@example.com \
CLOUDDNS_PASSWORD=b9841238feb177a84330f \
-lego --email you@example.com --dns clouddns -d '*.example.com' -d example.com run
+lego --dns clouddns -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,10 @@ lego --email you@example.com --dns clouddns -d '*.example.com' -d example.com ru
CLOUDDNS_EMAIL = "Account email"
CLOUDDNS_PASSWORD = "Account password"
[Configuration.Additional]
- CLOUDDNS_POLLING_INTERVAL = "Time between DNS propagation check"
- CLOUDDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CLOUDDNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- CLOUDDNS_HTTP_TIMEOUT = "API request timeout"
+ CLOUDDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ CLOUDDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ CLOUDDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ CLOUDDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://admin.vshosting.cloud/clouddns/swagger/"
diff --git a/providers/dns/clouddns/clouddns_test.go b/providers/dns/clouddns/clouddns_test.go
index d7bfc4a1f..f1e2a196e 100644
--- a/providers/dns/clouddns/clouddns_test.go
+++ b/providers/dns/clouddns/clouddns_test.go
@@ -63,6 +63,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -148,6 +149,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -161,6 +163,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/clouddns/internal/client.go b/providers/dns/clouddns/internal/client.go
index cd3da50c7..9fb6902de 100644
--- a/providers/dns/clouddns/internal/client.go
+++ b/providers/dns/clouddns/internal/client.go
@@ -122,6 +122,7 @@ func (c *Client) getDomain(ctx context.Context, zone string) (Domain, error) {
}
var result SearchResponse
+
err = c.do(req, &result)
if err != nil {
return Domain{}, err
@@ -143,6 +144,7 @@ func (c *Client) getRecord(ctx context.Context, domainID, recordName string) (Re
}
var result DomainInfo
+
err = c.do(req, &result)
if err != nil {
return Record{}, err
@@ -232,6 +234,7 @@ 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)
diff --git a/providers/dns/clouddns/internal/client_test.go b/providers/dns/clouddns/internal/client_test.go
index 2a4891cce..a5b780e42 100644
--- a/providers/dns/clouddns/internal/client_test.go
+++ b/providers/dns/clouddns/internal/client_test.go
@@ -1,130 +1,65 @@
package internal
import (
- "context"
- "encoding/json"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("clientID", "email@example.com", "secret", 300)
+ client.HTTPClient = server.Client()
+ client.apiBaseURL, _ = url.Parse(server.URL + "/api")
+ client.loginURL, _ = url.Parse(server.URL + "/login")
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("clientID", "email@example.com", "secret", 300)
- client.HTTPClient = server.Client()
- client.apiBaseURL, _ = url.Parse(server.URL + "/api")
- client.loginURL, _ = url.Parse(server.URL + "/login")
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestClient_AddRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /api/domain/search",
+ servermock.ResponseFromFixture("domain_search.json"),
+ servermock.CheckRequestJSONBodyFromFixture("domain_search-request.json")).
+ Route("POST /api/record-txt", nil,
+ servermock.CheckRequestJSONBodyFromFixture("record_txt-request.json")).
+ Route("PUT /api/domain/A/publish", nil,
+ servermock.CheckRequestJSONBodyFromFixture("publish-request.json")).
+ Route("POST /login",
+ servermock.ResponseFromFixture("login.json"),
+ servermock.CheckRequestJSONBodyFromFixture("login-request.json")).
+ Build(t)
- mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) {
- response := SearchResponse{
- Items: []Domain{
- {
- ID: "A",
- DomainName: "example.com",
- },
- },
- }
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
+ require.NoError(t, err)
- err := json.NewEncoder(rw).Encode(response)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
- mux.HandleFunc("/api/record-txt", func(rw http.ResponseWriter, req *http.Request) {})
- mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {})
- mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) {
- response := AuthResponse{
- Auth: Auth{
- AccessToken: "at",
- RefreshToken: "",
- },
- }
-
- err := json.NewEncoder(rw).Encode(response)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- err := client.AddRecord(context.Background(), "example.com", "_acme-challenge.example.com", "txt")
+ err = client.AddRecord(ctx, "example.com", "_acme-challenge.example.com", "txt")
require.NoError(t, err)
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /api/domain/search",
+ servermock.ResponseFromFixture("domain_search.json"),
+ servermock.CheckRequestJSONBodyFromFixture("domain_search-request.json")).
+ Route("GET /api/domain/A",
+ servermock.ResponseFromFixture("domain-request.json")).
+ Route("DELETE /api/record/R01", nil).
+ Route("PUT /api/domain/A/publish", nil,
+ servermock.CheckRequestJSONBodyFromFixture("publish-request.json")).
+ Route("POST /login",
+ servermock.ResponseFromFixture("login.json"),
+ servermock.CheckRequestJSONBodyFromFixture("login-request.json")).
+ Build(t)
- mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) {
- response := SearchResponse{
- Items: []Domain{
- {
- ID: "A",
- DomainName: "example.com",
- },
- },
- }
-
- err := json.NewEncoder(rw).Encode(response)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
- mux.HandleFunc("/api/domain/A", func(rw http.ResponseWriter, req *http.Request) {
- response := DomainInfo{
- ID: "Z",
- DomainName: "example.com",
- LastDomainRecordList: []Record{
- {
- ID: "R01",
- DomainID: "A",
- Name: "_acme-challenge.example.com",
- Value: "txt",
- Type: "TXT",
- },
- },
- SoaTTL: 300,
- }
-
- err := json.NewEncoder(rw).Encode(response)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
- mux.HandleFunc("/api/record/R01", func(rw http.ResponseWriter, req *http.Request) {})
- mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {})
- mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) {
- response := AuthResponse{
- Auth: Auth{
- AccessToken: "at",
- RefreshToken: "",
- },
- }
-
- err := json.NewEncoder(rw).Encode(response)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- ctx, err := client.CreateAuthenticatedContext(context.Background())
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
err = client.DeleteRecord(ctx, "example.com", "_acme-challenge.example.com")
diff --git a/providers/dns/clouddns/internal/fixtures/domain-request.json b/providers/dns/clouddns/internal/fixtures/domain-request.json
new file mode 100644
index 000000000..00f60b9bd
--- /dev/null
+++ b/providers/dns/clouddns/internal/fixtures/domain-request.json
@@ -0,0 +1,14 @@
+{
+ "id": "Z",
+ "domainName": "example.com",
+ "lastDomainRecordList": [
+ {
+ "id": "R01",
+ "domainId": "A",
+ "name": "_acme-challenge.example.com",
+ "value": "txt",
+ "type": "TXT"
+ }
+ ],
+ "soaTtl": 300
+}
diff --git a/providers/dns/clouddns/internal/fixtures/domain_search-request.json b/providers/dns/clouddns/internal/fixtures/domain_search-request.json
new file mode 100644
index 000000000..89043dc3a
--- /dev/null
+++ b/providers/dns/clouddns/internal/fixtures/domain_search-request.json
@@ -0,0 +1,14 @@
+{
+ "search": [
+ {
+ "name": "clientId",
+ "operator": "eq",
+ "value": "clientID"
+ },
+ {
+ "name": "domainName",
+ "operator": "eq",
+ "value": "example.com"
+ }
+ ]
+}
diff --git a/providers/dns/clouddns/internal/fixtures/domain_search.json b/providers/dns/clouddns/internal/fixtures/domain_search.json
new file mode 100644
index 000000000..4ee454732
--- /dev/null
+++ b/providers/dns/clouddns/internal/fixtures/domain_search.json
@@ -0,0 +1,8 @@
+{
+ "items": [
+ {
+ "id": "A",
+ "domainName": "example.com"
+ }
+ ]
+}
diff --git a/providers/dns/clouddns/internal/fixtures/login-request.json b/providers/dns/clouddns/internal/fixtures/login-request.json
new file mode 100644
index 000000000..132577e6b
--- /dev/null
+++ b/providers/dns/clouddns/internal/fixtures/login-request.json
@@ -0,0 +1,4 @@
+{
+ "email": "email@example.com",
+ "password": "secret"
+}
diff --git a/providers/dns/clouddns/internal/fixtures/login.json b/providers/dns/clouddns/internal/fixtures/login.json
new file mode 100644
index 000000000..e72ffb19b
--- /dev/null
+++ b/providers/dns/clouddns/internal/fixtures/login.json
@@ -0,0 +1,5 @@
+{
+ "auth": {
+ "accessToken": "at"
+ }
+}
diff --git a/providers/dns/clouddns/internal/fixtures/publish-request.json b/providers/dns/clouddns/internal/fixtures/publish-request.json
new file mode 100644
index 000000000..383e26958
--- /dev/null
+++ b/providers/dns/clouddns/internal/fixtures/publish-request.json
@@ -0,0 +1,3 @@
+{
+ "soaTtl": 300
+}
diff --git a/providers/dns/clouddns/internal/fixtures/record_txt-request.json b/providers/dns/clouddns/internal/fixtures/record_txt-request.json
new file mode 100644
index 000000000..cbc2a32a0
--- /dev/null
+++ b/providers/dns/clouddns/internal/fixtures/record_txt-request.json
@@ -0,0 +1,6 @@
+{
+ "domainId": "A",
+ "name": "_acme-challenge.example.com",
+ "value": "txt",
+ "type": "TXT"
+}
diff --git a/providers/dns/clouddns/internal/identity.go b/providers/dns/clouddns/internal/identity.go
index 4ea5c5049..6b20ad814 100644
--- a/providers/dns/clouddns/internal/identity.go
+++ b/providers/dns/clouddns/internal/identity.go
@@ -20,6 +20,7 @@ func (c *Client) login(ctx context.Context) (*AuthResponse, error) {
}
var result AuthResponse
+
err = c.do(req, &result)
if err != nil {
return nil, err
diff --git a/providers/dns/clouddns/internal/identity_test.go b/providers/dns/clouddns/internal/identity_test.go
index 3c727448d..267f73335 100644
--- a/providers/dns/clouddns/internal/identity_test.go
+++ b/providers/dns/clouddns/internal/identity_test.go
@@ -1,41 +1,22 @@
package internal
import (
- "context"
- "encoding/json"
- "net/http"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_CreateAuthenticatedContext(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /login",
+ servermock.ResponseFromFixture("login.json"),
+ servermock.CheckRequestJSONBodyFromFixture("login-request.json")).
+ Route("DELETE /api/record/xxx", nil).
+ Build(t)
- mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) {
- response := AuthResponse{
- Auth: Auth{
- AccessToken: "at",
- RefreshToken: "",
- },
- }
-
- err := json.NewEncoder(rw).Encode(response)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
- mux.HandleFunc("/api/record/xxx", func(rw http.ResponseWriter, req *http.Request) {
- authorization := req.Header.Get(authorizationHeader)
- if authorization != "Bearer at" {
- http.Error(rw, "invalid credential: "+authorization, http.StatusUnauthorized)
- return
- }
- })
-
- ctx, err := client.CreateAuthenticatedContext(context.Background())
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
at := getAccessToken(ctx)
diff --git a/providers/dns/clouddns/internal/types.go b/providers/dns/clouddns/internal/types.go
index a53c958a7..9de11d848 100644
--- a/providers/dns/clouddns/internal/types.go
+++ b/providers/dns/clouddns/internal/types.go
@@ -21,7 +21,7 @@ type Authorization struct {
}
type AuthResponse struct {
- Auth Auth `json:"auth,omitempty"`
+ Auth Auth `json:"auth"`
}
type Auth struct {
diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go
index ded6150e3..98b3495bb 100644
--- a/providers/dns/cloudflare/cloudflare.go
+++ b/providers/dns/cloudflare/cloudflare.go
@@ -11,22 +11,25 @@ import (
"sync"
"time"
- "github.com/cloudflare/cloudflare-go"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal"
)
// Environment variables names.
const (
envNamespace = "CLOUDFLARE_"
- EnvEmail = envNamespace + "EMAIL"
- EnvAPIKey = envNamespace + "API_KEY"
+ EnvEmail = envNamespace + "EMAIL"
+ EnvAPIKey = envNamespace + "API_KEY"
+
EnvDNSAPIToken = envNamespace + "DNS_API_TOKEN"
EnvZoneAPIToken = envNamespace + "ZONE_API_TOKEN"
+ EnvBaseURL = envNamespace + "BASE_URL"
+
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
@@ -53,6 +56,8 @@ type Config struct {
AuthToken string
ZoneToken string
+ BaseURL string
+
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
@@ -64,7 +69,7 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOneWithFallback(EnvTTL, minTTL, 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)),
+ PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)),
HTTPClient: &http.Client{
Timeout: env.GetOneWithFallback(EnvHTTPTimeout, 30*time.Second, env.ParseSecond, altEnvName(EnvHTTPTimeout)),
},
@@ -99,6 +104,7 @@ func NewDNSProvider() (*DNSProvider, error) {
)
if err != nil {
var errT error
+
values, errT = env.GetWithFallback(
[]string{EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)},
[]string{EnvZoneAPIToken, altEnvName(EnvZoneAPIToken), EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)},
@@ -114,6 +120,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config.AuthKey = values[EnvAPIKey]
config.AuthToken = values[EnvDNSAPIToken]
config.ZoneToken = values[EnvZoneAPIToken]
+ config.BaseURL = env.GetOrFile(EnvBaseURL)
return NewDNSProviderConfig(config)
}
@@ -148,6 +155,8 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// 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)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -155,19 +164,19 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err)
}
- zoneID, err := d.client.ZoneIDByName(authZone)
+ zoneID, err := d.client.ZoneIDByName(ctx, authZone)
if err != nil {
return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err)
}
- dnsRecord := cloudflare.CreateDNSRecordParams{
+ dnsRecord := internal.Record{
Type: "TXT",
Name: dns01.UnFqdn(info.EffectiveFQDN),
- Content: info.Value,
+ Content: `"` + info.Value + `"`,
TTL: d.config.TTL,
}
- response, err := d.client.CreateDNSRecord(context.Background(), zoneID, dnsRecord)
+ response, err := d.client.CreateDNSRecord(ctx, zoneID, dnsRecord)
if err != nil {
return fmt.Errorf("cloudflare: failed to create TXT record: %w", err)
}
@@ -183,6 +192,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)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -190,7 +201,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err)
}
- zoneID, err := d.client.ZoneIDByName(authZone)
+ zoneID, err := d.client.ZoneIDByName(ctx, authZone)
if err != nil {
return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err)
}
@@ -199,13 +210,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("cloudflare: unknown record ID for '%s'", info.EffectiveFQDN)
}
- err = d.client.DeleteDNSRecord(context.Background(), zoneID, recordID)
+ err = d.client.DeleteDNSRecord(ctx, zoneID, recordID)
if err != nil {
- log.Printf("cloudflare: failed to delete TXT record: %w", err)
+ log.Printf("cloudflare: failed to delete TXT record: %v", err)
}
// Delete record ID from map
diff --git a/providers/dns/cloudflare/cloudflare.toml b/providers/dns/cloudflare/cloudflare.toml
index 0a8295f69..c46130fe6 100644
--- a/providers/dns/cloudflare/cloudflare.toml
+++ b/providers/dns/cloudflare/cloudflare.toml
@@ -7,12 +7,12 @@ Since = "v0.3.0"
Example = '''
CLOUDFLARE_EMAIL=you@example.com \
CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \
-lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run
+lego --dns cloudflare -d '*.example.com' -d example.com run
# or
CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \
-lego --email you@example.com --dns cloudflare -d '*.example.com' -d example.com run
+lego --dns cloudflare -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -69,10 +69,11 @@ It follows the principle of least privilege and limits the possible damage, shou
CLOUDFLARE_DNS_API_TOKEN = "Alias to CF_DNS_API_TOKEN"
CLOUDFLARE_ZONE_API_TOKEN = "Alias to CF_ZONE_API_TOKEN"
[Configuration.Additional]
- CLOUDFLARE_POLLING_INTERVAL = "Time between DNS propagation check (in seconds)"
- CLOUDFLARE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation (in seconds)"
- CLOUDFLARE_TTL = "The TTL of the TXT record used for the DNS challenge (in seconds)"
- CLOUDFLARE_HTTP_TIMEOUT = "API request timeout (in seconds)"
+ CLOUDFLARE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ CLOUDFLARE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ CLOUDFLARE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ CLOUDFLARE_HTTP_TIMEOUT = "API request timeout in seconds (Default: )"
+ CLOUDFLARE_BASE_URL = "API base URL (Default: https://api.cloudflare.com/client/v4)"
[Links]
API = "https://api.cloudflare.com/"
diff --git a/providers/dns/cloudflare/cloudflare_test.go b/providers/dns/cloudflare/cloudflare_test.go
index f026bbc4c..8de9dd848 100644
--- a/providers/dns/cloudflare/cloudflare_test.go
+++ b/providers/dns/cloudflare/cloudflare_test.go
@@ -1,10 +1,12 @@
package cloudflare
import (
+ "net/http/httptest"
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -16,6 +18,7 @@ var envTest = tester.NewEnvTest(
EnvAPIKey,
EnvDNSAPIToken,
EnvZoneAPIToken,
+ EnvBaseURL,
altEnvEmail,
altEnvName(EnvAPIKey),
altEnvName(EnvDNSAPIToken),
@@ -78,6 +81,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -174,15 +178,18 @@ func TestNewDNSProviderWithToken(t *testing.T) {
}
defer envTest.RestoreEnv()
+
localEnvTest := tester.NewEnvTest(
EnvDNSAPIToken, altEnvName(EnvDNSAPIToken),
EnvZoneAPIToken, altEnvName(EnvZoneAPIToken),
).WithDomain(envDomain)
+
envTest.ClearEnv()
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer localEnvTest.RestoreEnv()
+
localEnvTest.ClearEnv()
localEnvTest.Apply(test.envVars)
@@ -197,6 +204,7 @@ func TestNewDNSProviderWithToken(t *testing.T) {
require.NotNil(t, p)
assert.Equal(t, test.expected.dnsToken, p.config.AuthToken)
assert.Equal(t, test.expected.zoneToken, p.config.ZoneToken)
+
if test.expected.sameClient {
assert.Equal(t, p.client.clientRead, p.client.clientEdit)
} else {
@@ -231,22 +239,17 @@ func TestNewDNSProviderConfig(t *testing.T) {
},
{
desc: "missing credentials",
- expected: "cloudflare: invalid credentials: key & email must not be empty",
+ expected: "cloudflare: invalid credentials: authEmail, authKey or authToken must be set",
},
{
desc: "missing email",
authKey: "123",
- expected: "cloudflare: invalid credentials: key & email must not be empty",
+ expected: "cloudflare: invalid credentials: authEmail and authKey must be set together",
},
{
desc: "missing api key",
authEmail: "test@example.com",
- expected: "cloudflare: invalid credentials: key & email must not be empty",
- },
- {
- desc: "missing api token, fallback to api key/email",
- authToken: "",
- expected: "cloudflare: invalid credentials: key & email must not be empty",
+ expected: "cloudflare: invalid credentials: authEmail and authKey must be set together",
},
}
@@ -277,6 +280,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -290,6 +294,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -298,3 +303,64 @@ func TestLiveCleanUp(t *testing.T) {
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.AuthEmail = "foo@example.com"
+ config.AuthKey = "secret"
+ config.BaseURL = server.URL
+ config.HTTPClient = server.Client()
+
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`).
+ With("X-Auth-Email", "foo@example.com").
+ With("X-Auth-Key", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ // https://developers.cloudflare.com/api/resources/zones/methods/list/
+ Route("GET /zones",
+ servermock.ResponseFromInternal("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com").
+ With("per_page", "50")).
+ // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/
+ Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records",
+ servermock.ResponseFromInternal("create_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/json"),
+ servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ // https://developers.cloudflare.com/api/resources/zones/methods/list/
+ Route("GET /zones",
+ servermock.ResponseFromInternal("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com").
+ With("per_page", "50")).
+ // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/
+ Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx",
+ servermock.ResponseFromInternal("delete_record.json")).
+ Build(t)
+
+ token := "abc"
+
+ provider.recordIDsMu.Lock()
+ provider.recordIDs["abc"] = "xxx"
+ provider.recordIDsMu.Unlock()
+
+ err := provider.CleanUp("example.com", token, "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/cloudflare/internal/client.go b/providers/dns/cloudflare/internal/client.go
new file mode 100644
index 000000000..b63612ce2
--- /dev/null
+++ b/providers/dns/cloudflare/internal/client.go
@@ -0,0 +1,202 @@
+/*
+Package internal Cloudflare API client.
+
+The official client is huge and still growing.
+- https://github.com/cloudflare/cloudflare-go/issues/4171
+*/
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.cloudflare.com/client/v4"
+
+// Client the Cloudflare API client.
+type Client struct {
+ authEmail string
+ authKey string
+ authToken string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(opts ...Option) (*Client, error) {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ client := &Client{
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }
+
+ for _, opt := range opts {
+ err := opt(client)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if client.authToken != "" {
+ return client, nil
+ }
+
+ if client.authEmail == "" && client.authKey == "" {
+ return nil, errors.New("invalid credentials: authEmail, authKey or authToken must be set")
+ }
+
+ if client.authEmail == "" || client.authKey == "" {
+ return nil, errors.New("invalid credentials: authEmail and authKey must be set together")
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return client, nil
+}
+
+// CreateDNSRecord creates a new DNS record for a zone.
+// https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/
+func (c *Client) CreateDNSRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
+ endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[Record]
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return &result.Result, nil
+}
+
+// DeleteDNSRecord deletes DNS record.
+// https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/
+func (c *Client) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
+ endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records", recordID)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+// ZonesByName returns a list of zones matching the given name.
+// https://developers.cloudflare.com/api/resources/zones/methods/list/
+func (c *Client) ZonesByName(ctx context.Context, name string) ([]Zone, error) {
+ endpoint := c.baseURL.JoinPath("zones")
+
+ query := endpoint.Query()
+ query.Set("name", name)
+ query.Set("per_page", "50")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[[]Zone]
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Result, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ // https://developers.cloudflare.com/fundamentals/api/how-to/make-api-calls/
+ if c.authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+c.authToken)
+ } else {
+ req.Header.Set("X-Auth-Email", c.authEmail)
+ req.Header.Set("X-Auth-Key", c.authKey)
+ }
+
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func 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 APIResponse[any]
+
+ 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.Errors)
+}
diff --git a/providers/dns/cloudflare/internal/client_test.go b/providers/dns/cloudflare/internal/client_test.go
new file mode 100644
index 000000000..9d286016f
--- /dev/null
+++ b/providers/dns/cloudflare/internal/client_test.go
@@ -0,0 +1,176 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(
+ WithAuthKey("foo@example.com", "secret"),
+ WithHTTPClient(server.Client()),
+ WithBaseURL(server.URL),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`).
+ WithAccept("application/json").
+ With("X-Auth-Email", "foo@example.com").
+ With("X-Auth-Key", "secret"),
+ )
+}
+
+func TestClient_CreateDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge.example.com",
+ TTL: 120,
+ Type: "TXT",
+ Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`,
+ }
+
+ newRecord, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: "023e105f4ecef8ad9ca31a8372d0c353",
+ Name: "example.com",
+ TTL: 3600,
+ Type: "A",
+ Comment: "Domain verification record",
+ Content: "198.51.100.4",
+ }
+
+ assert.Equal(t, expected, newRecord)
+}
+
+func TestClient_CreateDNSRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge.example.com",
+ TTL: 120,
+ Type: "TXT",
+ Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`,
+ }
+
+ _, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record)
+ require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header")
+}
+
+func TestClient_DeleteDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx",
+ servermock.ResponseFromFixture("delete_record.json")).
+ Build(t)
+
+ err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteDNSRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx")
+ require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header")
+}
+
+func TestClient_ZonesByName(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com").
+ With("per_page", "50")).
+ Build(t)
+
+ zones, err := client.ZonesByName(context.Background(), "example.com")
+ require.NoError(t, err)
+
+ expected := []Zone{
+ {
+ ID: "023e105f4ecef8ad9ca31a8372d0c353",
+ Account: Account{ID: "023e105f4ecef8ad9ca31a8372d0c353", Name: "Example Account Name"},
+ Meta: Meta{
+ CdnOnly: true,
+ CustomCertificateQuota: 1,
+ DNSOnly: true,
+ FoundationDNS: true,
+ PageRuleQuota: 100,
+ PhishingDetected: false,
+ Step: 2,
+ },
+ Name: "example.com",
+ Owner: Owner{
+ ID: "023e105f4ecef8ad9ca31a8372d0c353",
+ Name: "Example Org",
+ Type: "organization",
+ },
+ Plan: Plan{
+ ID: "023e105f4ecef8ad9ca31a8372d0c353",
+ CanSubscribe: false,
+ Currency: "USD",
+ ExternallyManaged: false,
+ Frequency: "monthly",
+ IsSubscribed: false,
+ LegacyDiscount: false,
+ LegacyID: "free",
+ Price: 10,
+ Name: "Example Org",
+ },
+ CnameSuffix: "cdn.cloudflare.com",
+ Paused: true,
+ Permissions: []string{"#worker:read"},
+ Tenant: Tenant{
+ ID: "023e105f4ecef8ad9ca31a8372d0c353",
+ Name: "Example Account Name",
+ },
+ TenantUnit: TenantUnit{
+ ID: "023e105f4ecef8ad9ca31a8372d0c353",
+ },
+ Type: "full",
+ VanityNameServers: []string{"ns1.example.com", "ns2.example.com"},
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_ZonesByName_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.ZonesByName(context.Background(), "example.com")
+ require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header")
+}
diff --git a/providers/dns/cloudflare/internal/fixtures/create_record-request.json b/providers/dns/cloudflare/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..1b8604dc9
--- /dev/null
+++ b/providers/dns/cloudflare/internal/fixtures/create_record-request.json
@@ -0,0 +1,6 @@
+{
+ "type": "TXT",
+ "name": "_acme-challenge.example.com",
+ "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"",
+ "ttl": 120
+}
diff --git a/providers/dns/cloudflare/internal/fixtures/create_record.json b/providers/dns/cloudflare/internal/fixtures/create_record.json
new file mode 100644
index 000000000..7e08e993b
--- /dev/null
+++ b/providers/dns/cloudflare/internal/fixtures/create_record.json
@@ -0,0 +1,40 @@
+{
+ "errors": [
+ {
+ "code": 1000,
+ "message": "message",
+ "documentation_url": "documentation_url",
+ "source": {
+ "pointer": "pointer"
+ }
+ }
+ ],
+ "messages": [
+ {
+ "code": 1000,
+ "message": "message",
+ "documentation_url": "documentation_url",
+ "source": {
+ "pointer": "pointer"
+ }
+ }
+ ],
+ "success": true,
+ "result": {
+ "name": "example.com",
+ "ttl": 3600,
+ "type": "A",
+ "comment": "Domain verification record",
+ "content": "198.51.100.4",
+ "proxied": true,
+ "settings": {
+ "ipv4_only": true,
+ "ipv6_only": true
+ },
+ "tags": [
+ "owner:dns-team"
+ ],
+ "id": "023e105f4ecef8ad9ca31a8372d0c353",
+ "proxiable": true
+ }
+}
diff --git a/providers/dns/cloudflare/internal/fixtures/delete_record.json b/providers/dns/cloudflare/internal/fixtures/delete_record.json
new file mode 100644
index 000000000..038ac7b23
--- /dev/null
+++ b/providers/dns/cloudflare/internal/fixtures/delete_record.json
@@ -0,0 +1,5 @@
+{
+ "result": {
+ "id": "023e105f4ecef8ad9ca31a8372d0c353"
+ }
+}
diff --git a/providers/dns/cloudflare/internal/fixtures/error.json b/providers/dns/cloudflare/internal/fixtures/error.json
new file mode 100644
index 000000000..1b2360cc4
--- /dev/null
+++ b/providers/dns/cloudflare/internal/fixtures/error.json
@@ -0,0 +1,17 @@
+{
+ "success": false,
+ "errors": [
+ {
+ "code": 6003,
+ "message": "Invalid request headers",
+ "error_chain": [
+ {
+ "code": 6103,
+ "message": "Invalid format for X-Auth-Key header"
+ }
+ ]
+ }
+ ],
+ "messages": [],
+ "result": null
+}
diff --git a/providers/dns/cloudflare/internal/fixtures/zones.json b/providers/dns/cloudflare/internal/fixtures/zones.json
new file mode 100644
index 000000000..1dd94c4e3
--- /dev/null
+++ b/providers/dns/cloudflare/internal/fixtures/zones.json
@@ -0,0 +1,83 @@
+{
+ "errors": [
+ {
+ "code": 1000,
+ "message": "message",
+ "documentation_url": "documentation_url",
+ "source": {
+ "pointer": "pointer"
+ }
+ }
+ ],
+ "messages": [
+ {
+ "code": 1000,
+ "message": "message",
+ "documentation_url": "documentation_url",
+ "source": {
+ "pointer": "pointer"
+ }
+ }
+ ],
+ "success": true,
+ "result": [
+ {
+ "id": "023e105f4ecef8ad9ca31a8372d0c353",
+ "account": {
+ "id": "023e105f4ecef8ad9ca31a8372d0c353",
+ "name": "Example Account Name"
+ },
+ "meta": {
+ "cdn_only": true,
+ "custom_certificate_quota": 1,
+ "dns_only": true,
+ "foundation_dns": true,
+ "page_rule_quota": 100,
+ "phishing_detected": false,
+ "step": 2
+ },
+ "name": "example.com",
+ "owner": {
+ "id": "023e105f4ecef8ad9ca31a8372d0c353",
+ "name": "Example Org",
+ "type": "organization"
+ },
+ "plan": {
+ "id": "023e105f4ecef8ad9ca31a8372d0c353",
+ "can_subscribe": false,
+ "currency": "USD",
+ "externally_managed": false,
+ "frequency": "monthly",
+ "is_subscribed": false,
+ "legacy_discount": false,
+ "legacy_id": "free",
+ "price": 10,
+ "name": "Example Org"
+ },
+ "cname_suffix": "cdn.cloudflare.com",
+ "paused": true,
+ "permissions": [
+ "#worker:read"
+ ],
+ "tenant": {
+ "id": "023e105f4ecef8ad9ca31a8372d0c353",
+ "name": "Example Account Name"
+ },
+ "tenant_unit": {
+ "id": "023e105f4ecef8ad9ca31a8372d0c353"
+ },
+ "type": "full",
+ "vanity_name_servers": [
+ "ns1.example.com",
+ "ns2.example.com"
+ ]
+ }
+ ],
+ "result_info": {
+ "count": 1,
+ "page": 1,
+ "per_page": 20,
+ "total_count": 1,
+ "total_pages": 1
+ }
+}
diff --git a/providers/dns/cloudflare/internal/options.go b/providers/dns/cloudflare/internal/options.go
new file mode 100644
index 000000000..aa551a422
--- /dev/null
+++ b/providers/dns/cloudflare/internal/options.go
@@ -0,0 +1,52 @@
+package internal
+
+import (
+ "net/http"
+ "net/url"
+)
+
+type Option func(c *Client) error
+
+func WithAuthKey(authEmail, authKey string) Option {
+ return func(c *Client) error {
+ c.authEmail = authEmail
+ c.authKey = authKey
+
+ return nil
+ }
+}
+
+func WithAuthToken(authToken string) Option {
+ return func(c *Client) error {
+ c.authToken = authToken
+
+ return nil
+ }
+}
+
+func WithBaseURL(baseURL string) Option {
+ return func(c *Client) error {
+ if baseURL == "" {
+ return nil
+ }
+
+ bu, err := url.Parse(baseURL)
+ if err != nil {
+ return err
+ }
+
+ c.baseURL = bu
+
+ return nil
+ }
+}
+
+func WithHTTPClient(client *http.Client) Option {
+ return func(c *Client) error {
+ if client != nil {
+ c.HTTPClient = client
+ }
+
+ return nil
+ }
+}
diff --git a/providers/dns/cloudflare/internal/types.go b/providers/dns/cloudflare/internal/types.go
new file mode 100644
index 000000000..50a7bbbf9
--- /dev/null
+++ b/providers/dns/cloudflare/internal/types.go
@@ -0,0 +1,123 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Record struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Type string `json:"type,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Content string `json:"content,omitempty"`
+}
+
+type APIResponse[T any] struct {
+ Errors Errors `json:"errors,omitempty"`
+ Messages []Message `json:"messages,omitempty"`
+ Success bool `json:"success,omitempty"`
+ Result T `json:"result,omitempty"`
+ ResultInfo *ResultInfo `json:"result_info,omitempty"`
+}
+
+type Message struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ DocumentationURL string `json:"documentation_url"`
+ Source *Source `json:"source"`
+ ErrorChain []ErrorChain `json:"error_chain"`
+}
+
+type Source struct {
+ Pointer string `json:"pointer"`
+}
+
+type ErrorChain struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+type Errors []Message
+
+func (e Errors) Error() string {
+ msg := new(strings.Builder)
+
+ for _, item := range e {
+ _, _ = fmt.Fprintf(msg, "%d: %s", item.Code, item.Message)
+
+ for _, link := range item.ErrorChain {
+ _, _ = fmt.Fprintf(msg, "; %d: %s", link.Code, link.Message)
+ }
+ }
+
+ return msg.String()
+}
+
+type ResultInfo struct {
+ Count int `json:"count"`
+ Page int `json:"page"`
+ PerPage int `json:"per_page"`
+ TotalCount int `json:"total_count"`
+ TotalPages int `json:"total_pages"`
+}
+
+type Zone struct {
+ ID string `json:"id"`
+ Account Account `json:"account"`
+ Meta Meta `json:"meta"`
+ Name string `json:"name"`
+ Owner Owner `json:"owner"`
+ Plan Plan `json:"plan"`
+ CnameSuffix string `json:"cname_suffix"`
+ Paused bool `json:"paused"`
+ Permissions []string `json:"permissions"`
+ Tenant Tenant `json:"tenant"`
+ TenantUnit TenantUnit `json:"tenant_unit"`
+ Type string `json:"type"`
+ VanityNameServers []string `json:"vanity_name_servers"`
+}
+
+type Account struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type Meta struct {
+ CdnOnly bool `json:"cdn_only"`
+ CustomCertificateQuota int `json:"custom_certificate_quota"`
+ DNSOnly bool `json:"dns_only"`
+ FoundationDNS bool `json:"foundation_dns"`
+ PageRuleQuota int `json:"page_rule_quota"`
+ PhishingDetected bool `json:"phishing_detected"`
+ Step int `json:"step"`
+}
+
+type Owner struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
+
+type Plan struct {
+ ID string `json:"id"`
+ CanSubscribe bool `json:"can_subscribe"`
+ Currency string `json:"currency"`
+ ExternallyManaged bool `json:"externally_managed"`
+ Frequency string `json:"frequency"`
+ IsSubscribed bool `json:"is_subscribed"`
+ LegacyDiscount bool `json:"legacy_discount"`
+ LegacyID string `json:"legacy_id"`
+ Price int `json:"price"`
+ Name string `json:"name"`
+}
+
+type Tenant struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type TenantUnit struct {
+ ID string `json:"id"`
+}
diff --git a/providers/dns/cloudflare/wrapper.go b/providers/dns/cloudflare/wrapper.go
index a93feeded..286c20ecd 100644
--- a/providers/dns/cloudflare/wrapper.go
+++ b/providers/dns/cloudflare/wrapper.go
@@ -2,15 +2,16 @@ package cloudflare
import (
"context"
+ "errors"
"sync"
- "github.com/cloudflare/cloudflare-go"
"github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal"
)
type metaClient struct {
- clientEdit *cloudflare.API // needs Zone/DNS/Edit permissions
- clientRead *cloudflare.API // needs Zone/Zone/Read permissions
+ clientEdit *internal.Client // needs Zone/DNS/Edit permissions
+ clientRead *internal.Client // needs Zone/Zone/Read permissions
zones map[string]string // caches calls to ZoneIDByName, see lookupZoneID()
zonesMu *sync.RWMutex
@@ -19,7 +20,10 @@ type metaClient struct {
func newClient(config *Config) (*metaClient, error) {
// with AuthKey/AuthEmail we can access all available APIs
if config.AuthToken == "" {
- client, err := cloudflare.New(config.AuthKey, config.AuthEmail, cloudflare.HTTPClient(config.HTTPClient))
+ client, err := internal.NewClient(
+ internal.WithBaseURL(config.BaseURL),
+ internal.WithHTTPClient(config.HTTPClient),
+ internal.WithAuthKey(config.AuthEmail, config.AuthKey))
if err != nil {
return nil, err
}
@@ -32,7 +36,10 @@ func newClient(config *Config) (*metaClient, error) {
}, nil
}
- dns, err := cloudflare.NewWithAPIToken(config.AuthToken, cloudflare.HTTPClient(config.HTTPClient))
+ dns, err := internal.NewClient(
+ internal.WithBaseURL(config.BaseURL),
+ internal.WithHTTPClient(config.HTTPClient),
+ internal.WithAuthToken(config.AuthToken))
if err != nil {
return nil, err
}
@@ -46,7 +53,10 @@ func newClient(config *Config) (*metaClient, error) {
}, nil
}
- zone, err := cloudflare.NewWithAPIToken(config.ZoneToken, cloudflare.HTTPClient(config.HTTPClient))
+ zone, err := internal.NewClient(
+ internal.WithBaseURL(config.BaseURL),
+ internal.WithHTTPClient(config.HTTPClient),
+ internal.WithAuthToken(config.ZoneToken))
if err != nil {
return nil, err
}
@@ -59,19 +69,15 @@ func newClient(config *Config) (*metaClient, error) {
}, nil
}
-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.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) {
- return m.clientEdit.ListDNSRecords(ctx, cloudflare.ZoneIdentifier(zoneID), rr)
+func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr internal.Record) (*internal.Record, error) {
+ return m.clientEdit.CreateDNSRecord(ctx, zoneID, rr)
}
func (m *metaClient) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
- return m.clientEdit.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), recordID)
+ return m.clientEdit.DeleteDNSRecord(ctx, zoneID, recordID)
}
-func (m *metaClient) ZoneIDByName(fdqn string) (string, error) {
+func (m *metaClient) ZoneIDByName(ctx context.Context, fdqn string) (string, error) {
m.zonesMu.RLock()
id := m.zones[fdqn]
m.zonesMu.RUnlock()
@@ -80,7 +86,12 @@ func (m *metaClient) ZoneIDByName(fdqn string) (string, error) {
return id, nil
}
- id, err := m.clientRead.ZoneIDByName(dns01.UnFqdn(fdqn))
+ zones, err := m.clientRead.ZonesByName(ctx, dns01.UnFqdn(fdqn))
+ if err != nil {
+ return "", err
+ }
+
+ id, err = extractZoneID(zones)
if err != nil {
return "", err
}
@@ -88,5 +99,17 @@ func (m *metaClient) ZoneIDByName(fdqn string) (string, error) {
m.zonesMu.Lock()
m.zones[fdqn] = id
m.zonesMu.Unlock()
+
return id, nil
}
+
+func extractZoneID(res []internal.Zone) (string, error) {
+ switch len(res) {
+ case 0:
+ return "", errors.New("zone could not be found")
+ case 1:
+ return res[0].ID, nil
+ default:
+ return "", errors.New("ambiguous zone name; an account ID might help")
+ }
+}
diff --git a/providers/dns/cloudns/cloudns.go b/providers/dns/cloudns/cloudns.go
index ef6524c4d..916d73bde 100644
--- a/providers/dns/cloudns/cloudns.go
+++ b/providers/dns/cloudns/cloudns.go
@@ -8,12 +8,14 @@ import (
"net/http"
"time"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/platform/wait"
"github.com/go-acme/lego/v4/providers/dns/cloudns/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -66,6 +68,7 @@ type DNSProvider struct {
// CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD.
func NewDNSProvider() (*DNSProvider, error) {
var subAuthID string
+
authID := env.GetOrFile(EnvAuthID)
if authID == "" {
subAuthID = env.GetOrFile(EnvSubAuthID)
@@ -99,7 +102,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("ClouDNS: %w", err)
}
- client.HTTPClient = config.HTTPClient
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
return &DNSProvider{client: client, config: config}, nil
}
@@ -162,14 +169,22 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// waitNameservers At the time of writing 4 servers are found as authoritative, but 8 are reported during the sync.
// If this is not done, the secondary verification done by Let's Encrypt server will fail quire a bit.
func (d *DNSProvider) waitNameservers(ctx context.Context, domain string, zone *internal.Zone) error {
- return wait.For("Nameserver sync on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) {
- syncProgress, err := d.client.GetUpdateStatus(ctx, zone.Name)
- if err != nil {
- return false, err
- }
+ return wait.Retry(ctx,
+ func() error {
+ syncProgress, err := d.client.GetUpdateStatus(ctx, zone.Name)
+ if err != nil {
+ return fmt.Errorf("nameserver sync on %s: %w", domain, err)
+ }
- log.Infof("[%s] Sync %d/%d complete", domain, syncProgress.Updated, syncProgress.Total)
+ log.Infof("[%s] Sync %d/%d complete", domain, syncProgress.Updated, syncProgress.Total)
- return syncProgress.Complete, nil
- })
+ if !syncProgress.Complete {
+ return fmt.Errorf("nameserver sync on %s not complete", domain)
+ }
+
+ return nil
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
+ backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
+ )
}
diff --git a/providers/dns/cloudns/cloudns.toml b/providers/dns/cloudns/cloudns.toml
index dd81da462..ad52ef5b1 100644
--- a/providers/dns/cloudns/cloudns.toml
+++ b/providers/dns/cloudns/cloudns.toml
@@ -7,7 +7,7 @@ Since = "v2.3.0"
Example = '''
CLOUDNS_AUTH_ID=xxxx \
CLOUDNS_AUTH_PASSWORD=yyyy \
-lego --email you@example.com --dns cloudns -d '*.example.com' -d example.com run
+lego --dns cloudns -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,10 +16,10 @@ lego --email you@example.com --dns cloudns -d '*.example.com' -d example.com run
CLOUDNS_AUTH_PASSWORD = "The password for API user ID"
[Configuration.Additional]
CLOUDNS_SUB_AUTH_ID = "The API sub user ID"
- CLOUDNS_POLLING_INTERVAL = "Time between DNS propagation check"
- CLOUDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CLOUDNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- CLOUDNS_HTTP_TIMEOUT = "API request timeout"
+ CLOUDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ CLOUDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)"
+ CLOUDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ CLOUDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.cloudns.net/wiki/article/42/"
diff --git a/providers/dns/cloudns/cloudns_test.go b/providers/dns/cloudns/cloudns_test.go
index ea4f25c95..024bd93d8 100644
--- a/providers/dns/cloudns/cloudns_test.go
+++ b/providers/dns/cloudns/cloudns_test.go
@@ -79,6 +79,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -169,6 +170,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -182,6 +184,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/cloudns/internal/client.go b/providers/dns/cloudns/internal/client.go
index 60d7e6bbe..278b8de49 100644
--- a/providers/dns/cloudns/internal/client.go
+++ b/providers/dns/cloudns/internal/client.go
@@ -171,6 +171,7 @@ func (c *Client) ListTxtRecords(ctx context.Context, zoneName, fqdn string) ([]T
}
var records []TXTRecord
+
for _, record := range raw {
if record.Host == subDomain && record.Type == "TXT" {
records = append(records, record)
@@ -279,6 +280,7 @@ func (c *Client) GetUpdateStatus(ctx context.Context, zoneName string) (*SyncPro
}
updatedCount := 0
+
for _, record := range records {
if record.Updated {
updatedCount++
diff --git a/providers/dns/cloudns/internal/client_test.go b/providers/dns/cloudns/internal/client_test.go
index 999bd1446..b9f6c5431 100644
--- a/providers/dns/cloudns/internal/client_test.go
+++ b/providers/dns/cloudns/internal/client_test.go
@@ -1,44 +1,26 @@
package internal
import (
- "context"
- "fmt"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, subAuthID string, handler http.HandlerFunc) *Client {
- t.Helper()
-
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- client, err := NewClient("myAuthID", subAuthID, "myAuthPassword")
- require.NoError(t, err)
-
- client.BaseURL, _ = url.Parse(server.URL)
- client.HTTPClient = server.Client()
-
- return client
-}
-
-func handlerMock(method string, jsonData []byte) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, "Incorrect method used", http.StatusBadRequest)
- return
- }
-
- _, err := rw.Write(jsonData)
+func setupClient(subAuthID string) func(server *httptest.Server) (*Client, error) {
+ return func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("myAuthID", subAuthID, "myAuthPassword")
if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
+ return nil, err
}
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
}
}
@@ -97,7 +79,7 @@ func TestClient_GetZone(t *testing.T) {
desc string
authFQDN string
apiResponse string
- expected
+ expected expected
}{
{
desc: "zone found",
@@ -132,9 +114,17 @@ func TestClient_GetZone(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse)))
+ client := servermock.NewBuilder[*Client](setupClient("")).
+ Route("GET /get-zone-info.json",
+ servermock.RawStringResponse(test.apiResponse),
+ servermock.CheckQueryParameter().Strict().
+ With("auth-id", "myAuthID").
+ With("auth-password", "myAuthPassword").
+ With("domain-name", "foo.com"),
+ ).
+ Build(t)
- zone, err := client.GetZone(context.Background(), test.authFQDN)
+ zone, err := client.GetZone(t.Context(), test.authFQDN)
if test.expected.errorMsg != "" {
require.EqualError(t, err, test.expected.errorMsg)
@@ -157,7 +147,7 @@ func TestClient_FindTxtRecord(t *testing.T) {
authFQDN string
zoneName string
apiResponse string
- expected
+ expected expected
}{
{
desc: "record found",
@@ -239,9 +229,19 @@ func TestClient_FindTxtRecord(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse)))
+ client := servermock.NewBuilder[*Client](setupClient("")).
+ Route("GET /records.json",
+ servermock.RawStringResponse(test.apiResponse),
+ servermock.CheckQueryParameter().Strict().
+ With("auth-id", "myAuthID").
+ With("auth-password", "myAuthPassword").
+ With("type", "TXT").
+ With("host", "_acme-challenge").
+ With("domain-name", test.zoneName),
+ ).
+ Build(t)
- txtRecord, err := client.FindTxtRecord(context.Background(), test.zoneName, test.authFQDN)
+ txtRecord, err := client.FindTxtRecord(t.Context(), test.zoneName, test.authFQDN)
if test.expected.errorMsg != "" {
require.EqualError(t, err, test.expected.errorMsg)
@@ -264,7 +264,7 @@ func TestClient_ListTxtRecord(t *testing.T) {
authFQDN string
zoneName string
apiResponse string
- expected
+ expected expected
}{
{
desc: "record found",
@@ -348,9 +348,19 @@ func TestClient_ListTxtRecord(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse)))
+ client := servermock.NewBuilder[*Client](setupClient("")).
+ Route("GET /records.json",
+ servermock.RawStringResponse(test.apiResponse),
+ servermock.CheckQueryParameter().Strict().
+ With("auth-id", "myAuthID").
+ With("auth-password", "myAuthPassword").
+ With("type", "TXT").
+ With("host", "_acme-challenge").
+ With("domain-name", test.zoneName),
+ ).
+ Build(t)
- txtRecords, err := client.ListTxtRecords(context.Background(), test.zoneName, test.authFQDN)
+ txtRecords, err := client.ListTxtRecords(t.Context(), test.zoneName, test.authFQDN)
if test.expected.errorMsg != "" {
require.EqualError(t, err, test.expected.errorMsg)
@@ -364,7 +374,7 @@ func TestClient_ListTxtRecord(t *testing.T) {
func TestClient_AddTxtRecord(t *testing.T) {
type expected struct {
- query string
+ query url.Values
errorMsg string
}
@@ -377,7 +387,7 @@ func TestClient_AddTxtRecord(t *testing.T) {
value string
ttl int
apiResponse string
- expected
+ expected expected
}{
{
desc: "sub-zone",
@@ -388,7 +398,15 @@ func TestClient_AddTxtRecord(t *testing.T) {
ttl: 60,
apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`,
expected: expected{
- query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`,
+ query: url.Values{
+ "auth-id": {"myAuthID"},
+ "auth-password": {"myAuthPassword"},
+ "domain-name": {"example.com"},
+ "host": {"_acme-challenge.foo"},
+ "record": {"txtTXTtxtTXTtxtTXTtxtTXT"},
+ "record-type": {"TXT"},
+ "ttl": {"60"},
+ },
},
},
{
@@ -400,7 +418,15 @@ func TestClient_AddTxtRecord(t *testing.T) {
ttl: 60,
apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`,
expected: expected{
- query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`,
+ query: url.Values{
+ "auth-id": {"myAuthID"},
+ "auth-password": {"myAuthPassword"},
+ "domain-name": {"example.com"},
+ "host": {"_acme-challenge"},
+ "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"},
+ "record-type": {"TXT"},
+ "ttl": {"60"},
+ },
},
},
{
@@ -412,7 +438,15 @@ func TestClient_AddTxtRecord(t *testing.T) {
ttl: 60,
apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`,
expected: expected{
- query: `auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`,
+ query: url.Values{
+ "auth-password": {"myAuthPassword"},
+ "domain-name": {"example.com"},
+ "host": {"_acme-challenge"},
+ "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"},
+ "record-type": {"TXT"},
+ "sub-auth-id": {"mySubAuthID"},
+ "ttl": {"60"},
+ },
},
},
{
@@ -424,7 +458,15 @@ func TestClient_AddTxtRecord(t *testing.T) {
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=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`,
+ query: url.Values{
+ "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.",
},
},
@@ -437,7 +479,15 @@ func TestClient_AddTxtRecord(t *testing.T) {
ttl: 120,
apiResponse: `[{}]`,
expected: expected{
- query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`,
+ query: url.Values{
+ "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",
},
},
@@ -445,17 +495,15 @@ func TestClient_AddTxtRecord(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := setupTest(t, test.subAuthID, func(rw http.ResponseWriter, req *http.Request) {
- if test.expected.query != req.URL.RawQuery {
- msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery)
- http.Error(rw, msg, http.StatusBadRequest)
- return
- }
+ client := servermock.NewBuilder[*Client](setupClient(test.subAuthID)).
+ Route("POST /add-record.json",
+ servermock.RawStringResponse(test.apiResponse),
+ servermock.CheckQueryParameter().Strict().
+ WithValues(test.expected.query),
+ ).
+ Build(t)
- handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req)
- })
-
- err := client.AddTxtRecord(context.Background(), test.zoneName, test.authFQDN, test.value, test.ttl)
+ err := client.AddTxtRecord(t.Context(), test.zoneName, test.authFQDN, test.value, test.ttl)
if test.expected.errorMsg != "" {
require.EqualError(t, err, test.expected.errorMsg)
@@ -468,7 +516,7 @@ func TestClient_AddTxtRecord(t *testing.T) {
func TestClient_RemoveTxtRecord(t *testing.T) {
type expected struct {
- query string
+ query url.Values
errorMsg string
}
@@ -477,7 +525,7 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
id int
zoneName string
apiResponse string
- expected
+ expected expected
}{
{
desc: "record found",
@@ -485,7 +533,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
zoneName: "foo.com",
apiResponse: `{ "status": "Success", "statusDescription": "The record was deleted successfully." }`,
expected: expected{
- query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769228`,
+ query: url.Values{
+ "auth-id": {"myAuthID"},
+ "auth-password": {"myAuthPassword"},
+ "domain-name": {"foo.com"},
+ "record-id": {"5769228"},
+ },
},
},
{
@@ -494,7 +547,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
zoneName: "foo.com",
apiResponse: `{ "status": "Failed", "statusDescription": "Invalid record-id param." }`,
expected: expected{
- query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769000`,
+ query: url.Values{
+ "auth-id": {"myAuthID"},
+ "auth-password": {"myAuthPassword"},
+ "domain-name": {"foo.com"},
+ "record-id": {"5769000"},
+ },
errorMsg: "failed to remove TXT record: Failed Invalid record-id param.",
},
},
@@ -504,7 +562,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
zoneName: "foo-plus.com",
apiResponse: `[{}]`,
expected: expected{
- query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo-plus.com&record-id=44`,
+ query: url.Values{
+ "auth-id": {"myAuthID"},
+ "auth-password": {"myAuthPassword"},
+ "domain-name": {"foo-plus.com"},
+ "record-id": {"44"},
+ },
errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse",
},
},
@@ -512,23 +575,15 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
- if test.expected.query != req.URL.RawQuery {
- msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery)
- http.Error(rw, msg, http.StatusBadRequest)
- return
- }
+ client := servermock.NewBuilder[*Client](setupClient("")).
+ Route("POST /delete-record.json",
+ servermock.RawStringResponse(test.apiResponse),
+ servermock.CheckQueryParameter().Strict().
+ WithValues(test.expected.query),
+ ).
+ Build(t)
- handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req)
- }))
- t.Cleanup(server.Close)
-
- client, err := NewClient("myAuthID", "", "myAuthPassword")
- require.NoError(t, err)
-
- client.BaseURL, _ = url.Parse(server.URL)
-
- err = client.RemoveTxtRecord(context.Background(), test.id, test.zoneName)
+ err := client.RemoveTxtRecord(t.Context(), test.id, test.zoneName)
if test.expected.errorMsg != "" {
require.EqualError(t, err, test.expected.errorMsg)
@@ -550,7 +605,7 @@ func TestClient_GetUpdateStatus(t *testing.T) {
authFQDN string
zoneName string
apiResponse string
- expected
+ expected expected
}{
{
desc: "50% sync",
@@ -590,15 +645,17 @@ func TestClient_GetUpdateStatus(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse)))
- t.Cleanup(server.Close)
+ client := servermock.NewBuilder[*Client](setupClient("")).
+ Route("GET /update-status.json",
+ servermock.RawStringResponse(test.apiResponse),
+ servermock.CheckQueryParameter().Strict().
+ With("auth-id", "myAuthID").
+ With("auth-password", "myAuthPassword").
+ With("domain-name", test.zoneName),
+ ).
+ Build(t)
- client, err := NewClient("myAuthID", "", "myAuthPassword")
- require.NoError(t, err)
-
- client.BaseURL, _ = url.Parse(server.URL)
-
- syncProgress, err := client.GetUpdateStatus(context.Background(), test.zoneName)
+ syncProgress, err := client.GetUpdateStatus(t.Context(), test.zoneName)
if test.expected.errorMsg != "" {
require.EqualError(t, err, test.expected.errorMsg)
diff --git a/providers/dns/cloudru/cloudru.go b/providers/dns/cloudru/cloudru.go
index 314c20445..dd597952a 100644
--- a/providers/dns/cloudru/cloudru.go
+++ b/providers/dns/cloudru/cloudru.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/cloudru/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -60,8 +61,9 @@ func NewDefaultConfig() *Config {
}
type DNSProvider struct {
- config *Config
- client *internal.Client
+ config *Config
+ client *internal.Client
+
records map[string]*internal.Record
recordsMu sync.Mutex
}
@@ -99,6 +101,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
diff --git a/providers/dns/cloudru/cloudru.toml b/providers/dns/cloudru/cloudru.toml
index f795c7ac4..b74098a72 100644
--- a/providers/dns/cloudru/cloudru.toml
+++ b/providers/dns/cloudru/cloudru.toml
@@ -8,7 +8,7 @@ Example = '''
CLOUDRU_SERVICE_INSTANCE_ID=ppp \
CLOUDRU_KEY_ID=xxx \
CLOUDRU_SECRET=yyy \
-lego --email you@example.com --dns cloudru -d '*.example.com' -d example.com run
+lego --dns cloudru -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,11 +17,11 @@ lego --email you@example.com --dns cloudru -d '*.example.com' -d example.com run
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"
+ CLOUDRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ CLOUDRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ CLOUDRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ CLOUDRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ CLOUDRU_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)"
[Links]
API = "https://cloud.ru/ru/docs/clouddns/ug/topics/api-ref.html"
diff --git a/providers/dns/cloudru/cloudru_test.go b/providers/dns/cloudru/cloudru_test.go
index 88addde93..3e506cb1c 100644
--- a/providers/dns/cloudru/cloudru_test.go
+++ b/providers/dns/cloudru/cloudru_test.go
@@ -67,6 +67,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -153,6 +154,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -166,6 +168,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/cloudru/internal/client.go b/providers/dns/cloudru/internal/client.go
index cb62c5bca..a00ae6ea8 100644
--- a/providers/dns/cloudru/internal/client.go
+++ b/providers/dns/cloudru/internal/client.go
@@ -61,6 +61,7 @@ func (c *Client) GetZones(ctx context.Context, parentID string) ([]Zone, error)
}
var zones APIResponse[Zone]
+
err = c.do(req, &zones)
if err != nil {
return nil, err
@@ -78,6 +79,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]Record, error
}
var records APIResponse[Record]
+
err = c.do(req, &records)
if err != nil {
return nil, err
@@ -95,6 +97,7 @@ func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record)
}
var result Record
+
err = c.do(req, &result)
if err != nil {
return nil, err
diff --git a/providers/dns/cloudru/internal/client_test.go b/providers/dns/cloudru/internal/client_test.go
index d96183d9f..3b087d617 100644
--- a/providers/dns/cloudru/internal/client_test.go
+++ b/providers/dns/cloudru/internal/client_test.go
@@ -1,64 +1,42 @@
package internal
import (
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
"time"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ 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),
+ }
- 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)
- }
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer xxx"))
}
func TestClient_GetZones(t *testing.T) {
- client := setupTest(t, "/zones", writeFixtureHandler(http.MethodGet, "zones.json"))
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("zones.json")).
+ Build(t)
- ctx := mockContext()
+ ctx := mockContext(t)
zones, err := client.GetZones(ctx, "xxx")
require.NoError(t, err)
@@ -78,9 +56,12 @@ func TestClient_GetZones(t *testing.T) {
}
func TestClient_GetRecords(t *testing.T) {
- client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodGet, "records.json"))
+ client := mockBuilder().
+ Route("GET /zones/zzz/records",
+ servermock.ResponseFromFixture("records.json")).
+ Build(t)
- ctx := mockContext()
+ ctx := mockContext(t)
records, err := client.GetRecords(ctx, "zzz")
require.NoError(t, err)
@@ -122,9 +103,13 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_CreateRecord(t *testing.T) {
- client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodPost, "record.json"))
+ client := mockBuilder().
+ Route("POST /zones/zzz/records",
+ servermock.ResponseFromFixture("record.json"),
+ servermock.CheckRequestJSONBody(`{"name":"www.example.com.","type":"TXT","values":["text"],"ttl":"3600"}`)).
+ Build(t)
- ctx := mockContext()
+ ctx := mockContext(t)
recordReq := Record{
Name: "www.example.com.",
@@ -150,9 +135,12 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, "/zones/zzz/records/example.com/TXT", writeFixtureHandler(http.MethodDelete, "record.json"))
+ client := mockBuilder().
+ Route("DELETE /zones/zzz/records/example.com/TXT",
+ servermock.ResponseFromFixture("record.json")).
+ Build(t)
- ctx := mockContext()
+ ctx := mockContext(t)
err := client.DeleteRecord(ctx, "zzz", "example.com", "TXT")
require.NoError(t, err)
diff --git a/providers/dns/cloudru/internal/identity.go b/providers/dns/cloudru/internal/identity.go
index 79df3c297..3bb09f3fa 100644
--- a/providers/dns/cloudru/internal/identity.go
+++ b/providers/dns/cloudru/internal/identity.go
@@ -49,6 +49,7 @@ func (c *Client) obtainToken(ctx context.Context) (*Token, error) {
}
tok := Token{}
+
err = json.Unmarshal(raw, &tok)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -88,6 +89,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errResp := &authResponseError{}
+
err := json.Unmarshal(raw, errResp)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/cloudru/internal/identity_test.go b/providers/dns/cloudru/internal/identity_test.go
index 7329e7f55..c1097c015 100644
--- a/providers/dns/cloudru/internal/identity_test.go
+++ b/providers/dns/cloudru/internal/identity_test.go
@@ -2,65 +2,51 @@ package internal
import (
"context"
- "encoding/json"
- "fmt"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func mockContext() context.Context {
- return context.WithValue(context.Background(), tokenKey, &Token{AccessToken: "xxx"})
+func mockContext(t *testing.T) context.Context {
+ t.Helper()
+
+ return context.WithValue(t.Context(), 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)
-
+func setupIdentityClient(server *httptest.Server) (*Client, error) {
client := NewClient("user", "secret")
client.HTTPClient = server.Client()
client.AuthEndpoint, _ = url.Parse(server.URL)
+ return client, nil
+}
+
+func TestClient_obtainToken(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupIdentityClient,
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ ).
+ Route("POST /", servermock.JSONEncode(Token{
+ AccessToken: "xxx",
+ TokenID: "yyy",
+ ExpiresIn: 666,
+ TokenType: "Bearer",
+ Scope: "openid profile email roles",
+ }),
+ servermock.CheckForm().Strict().
+ With("client_id", "user").
+ With("client_secret", "secret").
+ With("grant_type", "access_key"),
+ ).
+ Build(t)
+
assert.Nil(t, client.token)
- tok, err := client.obtainToken(context.Background())
+ tok, err := client.obtainToken(t.Context())
require.NoError(t, err)
assert.NotNil(t, tok)
@@ -69,19 +55,27 @@ func TestClient_obtainToken(t *testing.T) {
}
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)
+ client := servermock.NewBuilder[*Client](setupIdentityClient,
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ ).
+ Route("POST /", servermock.JSONEncode(Token{
+ AccessToken: "xxx",
+ TokenID: "yyy",
+ ExpiresIn: 666,
+ TokenType: "Bearer",
+ Scope: "openid profile email roles",
+ }),
+ servermock.CheckForm().Strict().
+ With("client_id", "user").
+ With("client_secret", "secret").
+ With("grant_type", "access_key"),
+ ).
+ Build(t)
assert.Nil(t, client.token)
- ctx, err := client.CreateAuthenticatedContext(context.Background())
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
tok := getToken(ctx)
diff --git a/providers/dns/cloudru/internal/types.go b/providers/dns/cloudru/internal/types.go
index d233c73bc..713fd459a 100644
--- a/providers/dns/cloudru/internal/types.go
+++ b/providers/dns/cloudru/internal/types.go
@@ -38,9 +38,9 @@ type Zone struct {
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"`
+ LastCheck time.Time `json:"lastCheck,omitzero"`
+ CreatedAt time.Time `json:"created_at,omitzero"`
+ UpdatedAt time.Time `json:"updated_at,omitzero"`
}
type Record struct {
diff --git a/providers/dns/cloudxns/cloudxns.toml b/providers/dns/cloudxns/cloudxns.toml
index 1486cc4fa..32eae8beb 100644
--- a/providers/dns/cloudxns/cloudxns.toml
+++ b/providers/dns/cloudxns/cloudxns.toml
@@ -9,7 +9,7 @@ Since = "v0.5.0"
Example = '''
CLOUDXNS_API_KEY=xxxx \
CLOUDXNS_SECRET_KEY=yyyy \
-lego --email you@example.com --dns cloudxns -d '*.example.com' -d example.com run
+lego --dns cloudxns -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,7 +17,7 @@ lego --email you@example.com --dns cloudxns -d '*.example.com' -d example.com ru
CLOUDXNS_API_KEY = "The API key"
CLOUDXNS_SECRET_KEY = "The API secret key"
[Configuration.Additional]
- CLOUDXNS_POLLING_INTERVAL = "Time between DNS propagation check"
- CLOUDXNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CLOUDXNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- CLOUDXNS_HTTP_TIMEOUT = "API request timeout"
+ CLOUDXNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: )"
+ CLOUDXNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: )"
+ CLOUDXNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: )"
+ CLOUDXNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: )"
diff --git a/providers/dns/com35/com35.go b/providers/dns/com35/com35.go
new file mode 100644
index 000000000..4a9de3a18
--- /dev/null
+++ b/providers/dns/com35/com35.go
@@ -0,0 +1,104 @@
+// Package com35 implements a DNS provider for solving the DNS-01 challenge using 35.com/三五互联.
+package com35
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/westcn"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "COM35_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultBaseURL = "https://api.35.cn/api/v2"
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config = westcn.Config
+
+// 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, 10*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ prv challenge.ProviderTimeout
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for 35.com/三五互联.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("35com: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for 35.com/三五互联.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("35com: the configuration of the DNS provider is nil")
+ }
+
+ provider, err := westcn.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("35com: %w", err)
+ }
+
+ return &DNSProvider{prv: provider}, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("35com: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("35com: %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.prv.Timeout()
+}
diff --git a/providers/dns/com35/com35.toml b/providers/dns/com35/com35.toml
new file mode 100644
index 000000000..386ee0043
--- /dev/null
+++ b/providers/dns/com35/com35.toml
@@ -0,0 +1,24 @@
+Name = "35.com/三五互联"
+Description = ''''''
+URL = "https://www.35.cn/"
+Code = "com35"
+Since = "v4.31.0"
+
+Example = '''
+COM35_USERNAME="xxx" \
+COM35_PASSWORD="yyy" \
+lego --dns com35 -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ COM35_USERNAME = "Username"
+ COM35_PASSWORD = "API password"
+ [Configuration.Additional]
+ COM35_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ COM35_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ COM35_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ COM35_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://api.35.cn/CustomerCenter/doc/domain_v2.html"
diff --git a/providers/dns/com35/com35_test.go b/providers/dns/com35/com35_test.go
new file mode 100644
index 000000000..78fd8f829
--- /dev/null
+++ b/providers/dns/com35/com35_test.go
@@ -0,0 +1,144 @@
+package com35
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "secret",
+ },
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "secret",
+ },
+ expected: "35com: some credentials information are missing: COM35_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "",
+ },
+ expected: "35com: some credentials information are missing: COM35_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "35com: some credentials information are missing: COM35_USERNAME,COM35_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.prv)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing username",
+ password: "secret",
+ expected: "35com: credentials missing",
+ },
+ {
+ desc: "missing password",
+ username: "user",
+ expected: "35com: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "35com: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Username = test.username
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.prv)
+ } 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/dns/conoha/conoha.go b/providers/dns/conoha/conoha.go
index aa6c68ce9..f7658647c 100644
--- a/providers/dns/conoha/conoha.go
+++ b/providers/dns/conoha/conoha.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/conoha/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -98,6 +99,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
identifier.HTTPClient = config.HTTPClient
}
+ identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient)
+
auth := internal.Auth{
TenantID: config.TenantID,
PasswordCredentials: internal.PasswordCredentials{
@@ -120,6 +123,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/conoha/conoha.toml b/providers/dns/conoha/conoha.toml
index 87903365f..be90acb0d 100644
--- a/providers/dns/conoha/conoha.toml
+++ b/providers/dns/conoha/conoha.toml
@@ -1,4 +1,4 @@
-Name = "ConoHa"
+Name = "ConoHa v2"
Description = ''''''
URL = "https://www.conoha.jp/"
Code = "conoha"
@@ -8,7 +8,7 @@ Example = '''
CONOHA_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \
CONOHA_API_USERNAME=xxxx \
CONOHA_API_PASSWORD=yyyy \
-lego --email you@example.com --dns conoha -d '*.example.com' -d example.com run
+lego --dns conoha -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,11 +17,11 @@ lego --email you@example.com --dns conoha -d '*.example.com' -d example.com run
CONOHA_API_USERNAME = "The API username"
CONOHA_API_PASSWORD = "The API password"
[Configuration.Additional]
- CONOHA_POLLING_INTERVAL = "Time between DNS propagation check"
- CONOHA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CONOHA_TTL = "The TTL of the TXT record used for the DNS challenge"
- CONOHA_HTTP_TIMEOUT = "API request timeout"
- CONOHA_REGION = "The region"
+ CONOHA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ CONOHA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ CONOHA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ CONOHA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ CONOHA_REGION = "The region (Default: tyo1)"
[Links]
- API = "https://www.conoha.jp/docs/"
+ API = "https://doc.conoha.jp/reference/api-vps2/api-dns-vps2"
diff --git a/providers/dns/conoha/conoha_test.go b/providers/dns/conoha/conoha_test.go
index 9db5ba79f..c1c445d48 100644
--- a/providers/dns/conoha/conoha_test.go
+++ b/providers/dns/conoha/conoha_test.go
@@ -72,6 +72,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -155,6 +156,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -168,6 +170,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/conoha/internal/client.go b/providers/dns/conoha/internal/client.go
index 87fbe5a0b..2f039489b 100644
--- a/providers/dns/conoha/internal/client.go
+++ b/providers/dns/conoha/internal/client.go
@@ -25,7 +25,7 @@ type Client struct {
}
// NewClient returns a client instance logged into the ConoHa service.
-func NewClient(region string, token string) (*Client, error) {
+func NewClient(region, token string) (*Client, error) {
baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region))
if err != nil {
return nil, err
@@ -54,7 +54,7 @@ func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, er
return "", fmt.Errorf("no such domain: %s", domainName)
}
-// https://www.conoha.jp/docs/paas-dns-list-domains.php
+// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-list-domains-v2/?btn_id=reference-api-vps2--sidebar_reference-paas-dns-list-domains-v2
func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) {
endpoint := c.baseURL.JoinPath("v1", "domains")
@@ -89,7 +89,7 @@ func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordTy
return "", errors.New("no such record")
}
-// https://www.conoha.jp/docs/paas-dns-list-records-in-a-domain.php
+// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-list-records-in-a-domain-v2/?btn_id=reference-paas-dns-list-domains-v2--sidebar_reference-paas-dns-list-records-in-a-domain-v2
func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) {
endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records")
@@ -114,7 +114,7 @@ func (c *Client) CreateRecord(ctx context.Context, domainID string, record Recor
return err
}
-// https://www.conoha.jp/docs/paas-dns-create-record.php
+// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-create-record-v2/?btn_id=reference-paas-dns-list-records-in-a-domain-v2--sidebar_reference-paas-dns-create-record-v2
func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records")
@@ -124,6 +124,7 @@ func (c *Client) createRecord(ctx context.Context, domainID string, record Recor
}
newRecord := &Record{}
+
err = c.do(req, newRecord)
if err != nil {
return nil, err
@@ -133,7 +134,7 @@ func (c *Client) createRecord(ctx context.Context, domainID string, record Recor
}
// DeleteRecord removes specified record.
-// https://www.conoha.jp/docs/paas-dns-delete-a-record.php
+// https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-delete-a-record-v2/?btn_id=reference-paas-dns-create-record-v2--sidebar_reference-paas-dns-delete-a-record-v2
func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error {
endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID)
diff --git a/providers/dns/conoha/internal/client_test.go b/providers/dns/conoha/internal/client_test.go
index bc27ec212..5e06ffc1d 100644
--- a/providers/dns/conoha/internal/client_test.go
+++ b/providers/dns/conoha/internal/client_test.go
@@ -2,7 +2,6 @@ package internal
import (
"bytes"
- "context"
"fmt"
"io"
"net/http"
@@ -12,60 +11,26 @@ import (
"path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("tyo1", "secret")
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- client, err := NewClient("tyo1", "secret")
- require.NoError(t, err)
-
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-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
- }
-
- writeFixture(rw, filename)
- }
-}
-
-func writeBodyHandler(method, content 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
- }
- _, err := fmt.Fprint(rw, content)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- }
-}
-
-func writeFixture(rw http.ResponseWriter, filename string) {
- 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)
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With("X-Auth-Token", "secret"))
}
func TestClient_GetDomainID(t *testing.T) {
@@ -77,36 +42,36 @@ func TestClient_GetDomainID(t *testing.T) {
testCases := []struct {
desc string
domainName string
- handler http.HandlerFunc
+ response string
expected expected
}{
{
desc: "success",
domainName: "domain1.com.",
- handler: writeFixtureHandler(http.MethodGet, "domains_GET.json"),
+ response: "domains_GET.json",
expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"},
},
{
desc: "non existing domain",
domainName: "domain1.com.",
- handler: writeBodyHandler(http.MethodGet, "{}"),
+ response: "empty.json",
expected: expected{error: true},
},
{
desc: "marshaling error",
domainName: "domain1.com.",
- handler: writeBodyHandler(http.MethodGet, "[]"),
+ response: "empty.json",
expected: expected{error: true},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)).
+ Build(t)
- mux.Handle("/v1/domains", test.handler)
-
- domainID, err := client.GetDomainID(context.Background(), test.domainName)
+ domainID, err := client.GetDomainID(t.Context(), test.domainName)
if test.expected.error {
require.Error(t, err)
@@ -127,16 +92,12 @@ func TestClient_CreateRecord(t *testing.T) {
{
desc: "success",
handler: func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
raw, err := io.ReadAll(req.Body)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
+
defer func() { _ = req.Body.Close() }()
if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` {
@@ -144,18 +105,21 @@ func TestClient_CreateRecord(t *testing.T) {
return
}
- writeFixture(rw, "domains-records_POST.json")
+ file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json"))
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ defer func() { _ = file.Close() }()
+
+ _, _ = io.Copy(rw, file)
},
assert: require.NoError,
},
{
desc: "bad request",
handler: func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
http.Error(rw, "OOPS", http.StatusBadRequest)
},
assert: require.Error,
@@ -164,9 +128,9 @@ func TestClient_CreateRecord(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.Handle("/v1/domains/lego/records", test.handler)
+ client := mockBuilder().
+ Route("POST /v1/domains/lego/records", test.handler).
+ Build(t)
domainID := "lego"
@@ -177,36 +141,30 @@ func TestClient_CreateRecord(t *testing.T) {
TTL: 300,
}
- err := client.CreateRecord(context.Background(), domainID, record)
+ err := client.CreateRecord(t.Context(), domainID, record)
test.assert(t, err)
})
}
}
func TestClient_GetRecordID(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records",
+ servermock.ResponseFromFixture("domains-records_GET.json")).
+ Build(t)
- mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records",
- writeFixtureHandler(http.MethodGet, "domains-records_GET.json"))
-
- recordID, err := client.GetRecordID(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153")
+ recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153")
require.NoError(t, err)
assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID)
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad",
+ servermock.ResponseFromFixture("domains-records_GET.json")).
+ Build(t)
- mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- rw.WriteHeader(http.StatusOK)
- })
-
- err := client.DeleteRecord(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad")
+ err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad")
require.NoError(t, err)
}
diff --git a/providers/dns/conoha/internal/fixtures/empty.json b/providers/dns/conoha/internal/fixtures/empty.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/providers/dns/conoha/internal/fixtures/empty.json
@@ -0,0 +1 @@
+{}
diff --git a/providers/dns/conoha/internal/identity.go b/providers/dns/conoha/internal/identity.go
index 995d55bb6..54fc46bc5 100644
--- a/providers/dns/conoha/internal/identity.go
+++ b/providers/dns/conoha/internal/identity.go
@@ -33,7 +33,7 @@ func NewIdentifier(region string) (*Identifier, error) {
}
// GetToken gets valid token information.
-// https://www.conoha.jp/docs/identity-post_tokens.php
+// https://doc.conoha.jp/reference/api-vps2/api-identity-vps2/identity-post_tokens-v2/?btn_id=reference-paas-dns-delete-a-record-v2--sidebar_reference-identity-post_tokens-v2
func (c *Identifier) GetToken(ctx context.Context, auth Auth) (*IdentityResponse, error) {
endpoint := c.baseURL.JoinPath("v2.0", "tokens")
diff --git a/providers/dns/conoha/internal/identity_test.go b/providers/dns/conoha/internal/identity_test.go
index 027c7f2c7..0bd4c936a 100644
--- a/providers/dns/conoha/internal/identity_test.go
+++ b/providers/dns/conoha/internal/identity_test.go
@@ -1,28 +1,33 @@
package internal
import (
- "context"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestNewClient(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
+func setupIdentifier(server *httptest.Server) (*Identifier, error) {
identifier, err := NewIdentifier("tyo1")
- require.NoError(t, err)
+ if err != nil {
+ return nil, err
+ }
identifier.HTTPClient = server.Client()
identifier.baseURL, _ = url.Parse(server.URL)
- mux.HandleFunc("/v2.0/tokens", writeFixtureHandler(http.MethodPost, "tokens_POST.json"))
+ return identifier, nil
+}
+
+func TestNewClient(t *testing.T) {
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifier,
+ servermock.CheckHeader().WithJSONHeaders(),
+ ).
+ Route("POST /v2.0/tokens", servermock.ResponseFromFixture("tokens_POST.json")).
+ Build(t)
auth := Auth{
TenantID: "487727e3921d44e3bfe7ebb337bf085e",
@@ -32,7 +37,7 @@ func TestNewClient(t *testing.T) {
},
}
- token, err := identifier.GetToken(context.Background(), auth)
+ token, err := identifier.GetToken(t.Context(), auth)
require.NoError(t, err)
expected := &IdentityResponse{Access: Access{Token: Token{ID: "sample00d88246078f2bexample788f7"}}}
diff --git a/providers/dns/conohav3/conohav3.go b/providers/dns/conohav3/conohav3.go
new file mode 100644
index 000000000..c1eace827
--- /dev/null
+++ b/providers/dns/conohav3/conohav3.go
@@ -0,0 +1,203 @@
+// Package conohav3 implements a DNS provider for solving the DNS-01 challenge using ConoHa VPS Ver 3.0 DNS.
+package conohav3
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/conohav3/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "CONOHAV3_"
+
+ EnvRegion = envNamespace + "REGION"
+ EnvTenantID = envNamespace + "TENANT_ID"
+ EnvAPIUserID = envNamespace + "API_USER_ID"
+ EnvAPIPassword = envNamespace + "API_PASSWORD"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Region string
+ TenantID string
+ UserID string
+ Password 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{
+ Region: env.GetOrDefaultString(EnvRegion, "c3j1"),
+ TTL: env.GetOrDefaultInt(EnvTTL, 60),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for ConoHa DNS.
+// Credentials must be passed in the environment variables:
+// CONOHAV3_TENANT_ID, CONOHAV3_API_USER_ID, CONOHAV3_API_PASSWORD.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvTenantID, EnvAPIUserID, EnvAPIPassword)
+ if err != nil {
+ return nil, fmt.Errorf("conohav3: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.TenantID = values[EnvTenantID]
+ config.UserID = values[EnvAPIUserID]
+ config.Password = values[EnvAPIPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ConoHa DNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("conohav3: the configuration of the DNS provider is nil")
+ }
+
+ if config.TenantID == "" || config.UserID == "" || config.Password == "" {
+ return nil, errors.New("conohav3: some credentials information are missing")
+ }
+
+ identifier, err := internal.NewIdentifier(config.Region)
+ if err != nil {
+ return nil, fmt.Errorf("conohav3: failed to create identity client: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ identifier.HTTPClient = config.HTTPClient
+ }
+
+ identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient)
+
+ auth := internal.Auth{
+ Identity: internal.Identity{
+ Methods: []string{"password"},
+ Password: internal.Password{
+ User: internal.User{
+ ID: config.UserID,
+ Password: config.Password,
+ },
+ },
+ },
+ Scope: internal.Scope{
+ Project: internal.Project{
+ ID: config.TenantID,
+ },
+ },
+ }
+
+ token, err := identifier.GetToken(context.Background(), auth)
+ if err != nil {
+ return nil, fmt.Errorf("conohav3: failed to log in: %w", err)
+ }
+
+ client, err := internal.NewClient(config.Region, token)
+ if err != nil {
+ return nil, fmt.Errorf("conohav3: failed to create client: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{config: config, client: client}, nil
+}
+
+// Present creates a TXT record 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("conohav3: could not find zone for domain %q: %w", domain, err)
+ }
+
+ ctx := context.Background()
+
+ id, err := d.client.GetDomainID(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("conohav3: failed to get domain ID: %w", err)
+ }
+
+ record := internal.Record{
+ Name: info.EffectiveFQDN,
+ Type: "TXT",
+ Data: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ err = d.client.CreateRecord(ctx, id, record)
+ if err != nil {
+ return fmt.Errorf("conohav3: failed to create record: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp clears ConoHa DNS 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("conohav3: could not find zone for domain %q: %w", domain, err)
+ }
+
+ ctx := context.Background()
+
+ domID, err := d.client.GetDomainID(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("conohav3: failed to get domain ID: %w", err)
+ }
+
+ recID, err := d.client.GetRecordID(ctx, domID, info.EffectiveFQDN, "TXT", info.Value)
+ if err != nil {
+ return fmt.Errorf("conohav3: failed to get record ID: %w", err)
+ }
+
+ err = d.client.DeleteRecord(ctx, domID, recID)
+ if err != nil {
+ return fmt.Errorf("conohav3: failed to delete record: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
diff --git a/providers/dns/conohav3/conohav3.toml b/providers/dns/conohav3/conohav3.toml
new file mode 100644
index 000000000..e2c80259d
--- /dev/null
+++ b/providers/dns/conohav3/conohav3.toml
@@ -0,0 +1,27 @@
+Name = "ConoHa v3"
+Description = ''''''
+URL = "https://www.conoha.jp/"
+Code = "conohav3"
+Since = "v4.24.0"
+
+Example = '''
+CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \
+CONOHAV3_API_USER_ID=xxxx \
+CONOHAV3_API_PASSWORD=yyyy \
+lego --dns conohav3 -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ CONOHAV3_TENANT_ID = "Tenant ID"
+ CONOHAV3_API_USER_ID = "The API user ID"
+ CONOHAV3_API_PASSWORD = "The API password"
+ [Configuration.Additional]
+ CONOHAV3_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ CONOHAV3_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ CONOHAV3_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ CONOHAV3_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ CONOHAV3_REGION = "The region (Default: c3j1)"
+
+[Links]
+ API = "https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/"
diff --git a/providers/dns/conohav3/conohav3_test.go b/providers/dns/conohav3/conohav3_test.go
new file mode 100644
index 000000000..d68ea3ebb
--- /dev/null
+++ b/providers/dns/conohav3/conohav3_test.go
@@ -0,0 +1,181 @@
+package conohav3
+
+import (
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvTenantID,
+ EnvAPIUserID,
+ EnvAPIPassword).
+ WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "complete credentials, but login failed",
+ envVars: map[string]string{
+ EnvTenantID: "tenant_id",
+ EnvAPIUserID: "api_user_id",
+ EnvAPIPassword: "api_password",
+ },
+ expected: `conohav3: failed to log in: unexpected status code: [status code: 400] body: {"code": 400, "error": "user does not exist"}`,
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvTenantID: "",
+ EnvAPIUserID: "",
+ EnvAPIPassword: "",
+ },
+ expected: "conohav3: some credentials information are missing: CONOHAV3_TENANT_ID,CONOHAV3_API_USER_ID,CONOHAV3_API_PASSWORD",
+ },
+ {
+ desc: "missing tenant id",
+ envVars: map[string]string{
+ EnvTenantID: "",
+ EnvAPIUserID: "api_user_id",
+ EnvAPIPassword: "api_password",
+ },
+ expected: "conohav3: some credentials information are missing: CONOHAV3_TENANT_ID",
+ },
+ {
+ desc: "missing api user id",
+ envVars: map[string]string{
+ EnvTenantID: "tenant_id",
+ EnvAPIUserID: "",
+ EnvAPIPassword: "api_password",
+ },
+ expected: "conohav3: some credentials information are missing: CONOHAV3_API_USER_ID",
+ },
+ {
+ desc: "missing api password",
+ envVars: map[string]string{
+ EnvTenantID: "tenant_id",
+ EnvAPIUserID: "api_user_id",
+ EnvAPIPassword: "",
+ },
+ expected: "conohav3: some credentials information are missing: CONOHAV3_API_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
+ expected string
+ tenant string
+ userid string
+ password string
+ }{
+ {
+ desc: "complete credentials, but login failed",
+ expected: `conohav3: failed to log in: unexpected status code: [status code: 400] body: {"code": 400, "error": "user does not exist"}`,
+ tenant: "tenant_id",
+ userid: "api_user_id",
+ password: "api_password",
+ },
+ {
+ desc: "missing credentials",
+ expected: "conohav3: some credentials information are missing",
+ },
+ {
+ desc: "missing tenant id",
+ expected: "conohav3: some credentials information are missing",
+ userid: "api_user_id",
+ password: "api_password",
+ },
+ {
+ desc: "missing api user id",
+ expected: "conohav3: some credentials information are missing",
+ tenant: "tenant_id",
+ password: "api_password",
+ },
+ {
+ desc: "missing api password",
+ expected: "conohav3: some credentials information are missing",
+ tenant: "tenant_id",
+ userid: "api_user_id",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.TenantID = test.tenant
+ config.UserID = test.userid
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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/dns/conohav3/internal/client.go b/providers/dns/conohav3/internal/client.go
new file mode 100644
index 000000000..2a9e7c2bc
--- /dev/null
+++ b/providers/dns/conohav3/internal/client.go
@@ -0,0 +1,204 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const dnsServiceBaseURL = "https://dns-service.%s.conoha.io"
+
+// Client is a ConoHa API client.
+type Client struct {
+ token string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient returns a client instance logged into the ConoHa service.
+func NewClient(region, token string) (*Client, error) {
+ baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region))
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ token: token,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 5 * time.Second},
+ }, nil
+}
+
+// GetDomainID returns an ID of specified domain.
+func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, error) {
+ domainList, err := c.getDomains(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ for _, domain := range domainList.Domains {
+ if domain.Name == domainName {
+ return domain.UUID, nil
+ }
+ }
+
+ return "", fmt.Errorf("no such domain: %s", domainName)
+}
+
+// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-get_domains_list-v3/?btn_id=reference-api-vps3--sidebar_reference-dnsaas-get_domains_list-v3
+func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) {
+ endpoint := c.baseURL.JoinPath("v1", "domains")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ domainList := &DomainListResponse{}
+
+ err = c.do(req, domainList)
+ if err != nil {
+ return nil, err
+ }
+
+ return domainList, nil
+}
+
+// GetRecordID returns an ID of specified record.
+func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordType, data string) (string, error) {
+ recordList, err := c.getRecords(ctx, domainID)
+ if err != nil {
+ return "", err
+ }
+
+ for _, record := range recordList.Records {
+ if record.Name == recordName && record.Type == recordType && record.Data == data {
+ return record.UUID, nil
+ }
+ }
+
+ return "", errors.New("no such record")
+}
+
+// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-get_records_list-v3/?btn_id=reference-dnsaas-get_domains_list-v3--sidebar_reference-dnsaas-get_records_list-v3
+func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) {
+ endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ recordList := &RecordListResponse{}
+
+ err = c.do(req, recordList)
+ if err != nil {
+ return nil, err
+ }
+
+ return recordList, nil
+}
+
+// CreateRecord adds new record.
+func (c *Client) CreateRecord(ctx context.Context, domainID string, record Record) error {
+ _, err := c.createRecord(ctx, domainID, record)
+ return err
+}
+
+// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-create_record-v3/?btn_id=reference-dnsaas-get_records_list-v3--sidebar_reference-dnsaas-create_record-v3
+func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) {
+ endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return nil, err
+ }
+
+ newRecord := &Record{}
+
+ err = c.do(req, newRecord)
+ if err != nil {
+ return nil, err
+ }
+
+ return newRecord, nil
+}
+
+// DeleteRecord removes specified record.
+// https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-delete_record-v3/?btn_id=reference-dnsaas-create_record-v3--sidebar_reference-dnsaas-delete_record-v3
+func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error {
+ endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID)
+
+ 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 {
+ if c.token != "" {
+ req.Header.Set("X-Auth-Token", c.token)
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
+ 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
+}
+
+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/dns/conohav3/internal/client_test.go b/providers/dns/conohav3/internal/client_test.go
new file mode 100644
index 000000000..66babae49
--- /dev/null
+++ b/providers/dns/conohav3/internal/client_test.go
@@ -0,0 +1,171 @@
+package internal
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("c3j1", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("X-Auth-Token", "secret"))
+}
+
+func TestClient_GetDomainID(t *testing.T) {
+ type expected struct {
+ domainID string
+ error bool
+ }
+
+ testCases := []struct {
+ desc string
+ domainName string
+ response string
+ expected expected
+ }{
+ {
+ desc: "success",
+ domainName: "domain1.com.",
+ response: "domains_GET.json",
+ expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"},
+ },
+ {
+ desc: "non existing domain",
+ domainName: "domain1.com.",
+ response: "empty.json",
+ expected: expected{error: true},
+ },
+ {
+ desc: "marshaling error",
+ domainName: "domain1.com.",
+ response: "empty.json",
+ expected: expected{error: true},
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)).
+ Build(t)
+
+ domainID, err := client.GetDomainID(t.Context(), test.domainName)
+
+ if test.expected.error {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, test.expected.domainID, domainID)
+ }
+ })
+ }
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ testCases := []struct {
+ desc string
+ handler http.HandlerFunc
+ assert require.ErrorAssertionFunc
+ }{
+ {
+ desc: "success",
+ handler: func(rw http.ResponseWriter, req *http.Request) {
+ raw, err := io.ReadAll(req.Body)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ defer func() { _ = req.Body.Close() }()
+
+ if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` {
+ http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
+ return
+ }
+
+ file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json"))
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ defer func() { _ = file.Close() }()
+
+ _, _ = io.Copy(rw, file)
+ },
+ assert: require.NoError,
+ },
+ {
+ desc: "bad request",
+ handler: func(rw http.ResponseWriter, req *http.Request) {
+ http.Error(rw, "OOPS", http.StatusBadRequest)
+ },
+ assert: require.Error,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /v1/domains/lego/records", test.handler).
+ Build(t)
+
+ domainID := "lego"
+
+ record := Record{
+ Name: "lego.com.",
+ Type: "TXT",
+ Data: "txtTXTtxt",
+ TTL: 300,
+ }
+
+ err := client.CreateRecord(t.Context(), domainID, record)
+ test.assert(t, err)
+ })
+ }
+}
+
+func TestClient_GetRecordID(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records",
+ servermock.ResponseFromFixture("domains-records_GET.json")).
+ Build(t)
+
+ recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153")
+ require.NoError(t, err)
+
+ assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad",
+ servermock.ResponseFromFixture("domains-records_GET.json")).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/conohav3/internal/fixtures/domains-records_GET.json b/providers/dns/conohav3/internal/fixtures/domains-records_GET.json
new file mode 100644
index 000000000..f982c1911
--- /dev/null
+++ b/providers/dns/conohav3/internal/fixtures/domains-records_GET.json
@@ -0,0 +1,43 @@
+{
+ "records": [
+ {
+ "uuid": "2e32e609-3a4f-45ba-bdef-e50eacd345ad",
+ "name": "www.example.com.",
+ "type": "A",
+ "ttl": 3600,
+ "created_at": "2012-11-02T19:56:26.000000",
+ "updated_at": "2012-11-04T13:22:36.000000",
+ "data": "15.185.172.153",
+ "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+ "version": 1,
+ "gslb_region": "JP",
+ "gslb_weight": 250,
+ "gslb_check": 12300
+ },
+ {
+ "uuid": "8e9ecf3e-fb92-4a3a-a8ae-7596f167bea3",
+ "name": "host1.example.com.",
+ "type": "A",
+ "ttl": 3600,
+ "created_at": "2012-11-04T13:57:50.000000",
+ "updated_at": null,
+ "data": "15.185.172.154",
+ "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+ "version": 1,
+ "gslb_region": "US",
+ "gslb_weight": 220,
+ "gslb_check": 12200
+ },
+ {
+ "uuid": "4ad19089-3e62-40f8-9482-17cc8ccb92cb",
+ "name": "web.example.com.",
+ "type": "CNAME",
+ "ttl": 3600,
+ "created_at": "2012-11-04T13:58:16.393735",
+ "updated_at": null,
+ "data": "www.example.com.",
+ "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+ "version": 1
+ }
+ ]
+}
diff --git a/providers/dns/conohav3/internal/fixtures/domains-records_POST.json b/providers/dns/conohav3/internal/fixtures/domains-records_POST.json
new file mode 100644
index 000000000..d0f71c03e
--- /dev/null
+++ b/providers/dns/conohav3/internal/fixtures/domains-records_POST.json
@@ -0,0 +1,13 @@
+{
+ "uuid": "2e32e609-3a4f-45ba-bdef-e50eacd345ad",
+ "name": "www.example.com.",
+ "type": "A",
+ "created_at": "2012-11-02T19:56:26.366792",
+ "updated_at": null,
+ "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+ "ttl": null,
+ "data": "192.0.2.3",
+ "gslb_check": 1,
+ "gslb_region": "JP",
+ "gslb_weight": 250
+}
diff --git a/providers/dns/conohav3/internal/fixtures/domains_GET.json b/providers/dns/conohav3/internal/fixtures/domains_GET.json
new file mode 100644
index 000000000..6f8603a57
--- /dev/null
+++ b/providers/dns/conohav3/internal/fixtures/domains_GET.json
@@ -0,0 +1,25 @@
+{
+ "domains": [
+ {
+ "uuid": "09494b72-b65b-4297-9efb-187f65a0553e",
+ "name": "domain1.com.",
+ "project_id": "cf661142-e577-40b5-b3eb-75795cdc0cd7",
+ "serial": 1701909248,
+ "ttl": 3600,
+ "email": "nsadmin1@example.org",
+ "created_at": "2023-12-07T00:34:08Z",
+ "updated_at": "2023-12-07T00:34:08Z"
+ },
+ {
+ "uuid": "cf661142-e577-40b5-b3eb-75795cdc0cd7",
+ "name": "domain2.com.",
+ "project_id": "cf661144-e578-39b6-b4eb-75794cdc1cd8",
+ "serial": 1351800670,
+ "ttl": 7200,
+ "email": "nsadmin2@example.org",
+ "created_at": "2012-11-01T20:11:08Z",
+ "updated_at": "2012-12-01T20:11:08Z"
+ }
+ ],
+ "total_count": 1
+}
diff --git a/providers/dns/conohav3/internal/fixtures/empty.json b/providers/dns/conohav3/internal/fixtures/empty.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/providers/dns/conohav3/internal/fixtures/empty.json
@@ -0,0 +1 @@
+{}
diff --git a/providers/dns/conohav3/internal/identity.go b/providers/dns/conohav3/internal/identity.go
new file mode 100644
index 000000000..6a9ad7f1e
--- /dev/null
+++ b/providers/dns/conohav3/internal/identity.go
@@ -0,0 +1,71 @@
+// internal/identity.go
+
+package internal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const identityBaseURL = "https://identity.%s.conoha.io"
+
+type Identifier struct {
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewIdentifier creates a new Identifier.
+func NewIdentifier(region string) (*Identifier, error) {
+ baseURL, err := url.Parse(fmt.Sprintf(identityBaseURL, region))
+ if err != nil {
+ return nil, err
+ }
+
+ return &Identifier{
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 5 * time.Second},
+ }, nil
+}
+
+// GetToken returns the x-subject-token from Identity API.
+// https://doc.conoha.jp/reference/api-vps3/api-identity-vps3/identity-post_tokens-v3/?btn_id=reference-api-guideline-v3--sidebar_reference-identity-post_tokens-v3
+func (c *Identifier) GetToken(ctx context.Context, auth Auth) (string, error) {
+ endpoint := c.baseURL.JoinPath("v3", "auth", "tokens")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, &IdentityRequest{Auth: auth})
+ if err != nil {
+ return "", err
+ }
+
+ return c.do(req)
+}
+
+// do sends the request and returns the token from x-subject-token header.
+func (c *Identifier) do(req *http.Request) (string, error) {
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return "", errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+ }
+
+ token := resp.Header.Get("x-subject-token")
+ if token == "" {
+ return "", errors.New("x-subject-token header is missing in response")
+ }
+
+ _, _ = io.Copy(io.Discard, resp.Body)
+
+ return token, nil
+}
diff --git a/providers/dns/conohav3/internal/identity_test.go b/providers/dns/conohav3/internal/identity_test.go
new file mode 100644
index 000000000..d479a18d9
--- /dev/null
+++ b/providers/dns/conohav3/internal/identity_test.go
@@ -0,0 +1,57 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupIdentifier(server *httptest.Server) (*Identifier, error) {
+ identifier, err := NewIdentifier("c3j1")
+ if err != nil {
+ return nil, err
+ }
+
+ identifier.HTTPClient = server.Client()
+ identifier.baseURL, _ = url.Parse(server.URL)
+
+ return identifier, nil
+}
+
+func TestGetToken_HeaderToken(t *testing.T) {
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifier,
+ servermock.CheckHeader().WithJSONHeaders(),
+ ).
+ Route("POST /v3/auth/tokens",
+ servermock.ResponseFromFixture("empty.json").
+ WithStatusCode(http.StatusCreated).
+ WithHeader("x-subject-token", "sample-header-token-123")).
+ Build(t)
+
+ auth := Auth{
+ Identity: Identity{
+ Methods: []string{"password"},
+ Password: Password{
+ User: User{
+ ID: "dummy-id",
+ Password: "dummy-password",
+ },
+ },
+ },
+ Scope: Scope{
+ Project: Project{
+ ID: "dummy-project-id",
+ },
+ },
+ }
+
+ token, err := identifier.GetToken(t.Context(), auth)
+ require.NoError(t, err)
+
+ assert.Equal(t, "sample-header-token-123", token)
+}
diff --git a/providers/dns/conohav3/internal/types.go b/providers/dns/conohav3/internal/types.go
new file mode 100644
index 000000000..99a162dd0
--- /dev/null
+++ b/providers/dns/conohav3/internal/types.go
@@ -0,0 +1,65 @@
+package internal
+
+// IdentityRequest is the top-level payload sent to the Identity v3.
+type IdentityRequest struct {
+ Auth Auth `json:"auth"`
+}
+
+// Auth authentication credentials (Identity) and scope (Scope).
+type Auth struct {
+ Identity Identity `json:"identity"`
+ Scope Scope `json:"scope"`
+}
+
+// Identity describes how the client will authenticate.
+// In ConoHa v3.0, only support the "password" method.
+type Identity struct {
+ Methods []string `json:"methods"`
+ Password Password `json:"password"`
+}
+
+// Password nests the concrete user credentials used by the password auth method.
+type Password struct {
+ User User `json:"user"`
+}
+
+// User holds the API User ID and password that will be verified by the Identity service.
+type User struct {
+ ID string `json:"id"`
+ Password string `json:"password"`
+}
+
+// Scope specifies which tenant the issued token should be scoped to.
+type Scope struct {
+ Project Project `json:"project"`
+}
+
+// Project identifies the target tenant by UUID.
+type Project struct {
+ ID string `json:"id"`
+}
+
+// DomainListResponse is returned by `GET /v1/domains` and contains all DNS zones (domains) owned by the project.
+type DomainListResponse struct {
+ Domains []Domain `json:"domains"`
+}
+
+// Domain represents a single hosted DNS zone.
+type Domain struct {
+ UUID string `json:"uuid"`
+ Name string `json:"name"`
+}
+
+// RecordListResponse is returned by `GET /v1/domains/{domain_uuid}/records` and lists every record in the zone.
+type RecordListResponse struct {
+ Records []Record `json:"records"`
+}
+
+// Record represents a DNS record inside a zone.
+type Record struct {
+ UUID string `json:"uuid,omitempty"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Data string `json:"data"`
+ TTL int `json:"ttl"`
+}
diff --git a/providers/dns/constellix/constellix.go b/providers/dns/constellix/constellix.go
index f981b4974..777e93308 100644
--- a/providers/dns/constellix/constellix.go
+++ b/providers/dns/constellix/constellix.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/constellix/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/hashicorp/go-retryablehttp"
)
@@ -96,7 +97,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
retryClient.HTTPClient = tr.Wrap(config.HTTPClient)
retryClient.Backoff = backoff
- client := internal.NewClient(retryClient.StandardClient())
+ client := internal.NewClient(clientdebug.Wrap(retryClient.StandardClient()))
return &DNSProvider{config: config, client: client}, nil
}
@@ -199,6 +200,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("constellix: failed to delete TXT records: %w", err)
}
+
return nil
}
diff --git a/providers/dns/constellix/constellix.toml b/providers/dns/constellix/constellix.toml
index 02442d31d..171a0de99 100644
--- a/providers/dns/constellix/constellix.toml
+++ b/providers/dns/constellix/constellix.toml
@@ -7,7 +7,7 @@ Since = "v3.4.0"
Example = '''
CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-lego --email you@example.com --dns constellix -d '*.example.com' -d example.com run
+lego --dns constellix -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns constellix -d '*.example.com' -d example.com
CONSTELLIX_API_KEY = "User API key"
CONSTELLIX_SECRET_KEY = "User secret key"
[Configuration.Additional]
- CONSTELLIX_POLLING_INTERVAL = "Time between DNS propagation check"
- CONSTELLIX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CONSTELLIX_TTL = "The TTL of the TXT record used for the DNS challenge"
- CONSTELLIX_HTTP_TIMEOUT = "API request timeout"
+ CONSTELLIX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ CONSTELLIX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ CONSTELLIX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ CONSTELLIX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api-docs.constellix.com"
diff --git a/providers/dns/constellix/constellix_test.go b/providers/dns/constellix/constellix_test.go
index e3a30caca..e38258292 100644
--- a/providers/dns/constellix/constellix_test.go
+++ b/providers/dns/constellix/constellix_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/constellix/internal/auth.go b/providers/dns/constellix/internal/auth.go
index 1a136012d..9193572eb 100644
--- a/providers/dns/constellix/internal/auth.go
+++ b/providers/dns/constellix/internal/auth.go
@@ -28,6 +28,7 @@ func NewTokenTransport(apiKey, secretKey string) (*TokenTransport, error) {
if apiKey == "" {
return nil, errors.New("credentials missing: API key")
}
+
if secretKey == "" {
return nil, errors.New("credentials missing: secret key")
}
@@ -57,6 +58,7 @@ func (t *TokenTransport) transport() http.RoundTripper {
if t.Transport != nil {
return t.Transport
}
+
return http.DefaultTransport
}
diff --git a/providers/dns/constellix/internal/domains.go b/providers/dns/constellix/internal/domains.go
index 485f0d537..fa7027f55 100644
--- a/providers/dns/constellix/internal/domains.go
+++ b/providers/dns/constellix/internal/domains.go
@@ -30,10 +30,12 @@ func (s *DomainService) GetAll(ctx context.Context, params *PaginationParameters
if errQ != nil {
return nil, errQ
}
+
req.URL.RawQuery = v.Encode()
}
var domains []Domain
+
err = s.client.do(req, &domains)
if err != nil {
return nil, err
@@ -78,6 +80,7 @@ func (s *DomainService) Search(ctx context.Context, filter searchFilter, value s
req.URL.RawQuery = query.Encode()
var domains []Domain
+
err = s.client.do(req, &domains)
if err != nil {
var nf *NotFound
diff --git a/providers/dns/constellix/internal/domains_test.go b/providers/dns/constellix/internal/domains_test.go
index 1b0779b3d..468db4613 100644
--- a/providers/dns/constellix/internal/domains_test.go
+++ b/providers/dns/constellix/internal/domains_test.go
@@ -1,94 +1,57 @@
package internal
import (
- "context"
- "io"
- "net/http"
"net/http/httptest"
- "os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(server.Client())
+ client.BaseURL = server.URL
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(server.Client())
- client.BaseURL = server.URL
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestDomainService_GetAll(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains", servermock.ResponseFromFixture("domains-GetAll.json")).
+ Build(t)
- mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/domains-GetAll.json")
- 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
- }
- })
-
- data, err := client.Domains.GetAll(context.Background(), nil)
+ data, err := client.Domains.GetAll(t.Context(), nil)
require.NoError(t, err)
expected := []Domain{
- {ID: 273301, Name: "aaa.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"},
- {ID: 273302, Name: "bbb.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"},
- {ID: 273303, Name: "ccc.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"},
- {ID: 273304, Name: "ddd.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"},
+ {ID: 273301, Name: "aaa.example", TypeID: 1, Version: 9, Status: "ACTIVE"},
+ {ID: 273302, Name: "bbb.example", TypeID: 1, Version: 9, Status: "ACTIVE"},
+ {ID: 273303, Name: "ccc.example", TypeID: 1, Version: 9, Status: "ACTIVE"},
+ {ID: 273304, Name: "ddd.example", TypeID: 1, Version: 9, Status: "ACTIVE"},
}
assert.Equal(t, expected, data)
}
func TestDomainService_Search(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/search",
+ servermock.ResponseFromFixture("domains-Search.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("exact", "example.com")).
+ Build(t)
- mux.HandleFunc("/v1/domains/search", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/domains-Search.json")
- 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
- }
- })
-
- data, err := client.Domains.Search(context.Background(), Exact, "lego.wtf")
+ data, err := client.Domains.Search(t.Context(), Exact, "example.com")
require.NoError(t, err)
expected := []Domain{
- {ID: 273302, Name: "lego.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"},
+ {ID: 273302, Name: "example.com", TypeID: 1, Version: 9, Status: "ACTIVE"},
}
assert.Equal(t, expected, data)
diff --git a/providers/dns/constellix/internal/fixtures/domains-GetAll.json b/providers/dns/constellix/internal/fixtures/domains-GetAll.json
index 5ff2ad41d..8ccb4e52c 100644
--- a/providers/dns/constellix/internal/fixtures/domains-GetAll.json
+++ b/providers/dns/constellix/internal/fixtures/domains-GetAll.json
@@ -1,7 +1,7 @@
[
{
"id": 273301,
- "name": "aaa.wtf",
+ "name": "aaa.example",
"soa": {
"primaryNameserver": "ns11.constellix.com.",
"email": "dns.constellix.com.",
@@ -36,7 +36,7 @@
},
{
"id": 273302,
- "name": "bbb.wtf",
+ "name": "bbb.example",
"soa": {
"primaryNameserver": "ns11.constellix.com.",
"email": "dns.constellix.com.",
@@ -71,7 +71,7 @@
},
{
"id": 273303,
- "name": "ccc.wtf",
+ "name": "ccc.example",
"soa": {
"primaryNameserver": "ns11.constellix.com.",
"email": "dns.constellix.com.",
@@ -106,7 +106,7 @@
},
{
"id": 273304,
- "name": "ddd.wtf",
+ "name": "ddd.example",
"soa": {
"primaryNameserver": "ns11.constellix.com.",
"email": "dns.constellix.com.",
diff --git a/providers/dns/constellix/internal/fixtures/domains-Search.json b/providers/dns/constellix/internal/fixtures/domains-Search.json
index 5d018a39a..c33272515 100644
--- a/providers/dns/constellix/internal/fixtures/domains-Search.json
+++ b/providers/dns/constellix/internal/fixtures/domains-Search.json
@@ -1,7 +1,7 @@
[
{
"id": 273302,
- "name": "lego.wtf",
+ "name": "example.com",
"soa": {
"primaryNameserver": "ns11.constellix.com.",
"email": "dns.constellix.com.",
diff --git a/providers/dns/constellix/internal/txtrecords.go b/providers/dns/constellix/internal/txtrecords.go
index 7880da4d2..bd00d84b7 100644
--- a/providers/dns/constellix/internal/txtrecords.go
+++ b/providers/dns/constellix/internal/txtrecords.go
@@ -32,6 +32,7 @@ func (s *TxtRecordService) Create(ctx context.Context, domainID int64, record Re
}
var records []Record
+
err = s.client.do(req, &records)
if err != nil {
return nil, err
@@ -54,6 +55,7 @@ func (s *TxtRecordService) GetAll(ctx context.Context, domainID int64) ([]Record
}
var records []Record
+
err = s.client.do(req, &records)
if err != nil {
return nil, err
@@ -76,6 +78,7 @@ func (s *TxtRecordService) Get(ctx context.Context, domainID, recordID int64) (*
}
var records Record
+
err = s.client.do(req, &records)
if err != nil {
return nil, err
@@ -103,6 +106,7 @@ func (s *TxtRecordService) Update(ctx context.Context, domainID, recordID int64,
}
var msg SuccessMessage
+
err = s.client.do(req, &msg)
if err != nil {
return nil, err
@@ -125,6 +129,7 @@ func (s *TxtRecordService) Delete(ctx context.Context, domainID, recordID int64)
}
var msg *SuccessMessage
+
err = s.client.do(req, &msg)
if err != nil {
return nil, err
diff --git a/providers/dns/constellix/internal/txtrecords_test.go b/providers/dns/constellix/internal/txtrecords_test.go
index 7adc4af5c..54d10dc38 100644
--- a/providers/dns/constellix/internal/txtrecords_test.go
+++ b/providers/dns/constellix/internal/txtrecords_test.go
@@ -1,41 +1,22 @@
package internal
import (
- "context"
"encoding/json"
- "io"
- "net/http"
"os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTxtRecordService_Create(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-Create.json"),
+ servermock.CheckRequestJSONBody(`{"name":""}`)).
+ Build(t)
- mux.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/records-Create.json")
- 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
- }
- })
-
- records, err := client.TxtRecords.Create(context.Background(), 12345, RecordRequest{})
+ records, err := client.TxtRecords.Create(t.Context(), 12345, RecordRequest{})
require.NoError(t, err)
recordsJSON, err := json.Marshal(records)
@@ -48,29 +29,11 @@ func TestTxtRecordService_Create(t *testing.T) {
}
func TestTxtRecordService_GetAll(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-GetAll.json")).
+ Build(t)
- mux.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/records-GetAll.json")
- 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
- }
- })
-
- records, err := client.TxtRecords.GetAll(context.Background(), 12345)
+ records, err := client.TxtRecords.GetAll(t.Context(), 12345)
require.NoError(t, err)
recordsJSON, err := json.Marshal(records)
@@ -83,29 +46,11 @@ func TestTxtRecordService_GetAll(t *testing.T) {
}
func TestTxtRecordService_Get(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/12345/records/txt/6789", servermock.ResponseFromFixture("records-Get.json")).
+ Build(t)
- mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/records-Get.json")
- 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
- }
- })
-
- record, err := client.TxtRecords.Get(context.Background(), 12345, 6789)
+ record, err := client.TxtRecords.Get(t.Context(), 12345, 6789)
require.NoError(t, err)
expected := &Record{
@@ -131,22 +76,12 @@ func TestTxtRecordService_Get(t *testing.T) {
}
func TestTxtRecordService_Update(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("PUT /v1/domains/12345/records/txt/6789",
+ servermock.RawStringResponse(`{"success":"Record updated successfully"}`)).
+ Build(t)
- mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPut {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- _, err := rw.Write([]byte(`{"success":"Record updated successfully"}`))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- msg, err := client.TxtRecords.Update(context.Background(), 12345, 6789, RecordRequest{})
+ msg, err := client.TxtRecords.Update(t.Context(), 12345, 6789, RecordRequest{})
require.NoError(t, err)
expected := &SuccessMessage{Success: "Record updated successfully"}
@@ -154,22 +89,12 @@ func TestTxtRecordService_Update(t *testing.T) {
}
func TestTxtRecordService_Delete(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /v1/domains/12345/records/txt/6789",
+ servermock.RawStringResponse(`{"success":"Record deleted successfully"}`)).
+ Build(t)
- mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- _, err := rw.Write([]byte(`{"success":"Record deleted successfully"}`))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- msg, err := client.TxtRecords.Delete(context.Background(), 12345, 6789)
+ msg, err := client.TxtRecords.Delete(t.Context(), 12345, 6789)
require.NoError(t, err)
expected := &SuccessMessage{Success: "Record deleted successfully"}
@@ -177,29 +102,11 @@ func TestTxtRecordService_Delete(t *testing.T) {
}
func TestTxtRecordService_Search(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/12345/records/txt/search", servermock.ResponseFromFixture("records-Search.json")).
+ Build(t)
- mux.HandleFunc("/v1/domains/12345/records/txt/search", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/records-Search.json")
- 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
- }
- })
-
- records, err := client.TxtRecords.Search(context.Background(), 12345, Exact, "test")
+ records, err := client.TxtRecords.Search(t.Context(), 12345, Exact, "test")
require.NoError(t, err)
recordsJSON, err := json.Marshal(records)
diff --git a/providers/dns/corenetworks/corenetworks.go b/providers/dns/corenetworks/corenetworks.go
index 119b3c16b..cde58a2bf 100644
--- a/providers/dns/corenetworks/corenetworks.go
+++ b/providers/dns/corenetworks/corenetworks.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/corenetworks/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -90,6 +91,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/corenetworks/corenetworks.toml b/providers/dns/corenetworks/corenetworks.toml
index f2bae017c..09840bb1b 100644
--- a/providers/dns/corenetworks/corenetworks.toml
+++ b/providers/dns/corenetworks/corenetworks.toml
@@ -7,7 +7,7 @@ Since = "v4.20.0"
Example = '''
CORENETWORKS_LOGIN="xxxx" \
CORENETWORKS_PASSWORD="yyyy" \
-lego --email you@example.com --dns corenetworks -d '*.example.com' -d example.com run
+lego --dns corenetworks -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,11 +15,11 @@ lego --email you@example.com --dns corenetworks -d '*.example.com' -d example.co
CORENETWORKS_LOGIN = "The username of the API account"
CORENETWORKS_PASSWORD = "The password"
[Configuration.Additional]
- CORENETWORKS_POLLING_INTERVAL = "Time between DNS propagation check"
- CORENETWORKS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- CORENETWORKS_TTL = "The TTL of the TXT record used for the DNS challenge"
- CORENETWORKS_HTTP_TIMEOUT = "API request timeout"
- CORENETWORKS_SEQUENCE_INTERVAL = "Time between sequential requests"
+ CORENETWORKS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ CORENETWORKS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ CORENETWORKS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ CORENETWORKS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ CORENETWORKS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
[Links]
API = "https://beta.api.core-networks.de/doc/"
diff --git a/providers/dns/corenetworks/corenetworks_test.go b/providers/dns/corenetworks/corenetworks_test.go
index 3cd80f88d..911693468 100644
--- a/providers/dns/corenetworks/corenetworks_test.go
+++ b/providers/dns/corenetworks/corenetworks_test.go
@@ -43,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -111,6 +112,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -124,6 +126,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/corenetworks/internal/client.go b/providers/dns/corenetworks/internal/client.go
index 993b01f1e..bdc17f2c1 100644
--- a/providers/dns/corenetworks/internal/client.go
+++ b/providers/dns/corenetworks/internal/client.go
@@ -38,7 +38,7 @@ func NewClient(login, password string) *Client {
// ListZone gets a list of all DNS zones.
// https://beta.api.core-networks.de/doc/#functon_dnszones
-func (c Client) ListZone(ctx context.Context) ([]Zone, error) {
+func (c *Client) ListZone(ctx context.Context) ([]Zone, error) {
endpoint := c.baseURL.JoinPath("dnszones")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -47,6 +47,7 @@ func (c Client) ListZone(ctx context.Context) ([]Zone, error) {
}
var zones []Zone
+
err = c.do(req, &zones)
if err != nil {
return nil, err
@@ -57,7 +58,7 @@ func (c Client) ListZone(ctx context.Context) ([]Zone, error) {
// GetZoneDetails provides detailed information about a DNS zone.
// https://beta.api.core-networks.de/doc/#functon_dnszones_details
-func (c Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails, error) {
+func (c *Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails, error) {
endpoint := c.baseURL.JoinPath("dnszones", zone)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -66,6 +67,7 @@ func (c Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails,
}
var details ZoneDetails
+
err = c.do(req, &details)
if err != nil {
return nil, err
@@ -76,7 +78,7 @@ func (c Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails,
// ListRecords gets a list of DNS records belonging to the zone.
// https://beta.api.core-networks.de/doc/#functon_dnszones_records
-func (c Client) ListRecords(ctx context.Context, zone string) ([]Record, error) {
+func (c *Client) ListRecords(ctx context.Context, zone string) ([]Record, error) {
endpoint := c.baseURL.JoinPath("dnszones", zone, "records")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -85,6 +87,7 @@ func (c Client) ListRecords(ctx context.Context, zone string) ([]Record, error)
}
var records []Record
+
err = c.do(req, &records)
if err != nil {
return nil, err
@@ -95,7 +98,7 @@ func (c Client) ListRecords(ctx context.Context, zone string) ([]Record, error)
// AddRecord adds a record.
// https://beta.api.core-networks.de/doc/#functon_dnszones_records_add
-func (c Client) AddRecord(ctx context.Context, zone string, record Record) error {
+func (c *Client) AddRecord(ctx context.Context, zone string, record Record) error {
endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "/")
if record.Name == "" {
@@ -117,7 +120,7 @@ func (c Client) AddRecord(ctx context.Context, zone string, record Record) error
// DeleteRecords deletes all DNS records of a zone that match the DNS record passed.
// https://beta.api.core-networks.de/doc/#functon_dnszones_records_delete
-func (c Client) DeleteRecords(ctx context.Context, zone string, record Record) error {
+func (c *Client) DeleteRecords(ctx context.Context, zone string, record Record) error {
endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "delete")
if record.Name == "" {
@@ -139,7 +142,7 @@ func (c Client) DeleteRecords(ctx context.Context, zone string, record Record) e
// CommitRecords sends a commit to the zone.
// https://beta.api.core-networks.de/doc/#functon_dnszones_commit
-func (c Client) CommitRecords(ctx context.Context, zone string) error {
+func (c *Client) CommitRecords(ctx context.Context, zone string) error {
endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "commit")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil)
@@ -155,7 +158,7 @@ func (c Client) CommitRecords(ctx context.Context, zone string) error {
return nil
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
at := getToken(req.Context())
if at != "" {
req.Header.Set(authorizationHeader, "Bearer "+at)
diff --git a/providers/dns/corenetworks/internal/client_test.go b/providers/dns/corenetworks/internal/client_test.go
index 0fff0d5ae..ca5c81a65 100644
--- a/providers/dns/corenetworks/internal/client_test.go
+++ b/providers/dns/corenetworks/internal/client_test.go
@@ -1,115 +1,36 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("user", "secret")
- client.baseURL, _ = url.Parse(server.URL)
- client.HTTPClient = server.Client()
-
- return client, mux
-}
-
-func testHandler(method string, statusCode int, 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.StatusMethodNotAllowed)
- return
- }
-
- rw.WriteHeader(statusCode)
-
- if statusCode == http.StatusNoContent {
- return
- }
-
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, fmt.Sprintf(`message %v`, err), http.StatusInternalServerError)
- return
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, fmt.Sprintf(`message %v`, err), http.StatusInternalServerError)
- return
- }
- }
-}
-
-func testHandlerAuth(method string, statusCode int, filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- rw.WriteHeader(statusCode)
-
- if statusCode == http.StatusNoContent {
- return
- }
-
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
- }
-}
-
-func TestClient_CreateAuthenticationToken(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/auth/token", testHandlerAuth(http.MethodPost, http.StatusOK, "auth.json"))
-
- ctx := context.Background()
-
- token, err := client.CreateAuthenticationToken(ctx)
- require.NoError(t, err)
-
- expected := &Token{
- Token: "authsecret",
- Expires: 123,
- }
- assert.Equal(t, expected, token)
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestClient_ListZone(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /dnszones/",
+ servermock.ResponseFromFixture("ListZone.json")).
+ Build(t)
- mux.HandleFunc("/dnszones/", testHandler(http.MethodGet, http.StatusOK, "ListZone.json"))
-
- ctx := context.Background()
+ ctx := t.Context()
zones, err := client.ListZone(ctx)
require.NoError(t, err)
@@ -123,13 +44,12 @@ func TestClient_ListZone(t *testing.T) {
}
func TestClient_GetZoneDetails(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /dnszones/example.com",
+ servermock.ResponseFromFixture("GetZoneDetails.json")).
+ Build(t)
- mux.HandleFunc("/dnszones/example.com", testHandler(http.MethodGet, http.StatusOK, "GetZoneDetails.json"))
-
- ctx := context.Background()
-
- zone, err := client.GetZoneDetails(ctx, "example.com")
+ zone, err := client.GetZoneDetails(t.Context(), "example.com")
require.NoError(t, err)
expected := &ZoneDetails{
@@ -143,13 +63,12 @@ func TestClient_GetZoneDetails(t *testing.T) {
}
func TestClient_ListRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /dnszones/example.com/records/",
+ servermock.ResponseFromFixture("ListRecords.json")).
+ Build(t)
- mux.HandleFunc("/dnszones/example.com/records/", testHandler(http.MethodGet, http.StatusOK, "ListRecords.json"))
-
- ctx := context.Background()
-
- records, err := client.ListRecords(ctx, "example.com")
+ records, err := client.ListRecords(t.Context(), "example.com")
require.NoError(t, err)
expected := []Record{
@@ -177,38 +96,35 @@ func TestClient_ListRecords(t *testing.T) {
}
func TestClient_AddRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/dnszones/example.com/records/", testHandler(http.MethodPost, http.StatusNoContent, ""))
-
- ctx := context.Background()
+ client := mockBuilder().
+ Route("POST /dnszones/example.com/records/",
+ servermock.Noop().WithStatusCode(http.StatusNoContent)).
+ Build(t)
record := Record{Name: "www", TTL: 3600, Type: "A", Data: "127.0.0.1"}
- err := client.AddRecord(ctx, "example.com", record)
+ err := client.AddRecord(t.Context(), "example.com", record)
require.NoError(t, err)
}
func TestClient_DeleteRecords(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/dnszones/example.com/records/delete", testHandler(http.MethodPost, http.StatusNoContent, ""))
-
- ctx := context.Background()
+ client := mockBuilder().
+ Route("POST /dnszones/example.com/records/delete",
+ servermock.Noop().WithStatusCode(http.StatusNoContent)).
+ Build(t)
record := Record{Name: "www", Type: "A", Data: "127.0.0.1"}
- err := client.DeleteRecords(ctx, "example.com", record)
+ err := client.DeleteRecords(t.Context(), "example.com", record)
require.NoError(t, err)
}
func TestClient_CommitRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /dnszones/example.com/records/commit",
+ servermock.Noop().WithStatusCode(http.StatusNoContent)).
+ Build(t)
- mux.HandleFunc("/dnszones/example.com/records/commit", testHandler(http.MethodPost, http.StatusNoContent, ""))
-
- ctx := context.Background()
-
- err := client.CommitRecords(ctx, "example.com")
+ err := client.CommitRecords(t.Context(), "example.com")
require.NoError(t, err)
}
diff --git a/providers/dns/corenetworks/internal/identity.go b/providers/dns/corenetworks/internal/identity.go
index 6a3b4d46a..a7e7448c0 100644
--- a/providers/dns/corenetworks/internal/identity.go
+++ b/providers/dns/corenetworks/internal/identity.go
@@ -13,7 +13,7 @@ const tokenKey token = "token"
// CreateAuthenticationToken gets an authentication token.
// https://beta.api.core-networks.de/doc/#functon_auth_token
-func (c Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) {
+func (c *Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) {
endpoint := c.baseURL.JoinPath("auth", "token")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, Auth{Login: c.login, Password: c.password})
@@ -22,6 +22,7 @@ func (c Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) {
}
var token Token
+
err = c.do(req, &token)
if err != nil {
return nil, err
@@ -30,7 +31,7 @@ func (c Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) {
return &token, nil
}
-func (c Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
tok, err := c.CreateAuthenticationToken(ctx)
if err != nil {
return nil, err
diff --git a/providers/dns/corenetworks/internal/identity_test.go b/providers/dns/corenetworks/internal/identity_test.go
new file mode 100644
index 000000000..b5e05ed3f
--- /dev/null
+++ b/providers/dns/corenetworks/internal/identity_test.go
@@ -0,0 +1,24 @@
+package internal
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClient_CreateAuthenticationToken(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /auth/token", servermock.ResponseFromFixture("auth.json")).
+ Build(t)
+
+ token, err := client.CreateAuthenticationToken(t.Context())
+ require.NoError(t, err)
+
+ expected := &Token{
+ Token: "authsecret",
+ Expires: 123,
+ }
+ assert.Equal(t, expected, token)
+}
diff --git a/providers/dns/cpanel/cpanel.go b/providers/dns/cpanel/cpanel.go
index 4c80e4db8..f335c0a8c 100644
--- a/providers/dns/cpanel/cpanel.go
+++ b/providers/dns/cpanel/cpanel.go
@@ -17,6 +17,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/cpanel"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared"
"github.com/go-acme/lego/v4/providers/dns/cpanel/internal/whm"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -146,12 +147,16 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error {
valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value))
- var found bool
- var existingRecord shared.ZoneRecord
+ var (
+ found bool
+ existingRecord shared.ZoneRecord
+ )
+
for _, record := range zoneInfo {
if slices.Contains(record.DataB64, valueB64) {
existingRecord = record
found = true
+
break
}
}
@@ -220,12 +225,16 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value))
- var found bool
- var existingRecord shared.ZoneRecord
+ var (
+ found bool
+ existingRecord shared.ZoneRecord
+ )
+
for _, record := range zoneInfo {
if slices.Contains(record.DataB64, valueB64) {
existingRecord = record
found = true
+
break
}
}
@@ -235,6 +244,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
}
var newData []string
+
for _, dataB64 := range existingRecord.DataB64 {
if dataB64 == valueB64 {
continue
@@ -291,6 +301,7 @@ func getZoneSerial(zoneFqdn string, zoneInfo []shared.ZoneRecord) (uint32, error
}
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)
@@ -314,6 +325,8 @@ func createClient(config *Config) (apiClient, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return client, nil
case "whm":
@@ -326,6 +339,8 @@ func createClient(config *Config) (apiClient, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return client, nil
default:
diff --git a/providers/dns/cpanel/cpanel.toml b/providers/dns/cpanel/cpanel.toml
index 10f75b385..b64adf0cf 100644
--- a/providers/dns/cpanel/cpanel.toml
+++ b/providers/dns/cpanel/cpanel.toml
@@ -7,18 +7,18 @@ 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 -d '*.example.com' -d example.com run
+CPANEL_USERNAME="yyyy" \
+CPANEL_TOKEN="xxxx" \
+CPANEL_BASE_URL="https://example.com:2083" \
+lego --dns cpanel -d '*.example.com' -d example.com 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 -d '*.example.com' -d example.com run
+CPANEL_MODE=whm \
+CPANEL_USERNAME="yyyy" \
+CPANEL_TOKEN="xxxx" \
+CPANEL_BASE_URL="https://example.com:2087" \
+lego --dns cpanel -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -28,11 +28,10 @@ lego --email you@example.com --dns cpanel -d '*.example.com' -d example.com run
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"
+ CPANEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ CPANEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ CPANEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ CPANEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API_CPANEL = "https://api.docs.cpanel.net/cpanel/introduction/"
diff --git a/providers/dns/cpanel/cpanel_test.go b/providers/dns/cpanel/cpanel_test.go
index 614b9e1c7..5d85b8b5b 100644
--- a/providers/dns/cpanel/cpanel_test.go
+++ b/providers/dns/cpanel/cpanel_test.go
@@ -75,6 +75,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -282,6 +283,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -295,6 +297,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/cpanel/internal/cpanel/client.go b/providers/dns/cpanel/internal/cpanel/client.go
index 3bca6b521..e869f6f4b 100644
--- a/providers/dns/cpanel/internal/cpanel/client.go
+++ b/providers/dns/cpanel/internal/cpanel/client.go
@@ -24,7 +24,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(baseURL string, username string, token string) (*Client, error) {
+func NewClient(baseURL, username, token string) (*Client, error) {
apiEndpoint, err := url.Parse(baseURL)
if err != nil {
return nil, err
@@ -40,7 +40,7 @@ func NewClient(baseURL string, username string, token string) (*Client, error) {
// 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) {
+func (c *Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) {
endpoint := c.baseURL.JoinPath("DNS", "parse_zone")
query := endpoint.Query()
@@ -64,7 +64,7 @@ func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shar
// 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) {
+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)
@@ -76,7 +76,7 @@ func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, rec
// 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) {
+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)
@@ -88,12 +88,12 @@ func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, re
// DeleteRecord deletes an existing record.
//
// remove=22
-func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) {
+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) {
+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()
@@ -116,7 +116,7 @@ func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, d
return &result.Data, nil
}
-func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error {
+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)
diff --git a/providers/dns/cpanel/internal/cpanel/client_test.go b/providers/dns/cpanel/internal/cpanel/client_test.go
index 8516259d6..533d1130d 100644
--- a/providers/dns/cpanel/internal/cpanel/client_test.go
+++ b/providers/dns/cpanel/internal/cpanel/client_test.go
@@ -1,61 +1,40 @@
package cpanel
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-acme/lego/v4/providers/dns/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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "user", "secret")
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client.HTTPClient = server.Client()
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("cpanel user:secret"))
}
func TestClient_FetchZoneInformation(t *testing.T) {
- client := setupTest(t, "/execute/DNS/parse_zone", "zone-info.json")
+ client := mockBuilder().
+ Route("GET /execute/DNS/parse_zone",
+ servermock.ResponseFromFixture("zone-info.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("zone", "example.com")).
+ Build(t)
- zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
+ zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com")
require.NoError(t, err)
expected := []shared.ZoneRecord{{
@@ -71,16 +50,27 @@ func TestClient_FetchZoneInformation(t *testing.T) {
}
func TestClient_FetchZoneInformation_error(t *testing.T) {
- client := setupTest(t, "/execute/DNS/parse_zone", "zone-info_error.json")
+ client := mockBuilder().
+ Route("GET /execute/DNS/parse_zone",
+ servermock.ResponseFromFixture("zone-info_error.json")).
+ Build(t)
- zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
- require.Error(t, err)
+ zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com")
+ require.EqualError(t, err, "error(0): You do not control a DNS zone named example.com.: a, b, c")
assert.Nil(t, zoneInfo)
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json")
+ client := mockBuilder().
+ Route("GET /execute/DNS/mass_edit_zone",
+ servermock.ResponseFromFixture("update-zone.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("zone", "example.com").
+ With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`).
+ With("serial", "123456").
+ With("zone", "example.com")).
+ Build(t)
record := shared.Record{
DName: "example",
@@ -89,7 +79,7 @@ func TestClient_AddRecord(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
@@ -98,7 +88,10 @@ func TestClient_AddRecord(t *testing.T) {
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json")
+ client := mockBuilder().
+ Route("GET /execute/DNS/mass_edit_zone",
+ servermock.ResponseFromFixture("update-zone_error.json")).
+ Build(t)
record := shared.Record{
DName: "example",
@@ -107,14 +100,21 @@ func TestClient_AddRecord_error(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.AddRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("GET /execute/DNS/mass_edit_zone",
+ servermock.ResponseFromFixture("update-zone.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`).
+ With("serial", "123456").
+ With("zone", "example.com")).
+ Build(t)
record := shared.Record{
LineIndex: 9,
@@ -124,7 +124,7 @@ func TestClient_EditRecord(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
@@ -133,7 +133,10 @@ func TestClient_EditRecord(t *testing.T) {
}
func TestClient_EditRecord_error(t *testing.T) {
- client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json")
+ client := mockBuilder().
+ Route("GET /execute/DNS/mass_edit_zone",
+ servermock.ResponseFromFixture("update-zone_error.json")).
+ Build(t)
record := shared.Record{
LineIndex: 9,
@@ -143,16 +146,23 @@ func TestClient_EditRecord_error(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.EditRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("GET /execute/DNS/mass_edit_zone",
+ servermock.ResponseFromFixture("update-zone.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("remove", "0").
+ With("serial", "123456").
+ With("zone", "example.com")).
+ Build(t)
- zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
+ zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
@@ -161,9 +171,12 @@ func TestClient_DeleteRecord(t *testing.T) {
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json")
+ client := mockBuilder().
+ Route("GET /execute/DNS/mass_edit_zone",
+ servermock.ResponseFromFixture("update-zone_error.json")).
+ Build(t)
- zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
+ zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0)
require.Error(t, err)
assert.Nil(t, zoneSerial)
diff --git a/providers/dns/cpanel/internal/cpanel/types.go b/providers/dns/cpanel/internal/cpanel/types.go
index cb4dbd535..0a3053647 100644
--- a/providers/dns/cpanel/internal/cpanel/types.go
+++ b/providers/dns/cpanel/internal/cpanel/types.go
@@ -6,7 +6,7 @@ import (
)
type APIResponse[T any] struct {
- Metadata Metadata `json:"metadata,omitempty"`
+ Metadata Metadata `json:"metadata"`
Data T `json:"data,omitempty"`
Status int `json:"status,omitempty"`
diff --git a/providers/dns/cpanel/internal/whm/client.go b/providers/dns/cpanel/internal/whm/client.go
index d375b83e3..742b25b6a 100644
--- a/providers/dns/cpanel/internal/whm/client.go
+++ b/providers/dns/cpanel/internal/whm/client.go
@@ -24,7 +24,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(baseURL string, username string, token string) (*Client, error) {
+func NewClient(baseURL, username, token string) (*Client, error) {
apiEndpoint, err := url.Parse(baseURL)
if err != nil {
return nil, err
@@ -40,7 +40,7 @@ func NewClient(baseURL string, username string, token string) (*Client, error) {
// 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) {
+func (c *Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) {
endpoint := c.baseURL.JoinPath("parse_dns_zone")
query := endpoint.Query()
@@ -64,7 +64,7 @@ func (c Client) FetchZoneInformation(ctx context.Context, domain string) ([]shar
// 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) {
+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)
@@ -76,7 +76,7 @@ func (c Client) AddRecord(ctx context.Context, serial uint32, domain string, rec
// 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) {
+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)
@@ -88,12 +88,12 @@ func (c Client) EditRecord(ctx context.Context, serial uint32, domain string, re
// DeleteRecord deletes an existing record.
//
// remove=22
-func (c Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) {
+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) {
+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()
@@ -116,7 +116,7 @@ func (c Client) updateZone(ctx context.Context, serial uint32, domain, action, d
return &result.Data, nil
}
-func (c Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error {
+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()
diff --git a/providers/dns/cpanel/internal/whm/client_test.go b/providers/dns/cpanel/internal/whm/client_test.go
index f4f6d7b19..47686bf09 100644
--- a/providers/dns/cpanel/internal/whm/client_test.go
+++ b/providers/dns/cpanel/internal/whm/client_test.go
@@ -1,61 +1,41 @@
package whm
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-acme/lego/v4/providers/dns/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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "user", "secret")
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client.HTTPClient = server.Client()
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("whm user:secret"))
}
func TestClient_FetchZoneInformation(t *testing.T) {
- client := setupTest(t, "/json-api/parse_dns_zone", "zone-info.json")
+ client := mockBuilder().
+ Route("GET /json-api/parse_dns_zone",
+ servermock.ResponseFromFixture("zone-info.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("api.version", "1").
+ With("zone", "example.com")).
+ Build(t)
- zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
+ zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com")
require.NoError(t, err)
expected := []shared.ZoneRecord{{
@@ -71,16 +51,27 @@ func TestClient_FetchZoneInformation(t *testing.T) {
}
func TestClient_FetchZoneInformation_error(t *testing.T) {
- client := setupTest(t, "/json-api/parse_dns_zone", "zone-info_error.json")
+ client := mockBuilder().
+ Route("GET /json-api/parse_dns_zone",
+ servermock.ResponseFromFixture("zone-info_error.json")).
+ Build(t)
- zoneInfo, err := client.FetchZoneInformation(context.Background(), "example.com")
+ zoneInfo, err := client.FetchZoneInformation(t.Context(), "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")
+ client := mockBuilder().
+ Route("GET /json-api/mass_edit_dns_zone",
+ servermock.ResponseFromFixture("update-zone.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`).
+ With("api.version", "1").
+ With("serial", "123456").
+ With("zone", "example.com")).
+ Build(t)
record := shared.Record{
DName: "example",
@@ -89,7 +80,7 @@ func TestClient_AddRecord(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
@@ -98,7 +89,10 @@ func TestClient_AddRecord(t *testing.T) {
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json")
+ client := mockBuilder().
+ Route("GET /json-api/mass_edit_dns_zone",
+ servermock.ResponseFromFixture("update-zone_error.json")).
+ Build(t)
record := shared.Record{
DName: "example",
@@ -107,14 +101,22 @@ func TestClient_AddRecord_error(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.AddRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.AddRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("GET /json-api/mass_edit_dns_zone",
+ servermock.ResponseFromFixture("update-zone.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`).
+ With("api.version", "1").
+ With("serial", "123456").
+ With("zone", "example.com")).
+ Build(t)
record := shared.Record{
LineIndex: 9,
@@ -124,7 +126,7 @@ func TestClient_EditRecord(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
@@ -133,7 +135,10 @@ func TestClient_EditRecord(t *testing.T) {
}
func TestClient_EditRecord_error(t *testing.T) {
- client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json")
+ client := mockBuilder().
+ Route("GET /json-api/mass_edit_dns_zone",
+ servermock.ResponseFromFixture("update-zone_error.json")).
+ Build(t)
record := shared.Record{
LineIndex: 9,
@@ -143,16 +148,24 @@ func TestClient_EditRecord_error(t *testing.T) {
Data: []string{"string1", "string2"},
}
- zoneSerial, err := client.EditRecord(context.Background(), 123456, "example.com", record)
+ zoneSerial, err := client.EditRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("GET /json-api/mass_edit_dns_zone",
+ servermock.ResponseFromFixture("update-zone.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("remove", "0").
+ With("api.version", "1").
+ With("serial", "123456").
+ With("zone", "example.com")).
+ Build(t)
- zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
+ zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0)
require.NoError(t, err)
expected := &shared.ZoneSerial{NewSerial: "2021031903"}
@@ -161,9 +174,12 @@ func TestClient_DeleteRecord(t *testing.T) {
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json")
+ client := mockBuilder().
+ Route("GET /json-api/mass_edit_dns_zone",
+ servermock.ResponseFromFixture("update-zone_error.json")).
+ Build(t)
- zoneSerial, err := client.DeleteRecord(context.Background(), 123456, "example.com", 0)
+ zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0)
require.Error(t, err)
assert.Nil(t, zoneSerial)
diff --git a/providers/dns/cpanel/internal/whm/types.go b/providers/dns/cpanel/internal/whm/types.go
index f1884a04d..d0604a565 100644
--- a/providers/dns/cpanel/internal/whm/types.go
+++ b/providers/dns/cpanel/internal/whm/types.go
@@ -7,7 +7,7 @@ import (
)
type APIResponse[T any] struct {
- Metadata Metadata `json:"metadata,omitempty"`
+ Metadata Metadata `json:"metadata"`
Data T `json:"data,omitempty"`
}
diff --git a/providers/dns/czechia/czechia.go b/providers/dns/czechia/czechia.go
new file mode 100644
index 000000000..3ff397c35
--- /dev/null
+++ b/providers/dns/czechia/czechia.go
@@ -0,0 +1,159 @@
+// Package czechia implements a DNS provider for solving the DNS-01 challenge using Czechia.
+package czechia
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/czechia/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "CZECHIA_"
+
+ EnvToken = envNamespace + "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 {
+ 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, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Czechia.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("czechia: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Token = values[EnvToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Czechia.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("czechia: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Token)
+ if err != nil {
+ return nil, fmt.Errorf("czechia: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("czechia: %w", err)
+ }
+
+ record := internal.TXTRecord{
+ Hostname: subDomain,
+ Text: info.Value,
+ TTL: d.config.TTL,
+ PublishZone: 1,
+ }
+
+ err = d.client.AddTXTRecord(ctx, dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("czechia: add TXT 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("czechia: %w", err)
+ }
+
+ record := internal.TXTRecord{
+ Hostname: subDomain,
+ Text: info.Value,
+ TTL: d.config.TTL,
+ PublishZone: 1,
+ }
+
+ err = d.client.DeleteTXTRecord(ctx, dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("czechia: delete TXT 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/dns/czechia/czechia.toml b/providers/dns/czechia/czechia.toml
new file mode 100644
index 000000000..2a66d2054
--- /dev/null
+++ b/providers/dns/czechia/czechia.toml
@@ -0,0 +1,22 @@
+Name = "Czechia"
+Description = ''''''
+URL = "https://www.czechia.com/"
+Code = "czechia"
+Since = "v4.33.0"
+
+Example = '''
+CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns czechia -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ CZECHIA_TOKEN = "Authorization token"
+ [Configuration.Additional]
+ CZECHIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ CZECHIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ CZECHIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ CZECHIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://api.czechia.com/swagger/index.html"
diff --git a/providers/dns/czechia/czechia_test.go b/providers/dns/czechia/czechia_test.go
new file mode 100644
index 000000000..7d9a2676c
--- /dev/null
+++ b/providers/dns/czechia/czechia_test.go
@@ -0,0 +1,165 @@
+package czechia
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "czechia: some credentials information are missing: CZECHIA_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
+ token string
+ expected string
+ }{
+ {
+ desc: "success",
+ token: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "czechia: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ 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)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Token = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("AuthorizationToken", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/czechia/internal/client.go b/providers/dns/czechia/internal/client.go
new file mode 100644
index 000000000..f3e0e462e
--- /dev/null
+++ b/providers/dns/czechia/internal/client.go
@@ -0,0 +1,124 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.czechia.com/api"
+
+const authorizationTokenHeader = "AuthorizationToken"
+
+// Client the Czechia API client.
+type Client struct {
+ token string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(token string) (*Client, error) {
+ if token == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ token: token,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, domain string, record TXTRecord) error {
+ endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) DeleteTXTRecord(ctx context.Context, domain string, record TXTRecord) error {
+ endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT")
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set(authorizationTokenHeader, 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 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ 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
+}
diff --git a/providers/dns/czechia/internal/client_test.go b/providers/dns/czechia/internal/client_test.go
new file mode 100644
index 000000000..c6f1141c5
--- /dev/null
+++ b/providers/dns/czechia/internal/client_test.go
@@ -0,0 +1,67 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(authorizationTokenHeader, "secret"),
+ )
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ record := TXTRecord{
+ Hostname: "_acme-challenge",
+ Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ PublishZone: 1,
+ }
+
+ err := client.AddTXTRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /DNS/example.com/TXT",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"),
+ ).
+ Build(t)
+
+ record := TXTRecord{
+ Hostname: "_acme-challenge",
+ Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ PublishZone: 1,
+ }
+
+ err := client.DeleteTXTRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/czechia/internal/fixtures/add_txt_record-request.json b/providers/dns/czechia/internal/fixtures/add_txt_record-request.json
new file mode 100644
index 000000000..ed5830093
--- /dev/null
+++ b/providers/dns/czechia/internal/fixtures/add_txt_record-request.json
@@ -0,0 +1,6 @@
+{
+ "hostName": "_acme-challenge",
+ "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "publishZone": 1
+}
diff --git a/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json b/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json
new file mode 100644
index 000000000..ed5830093
--- /dev/null
+++ b/providers/dns/czechia/internal/fixtures/delete_txt_record-request.json
@@ -0,0 +1,6 @@
+{
+ "hostName": "_acme-challenge",
+ "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "publishZone": 1
+}
diff --git a/providers/dns/czechia/internal/types.go b/providers/dns/czechia/internal/types.go
new file mode 100644
index 000000000..f4a9bfef7
--- /dev/null
+++ b/providers/dns/czechia/internal/types.go
@@ -0,0 +1,8 @@
+package internal
+
+type TXTRecord struct {
+ Hostname string `json:"hostName,omitempty"`
+ Text string `json:"text,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ PublishZone int `json:"publishZone,omitempty"`
+}
diff --git a/providers/dns/ddnss/ddnss.go b/providers/dns/ddnss/ddnss.go
new file mode 100644
index 000000000..381151c55
--- /dev/null
+++ b/providers/dns/ddnss/ddnss.go
@@ -0,0 +1,130 @@
+// Package ddnss implements a DNS provider for solving the DNS-01 challenge using DynDNS Service.
+package ddnss
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/ddnss/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "DDNSS_"
+
+ EnvKey = envNamespace + "KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ 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.
+type Config struct {
+ Key string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ SequenceInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
+ 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 DynDNS Service.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvKey)
+ if err != nil {
+ return nil, fmt.Errorf("ddnss: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Key = values[EnvKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for DynDNS Service.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("ddnss: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(&internal.Authentication{Key: config.Key})
+ if err != nil {
+ return nil, fmt.Errorf("ddnss: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ err := d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+ if err != nil {
+ return fmt.Errorf("ddnss: add TXT record: %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)
+
+ err := d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("ddnss: remove TXT 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
+}
+
+// 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/dns/ddnss/ddnss.toml b/providers/dns/ddnss/ddnss.toml
new file mode 100644
index 000000000..0d0a7132c
--- /dev/null
+++ b/providers/dns/ddnss/ddnss.toml
@@ -0,0 +1,23 @@
+Name = "DDnss (DynDNS Service)"
+Description = ''''''
+URL = "https://ddnss.de/"
+Code = "ddnss"
+Since = "v4.32.0"
+
+Example = '''
+DDNSS_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns ddnss -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ DDNSS_KEY = "Update key"
+ [Configuration.Additional]
+ DDNSS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DDNSS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DDNSS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ DDNSS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ DDNSS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://ddnss.de/info.php"
diff --git a/providers/dns/ddnss/ddnss_test.go b/providers/dns/ddnss/ddnss_test.go
new file mode 100644
index 000000000..5b1d7df58
--- /dev/null
+++ b/providers/dns/ddnss/ddnss_test.go
@@ -0,0 +1,168 @@
+package ddnss
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvKey: "secret",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "ddnss: some credentials information are missing: DDNSS_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ Key string
+ expected string
+ }{
+ {
+ desc: "success",
+ Key: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "ddnss: missing credentials",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Key = test.Key
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Key = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL = server.URL
+
+ return p, nil
+ },
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromInternal("success.html"),
+ servermock.CheckQueryParameter().Strict().
+ With("host", "_acme-challenge.example.com").
+ With("key", "secret").
+ With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("txtm", "1"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromInternal("success.html"),
+ servermock.CheckQueryParameter().Strict().
+ With("host", "_acme-challenge.example.com").
+ With("key", "secret").
+ With("txtm", "2"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/ddnss/internal/client.go b/providers/dns/ddnss/internal/client.go
new file mode 100644
index 000000000..a0cf4b4a6
--- /dev/null
+++ b/providers/dns/ddnss/internal/client.go
@@ -0,0 +1,137 @@
+package internal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ "golang.org/x/net/html"
+)
+
+const defaultBaseURL = "https://ddnss.de/upd.php"
+
+// Client the DDns API client.
+type Client struct {
+ auth *Authentication
+
+ BaseURL string
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(auth *Authentication) (*Client, error) {
+ if auth == nil {
+ return nil, errors.New("credentials missing")
+ }
+
+ err := auth.validate()
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ auth: auth,
+ BaseURL: defaultBaseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, host, value string) error {
+ return c.update(ctx, map[string]string{
+ "host": host,
+ "txt": value,
+ "txtm": "1",
+ })
+}
+
+func (c *Client) RemoveTXTRecord(ctx context.Context, host string) error {
+ return c.update(ctx, map[string]string{
+ "host": host,
+ "txtm": "2",
+ })
+}
+
+func (c *Client) update(ctx context.Context, params map[string]string) error {
+ endpoint, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return err
+ }
+
+ query := endpoint.Query()
+
+ for k, v := range params {
+ query.Set(k, v)
+ }
+
+ c.auth.set(query)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return fmt.Errorf("unable to create request: %w", err)
+ }
+
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ content, err := readPage(raw)
+ if err != nil {
+ return err
+ }
+
+ if strings.Contains(content, "Updated 1 hostname.") {
+ return nil
+ }
+
+ return fmt.Errorf("unexpected response: %s", content)
+}
+
+func readPage(raw []byte) (string, error) {
+ page, err := html.Parse(strings.NewReader(string(raw)))
+ if err != nil {
+ return "", err
+ }
+
+ var b strings.Builder
+ extractText(page, &b)
+
+ return strings.TrimSpace(b.String()), nil
+}
+
+func extractText(n *html.Node, b *strings.Builder) {
+ if n.Type == html.TextNode {
+ text := strings.TrimSpace(n.Data)
+ if text != "" {
+ b.WriteString(text + " ")
+ }
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ extractText(c, b)
+ }
+}
diff --git a/providers/dns/ddnss/internal/client_test.go b/providers/dns/ddnss/internal/client_test.go
new file mode 100644
index 000000000..3faddded0
--- /dev/null
+++ b/providers/dns/ddnss/internal/client_test.go
@@ -0,0 +1,56 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(&Authentication{Key: "secret"})
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ )
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("success.html"),
+ servermock.CheckQueryParameter().Strict().
+ With("host", "_acme-challenge.example.com").
+ With("key", "secret").
+ With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("txtm", "1"),
+ ).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "_acme-challenge.example.com", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")
+ require.NoError(t, err)
+}
+
+func TestClient_RemoveTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromFixture("success.html"),
+ servermock.CheckQueryParameter().Strict().
+ With("host", "_acme-challenge.example.com").
+ With("key", "secret").
+ With("txtm", "2"),
+ ).
+ Build(t)
+
+ err := client.RemoveTXTRecord(t.Context(), "_acme-challenge.example.com")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/ddnss/internal/fixtures/error.html b/providers/dns/ddnss/internal/fixtures/error.html
new file mode 100644
index 000000000..f0599ad9a
--- /dev/null
+++ b/providers/dns/ddnss/internal/fixtures/error.html
@@ -0,0 +1,12 @@
+
+
+ DDNSS - Kostenloser DynDNS Service : Re-ProutDNS v5.01v
+
+
+
+Error Occurred While Processing Request :
+
+ - badysys : Der System Parameter ist ungültig.
+ - badauth : Die Authorisation ist fehlgeschlagen. Die Parameter username und/oder password sind falsch.
+ - notfqdn : Hostname fehlt oder ist falsch.
+
diff --git a/providers/dns/ddnss/internal/fixtures/success.html b/providers/dns/ddnss/internal/fixtures/success.html
new file mode 100644
index 000000000..f51957334
--- /dev/null
+++ b/providers/dns/ddnss/internal/fixtures/success.html
@@ -0,0 +1,8 @@
+
+
+ DDNSS - Kostenloser DynDNS Service : Re-ProutDNS v5.01v
+
+
+
+Updated 1 hostname.
+
diff --git a/providers/dns/ddnss/internal/types.go b/providers/dns/ddnss/internal/types.go
new file mode 100644
index 000000000..37d41e076
--- /dev/null
+++ b/providers/dns/ddnss/internal/types.go
@@ -0,0 +1,39 @@
+package internal
+
+import (
+ "errors"
+ "net/url"
+)
+
+type Authentication struct {
+ Username string `url:"user,omitempty"`
+ Password string `url:"pwd,omitempty"`
+ Key string `url:"key,omitempty"`
+}
+
+func (a *Authentication) validate() error {
+ if a.Username == "" && a.Password == "" && a.Key == "" {
+ return errors.New("missing credentials")
+ }
+
+ if a.Username != "" && a.Password != "" && a.Key != "" {
+ return errors.New("only one of username, password or key can be set")
+ }
+
+ if (a.Username != "" && a.Password == "") || a.Username == "" && a.Password != "" {
+ return errors.New("username and password must be set together")
+ }
+
+ return nil
+}
+
+func (a *Authentication) set(query url.Values) {
+ if a.Key != "" {
+ query.Set("key", a.Key)
+
+ return
+ }
+
+ query.Set("user", a.Username)
+ query.Set("pwd", a.Password)
+}
diff --git a/providers/dns/derak/derak.go b/providers/dns/derak/derak.go
index 6e726620a..78165b936 100644
--- a/providers/dns/derak/derak.go
+++ b/providers/dns/derak/derak.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/derak/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/miekg/dns"
)
@@ -94,6 +95,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -160,6 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("derak: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
diff --git a/providers/dns/derak/derak.toml b/providers/dns/derak/derak.toml
index 202d20834..72f49883a 100644
--- a/providers/dns/derak/derak.toml
+++ b/providers/dns/derak/derak.toml
@@ -6,7 +6,7 @@ Since = "v4.12.0"
Example = '''
DERAK_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns derak -d '*.example.com' -d example.com run
+lego --dns derak -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,7 +14,7 @@ lego --email you@example.com --dns derak -d '*.example.com' -d example.com run
DERAK_API_KEY = "The API key"
[Configuration.Additional]
DERAK_WEBSITE_ID = "Force the zone/website ID"
- DERAK_POLLING_INTERVAL = "Time between DNS propagation check"
- DERAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DERAK_TTL = "The TTL of the TXT record used for the DNS challenge"
- DERAK_HTTP_TIMEOUT = "API request timeout"
+ DERAK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ DERAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ DERAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ DERAK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
diff --git a/providers/dns/derak/derak_test.go b/providers/dns/derak/derak_test.go
index e58cfb6c1..b83eb2c8c 100644
--- a/providers/dns/derak/derak_test.go
+++ b/providers/dns/derak/derak_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/derak/internal/client.go b/providers/dns/derak/internal/client.go
index 3e7c76fdb..4352e198b 100644
--- a/providers/dns/derak/internal/client.go
+++ b/providers/dns/derak/internal/client.go
@@ -37,13 +37,14 @@ func NewClient(apiKey string) *Client {
// GetRecords gets all records.
// Note: the response is not influenced by the query parameters, so the documentation seems wrong.
-func (c Client) GetRecords(ctx context.Context, zoneID string, params *GetRecordsParameters) (*GetRecordsResponse, error) {
+func (c *Client) GetRecords(ctx context.Context, zoneID string, params *GetRecordsParameters) (*GetRecordsResponse, error) {
endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords")
v, err := querystring.Values(params)
if err != nil {
return nil, err
}
+
endpoint.RawQuery = v.Encode()
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -52,6 +53,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID string, params *GetRecord
}
response := &GetRecordsResponse{}
+
err = c.do(req, response)
if err != nil {
return nil, err
@@ -61,7 +63,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID string, params *GetRecord
}
// GetRecord gets a record by ID.
-func (c Client) GetRecord(ctx context.Context, zoneID string, recordID string) (*Record, error) {
+func (c *Client) GetRecord(ctx context.Context, zoneID, recordID string) (*Record, error) {
endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -70,6 +72,7 @@ func (c Client) GetRecord(ctx context.Context, zoneID string, recordID string) (
}
response := &Record{}
+
err = c.do(req, response)
if err != nil {
return nil, err
@@ -79,7 +82,7 @@ func (c Client) GetRecord(ctx context.Context, zoneID string, recordID string) (
}
// CreateRecord creates a new record.
-func (c Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
+func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords")
req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record)
@@ -88,6 +91,7 @@ func (c Client) CreateRecord(ctx context.Context, zoneID string, record Record)
}
response := &Record{}
+
err = c.do(req, response)
if err != nil {
return nil, err
@@ -97,7 +101,7 @@ func (c Client) CreateRecord(ctx context.Context, zoneID string, record Record)
}
// EditRecord edits an existing record.
-func (c Client) EditRecord(ctx context.Context, zoneID string, recordID string, record Record) (*Record, error) {
+func (c *Client) EditRecord(ctx context.Context, zoneID, recordID string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID)
req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, record)
@@ -106,6 +110,7 @@ func (c Client) EditRecord(ctx context.Context, zoneID string, recordID string,
}
response := &Record{}
+
err = c.do(req, response)
if err != nil {
return nil, err
@@ -115,7 +120,7 @@ func (c Client) EditRecord(ctx context.Context, zoneID string, recordID string,
}
// DeleteRecord deletes an existing record.
-func (c Client) DeleteRecord(ctx context.Context, zoneID string, recordID string) error {
+func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -140,13 +145,14 @@ func (c Client) DeleteRecord(ctx context.Context, zoneID string, recordID string
// GetZones gets zones.
// Note: it's not a part of the official API, there is no documentation about this.
// The endpoint comes from UI calls analysis.
-func (c Client) GetZones(ctx context.Context) ([]Zone, error) {
+func (c *Client) GetZones(ctx context.Context) ([]Zone, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.zoneEndpoint, http.NoBody)
if err != nil {
return nil, err
}
response := &APIResponse[[]Zone]{}
+
err = c.do(req, response)
if err != nil {
return nil, err
@@ -159,7 +165,7 @@ func (c Client) GetZones(ctx context.Context) ([]Zone, error) {
return response.Result, nil
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
req.SetBasicAuth("api", c.apiKey)
resp, err := c.HTTPClient.Do(req)
@@ -221,6 +227,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var response APIResponse[any]
+
err := json.Unmarshal(raw, &response)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/derak/internal/client_test.go b/providers/dns/derak/internal/client_test.go
index 3d542e4a7..322a7f48c 100644
--- a/providers/dns/derak/internal/client_test.go
+++ b/providers/dns/derak/internal/client_test.go
@@ -1,83 +1,39 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
"time"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("secret")
client.baseURL, _ = url.Parse(server.URL)
client.zoneEndpoint = server.URL
client.HTTPClient = server.Client()
- return client, mux
+ return client, nil
}
-func testHandler(method string, statusCode int, filename string) func(rw http.ResponseWriter, req *http.Request) {
- 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
- }
-
- username, password, ok := req.BasicAuth()
- if !ok {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- if username != "api" {
- 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
- }
-
- 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 mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("api", "secret"))
}
func TestGetRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
+ servermock.ResponseFromFixture("records-GET.json")).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
- testHandler(http.MethodGet, http.StatusOK, "records-GET.json"))
-
- records, err := client.GetRecords(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`})
+ records, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`})
require.NoError(t, err)
excepted := &GetRecordsResponse{Data: []Record{
@@ -135,22 +91,23 @@ func TestGetRecords(t *testing.T) {
}
func TestGetRecords_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
- testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
-
- _, err := client.GetRecords(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`})
+ _, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`})
require.Error(t, err)
}
func TestGetRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c",
+ servermock.ResponseFromFixture("record-GET.json")).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c",
- testHandler(http.MethodGet, http.StatusOK, "record-GET.json"))
-
- record, err := client.GetRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c")
+ record, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c")
require.NoError(t, err)
excepted := &Record{
@@ -164,20 +121,22 @@ func TestGetRecord(t *testing.T) {
}
func TestGetRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c",
- testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
-
- _, err := client.GetRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c")
+ _, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c")
require.Error(t, err)
}
func TestCreateRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
- testHandler(http.MethodPut, http.StatusCreated, "record-PUT.json"))
+ client := mockBuilder().
+ Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
+ servermock.ResponseFromFixture("record-PUT.json").
+ WithStatusCode(http.StatusCreated)).
+ Build(t)
r := Record{
Type: "TXT",
@@ -186,7 +145,7 @@ func TestCreateRecord(t *testing.T) {
TTL: 120,
}
- record, err := client.CreateRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", r)
+ record, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r)
require.NoError(t, err)
excepted := &Record{
@@ -200,10 +159,11 @@ func TestCreateRecord(t *testing.T) {
}
func TestCreateRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
- testHandler(http.MethodPut, http.StatusUnauthorized, "error.json"))
+ client := mockBuilder().
+ Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
r := Record{
Type: "TXT",
@@ -212,17 +172,17 @@ func TestCreateRecord_error(t *testing.T) {
TTL: 120,
}
- _, err := client.CreateRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", r)
+ _, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r)
require.Error(t, err)
}
func TestEditRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2",
+ servermock.ResponseFromFixture("record-PATCH.json")).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2",
- testHandler(http.MethodPatch, http.StatusOK, "record-PATCH.json"))
-
- record, err := client.EditRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{
+ record, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{
Content: "foo",
})
require.NoError(t, err)
@@ -238,43 +198,48 @@ func TestEditRecord(t *testing.T) {
}
func TestEditRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2",
- testHandler(http.MethodPatch, http.StatusUnauthorized, "error.json"))
-
- _, err := client.EditRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{
+ _, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{
Content: "foo",
})
require.Error(t, err)
}
func TestDeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df",
+ servermock.ResponseFromFixture("record-DELETE.json")).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df",
- testHandler(http.MethodDelete, http.StatusOK, "record-DELETE.json"))
-
- err := client.DeleteRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df")
+ err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df")
require.NoError(t, err)
}
func TestDeleteRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df",
- testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json"))
-
- err := client.DeleteRecord(context.Background(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df")
+ err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df")
require.Error(t, err)
}
func TestGetZones(t *testing.T) {
- client, mux := setupTest(t)
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().
+ WithBasicAuth("api", "secret"),
+ ).
+ Route("GET /", servermock.ResponseFromFixture("service-cdn-zones.json")).
+ Build(t)
- mux.HandleFunc("/", testHandler(http.MethodGet, http.StatusOK, "service-cdn-zones.json"))
-
- zones, err := client.GetZones(context.Background())
+ zones, err := client.GetZones(t.Context())
require.NoError(t, err)
excepted := []Zone{{
@@ -303,10 +268,11 @@ func TestGetZones(t *testing.T) {
}
func TestGetZones_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /", servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- mux.HandleFunc("/", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
-
- _, err := client.GetZones(context.Background())
+ _, err := client.GetZones(t.Context())
require.Error(t, err)
}
diff --git a/providers/dns/derak/internal/types.go b/providers/dns/derak/internal/types.go
index 15ed00617..02116314f 100644
--- a/providers/dns/derak/internal/types.go
+++ b/providers/dns/derak/internal/types.go
@@ -46,7 +46,7 @@ type Zone struct {
HumanReadable string `json:"humanReadable,omitempty"`
Serial string `json:"serial,omitempty"`
CreationTime int64 `json:"creationTime,omitempty"`
- CreationTimeDate time.Time `json:"creationTimeDate,omitempty"`
+ CreationTimeDate time.Time `json:"creationTimeDate,omitzero"`
Status string `json:"status,omitempty"`
IsMoved bool `json:"is_moved,omitempty"`
Paused bool `json:"paused,omitempty"`
diff --git a/providers/dns/desec/desec.go b/providers/dns/desec/desec.go
index 9d1e20e53..9cc54f65e 100644
--- a/providers/dns/desec/desec.go
+++ b/providers/dns/desec/desec.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/nrdcg/desec"
)
@@ -88,6 +89,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config.HTTPClient != nil {
opts.HTTPClient = config.HTTPClient
}
+
+ opts.HTTPClient = clientdebug.Wrap(opts.HTTPClient)
+
opts.Logger = log.Default()
client := desec.New(config.Token, opts)
@@ -176,6 +180,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
records := make([]string, 0)
+
for _, record := range rrSet.Records {
if record != fmt.Sprintf(`%q`, info.Value) {
records = append(records, record)
diff --git a/providers/dns/desec/desec.toml b/providers/dns/desec/desec.toml
index 6f5486027..f7e66ae07 100644
--- a/providers/dns/desec/desec.toml
+++ b/providers/dns/desec/desec.toml
@@ -6,17 +6,17 @@ Since = "v3.7.0"
Example = '''
DESEC_TOKEN=x-xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns desec -d '*.example.com' -d example.com run
+lego --dns desec -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
DESEC_TOKEN = "Domain token"
[Configuration.Additional]
- DESEC_POLLING_INTERVAL = "Time between DNS propagation check"
- DESEC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DESEC_TTL = "The TTL of the TXT record used for the DNS challenge"
- DESEC_HTTP_TIMEOUT = "API request timeout"
+ DESEC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)"
+ DESEC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ DESEC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ DESEC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://desec.readthedocs.io/en/latest/"
diff --git a/providers/dns/desec/desec_test.go b/providers/dns/desec/desec_test.go
index f91f9e82a..93d9bd010 100644
--- a/providers/dns/desec/desec_test.go
+++ b/providers/dns/desec/desec_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/designate/designate.go b/providers/dns/designate/designate.go
index e2a5721c0..41bf251f6 100644
--- a/providers/dns/designate/designate.go
+++ b/providers/dns/designate/designate.go
@@ -68,8 +68,9 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *gophercloud.ServiceClient
+ config *Config
+ client *gophercloud.ServiceClient
+
dnsEntriesMu sync.Mutex
}
@@ -85,7 +86,6 @@ func NewDNSProvider() (*DNSProvider, error) {
opts, erro := clientconfig.AuthOptions(&clientconfig.ClientOpts{
Cloud: val[EnvCloud],
})
-
if erro != nil {
return nil, fmt.Errorf("designate: %w", erro)
}
@@ -202,6 +202,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("designate: error for %s in CleanUp: %w", info.EffectiveFQDN, err)
}
+
return nil
}
@@ -241,14 +242,20 @@ func (d *DNSProvider) updateRecord(record *recordsets.RecordSet, value string) e
}
result := recordsets.Update(d.client, record.ZoneID, record.ID, updateOpts)
+
return result.Err
}
func (d *DNSProvider) getZoneID(wanted string) (string, error) {
- allPages, err := zones.List(d.client, nil).AllPages()
+ listOpts := zones.ListOpts{
+ Name: wanted,
+ }
+
+ allPages, err := zones.List(d.client, listOpts).AllPages()
if err != nil {
return "", err
}
+
allZones, err := zones.ExtractZones(allPages)
if err != nil {
return "", err
@@ -259,14 +266,21 @@ func (d *DNSProvider) getZoneID(wanted string) (string, error) {
return zone.ID, nil
}
}
+
return "", fmt.Errorf("zone id not found for %s", wanted)
}
func (d *DNSProvider) getRecord(zoneID, wanted string) (*recordsets.RecordSet, error) {
- allPages, err := recordsets.ListByZone(d.client, zoneID, nil).AllPages()
+ listOpts := recordsets.ListOpts{
+ Name: wanted,
+ Type: "TXT",
+ }
+
+ allPages, err := recordsets.ListByZone(d.client, zoneID, listOpts).AllPages()
if err != nil {
return nil, err
}
+
allRecords, err := recordsets.ExtractRecordSets(allPages)
if err != nil {
return nil, err
diff --git a/providers/dns/designate/designate.toml b/providers/dns/designate/designate.toml
index aec11eb1e..a36034f64 100644
--- a/providers/dns/designate/designate.toml
+++ b/providers/dns/designate/designate.toml
@@ -7,7 +7,7 @@ Since = "v2.2.0"
Example = '''
# With a `clouds.yaml`
OS_CLOUD=my_openstack \
-lego --email you@example.com --dns designate -d '*.example.com' -d example.com run
+lego --dns designate -d '*.example.com' -d example.com run
# or
@@ -16,7 +16,7 @@ OS_REGION_NAME=RegionOne \
OS_PROJECT_ID=23d4522a987d4ab529f722a007c27846
OS_USERNAME=myuser \
OS_PASSWORD=passw0rd \
-lego --email you@example.com --dns designate -d '*.example.com' -d example.com run
+lego --dns designate -d '*.example.com' -d example.com run
# or
@@ -25,7 +25,7 @@ OS_REGION_NAME=RegionOne \
OS_AUTH_TYPE=v3applicationcredential \
OS_APPLICATION_CREDENTIAL_ID=imn74uq0or7dyzz20dwo1ytls4me8dry \
OS_APPLICATION_CREDENTIAL_SECRET=68FuSPSdQqkFQYH5X1OoriEIJOwyLtQ8QSqXZOc9XxFK1A9tzZT6He2PfPw0OMja \
-lego --email you@example.com --dns designate -d '*.example.com' -d example.com run
+lego --dns designate -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -64,9 +64,9 @@ Public cloud providers with support for Designate:
OS_PROJECT_ID = "Project ID"
OS_TENANT_NAME = "Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID)"
DESIGNATE_ZONE_NAME = "The zone name to use in the OpenStack Project to manage TXT records."
- DESIGNATE_POLLING_INTERVAL = "Time between DNS propagation check"
- DESIGNATE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DESIGNATE_TTL = "The TTL of the TXT record used for the DNS challenge"
+ DESIGNATE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ DESIGNATE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)"
+ DESIGNATE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)"
[Links]
API = "https://docs.openstack.org/designate/latest/"
diff --git a/providers/dns/designate/designate_test.go b/providers/dns/designate/designate_test.go
index 881faeef1..e5edf81f8 100644
--- a/providers/dns/designate/designate_test.go
+++ b/providers/dns/designate/designate_test.go
@@ -105,6 +105,7 @@ func TestNewDNSProvider_fromEnv(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -192,6 +193,7 @@ func TestNewDNSProvider_fromCloud(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(map[string]string{
@@ -265,10 +267,10 @@ func TestNewDNSProviderConfig(t *testing.T) {
func createCloudsYaml(t *testing.T, cloudName string, cloud clientconfig.Cloud) string {
t.Helper()
- file, err := os.CreateTemp("", "lego_test")
+ file, err := os.CreateTemp(t.TempDir(), "lego_test")
require.NoError(t, err)
- t.Cleanup(func() { _ = os.RemoveAll(file.Name()) })
+ t.Cleanup(func() { _ = file.Close() })
clouds := clientconfig.Clouds{
Clouds: map[string]clientconfig.Cloud{
@@ -331,6 +333,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -344,6 +347,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/digitalocean/digitalocean.go b/providers/dns/digitalocean/digitalocean.go
index 976b1f2e6..26c6fb9d4 100644
--- a/providers/dns/digitalocean/digitalocean.go
+++ b/providers/dns/digitalocean/digitalocean.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/digitalocean/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -46,7 +47,7 @@ func NewDefaultConfig() *Config {
return &Config{
BaseURL: env.GetOrDefaultString(EnvAPIUrl, internal.DefaultBaseURL),
TTL: env.GetOrDefaultInt(EnvTTL, 30),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
@@ -88,10 +89,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("digitalocean: credentials missing")
}
- client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken))
+ client := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken),
+ ),
+ )
if config.BaseURL != "" {
var err error
+
client.BaseURL, err = url.Parse(config.BaseURL)
if err != nil {
return nil, fmt.Errorf("digitalocean: %w", err)
@@ -147,6 +153,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("digitalocean: unknown record ID for '%s'", info.EffectiveFQDN)
}
diff --git a/providers/dns/digitalocean/digitalocean.toml b/providers/dns/digitalocean/digitalocean.toml
index ef2e9de7c..8f9107c26 100644
--- a/providers/dns/digitalocean/digitalocean.toml
+++ b/providers/dns/digitalocean/digitalocean.toml
@@ -6,7 +6,7 @@ Since = "v0.3.0"
Example = '''
DO_AUTH_TOKEN=xxxxxx \
-lego --email you@example.com --dns digitalocean -d '*.example.com' -d example.com run
+lego --dns digitalocean -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns digitalocean -d '*.example.com' -d example.co
DO_AUTH_TOKEN = "Authentication token"
[Configuration.Additional]
DO_API_URL = "The URL of the API"
- DO_POLLING_INTERVAL = "Time between DNS propagation check"
- DO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DO_TTL = "The TTL of the TXT record used for the DNS challenge"
- DO_HTTP_TIMEOUT = "API request timeout"
+ DO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ DO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)"
+ DO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developers.digitalocean.com/documentation/v2/#domain-records"
diff --git a/providers/dns/digitalocean/digitalocean_test.go b/providers/dns/digitalocean/digitalocean_test.go
index bfd2d68c0..d066e12db 100644
--- a/providers/dns/digitalocean/digitalocean_test.go
+++ b/providers/dns/digitalocean/digitalocean_test.go
@@ -1,36 +1,30 @@
package digitalocean
import (
- "bytes"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-acme/lego/v4/platform/tester"
- "github.com/stretchr/testify/assert"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
var envTest = tester.NewEnvTest(EnvAuthToken)
-func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) {
- t.Helper()
+func mockProvider() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.AuthToken = "asdf1234"
+ config.BaseURL = server.URL
+ config.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- config := NewDefaultConfig()
- config.AuthToken = "asdf1234"
- config.BaseURL = server.URL
- config.HTTPClient = server.Client()
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- return provider, mux
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("Authorization", "Bearer asdf1234"))
}
func TestNewDNSProvider(t *testing.T) {
@@ -57,6 +51,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -111,26 +106,9 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
func TestDNSProvider_Present(t *testing.T) {
- provider, mux := setupTest(t)
-
- mux.HandleFunc("/v2/domains/example.com/records", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodPost, r.Method, "method")
-
- assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept")
- assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
- assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization")
-
- reqBody, err := io.ReadAll(r.Body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`
- assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody)))
-
- w.WriteHeader(http.StatusCreated)
- _, err = fmt.Fprintf(w, `{
+ provider := mockProvider().
+ Route("POST /v2/domains/example.com/records",
+ servermock.RawStringResponse(`{
"domain_record": {
"id": 1234567,
"type": "TXT",
@@ -140,36 +118,26 @@ func TestDNSProvider_Present(t *testing.T) {
"port": null,
"weight": null
}
- }`)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ }`).
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)).
+ Build(t)
err := provider.Present("example.com", "", "foobar")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
- provider, mux := setupTest(t)
-
- mux.HandleFunc("/v2/domains/example.com/records/1234567", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodDelete, r.Method, "method")
-
- assert.Equal(t, "/v2/domains/example.com/records/1234567", r.URL.Path, "Path")
-
- assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept")
- assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
- assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization")
-
- w.WriteHeader(http.StatusNoContent)
- })
+ provider := mockProvider().
+ Route("DELETE /v2/domains/example.com/records/1234567",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
provider.recordIDsMu.Lock()
provider.recordIDs["token"] = 1234567
provider.recordIDsMu.Unlock()
err := provider.CleanUp("example.com", "token", "")
- require.NoError(t, err, "fail to remove TXT record")
+ require.NoError(t, err)
}
diff --git a/providers/dns/digitalocean/internal/client.go b/providers/dns/digitalocean/internal/client.go
index e7dd181b2..395de478c 100644
--- a/providers/dns/digitalocean/internal/client.go
+++ b/providers/dns/digitalocean/internal/client.go
@@ -45,6 +45,7 @@ func (c *Client) AddTxtRecord(ctx context.Context, zone string, record Record) (
}
respData := &TxtRecordResponse{}
+
err = c.do(req, respData)
if err != nil {
return nil, err
@@ -120,6 +121,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errInfo APIError
+
err := json.Unmarshal(raw, &errInfo)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/digitalocean/internal/client_test.go b/providers/dns/digitalocean/internal/client_test.go
index 081e1a109..65ce5dfaa 100644
--- a/providers/dns/digitalocean/internal/client_test.go
+++ b/providers/dns/digitalocean/internal/client_test.go
@@ -1,95 +1,35 @@
package internal
import (
- "bytes"
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
+ client.BaseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
- client.BaseURL, _ = url.Parse(server.URL)
-
- mux.HandleFunc(pattern, handler)
-
- return client
-}
-
-func checkHeader(req *http.Request, name, value string) error {
- val := req.Header.Get(name)
- if val != value {
- return fmt.Errorf("invalid header value, got: %s want %s", val, value)
- }
- return nil
-}
-
-func writeFixture(rw http.ResponseWriter, filename string) {
- 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)
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer secret"))
}
func TestClient_AddTxtRecord(t *testing.T) {
- client := setupTest(t, "/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- err := checkHeader(req, "Accept", "application/json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- err = checkHeader(req, "Content-Type", "application/json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- err = checkHeader(req, "Authorization", "Bearer secret")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- reqBody, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`
- if expectedReqBody != string(bytes.TrimSpace(reqBody)) {
- http.Error(rw, fmt.Sprintf("unexpected request body: %s", string(bytes.TrimSpace(reqBody))), http.StatusBadRequest)
- return
- }
-
- rw.WriteHeader(http.StatusCreated)
- writeFixture(rw, "domains-records_POST.json")
- })
+ client := mockBuilder().
+ Route("POST /v2/domains/example.com/records",
+ servermock.ResponseFromFixture("domains-records_POST.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)).
+ Build(t)
record := Record{
Type: "TXT",
@@ -98,7 +38,7 @@ func TestClient_AddTxtRecord(t *testing.T) {
TTL: 30,
}
- newRecord, err := client.AddTxtRecord(context.Background(), "example.com", record)
+ newRecord, err := client.AddTxtRecord(t.Context(), "example.com", record)
require.NoError(t, err)
expected := &TxtRecordResponse{DomainRecord: Record{
@@ -113,27 +53,12 @@ func TestClient_AddTxtRecord(t *testing.T) {
}
func TestClient_RemoveTxtRecord(t *testing.T) {
- client := setupTest(t, "/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
+ client := mockBuilder().
+ Route("DELETE /v2/domains/example.com/records/1234567",
+ servermock.ResponseFromFixture("domains-records_POST.json").
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
- err := checkHeader(req, "Accept", "application/json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- err = checkHeader(req, "Authorization", "Bearer secret")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(http.StatusNoContent)
- })
-
- err := client.RemoveTxtRecord(context.Background(), "example.com", 1234567)
+ err := client.RemoveTxtRecord(t.Context(), "example.com", 1234567)
require.NoError(t, err)
}
diff --git a/providers/dns/directadmin/directadmin.go b/providers/dns/directadmin/directadmin.go
index de9b14945..8dfa132ae 100644
--- a/providers/dns/directadmin/directadmin.go
+++ b/providers/dns/directadmin/directadmin.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/directadmin/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -99,6 +100,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{client: client, config: config}, nil
}
diff --git a/providers/dns/directadmin/directadmin.toml b/providers/dns/directadmin/directadmin.toml
index 6b9f1353f..294eaca1c 100644
--- a/providers/dns/directadmin/directadmin.toml
+++ b/providers/dns/directadmin/directadmin.toml
@@ -8,7 +8,7 @@ Example = '''
DIRECTADMIN_API_URL="http://example.com:2222" \
DIRECTADMIN_USERNAME=xxxx \
DIRECTADMIN_PASSWORD=yyy \
-lego --email you@example.com --dns directadmin -d '*.example.com' -d example.com run
+lego --dns directadmin -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -18,10 +18,10 @@ lego --email you@example.com --dns directadmin -d '*.example.com' -d example.com
DIRECTADMIN_PASSWORD = "API password"
[Configuration.Additional]
DIRECTADMIN_ZONE_NAME = "Zone name used to add the TXT record"
- DIRECTADMIN_POLLING_INTERVAL = "Time between DNS propagation check"
- DIRECTADMIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DIRECTADMIN_TTL = "The TTL of the TXT record used for the DNS challenge"
- DIRECTADMIN_HTTP_TIMEOUT = "API request timeout"
+ DIRECTADMIN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ DIRECTADMIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DIRECTADMIN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)"
+ DIRECTADMIN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.directadmin.com/api.php"
diff --git a/providers/dns/directadmin/directadmin_test.go b/providers/dns/directadmin/directadmin_test.go
index 10c079f73..aed3ba505 100644
--- a/providers/dns/directadmin/directadmin_test.go
+++ b/providers/dns/directadmin/directadmin_test.go
@@ -59,6 +59,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -135,6 +136,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -148,6 +150,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/directadmin/internal/client.go b/providers/dns/directadmin/internal/client.go
index fb84257bc..64409a79d 100644
--- a/providers/dns/directadmin/internal/client.go
+++ b/providers/dns/directadmin/internal/client.go
@@ -38,7 +38,7 @@ func NewClient(baseURL, username, password string) (*Client, error) {
}, nil
}
-func (c Client) SetRecord(ctx context.Context, domain string, record Record) error {
+func (c *Client) SetRecord(ctx context.Context, domain string, record Record) error {
data, err := querystring.Values(record)
if err != nil {
return err
@@ -49,7 +49,7 @@ func (c Client) SetRecord(ctx context.Context, domain string, record Record) err
return c.do(ctx, domain, data)
}
-func (c Client) DeleteRecord(ctx context.Context, domain string, record Record) error {
+func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error {
data, err := querystring.Values(record)
if err != nil {
return err
@@ -60,7 +60,7 @@ func (c Client) DeleteRecord(ctx context.Context, domain string, record Record)
return c.do(ctx, domain, data)
}
-func (c Client) do(ctx context.Context, domain string, data url.Values) error {
+func (c *Client) do(ctx context.Context, domain string, data url.Values) error {
endpoint := c.baseURL.JoinPath("CMD_API_DNS_CONTROL")
query := endpoint.Query()
@@ -94,6 +94,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errInfo APIError
+
err := json.Unmarshal(raw, &errInfo)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/directadmin/internal/client_test.go b/providers/dns/directadmin/internal/client_test.go
index ded4769e3..759a7fb4e 100644
--- a/providers/dns/directadmin/internal/client_test.go
+++ b/providers/dns/directadmin/internal/client_test.go
@@ -1,89 +1,48 @@
package internal
import (
- "context"
- "encoding/json"
"fmt"
- "io"
"net/http"
"net/http/httptest"
- "net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, _ := NewClient(server.URL, "user", "secret")
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client, _ := NewClient(server.URL, "user", "secret")
- client.HTTPClient = server.Client()
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
}
-func newJSONErrorf(reason string, a ...any) string {
- err := APIError{
+func newAPIError(reason string, a ...any) APIError {
+ return APIError{
Message: "Cannot View Dns Record",
Result: fmt.Sprintf(reason, a...),
}
-
- data, _ := json.Marshal(err)
-
- return string(data)
-}
-
-func testHandler(kv map[string]string) func(rw http.ResponseWriter, req *http.Request) {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- domain := req.URL.Query().Get("domain")
- if domain != "example.com" {
- http.Error(rw, newJSONErrorf("invalid domain: %s", domain), http.StatusUnauthorized)
- return
- }
-
- data, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- values, err := url.ParseQuery(string(data))
- if err != nil {
- http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- for k, v := range kv {
- actual := values.Get(k)
- if v != actual {
- http.Error(rw, newJSONErrorf("invalid %q: %s", k, actual), http.StatusBadRequest)
- return
- }
- }
- }
}
func TestClient_SetRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- kv := map[string]string{
- "action": "add",
- "name": "foo",
- "type": "TXT",
- "value": "txtTXTtxt",
- "ttl": "123",
- }
-
- mux.HandleFunc("/CMD_API_DNS_CONTROL", testHandler(kv))
+ client := mockBuilder().
+ Route("POST /CMD_API_DNS_CONTROL", nil,
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "example.com").
+ With("json", "yes"),
+ servermock.CheckForm().UsePostForm().Strict().
+ With("action", "add").
+ With("name", "foo").
+ With("type", "TXT").
+ With("value", "txtTXTtxt").
+ With("ttl", "123"),
+ ).
+ Build(t)
record := Record{
Name: "foo",
@@ -92,16 +51,16 @@ func TestClient_SetRecord(t *testing.T) {
TTL: 123,
}
- err := client.SetRecord(context.Background(), "example.com", record)
+ err := client.SetRecord(t.Context(), "example.com", record)
require.NoError(t, err)
}
func TestClient_SetRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/CMD_API_DNS_CONTROL", func(rw http.ResponseWriter, req *http.Request) {
- http.Error(rw, newJSONErrorf("OOPS"), http.StatusInternalServerError)
- })
+ client := mockBuilder().
+ Route("POST /CMD_API_DNS_CONTROL",
+ servermock.JSONEncode(newAPIError("OOPS")).
+ WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
record := Record{
Name: "foo",
@@ -110,22 +69,23 @@ func TestClient_SetRecord_error(t *testing.T) {
TTL: 123,
}
- err := client.SetRecord(context.Background(), "example.com", record)
+ err := client.SetRecord(t.Context(), "example.com", record)
require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS")
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- kv := map[string]string{
- "action": "delete",
- "name": "foo",
- "type": "TXT",
- "value": "txtTXTtxt",
- "ttl": "",
- }
-
- mux.HandleFunc("/CMD_API_DNS_CONTROL", testHandler(kv))
+ client := mockBuilder().
+ Route("POST /CMD_API_DNS_CONTROL", nil,
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "example.com").
+ With("json", "yes"),
+ servermock.CheckForm().UsePostForm().Strict().
+ With("action", "delete").
+ With("name", "foo").
+ With("type", "TXT").
+ With("value", "txtTXTtxt"),
+ ).
+ Build(t)
record := Record{
Name: "foo",
@@ -133,16 +93,16 @@ func TestClient_DeleteRecord(t *testing.T) {
Value: "txtTXTtxt",
}
- err := client.DeleteRecord(context.Background(), "example.com", record)
+ err := client.DeleteRecord(t.Context(), "example.com", record)
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/CMD_API_DNS_CONTROL", func(rw http.ResponseWriter, req *http.Request) {
- http.Error(rw, newJSONErrorf("OOPS"), http.StatusInternalServerError)
- })
+ client := mockBuilder().
+ Route("POST /CMD_API_DNS_CONTROL",
+ servermock.JSONEncode(newAPIError("OOPS")).
+ WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
record := Record{
Name: "foo",
@@ -150,6 +110,6 @@ func TestClient_DeleteRecord_error(t *testing.T) {
Value: "txtTXTtxt",
}
- err := client.DeleteRecord(context.Background(), "example.com", record)
+ err := client.DeleteRecord(t.Context(), "example.com", record)
require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS")
}
diff --git a/providers/dns/dns_providers_test.go b/providers/dns/dns_providers_test.go
index 1f39e2bdd..3b82784b4 100644
--- a/providers/dns/dns_providers_test.go
+++ b/providers/dns/dns_providers_test.go
@@ -13,6 +13,7 @@ var envTest = tester.NewEnvTest("EXEC_PATH")
func TestKnownDNSProviderSuccess(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.Apply(map[string]string{
"EXEC_PATH": "abc",
})
@@ -26,6 +27,7 @@ func TestKnownDNSProviderSuccess(t *testing.T) {
func TestKnownDNSProviderError(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
provider, err := NewDNSChallengeProviderByName("exec")
diff --git a/providers/dns/dnsexit/dnsexit.go b/providers/dns/dnsexit/dnsexit.go
new file mode 100644
index 000000000..ce9373a50
--- /dev/null
+++ b/providers/dns/dnsexit/dnsexit.go
@@ -0,0 +1,163 @@
+// Package dnsexit implements a DNS provider for solving the DNS-01 challenge using DNSExit.
+package dnsexit
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/dnsexit/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "DNSEXIT_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 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
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for DNSExit.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("dnsexit: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for DNSExit.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("dnsexit: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("dnsexit: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("dnsexit: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: subDomain,
+ Content: info.Value,
+ TTL: toMinutes(d.config.TTL),
+ }
+
+ err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("dnsexit: add record: %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("dnsexit: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: subDomain,
+ Content: info.Value,
+ }
+
+ err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("dnsexit: add 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
+}
+
+func toMinutes(seconds int) int {
+ i := seconds / 60
+ if seconds%60 > 0 {
+ i++
+ }
+
+ return i
+}
diff --git a/providers/dns/dnsexit/dnsexit.toml b/providers/dns/dnsexit/dnsexit.toml
new file mode 100644
index 000000000..0d5321835
--- /dev/null
+++ b/providers/dns/dnsexit/dnsexit.toml
@@ -0,0 +1,22 @@
+Name = "DNSExit"
+Description = ''''''
+URL = "https://dnsexit.com"
+Code = "dnsexit"
+Since = "v4.32.0"
+
+Example = '''
+DNSEXIT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns dnsexit -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ DNSEXIT_API_KEY = "API key"
+ [Configuration.Additional]
+ DNSEXIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ DNSEXIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ DNSEXIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ DNSEXIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://dnsexit.com/dns/dns-api/"
diff --git a/providers/dns/dnsexit/dnsexit_test.go b/providers/dns/dnsexit/dnsexit_test.go
new file mode 100644
index 000000000..31fe61497
--- /dev/null
+++ b/providers/dns/dnsexit/dnsexit_test.go
@@ -0,0 +1,165 @@
+package dnsexit
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "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: "key",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "dnsexit: some credentials information are missing: DNSEXIT_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "key",
+ },
+ {
+ desc: "missing credentials",
+ expected: "dnsexit: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("apikey", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromInternal("success.json"),
+ servermock.CheckRequestJSONBodyFromInternal("add_record-request.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromInternal("success.json"),
+ servermock.CheckRequestJSONBodyFromInternal("delete_record-request.json"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/dnsexit/internal/client.go b/providers/dns/dnsexit/internal/client.go
new file mode 100644
index 000000000..9b0164846
--- /dev/null
+++ b/providers/dns/dnsexit/internal/client.go
@@ -0,0 +1,156 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.dnsexit.com/dns/"
+
+// Client the DNSExit API client.
+type Client struct {
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey string) (*Client, error) {
+ if apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// AddRecord adds a record.
+// https://dnsexit.com/dns/dns-api/#example-add-spf
+// https://dnsexit.com/dns/dns-api/#example-lse
+func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error {
+ payload := APIRequest{
+ Domain: domain,
+ Add: []Record{record},
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload)
+ if err != nil {
+ return err
+ }
+
+ err = c.do(req)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// DeleteRecord deletes a record.
+// https://dnsexit.com/dns/dns-api/#delete-a-record
+func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error {
+ payload := APIRequest{
+ Domain: domain,
+ Delete: []Record{record},
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload)
+ if err != nil {
+ return err
+ }
+
+ err = c.do(req)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) do(req *http.Request) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set("apikey", c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode > http.StatusBadRequest {
+ return parseError(req, resp)
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ result := &APIResponse{}
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ if result.Code != 0 {
+ return result
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIResponse
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/dnsexit/internal/client_test.go b/providers/dns/dnsexit/internal/client_test.go
new file mode 100644
index 000000000..26ea01203
--- /dev/null
+++ b/providers/dns/dnsexit/internal/client_test.go
@@ -0,0 +1,111 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("apikey", "secret"),
+ )
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("success.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "_acme-challenge",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 2,
+ }
+
+ err := client.AddRecord(context.Background(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "_acme-challenge",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 480,
+ Overwrite: true,
+ }
+
+ err := client.AddRecord(context.Background(), "example.com", record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("success.json"),
+ servermock.CheckRequestJSONBodyFromFixture("delete_record-request.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "_acme-challenge",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ }
+
+ err := client.DeleteRecord(context.Background(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "foo",
+ Content: "txtTXTtxt",
+ }
+
+ err := client.DeleteRecord(context.Background(), "example.com", record)
+
+ require.Error(t, err)
+
+ require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)")
+}
diff --git a/providers/dns/dnsexit/internal/fixtures/add_record-request.json b/providers/dns/dnsexit/internal/fixtures/add_record-request.json
new file mode 100644
index 000000000..6e5e2b520
--- /dev/null
+++ b/providers/dns/dnsexit/internal/fixtures/add_record-request.json
@@ -0,0 +1,11 @@
+{
+ "domain": "example.com",
+ "add": [
+ {
+ "type": "TXT",
+ "name": "_acme-challenge",
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 2
+ }
+ ]
+}
diff --git a/providers/dns/dnsexit/internal/fixtures/delete_record-request.json b/providers/dns/dnsexit/internal/fixtures/delete_record-request.json
new file mode 100644
index 000000000..dcfef9cdf
--- /dev/null
+++ b/providers/dns/dnsexit/internal/fixtures/delete_record-request.json
@@ -0,0 +1,10 @@
+{
+ "domain": "example.com",
+ "delete": [
+ {
+ "type": "TXT",
+ "name": "_acme-challenge",
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ }
+ ]
+}
diff --git a/providers/dns/dnsexit/internal/fixtures/error.json b/providers/dns/dnsexit/internal/fixtures/error.json
new file mode 100644
index 000000000..9ba835895
--- /dev/null
+++ b/providers/dns/dnsexit/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+ "code": 6,
+ "message": "JSON Defined Record Type not Supported"
+}
diff --git a/providers/dns/dnsexit/internal/fixtures/success.json b/providers/dns/dnsexit/internal/fixtures/success.json
new file mode 100644
index 000000000..3af47a936
--- /dev/null
+++ b/providers/dns/dnsexit/internal/fixtures/success.json
@@ -0,0 +1,7 @@
+{
+ "code": 0,
+ "details": [
+ "UPDATE Record A example.com. TTL(hh:mm) 08:00 IP 1.1.1.10"
+ ],
+ "message": "Success"
+}
diff --git a/providers/dns/dnsexit/internal/types.go b/providers/dns/dnsexit/internal/types.go
new file mode 100644
index 000000000..db254549f
--- /dev/null
+++ b/providers/dns/dnsexit/internal/types.go
@@ -0,0 +1,41 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Record struct {
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"` // NOTE: ttl value is in minutes.
+ Overwrite bool `json:"overwrite,omitempty"`
+}
+
+type APIRequest struct {
+ Domain string `json:"domain,omitempty"`
+ Add []Record `json:"add,omitempty"`
+ Delete []Record `json:"delete,omitempty"`
+ Update []Record `json:"update,omitempty"`
+}
+
+// https://dnsexit.com/dns/dns-api/#server-reply
+
+type APIResponse struct {
+ Code int `json:"code,omitempty"`
+ Details []string `json:"details,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+func (a APIResponse) Error() string {
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "%s (code=%d)", a.Message, a.Code)
+
+ for _, detail := range a.Details {
+ _, _ = fmt.Fprintf(msg, ", %s", detail)
+ }
+
+ return msg.String()
+}
diff --git a/providers/dns/dnshomede/dnshomede.go b/providers/dns/dnshomede/dnshomede.go
index 1b81be744..c76ed6de2 100644
--- a/providers/dns/dnshomede/dnshomede.go
+++ b/providers/dns/dnshomede/dnshomede.go
@@ -6,12 +6,12 @@ import (
"errors"
"fmt"
"net/http"
- "strings"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/dnshomede/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -57,14 +57,15 @@ type DNSProvider struct {
// Credentials must be passed in the environment variable: DNSHOMEDE_CREDENTIALS.
func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
+
values, err := env.Get(EnvCredentials)
if err != nil {
return nil, fmt.Errorf("dnshomede: %w", err)
}
- credentials, err := parseCredentials(values[EnvCredentials])
+ credentials, err := env.ParsePairs(values[EnvCredentials])
if err != nil {
- return nil, fmt.Errorf("dnshomede: %w", err)
+ return nil, fmt.Errorf("dnshomede: credentials: %w", err)
}
config.Credentials = credentials
@@ -93,6 +94,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := internal.NewClient(config.Credentials)
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -131,19 +138,3 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) Sequential() time.Duration {
return d.config.SequenceInterval
}
-
-func parseCredentials(raw string) (map[string]string, error) {
- credentials := make(map[string]string)
-
- credStrings := strings.Split(strings.TrimSuffix(raw, ","), ",")
- for _, credPair := range credStrings {
- data := strings.Split(credPair, ":")
- if len(data) != 2 {
- return nil, fmt.Errorf("invalid credential pair: %q", credPair)
- }
-
- credentials[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1])
- }
-
- return credentials, nil
-}
diff --git a/providers/dns/dnshomede/dnshomede.toml b/providers/dns/dnshomede/dnshomede.toml
index 3aafb4ef8..9c3b65277 100644
--- a/providers/dns/dnshomede/dnshomede.toml
+++ b/providers/dns/dnshomede/dnshomede.toml
@@ -6,17 +6,17 @@ Since = "v4.10.0"
Example = '''
DNSHOMEDE_CREDENTIALS=example.org:password \
-lego --email you@example.com --dns dnshomede -d '*.example.com' -d example.com run
+lego --dns dnshomede -d '*.example.com' -d example.com run
DNSHOMEDE_CREDENTIALS=my.example.org:password1,demo.example.org:password2 \
-lego --email you@example.com --dns dnshomede -d my.example.org -d demo.example.org
+lego --dns dnshomede -d my.example.org -d demo.example.org
'''
[Configuration]
[Configuration.Credentials]
DNSHOMEDE_CREDENTIALS = "Comma-separated list of domain:password credential pairs"
[Configuration.Additional]
- DNSHOMEDE_POLLING_INTERVAL = "Time between DNS propagation checks"
- DNSHOMEDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)"
- DNSHOMEDE_SEQUENCE_INTERVAL = "Time between sequential requests"
- DNSHOMEDE_HTTP_TIMEOUT = "API request timeout"
+ DNSHOMEDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 1200)"
+ DNSHOMEDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2)"
+ DNSHOMEDE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)"
+ DNSHOMEDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
diff --git a/providers/dns/dnshomede/dnshomede_test.go b/providers/dns/dnshomede/dnshomede_test.go
index 6b79912e8..5035a7837 100644
--- a/providers/dns/dnshomede/dnshomede_test.go
+++ b/providers/dns/dnshomede/dnshomede_test.go
@@ -34,7 +34,7 @@ func TestNewDNSProvider(t *testing.T) {
envVars: map[string]string{
EnvCredentials: ",",
},
- expected: `dnshomede: invalid credential pair: ""`,
+ expected: `dnshomede: credentials: incorrect pair: `,
},
{
desc: "missing password",
@@ -55,7 +55,7 @@ func TestNewDNSProvider(t *testing.T) {
envVars: map[string]string{
EnvCredentials: "example.org:123,example.net",
},
- expected: `dnshomede: invalid credential pair: "example.net"`,
+ expected: "dnshomede: credentials: incorrect pair: example.net",
},
{
desc: "missing credentials",
@@ -69,6 +69,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -144,6 +145,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -157,6 +159,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/dnshomede/internal/client_test.go b/providers/dns/dnshomede/internal/client_test.go
index e6f2c1b7d..6e1593fe7 100644
--- a/providers/dns/dnshomede/internal/client_test.go
+++ b/providers/dns/dnshomede/internal/client_test.go
@@ -1,89 +1,109 @@
package internal
import (
- "context"
"fmt"
- "net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, credentials map[string]string, handler http.HandlerFunc) *Client {
- t.Helper()
+func setupClient(credentials map[string]string) func(server *httptest.Server) (*Client, error) {
+ return func(server *httptest.Server) (*Client, error) {
+ client := NewClient(credentials)
+ client.HTTPClient = server.Client()
+ client.baseURL = server.URL
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/", handler)
-
- client := NewClient(credentials)
- client.HTTPClient = server.Client()
- client.baseURL = server.URL
-
- return client
+ return client, nil
+ }
}
func TestClient_Add(t *testing.T) {
txtValue := "123456789012"
- client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(addAction, txtValue))
+ client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})).
+ Route("POST /",
+ servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)),
+ servermock.CheckQueryParameter().Strict().
+ With("acme", addAction).With("txt", txtValue)).
+ Build(t)
- err := client.Add(context.Background(), "example.org", txtValue)
+ err := client.Add(t.Context(), "example.org", txtValue)
require.NoError(t, err)
}
func TestClient_Add_error(t *testing.T) {
txtValue := "123456789012"
- client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(addAction, txtValue))
+ client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})).
+ Route("POST /",
+ servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)),
+ servermock.CheckQueryParameter().Strict().
+ With("acme", addAction).With("txt", txtValue)).
+ Build(t)
- err := client.Add(context.Background(), "example.org", txtValue)
- require.Error(t, err)
+ err := client.Add(t.Context(), "example.org", txtValue)
+
+ require.EqualError(t, err, "domain example.org not found in credentials, check your credentials map")
}
func TestClient_Remove(t *testing.T) {
txtValue := "ABCDEFGHIJKL"
- client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(removeAction, txtValue))
+ client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})).
+ Route("POST /",
+ servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)),
+ servermock.CheckQueryParameter().Strict().
+ With("acme", removeAction).With("txt", txtValue)).
+ Build(t)
- err := client.Remove(context.Background(), "example.org", txtValue)
+ err := client.Remove(t.Context(), "example.org", txtValue)
require.NoError(t, err)
}
func TestClient_Remove_error(t *testing.T) {
txtValue := "ABCDEFGHIJKL"
- client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(removeAction, txtValue))
+ testCases := []struct {
+ desc string
+ hostname string
+ response string
+ expected string
+ }{
+ {
+ desc: "response error - txt",
+ hostname: "example.com",
+ response: "error - no valid acme txt record",
+ expected: "error - no valid acme txt record",
+ },
+ {
+ desc: "response error - acme",
+ hostname: "example.com",
+ response: "nochg 1234:1234:1234:1234:1234:1234:1234:1234",
+ expected: "nochg 1234:1234:1234:1234:1234:1234:1234:1234",
+ },
+ {
+ desc: "credential error",
+ hostname: "example.org",
+ response: fmt.Sprintf("%s %s", successCode, txtValue),
+ expected: "domain example.org not found in credentials, check your credentials map",
+ },
+ }
- err := client.Remove(context.Background(), "example.org", txtValue)
- require.Error(t, err)
-}
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
-func handlerMock(action, value string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- rw.WriteHeader(http.StatusOK)
+ client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})).
+ Route("POST /",
+ servermock.RawStringResponse(test.response),
+ servermock.CheckQueryParameter().Strict().
+ With("acme", removeAction).With("txt", txtValue)).
+ Build(t)
- query := req.URL.Query()
-
- if query.Get("acme") != action {
- _, _ = rw.Write([]byte("nochg 1234:1234:1234:1234:1234:1234:1234:1234"))
- return
- }
-
- txtValue := query.Get("txt")
- if len(txtValue) < 12 {
- _, _ = rw.Write([]byte("error - no valid acme txt record"))
- return
- }
-
- if txtValue != value {
- http.Error(rw, fmt.Sprintf("got: %q, expected: %q", txtValue, value), http.StatusBadRequest)
- return
- }
-
- _, _ = fmt.Fprintf(rw, "%s %s", successCode, txtValue)
+ err := client.Remove(t.Context(), test.hostname, txtValue)
+ require.EqualError(t, err, test.expected)
+ })
}
}
diff --git a/providers/dns/dnshomede/internal/readme.md b/providers/dns/dnshomede/internal/readme.md
index 014b062a1..622c4354d 100644
--- a/providers/dns/dnshomede/internal/readme.md
+++ b/providers/dns/dnshomede/internal/readme.md
@@ -16,7 +16,7 @@ Always returns StatusOK (200)
If the API call works the first word of the response body is `successfully`.
-If an error encoured the response body is `error - `.
+If an error occurs the response body is `error - `.
Can be a POST or a GET.
@@ -35,6 +35,6 @@ Always returns StatusOK (200)
If the API call works the first word of the response body is `successfully`.
-If an error encoured the response body is `error - `.
+If an error occurs the response body is `error - `.
Can be a POST or a GET.
diff --git a/providers/dns/dnsimple/dnsimple.go b/providers/dns/dnsimple/dnsimple.go
index db80eb80c..adf7d48e2 100644
--- a/providers/dns/dnsimple/dnsimple.go
+++ b/providers/dns/dnsimple/dnsimple.go
@@ -8,10 +8,11 @@ import (
"strconv"
"time"
- "github.com/dnsimple/dnsimple-go/dnsimple"
+ "github.com/dnsimple/dnsimple-go/v4/dnsimple"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
"golang.org/x/oauth2"
)
@@ -79,8 +80,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("dnsimple: OAuth token is missing")
}
- ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.AccessToken})
- client := dnsimple.NewClient(oauth2.NewClient(context.Background(), ts))
+ client := dnsimple.NewClient(
+ clientdebug.Wrap(
+ oauth2.NewClient(
+ context.Background(),
+ oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.AccessToken}),
+ ),
+ ),
+ )
client.SetUserAgent(useragent.Get())
if config.BaseURL != "" {
@@ -94,14 +101,16 @@ 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)
- zoneName, err := d.getHostedZone(info.EffectiveFQDN)
+ zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("dnsimple: %w", err)
}
- accountID, err := d.getAccountID()
+ accountID, err := d.getAccountID(ctx)
if err != nil {
return fmt.Errorf("dnsimple: %w", err)
}
@@ -111,7 +120,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("dnsimple: %w", err)
}
- _, err = d.client.Zones.CreateRecord(context.Background(), accountID, zoneName, recordAttributes)
+ _, err = d.client.Zones.CreateRecord(ctx, accountID, zoneName, recordAttributes)
if err != nil {
return fmt.Errorf("dnsimple: API call failed: %w", err)
}
@@ -121,21 +130,24 @@ 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)
- records, err := d.findTxtRecords(info.EffectiveFQDN)
+ records, err := d.findTxtRecords(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("dnsimple: %w", err)
}
- accountID, err := d.getAccountID()
+ accountID, err := d.getAccountID(ctx)
if err != nil {
return fmt.Errorf("dnsimple: %w", err)
}
var lastErr error
+
for _, rec := range records {
- _, err := d.client.Zones.DeleteRecord(context.Background(), accountID, rec.ZoneID, rec.ID)
+ _, err := d.client.Zones.DeleteRecord(ctx, accountID, rec.ZoneID, rec.ID)
if err != nil {
lastErr = fmt.Errorf("dnsimple: %w", err)
}
@@ -150,45 +162,36 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
-func (d *DNSProvider) getHostedZone(domain string) (string, error) {
+func (d *DNSProvider) getHostedZone(ctx context.Context, 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)
}
- accountID, err := d.getAccountID()
+ accountID, err := d.getAccountID(ctx)
if err != nil {
return "", err
}
- zoneName := dns01.UnFqdn(authZone)
-
- zones, err := d.client.Zones.ListZones(context.Background(), accountID, &dnsimple.ZoneListOptions{NameLike: &zoneName})
+ hostedZone, err := d.client.Zones.GetZone(ctx, accountID, dns01.UnFqdn(authZone))
if err != nil {
- return "", fmt.Errorf("API call failed: %w", err)
+ return "", fmt.Errorf("get zone: %w", err)
}
- var hostedZone dnsimple.Zone
- for _, zone := range zones.Data {
- if zone.Name == zoneName {
- hostedZone = zone
- }
- }
-
- if hostedZone.ID == 0 {
+ if hostedZone == nil || hostedZone.Data == nil || hostedZone.Data.ID == 0 {
return "", fmt.Errorf("zone %s not found in DNSimple for domain %s", authZone, domain)
}
- return hostedZone.Name, nil
+ return hostedZone.Data.Name, nil
}
-func (d *DNSProvider) findTxtRecords(fqdn string) ([]dnsimple.ZoneRecord, error) {
- zoneName, err := d.getHostedZone(fqdn)
+func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]dnsimple.ZoneRecord, error) {
+ zoneName, err := d.getHostedZone(ctx, fqdn)
if err != nil {
return nil, err
}
- accountID, err := d.getAccountID()
+ accountID, err := d.getAccountID(ctx)
if err != nil {
return nil, err
}
@@ -198,7 +201,7 @@ func (d *DNSProvider) findTxtRecords(fqdn string) ([]dnsimple.ZoneRecord, error)
return nil, err
}
- result, err := d.client.Zones.ListRecords(context.Background(), accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: &subDomain, Type: dnsimple.String("TXT"), ListOptions: dnsimple.ListOptions{}})
+ result, err := d.client.Zones.ListRecords(ctx, accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: &subDomain, Type: dnsimple.String("TXT"), ListOptions: dnsimple.ListOptions{}})
if err != nil {
return nil, fmt.Errorf("API call has failed: %w", err)
}
@@ -220,8 +223,8 @@ func newTxtRecord(zoneName, fqdn, value string, ttl int) (dnsimple.ZoneRecordAtt
}, nil
}
-func (d *DNSProvider) getAccountID() (string, error) {
- whoamiResponse, err := d.client.Identity.Whoami(context.Background())
+func (d *DNSProvider) getAccountID(ctx context.Context) (string, error) {
+ whoamiResponse, err := d.client.Identity.Whoami(ctx)
if err != nil {
return "", err
}
diff --git a/providers/dns/dnsimple/dnsimple.toml b/providers/dns/dnsimple/dnsimple.toml
index 4d31daae1..158fb7011 100644
--- a/providers/dns/dnsimple/dnsimple.toml
+++ b/providers/dns/dnsimple/dnsimple.toml
@@ -6,7 +6,7 @@ Since = "v0.3.0"
Example = '''
DNSIMPLE_OAUTH_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \
-lego --email you@example.com --dns dnsimple -d '*.example.com' -d example.com run
+lego --dns dnsimple -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -32,9 +32,9 @@ Only Account API tokens are supported, if you try to use a User API token you wi
DNSIMPLE_OAUTH_TOKEN = "OAuth token"
[Configuration.Additional]
DNSIMPLE_BASE_URL = "API endpoint URL"
- DNSIMPLE_POLLING_INTERVAL = "Time between DNS propagation check"
- DNSIMPLE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DNSIMPLE_TTL = "The TTL of the TXT record used for the DNS challenge"
+ DNSIMPLE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DNSIMPLE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DNSIMPLE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
[Links]
API = "https://developer.dnsimple.com/v2/"
diff --git a/providers/dns/dnsimple/dnsimple_test.go b/providers/dns/dnsimple/dnsimple_test.go
index c07f965b4..2a52dd2de 100644
--- a/providers/dns/dnsimple/dnsimple_test.go
+++ b/providers/dns/dnsimple/dnsimple_test.go
@@ -51,6 +51,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.go b/providers/dns/dnsmadeeasy/dnsmadeeasy.go
index e4e77726f..69f2096fb 100644
--- a/providers/dns/dnsmadeeasy/dnsmadeeasy.go
+++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.go
@@ -15,6 +15,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -47,15 +48,22 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
+ tr := &http.Transport{}
+
+ defaultTransport, ok := http.DefaultTransport.(*http.Transport)
+ if ok {
+ tr = defaultTransport.Clone()
+ }
+
+ tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
+
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
- Transport: &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
- },
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
+ Transport: tr,
},
}
}
@@ -105,7 +113,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("dnsmadeeasy: %w", err)
}
- client.HTTPClient = config.HTTPClient
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
client.BaseURL, err = url.Parse(baseURL)
if err != nil {
return nil, err
@@ -142,6 +155,7 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("dnsmadeeasy: unable to create record for %s: %w", name, err)
}
+
return nil
}
@@ -164,6 +178,7 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error {
// find matching records
name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1)
+
records, err := d.client.GetRecords(ctx, domain, name, "TXT")
if err != nil {
return fmt.Errorf("dnsmadeeasy: unable to get records for domain %s: %w", domain.Name, err)
@@ -171,6 +186,7 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error {
// delete records
var lastError error
+
for _, record := range *records {
err = d.client.DeleteRecord(ctx, record)
if err != nil {
diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.toml b/providers/dns/dnsmadeeasy/dnsmadeeasy.toml
index 28b38e771..d71ab5303 100644
--- a/providers/dns/dnsmadeeasy/dnsmadeeasy.toml
+++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.toml
@@ -7,7 +7,7 @@ Since = "v0.4.0"
Example = '''
DNSMADEEASY_API_KEY=xxxxxx \
DNSMADEEASY_API_SECRET=yyyyy \
-lego --email you@example.com --dns dnsmadeeasy -d '*.example.com' -d example.com run
+lego --dns dnsmadeeasy -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,10 +16,10 @@ lego --email you@example.com --dns dnsmadeeasy -d '*.example.com' -d example.com
DNSMADEEASY_API_SECRET = "The API Secret key"
[Configuration.Additional]
DNSMADEEASY_SANDBOX = "Activate the sandbox (boolean)"
- DNSMADEEASY_POLLING_INTERVAL = "Time between DNS propagation check"
- DNSMADEEASY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DNSMADEEASY_TTL = "The TTL of the TXT record used for the DNS challenge"
- DNSMADEEASY_HTTP_TIMEOUT = "API request timeout"
+ DNSMADEEASY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DNSMADEEASY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DNSMADEEASY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ DNSMADEEASY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://api-docs.dnsmadeeasy.com/"
diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go b/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go
index 5c508e60d..f6fc2e273 100644
--- a/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go
+++ b/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go
@@ -59,6 +59,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -135,6 +136,7 @@ func TestLivePresentAndCleanup(t *testing.T) {
os.Setenv(EnvSandbox, "true")
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/dnsmadeeasy/internal/client.go b/providers/dns/dnsmadeeasy/internal/client.go
index 491d5fd98..7963ad614 100644
--- a/providers/dns/dnsmadeeasy/internal/client.go
+++ b/providers/dns/dnsmadeeasy/internal/client.go
@@ -15,6 +15,7 @@ import (
"strconv"
"time"
+ "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
@@ -57,10 +58,8 @@ func NewClient(apiKey, apiSecret string) (*Client, error) {
func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error) {
endpoint := c.BaseURL.JoinPath("dns", "managed", "name")
- domainName := authZone[0 : len(authZone)-1]
-
query := endpoint.Query()
- query.Set("domainname", domainName)
+ query.Set("domainname", dns01.UnFqdn(authZone))
endpoint.RawQuery = query.Encode()
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -69,6 +68,7 @@ func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error
}
domain := &Domain{}
+
err = c.do(req, domain)
if err != nil {
return nil, err
@@ -92,6 +92,7 @@ func (c *Client) GetRecords(ctx context.Context, domain *Domain, recordName, rec
}
records := &recordsResponse{}
+
err = c.do(req, records)
if err != nil {
return nil, err
@@ -173,10 +174,12 @@ func (c *Client) sign(req *http.Request, timestamp string) error {
func computeHMAC(message, secret string) (string, error) {
key := []byte(secret)
h := hmac.New(sha1.New, key)
+
_, err := h.Write([]byte(message))
if err != nil {
return "", err
}
+
return hex.EncodeToString(h.Sum(nil)), nil
}
diff --git a/providers/dns/dnsmadeeasy/internal/client_test.go b/providers/dns/dnsmadeeasy/internal/client_test.go
index 721214693..cde212fc8 100644
--- a/providers/dns/dnsmadeeasy/internal/client_test.go
+++ b/providers/dns/dnsmadeeasy/internal/client_test.go
@@ -2,14 +2,132 @@ package internal
import (
"net/http"
+ "net/http/httptest"
+ "net/url"
"testing"
"time"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func Test_sign(t *testing.T) {
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("key", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With("x-dnsme-apiKey", "key").
+ WithRegexp("x-dnsme-requestDate", `\w+, \d+ \w+ \d+ \d+:\d+:\d+ UTC`).
+ WithRegexp("x-dnsme-hmac", `[a-z0-9]+`),
+ )
+}
+
+func TestClient_GetDomain(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/managed/name",
+ servermock.RawStringResponse(`{"id": 1, "name": "foo"}`),
+ servermock.CheckQueryParameter().Strict().
+ With("domainname", "example.com")).
+ Build(t)
+
+ domain, err := client.GetDomain(t.Context(), "example.com.")
+ require.NoError(t, err)
+
+ expected := &Domain{
+ ID: 1,
+ Name: "foo",
+ }
+
+ assert.Equal(t, expected, domain)
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/managed/1/records",
+ servermock.ResponseFromFixture("get_records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("recordName", "foo").
+ With("type", "TXT"),
+ ).
+ Build(t)
+
+ domain := &Domain{ID: 1, Name: "foo"}
+
+ records, err := client.GetRecords(t.Context(), domain, "foo", "TXT")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {
+ ID: 1,
+ Type: "TXT",
+ Name: "foo",
+ Value: "aaa",
+ TTL: 60,
+ SourceID: 123,
+ },
+ {
+ ID: 2,
+ Type: "TXT",
+ Name: "bar",
+ Value: "bbb",
+ TTL: 120,
+ SourceID: 456,
+ },
+ }
+
+ assert.Equal(t, &expected, records)
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/managed/1/records", nil,
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ domain := &Domain{ID: 1, Name: "foo"}
+
+ record := &Record{
+ ID: 1,
+ Type: "TXT",
+ Name: "foo",
+ Value: "aaa",
+ TTL: 60,
+ SourceID: 123,
+ }
+
+ err := client.CreateRecord(t.Context(), domain, record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/managed/123/records/1", nil).
+ Build(t)
+
+ record := Record{
+ ID: 1,
+ Type: "TXT",
+ Name: "foo",
+ Value: "aaa",
+ TTL: 60,
+ SourceID: 123,
+ }
+
+ err := client.DeleteRecord(t.Context(), record)
+ require.NoError(t, err)
+}
+
+func TestClient_sign(t *testing.T) {
apiKey := "key"
client := Client{apiKey: apiKey, apiSecret: "secret"}
diff --git a/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json b/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..9a08b6544
--- /dev/null
+++ b/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json
@@ -0,0 +1,8 @@
+{
+ "id": 1,
+ "type": "TXT",
+ "name": "foo",
+ "value": "aaa",
+ "ttl": 60,
+ "sourceId": 123
+}
diff --git a/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json b/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json
new file mode 100644
index 000000000..5667e5e1d
--- /dev/null
+++ b/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json
@@ -0,0 +1,20 @@
+{
+ "data": [
+ {
+ "id": 1,
+ "type": "TXT",
+ "name": "foo",
+ "value": "aaa",
+ "ttl": 60,
+ "sourceId": 123
+ },
+ {
+ "id": 2,
+ "type": "TXT",
+ "name": "bar",
+ "value": "bbb",
+ "ttl": 120,
+ "sourceId": 456
+ }
+ ]
+}
diff --git a/providers/dns/dnspod/dnspod.go b/providers/dns/dnspod/dnspod.go
index ab8f20c8d..52a873c7b 100644
--- a/providers/dns/dnspod/dnspod.go
+++ b/providers/dns/dnspod/dnspod.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/nrdcg/dnspod-go"
)
@@ -82,7 +83,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
params := dnspod.CommonParams{LoginToken: config.LoginToken, Format: "json"}
client := dnspod.NewClient(params)
- client.HTTPClient = config.HTTPClient
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
return &DNSProvider{client: client, config: config}, nil
}
@@ -129,6 +135,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return err
}
}
+
return nil
}
@@ -150,6 +157,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) {
}
var hostedZone dnspod.Domain
+
for _, zone := range zones {
if zone.Name == dns01.UnFqdn(authZone) {
hostedZone = zone
@@ -157,7 +165,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) {
}
if hostedZone.ID == "" || hostedZone.ID == "0" {
- return "", "", fmt.Errorf("zone %s not found in dnspod for domain %s", authZone, domain)
+ return "", "", fmt.Errorf("zone %s not found for domain %s", authZone, domain)
}
return hostedZone.ID.String(), hostedZone.Name, nil
@@ -185,6 +193,7 @@ func (d *DNSProvider) findTxtRecords(fqdn, zoneID, zoneName string) ([]dnspod.Re
}
var records []dnspod.Record
+
result, _, err := d.client.Records.List(zoneID, subDomain)
if err != nil {
return records, fmt.Errorf("API call has failed: %w", err)
diff --git a/providers/dns/dnspod/dnspod.toml b/providers/dns/dnspod/dnspod.toml
index 7723f12ed..162685d76 100644
--- a/providers/dns/dnspod/dnspod.toml
+++ b/providers/dns/dnspod/dnspod.toml
@@ -8,17 +8,17 @@ Since = "v0.4.0"
Example = '''
DNSPOD_API_KEY=xxxxxx \
-lego --email you@example.com --dns dnspod -d '*.example.com' -d example.com run
+lego --dns dnspod -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
DNSPOD_API_KEY = "The user token"
[Configuration.Additional]
- DNSPOD_POLLING_INTERVAL = "Time between DNS propagation check"
- DNSPOD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DNSPOD_TTL = "The TTL of the TXT record used for the DNS challenge"
- DNSPOD_HTTP_TIMEOUT = "API request timeout"
+ DNSPOD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DNSPOD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DNSPOD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ DNSPOD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://docs.dnspod.com/api/"
diff --git a/providers/dns/dnspod/dnspod_test.go b/providers/dns/dnspod/dnspod_test.go
index 640ec34c6..5d339353a 100644
--- a/providers/dns/dnspod/dnspod_test.go
+++ b/providers/dns/dnspod/dnspod_test.go
@@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -96,6 +97,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -109,6 +111,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/dode/dode.go b/providers/dns/dode/dode.go
index 9f307f046..59ad785e8 100644
--- a/providers/dns/dode/dode.go
+++ b/providers/dns/dode/dode.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/dode/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -85,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/dode/dode.toml b/providers/dns/dode/dode.toml
index a6a6e8f29..eb629bb3e 100644
--- a/providers/dns/dode/dode.toml
+++ b/providers/dns/dode/dode.toml
@@ -6,18 +6,17 @@ Since = "v2.4.0"
Example = '''
DODE_TOKEN=xxxxxx \
-lego --email you@example.com --dns dode -d '*.example.com' -d example.com run
+lego --dns dode -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
DODE_TOKEN = "API token"
[Configuration.Additional]
- DODE_POLLING_INTERVAL = "Time between DNS propagation check"
- DODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DODE_TTL = "The TTL of the TXT record used for the DNS challenge"
- DODE_HTTP_TIMEOUT = "API request timeout"
- DODE_SEQUENCE_INTERVAL = "Time between sequential requests"
+ DODE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DODE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ DODE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
[Links]
API = "https://www.do.de/wiki/freie-ssl-tls-zertifikate-ueber-acme/"
diff --git a/providers/dns/dode/dode_test.go b/providers/dns/dode/dode_test.go
index 3d8e9395a..fefcc79b1 100644
--- a/providers/dns/dode/dode_test.go
+++ b/providers/dns/dode/dode_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/dode/internal/client.go b/providers/dns/dode/internal/client.go
index 91fa439c7..6824e7c48 100644
--- a/providers/dns/dode/internal/client.go
+++ b/providers/dns/dode/internal/client.go
@@ -36,7 +36,7 @@ func NewClient(token string) *Client {
// UpdateTxtRecord Update the domains TXT record
// To update the TXT record we just need to make one simple get request.
-func (c Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clearRecord bool) error {
+func (c *Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clearRecord bool) error {
endpoint := c.baseURL.JoinPath("letsencrypt")
query := endpoint.Query()
@@ -70,6 +70,7 @@ func (c Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clearReco
}
var response apiResponse
+
err = json.Unmarshal(raw, &response)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/dode/internal/client_test.go b/providers/dns/dode/internal/client_test.go
index 116ca8c4c..6fbaa8c1d 100644
--- a/providers/dns/dode/internal/client_test.go
+++ b/providers/dns/dode/internal/client_test.go
@@ -1,93 +1,44 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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
- }
-
- query := req.URL.Query()
- if query.Get("token") != "secret" {
- http.Error(rw, fmt.Sprintf("invalid credentials: %q", query.Get("token")), http.StatusUnauthorized)
- return
- }
-
- if query.Get("domain") != "example.com" {
- http.Error(rw, fmt.Sprintf("invalid domain: %q", query.Get("domain")), http.StatusBadRequest)
- return
- }
-
- if query.Has("action") {
- if query.Get("action") != "delete" {
- http.Error(rw, fmt.Sprintf("invalid action: %q", query.Get("action")), http.StatusBadRequest)
- return
- }
- } else {
- if query.Get("value") != "value" {
- http.Error(rw, fmt.Sprintf("invalid value: %q", query.Get("value")), 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
- }
- })
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("secret")
client.HTTPClient = server.Client()
client.baseURL, _ = url.Parse(server.URL)
- return client
+ return client, nil
}
func TestClient_UpdateTxtRecord(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json")
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain", "example.com").
+ With("token", "secret").
+ With("value", "value")).
+ Build(t)
- err := client.UpdateTxtRecord(context.Background(), "example.com.", "value", false)
+ err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", false)
require.NoError(t, err)
}
func TestClient_UpdateTxtRecord_clear(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json")
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("action", "delete").
+ With("domain", "example.com").
+ With("token", "secret")).
+ Build(t)
- err := client.UpdateTxtRecord(context.Background(), "example.com.", "value", true)
+ err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", true)
require.NoError(t, err)
}
diff --git a/providers/dns/domeneshop/domeneshop.go b/providers/dns/domeneshop/domeneshop.go
index c194f5608..fb16b442e 100644
--- a/providers/dns/domeneshop/domeneshop.go
+++ b/providers/dns/domeneshop/domeneshop.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/domeneshop/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -86,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/domeneshop/domeneshop.toml b/providers/dns/domeneshop/domeneshop.toml
index 8dfe806e5..b74af598e 100644
--- a/providers/dns/domeneshop/domeneshop.toml
+++ b/providers/dns/domeneshop/domeneshop.toml
@@ -8,7 +8,7 @@ Since = "v4.3.0"
Example = '''
DOMENESHOP_API_TOKEN= \
DOMENESHOP_API_SECRET= \
-lego --email example@example.com --dns domeneshop -d '*.example.com' -d example.com run
+lego --dns domeneshop -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -24,9 +24,9 @@ Visit the following page for information on how to create API credentials with D
DOMENESHOP_API_TOKEN = "API token"
DOMENESHOP_API_SECRET = "API secret"
[Configuration.Additional]
- DOMENESHOP_POLLING_INTERVAL = "Time between DNS propagation check"
- DOMENESHOP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DOMENESHOP_HTTP_TIMEOUT = "API request timeout"
+ DOMENESHOP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)"
+ DOMENESHOP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ DOMENESHOP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.domeneshop.no/docs"
diff --git a/providers/dns/domeneshop/domeneshop_test.go b/providers/dns/domeneshop/domeneshop_test.go
index 389975bca..086efd44a 100644
--- a/providers/dns/domeneshop/domeneshop_test.go
+++ b/providers/dns/domeneshop/domeneshop_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -130,6 +131,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -143,6 +145,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/domeneshop/internal/client.go b/providers/dns/domeneshop/internal/client.go
index b7ebb9940..9ab964222 100644
--- a/providers/dns/domeneshop/internal/client.go
+++ b/providers/dns/domeneshop/internal/client.go
@@ -72,7 +72,7 @@ func (c *Client) GetDomainByName(ctx context.Context, domain string) (*Domain, e
// CreateTXTRecord creates a TXT record with the provided host (subdomain) and data.
// https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns/post
-func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host string, data string) error {
+func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host, data string) error {
endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns")
record := DNSRecord{
@@ -92,7 +92,7 @@ func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host strin
// DeleteTXTRecord deletes the DNS record matching the provided host and data.
// https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns~1{recordId}/delete
-func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host string, data string) error {
+func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host, data string) error {
record, err := c.getDNSRecordByHostData(ctx, *domain, host, data)
if err != nil {
return err
@@ -110,7 +110,7 @@ func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host strin
// getDNSRecordByHostData finds the first matching DNS record with the provided host and data.
// https://api.domeneshop.no/docs/#operation/getDnsRecords
-func (c *Client) getDNSRecordByHostData(ctx context.Context, domain Domain, host string, data string) (*DNSRecord, error) {
+func (c *Client) getDNSRecordByHostData(ctx context.Context, domain Domain, host, data string) (*DNSRecord, error) {
endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
diff --git a/providers/dns/domeneshop/internal/client_test.go b/providers/dns/domeneshop/internal/client_test.go
index 71205cac4..2f5fb0d95 100644
--- a/providers/dns/domeneshop/internal/client_test.go
+++ b/providers/dns/domeneshop/internal/client_test.go
@@ -1,124 +1,58 @@
package internal
import (
- "context"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-const authorizationHeader = "Authorization"
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("token", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("token", "secret")
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("token", "secret"),
+ )
}
func TestClient_CreateTXTRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /domains/1/dns",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
- mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "Basic dG9rZW46c2VjcmV0" {
- http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
- return
- }
-
- _, _ = rw.Write([]byte(`{"id": 1}`))
- })
-
- err := client.CreateTXTRecord(context.Background(), &Domain{ID: 1}, "example", "txtTXTtxt")
+ err := client.CreateTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt")
require.NoError(t, err)
}
func TestClient_DeleteTXTRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/1/dns",
+ servermock.ResponseFromFixture("delete_record.json")).
+ Route("DELETE /domains/1/dns/1", nil).
+ Build(t)
- mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "Basic dG9rZW46c2VjcmV0" {
- http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
- return
- }
-
- _, _ = rw.Write([]byte(`[
- {
- "id": 1,
- "host": "example.com",
- "ttl": 3600,
- "type": "TXT",
- "data": "txtTXTtxt"
- }
-]`))
- })
-
- mux.HandleFunc("/domains/1/dns/1", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "Basic dG9rZW46c2VjcmV0" {
- http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
- return
- }
- })
-
- err := client.DeleteTXTRecord(context.Background(), &Domain{ID: 1}, "example.com", "txtTXTtxt")
+ err := client.DeleteTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt")
require.NoError(t, err)
}
func TestClient_getDNSRecordByHostData(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/1/dns",
+ servermock.ResponseFromFixture("getDnsRecords.json")).
+ Build(t)
- mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "Basic dG9rZW46c2VjcmV0" {
- http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
- return
- }
-
- _, _ = rw.Write([]byte(`[
- {
- "id": 1,
- "host": "example.com",
- "ttl": 3600,
- "type": "TXT",
- "data": "txtTXTtxt"
- }
-]`))
- })
-
- record, err := client.getDNSRecordByHostData(context.Background(), Domain{ID: 1}, "example.com", "txtTXTtxt")
+ record, err := client.getDNSRecordByHostData(t.Context(), Domain{ID: 1}, "example.com", "txtTXTtxt")
require.NoError(t, err)
expected := &DNSRecord{
@@ -133,45 +67,12 @@ func TestClient_getDNSRecordByHostData(t *testing.T) {
}
func TestClient_GetDomainByName(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/",
+ servermock.ResponseFromFixture("getDomains.json")).
+ Build(t)
- mux.HandleFunc("/domains", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "Basic dG9rZW46c2VjcmV0" {
- http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
- return
- }
-
- _, _ = rw.Write([]byte(`[
- {
- "id": 1,
- "domain": "example.com",
- "expiry_date": "2019-08-24",
- "registered_date": "2019-08-24",
- "renew": true,
- "registrant": "Ola Nordmann",
- "status": "active",
- "nameservers": [
- "ns1.hyp.net",
- "ns2.hyp.net",
- "ns3.hyp.net"
- ],
- "services": {
- "registrar": true,
- "dns": true,
- "email": true,
- "webhotel": "none"
- }
- }
-]`))
- })
-
- domain, err := client.GetDomainByName(context.Background(), "example.com")
+ domain, err := client.GetDomainByName(t.Context(), "example.com")
require.NoError(t, err)
expected := &Domain{
diff --git a/providers/dns/domeneshop/internal/fixtures/create_record-request.json b/providers/dns/domeneshop/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..6bd3ca4ce
--- /dev/null
+++ b/providers/dns/domeneshop/internal/fixtures/create_record-request.json
@@ -0,0 +1,7 @@
+{
+ "data": "txtTXTtxt",
+ "host": "example.com",
+ "id": 0,
+ "ttl": 300,
+ "type": "TXT"
+}
diff --git a/providers/dns/domeneshop/internal/fixtures/create_record.json b/providers/dns/domeneshop/internal/fixtures/create_record.json
new file mode 100644
index 000000000..2572ae5fe
--- /dev/null
+++ b/providers/dns/domeneshop/internal/fixtures/create_record.json
@@ -0,0 +1,3 @@
+{
+ "id": 1
+}
diff --git a/providers/dns/domeneshop/internal/fixtures/delete_record.json b/providers/dns/domeneshop/internal/fixtures/delete_record.json
new file mode 100644
index 000000000..f3f987eef
--- /dev/null
+++ b/providers/dns/domeneshop/internal/fixtures/delete_record.json
@@ -0,0 +1,9 @@
+[
+ {
+ "id": 1,
+ "host": "example.com",
+ "ttl": 3600,
+ "type": "TXT",
+ "data": "txtTXTtxt"
+ }
+]
diff --git a/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json b/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json
new file mode 100644
index 000000000..f3f987eef
--- /dev/null
+++ b/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json
@@ -0,0 +1,9 @@
+[
+ {
+ "id": 1,
+ "host": "example.com",
+ "ttl": 3600,
+ "type": "TXT",
+ "data": "txtTXTtxt"
+ }
+]
diff --git a/providers/dns/domeneshop/internal/fixtures/getDomains.json b/providers/dns/domeneshop/internal/fixtures/getDomains.json
new file mode 100644
index 000000000..b491d7f53
--- /dev/null
+++ b/providers/dns/domeneshop/internal/fixtures/getDomains.json
@@ -0,0 +1,22 @@
+[
+ {
+ "id": 1,
+ "domain": "example.com",
+ "expiry_date": "2019-08-24",
+ "registered_date": "2019-08-24",
+ "renew": true,
+ "registrant": "Ola Nordmann",
+ "status": "active",
+ "nameservers": [
+ "ns1.hyp.net",
+ "ns2.hyp.net",
+ "ns3.hyp.net"
+ ],
+ "services": {
+ "registrar": true,
+ "dns": true,
+ "email": true,
+ "webhotel": "none"
+ }
+ }
+]
diff --git a/providers/dns/dreamhost/dreamhost.go b/providers/dns/dreamhost/dreamhost.go
index 5b4960ee0..8663e18ce 100644
--- a/providers/dns/dreamhost/dreamhost.go
+++ b/providers/dns/dreamhost/dreamhost.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/dreamhost/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -86,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
if config.BaseURL != "" {
client.BaseURL = config.BaseURL
}
@@ -96,6 +99,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
+
err := d.client.AddRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
if err != nil {
return fmt.Errorf("dreamhost: %w", err)
diff --git a/providers/dns/dreamhost/dreamhost.toml b/providers/dns/dreamhost/dreamhost.toml
index a359ad97f..c3a9db360 100644
--- a/providers/dns/dreamhost/dreamhost.toml
+++ b/providers/dns/dreamhost/dreamhost.toml
@@ -6,17 +6,16 @@ Since = "v1.1.0"
Example = '''
DREAMHOST_API_KEY="YOURAPIKEY" \
-lego --email you@example.com --dns dreamhost -d '*.example.com' -d example.com run
+lego --dns dreamhost -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
DREAMHOST_API_KEY = "The API key"
[Configuration.Additional]
- DREAMHOST_POLLING_INTERVAL = "Time between DNS propagation check"
- DREAMHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DREAMHOST_TTL = "The TTL of the TXT record used for the DNS challenge"
- DREAMHOST_HTTP_TIMEOUT = "API request timeout"
+ DREAMHOST_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)"
+ DREAMHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 3600)"
+ DREAMHOST_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://help.dreamhost.com/hc/en-us/articles/217560167-API_overview"
diff --git a/providers/dns/dreamhost/dreamhost_test.go b/providers/dns/dreamhost/dreamhost_test.go
index 0f91ffae2..5af0b892d 100644
--- a/providers/dns/dreamhost/dreamhost_test.go
+++ b/providers/dns/dreamhost/dreamhost_test.go
@@ -1,13 +1,12 @@
package dreamhost
import (
- "fmt"
- "net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -23,22 +22,15 @@ const (
fakeKeyAuth = "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"
)
-func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = fakeAPIKey
+ config.BaseURL = server.URL
+ config.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- config := NewDefaultConfig()
- config.APIKey = fakeAPIKey
- config.BaseURL = server.URL
- config.HTTPClient = server.Client()
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- return provider, mux
+ return NewDNSProviderConfig(config)
+ })
}
func TestNewDNSProvider(t *testing.T) {
@@ -65,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -115,70 +108,51 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
func TestDNSProvider_Present(t *testing.T) {
- provider, mux := setupTest(t)
-
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodGet, r.Method, "method")
-
- q := r.URL.Query()
- 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 {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ provider := mockBuilder().
+ Route("GET /",
+ servermock.RawStringResponse(`{"data":"record_added","result":"success"}`),
+ servermock.CheckQueryParameter().Strict().
+ With("cmd", "dns-add_record").
+ With("comment", "Managed+By+lego").
+ With("format", "json").
+ With("record", "_acme-challenge.example.com").
+ With("type", "TXT").
+ With("key", fakeAPIKey).
+ With("value", fakeKeyAuth),
+ ).
+ Build(t)
err := provider.Present("example.com", "", fakeChallengeToken)
require.NoError(t, err)
}
func TestDNSProvider_PresentFailed(t *testing.T) {
- provider, mux := setupTest(t)
-
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodGet, r.Method, "method")
-
- _, err := fmt.Fprintf(w, `{"data":"record_already_exists_remove_first","result":"error"}`)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ provider := mockBuilder().
+ Route("GET /",
+ servermock.RawStringResponse(`{"data":"record_already_exists_remove_first","result":"error"}`)).
+ Build(t)
err := provider.Present("example.com", "", fakeChallengeToken)
require.EqualError(t, err, "dreamhost: add TXT record failed: record_already_exists_remove_first")
}
func TestDNSProvider_Cleanup(t *testing.T) {
- provider, mux := setupTest(t)
-
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(t, http.MethodGet, r.Method, "method")
-
- q := r.URL.Query()
- 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 {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ provider := mockBuilder().
+ Route("GET /",
+ servermock.RawStringResponse(`{"data":"record_removed","result":"success"}`),
+ servermock.CheckQueryParameter().Strict().
+ With("cmd", "dns-remove_record").
+ With("comment", "Managed+By+lego").
+ With("format", "json").
+ With("record", "_acme-challenge.example.com").
+ With("type", "TXT").
+ With("key", fakeAPIKey).
+ With("value", fakeKeyAuth),
+ ).
+ Build(t)
err := provider.CleanUp("example.com", "", fakeChallengeToken)
- require.NoError(t, err, "failed to remove TXT record")
+ require.NoError(t, err)
}
func TestLivePresentAndCleanUp(t *testing.T) {
@@ -187,6 +161,7 @@ func TestLivePresentAndCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/dreamhost/internal/client.go b/providers/dns/dreamhost/internal/client.go
index dee808ac8..02b33ad57 100644
--- a/providers/dns/dreamhost/internal/client.go
+++ b/providers/dns/dreamhost/internal/client.go
@@ -101,6 +101,7 @@ func (c *Client) updateTxtRecord(ctx context.Context, endpoint *url.URL) error {
}
var response apiResponse
+
err = json.Unmarshal(raw, &response)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/dreamhost/internal/client_test.go b/providers/dns/dreamhost/internal/client_test.go
index eff520df0..a836658f9 100644
--- a/providers/dns/dreamhost/internal/client_test.go
+++ b/providers/dns/dreamhost/internal/client_test.go
@@ -1,15 +1,59 @@
package internal
import (
+ "net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-const fakeAPIKey = "asdf1234"
+func setupClient(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", servermock.RawStringResponse(`{}`),
+ servermock.CheckQueryParameter().Strict().
+ With("cmd", "dns-add_record").
+ With("comment", "Managed+By+lego").
+ With("format", "json").
+ With("key", "secret").
+ With("record", "example.com").
+ With("type", "TXT").
+ With("value", "aaa")).
+ Build(t)
+
+ err := client.AddRecord(t.Context(), "example.com", "aaa")
+ require.NoError(t, err)
+}
+
+func TestClient_RemoveRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", servermock.RawStringResponse(`{}`),
+ servermock.CheckQueryParameter().Strict().
+ With("cmd", "dns-remove_record").
+ With("comment", "Managed+By+lego").
+ With("format", "json").
+ With("key", "secret").
+ With("record", "example.com").
+ With("type", "TXT").
+ With("value", "aaa")).
+ Build(t)
+
+ err := client.RemoveRecord(t.Context(), "example.com", "aaa")
+ require.NoError(t, err)
+}
func TestClient_buildQuery(t *testing.T) {
+ const fakeAPIKey = "asdf1234"
+
testCases := []struct {
desc string
apiKey string
diff --git a/providers/dns/duckdns/duckdns.go b/providers/dns/duckdns/duckdns.go
index 687f5bbac..1aae0a06c 100644
--- a/providers/dns/duckdns/duckdns.go
+++ b/providers/dns/duckdns/duckdns.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/duckdns/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -86,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/duckdns/duckdns.toml b/providers/dns/duckdns/duckdns.toml
index a0ae92c2d..6866da57c 100644
--- a/providers/dns/duckdns/duckdns.toml
+++ b/providers/dns/duckdns/duckdns.toml
@@ -6,18 +6,17 @@ Since = "v0.5.0"
Example = '''
DUCKDNS_TOKEN=xxxxxx \
-lego --email you@example.com --dns duckdns -d '*.example.com' -d example.com run
+lego --dns duckdns -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
DUCKDNS_TOKEN = "Account token"
[Configuration.Additional]
- DUCKDNS_POLLING_INTERVAL = "Time between DNS propagation check"
- DUCKDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DUCKDNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- DUCKDNS_HTTP_TIMEOUT = "API request timeout"
- DUCKDNS_SEQUENCE_INTERVAL = "Time between sequential requests"
+ DUCKDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DUCKDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DUCKDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ DUCKDNS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
[Links]
API = "https://www.duckdns.org/spec.jsp"
diff --git a/providers/dns/duckdns/duckdns_test.go b/providers/dns/duckdns/duckdns_test.go
index b89966a36..769513fbf 100644
--- a/providers/dns/duckdns/duckdns_test.go
+++ b/providers/dns/duckdns/duckdns_test.go
@@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -94,6 +95,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -107,6 +109,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/duckdns/internal/client.go b/providers/dns/duckdns/internal/client.go
index 0ed1bc864..c5d7ef97c 100644
--- a/providers/dns/duckdns/internal/client.go
+++ b/providers/dns/duckdns/internal/client.go
@@ -21,6 +21,7 @@ const defaultBaseURL = "https://www.duckdns.org/update"
type Client struct {
token string
+ baseURL string
HTTPClient *http.Client
}
@@ -28,23 +29,24 @@ type Client struct {
func NewClient(token string) *Client {
return &Client{
token: token,
+ baseURL: defaultBaseURL,
HTTPClient: &http.Client{Timeout: 5 * time.Second},
}
}
-func (c Client) AddTXTRecord(ctx context.Context, domain, value string) error {
+func (c *Client) AddTXTRecord(ctx context.Context, domain, value string) error {
return c.UpdateTxtRecord(ctx, domain, value, false)
}
-func (c Client) RemoveTXTRecord(ctx context.Context, domain string) error {
+func (c *Client) RemoveTXTRecord(ctx context.Context, domain string) error {
return c.UpdateTxtRecord(ctx, domain, "", true)
}
// UpdateTxtRecord Update the domains TXT record
// To update the TXT record we just need to make one simple get request.
// In DuckDNS you only have one TXT record shared with the domain and all subdomains.
-func (c Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRecord bool) error {
- endpoint, _ := url.Parse(defaultBaseURL)
+func (c *Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRecord bool) error {
+ endpoint, _ := url.Parse(c.baseURL)
mainDomain := getMainDomain(domain)
if mainDomain == "" {
@@ -79,6 +81,7 @@ func (c Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRe
if body != "OK" {
return fmt.Errorf("request to change TXT record for DuckDNS returned the following result (%s) this does not match expectation (OK) used url [%s]", body, endpoint)
}
+
return nil
}
@@ -96,6 +99,7 @@ func getMainDomain(domain string) string {
}
firstSubDomainIndex := split[len(split)-3]
+
return domain[firstSubDomainIndex:]
}
diff --git a/providers/dns/duckdns/internal/client_test.go b/providers/dns/duckdns/internal/client_test.go
index 4df17d049..aaa441fad 100644
--- a/providers/dns/duckdns/internal/client_test.go
+++ b/providers/dns/duckdns/internal/client_test.go
@@ -1,11 +1,50 @@
package internal
import (
+ "net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
+func setupClient(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.baseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", servermock.RawStringResponse("OK"),
+ servermock.CheckQueryParameter().Strict().
+ With("clear", "false").
+ With("domains", "com").
+ With("token", "secret").
+ With("txt", "value")).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example.com", "value")
+ require.NoError(t, err)
+}
+
+func TestClient_RemoveTXTRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", servermock.RawStringResponse("OK"),
+ servermock.CheckQueryParameter().Strict().
+ With("clear", "true").
+ With("domains", "com").
+ With("token", "secret").
+ With("txt", "")).
+ Build(t)
+
+ err := client.RemoveTXTRecord(t.Context(), "example.com")
+ require.NoError(t, err)
+}
+
func Test_getMainDomain(t *testing.T) {
testCases := []struct {
desc string
diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go
index 627626df6..0cd445c39 100644
--- a/providers/dns/dyn/dyn.go
+++ b/providers/dns/dyn/dyn.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/dyn/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -92,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/dyn/dyn.toml b/providers/dns/dyn/dyn.toml
index e7607d0a2..c4b3563e0 100644
--- a/providers/dns/dyn/dyn.toml
+++ b/providers/dns/dyn/dyn.toml
@@ -8,7 +8,7 @@ Example = '''
DYN_CUSTOMER_NAME=xxxxxx \
DYN_USER_NAME=yyyyy \
DYN_PASSWORD=zzzz \
-lego --email you@example.com --dns dyn -d '*.example.com' -d example.com run
+lego --dns dyn -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,10 @@ lego --email you@example.com --dns dyn -d '*.example.com' -d example.com run
DYN_USER_NAME = "User name"
DYN_PASSWORD = "Password"
[Configuration.Additional]
- DYN_POLLING_INTERVAL = "Time between DNS propagation check"
- DYN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DYN_TTL = "The TTL of the TXT record used for the DNS challenge"
- DYN_HTTP_TIMEOUT = "API request timeout"
+ DYN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DYN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DYN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ DYN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://help.dyn.com/rest/"
diff --git a/providers/dns/dyn/dyn_test.go b/providers/dns/dyn/dyn_test.go
index 25f1f5614..5b4d1c6b6 100644
--- a/providers/dns/dyn/dyn_test.go
+++ b/providers/dns/dyn/dyn_test.go
@@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -155,6 +156,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -168,6 +170,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/dyn/internal/client.go b/providers/dns/dyn/internal/client.go
index 43981cc44..a54113eec 100644
--- a/providers/dns/dyn/internal/client.go
+++ b/providers/dns/dyn/internal/client.go
@@ -28,7 +28,7 @@ type Client struct {
}
// NewClient Creates a new Client.
-func NewClient(customerName string, username string, password string) *Client {
+func NewClient(customerName, username, password string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
@@ -127,6 +127,7 @@ func (c *Client) do(req *http.Request) (*APIResponse, error) {
}
var response APIResponse
+
err = json.Unmarshal(raw, &response)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/dyn/internal/client_test.go b/providers/dns/dyn/internal/client_test.go
index 87bee1cd3..f166e7d8d 100644
--- a/providers/dns/dyn/internal/client_test.go
+++ b/providers/dns/dyn/internal/client_test.go
@@ -1,122 +1,59 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, handlerFunc http.HandlerFunc) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc(pattern, handlerFunc)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("bob", "user", "secret")
client.HTTPClient = server.Client()
client.baseURL, _ = url.Parse(server.URL)
- return client
+ return client, nil
}
-func authenticatedHandler(method string, status int, file 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
- }
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("bob", "user", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- token := req.Header.Get(authTokenHeader)
- if token != "tok" {
- http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), 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
- }
- }
-}
-
-func unauthenticatedHandler(method string, status int, file 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
- }
-
- token := req.Header.Get(authTokenHeader)
- if token != "" {
- http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), 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
- }
- }
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders())
}
func TestClient_Publish(t *testing.T) {
- client := setupTest(t, "/Zone/example.com", unauthenticatedHandler(http.MethodPut, http.StatusOK, "publish.json"))
+ client := mockBuilder().
+ Route("PUT /Zone/example.com", servermock.ResponseFromFixture("publish.json"),
+ servermock.CheckRequestJSONBody(`{"publish":true,"notes":"my message"}`)).
+ Build(t)
- err := client.Publish(context.Background(), "example.com", "my message")
+ err := client.Publish(t.Context(), "example.com", "my message")
require.NoError(t, err)
}
func TestClient_AddTXTRecord(t *testing.T) {
- client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodPost, http.StatusCreated, "create-txt-record.json"))
+ client := mockBuilder().
+ Route("POST /TXTRecord/example.com/example.com.", servermock.ResponseFromFixture("create-txt-record.json"),
+ servermock.CheckRequestJSONBody(`{"rdata":{"txtdata":"txt"},"ttl":"120"}`)).
+ Build(t)
- err := client.AddTXTRecord(context.Background(), "example.com", "example.com.", "txt", 120)
+ err := client.AddTXTRecord(t.Context(), "example.com", "example.com.", "txt", 120)
require.NoError(t, err)
}
func TestClient_RemoveTXTRecord(t *testing.T) {
- client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodDelete, http.StatusOK, ""))
+ client := mockBuilder().
+ Route("DELETE /TXTRecord/example.com/example.com.", nil).
+ Build(t)
- err := client.RemoveTXTRecord(context.Background(), "example.com", "example.com.")
+ err := client.RemoveTXTRecord(t.Context(), "example.com", "example.com.")
require.NoError(t, err)
}
diff --git a/providers/dns/dyn/internal/session.go b/providers/dns/dyn/internal/session.go
index 647080fa8..088510152 100644
--- a/providers/dns/dyn/internal/session.go
+++ b/providers/dns/dyn/internal/session.go
@@ -33,6 +33,7 @@ func (c *Client) login(ctx context.Context) (session, error) {
}
var s session
+
err = json.Unmarshal(dynRes.Data, &s)
if err != nil {
return session{}, errutils.NewUnmarshalError(req, http.StatusOK, dynRes.Data, err)
diff --git a/providers/dns/dyn/internal/session_test.go b/providers/dns/dyn/internal/session_test.go
index 76d5bef4e..349b1b190 100644
--- a/providers/dns/dyn/internal/session_test.go
+++ b/providers/dns/dyn/internal/session_test.go
@@ -2,21 +2,26 @@ package internal
import (
"context"
- "net/http"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func mockContext() context.Context {
- return context.WithValue(context.Background(), tokenKey, "tok")
+func mockContext(t *testing.T) context.Context {
+ t.Helper()
+
+ return context.WithValue(t.Context(), tokenKey, "tok")
}
func TestClient_login(t *testing.T) {
- client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json"))
+ client := mockBuilder().
+ Route("POST /Session", servermock.ResponseFromFixture("login.json"),
+ servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)).
+ Build(t)
- sess, err := client.login(context.Background())
+ sess, err := client.login(t.Context())
require.NoError(t, err)
expected := session{Token: "tok", Version: "456"}
@@ -25,16 +30,24 @@ func TestClient_login(t *testing.T) {
}
func TestClient_Logout(t *testing.T) {
- client := setupTest(t, "/Session", authenticatedHandler(http.MethodDelete, http.StatusOK, ""))
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ With(authTokenHeader, "tok"),
+ ).
+ Route("DELETE /Session", nil).
+ Build(t)
- err := client.Logout(mockContext())
+ err := client.Logout(mockContext(t))
require.NoError(t, err)
}
func TestClient_CreateAuthenticatedContext(t *testing.T) {
- client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json"))
+ client := mockBuilder().
+ Route("POST /Session", servermock.ResponseFromFixture("login.json"),
+ servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)).
+ Build(t)
- ctx, err := client.CreateAuthenticatedContext(context.Background())
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
at := getToken(ctx)
diff --git a/providers/dns/dyndnsfree/dyndnsfree.go b/providers/dns/dyndnsfree/dyndnsfree.go
new file mode 100644
index 000000000..09be2bfbd
--- /dev/null
+++ b/providers/dns/dyndnsfree/dyndnsfree.go
@@ -0,0 +1,120 @@
+// Package dyndnsfree implements a DNS provider for solving the DNS-01 challenge using DynDnsFree.de API.
+package dyndnsfree
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/dyndnsfree/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "DYNDNSFREE_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Username string
+ Password string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for DynDNSFree.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("dyndnsfree: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for DynDNSFree.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("dyndnsfree: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("dyndnsfree: new client: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("dyndnsforfree: could not find zone for domain %q: %w", domain, err)
+ }
+
+ err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+ if err != nil {
+ return fmt.Errorf("dyndnsfree: add record: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ // Records are deleted automatically.
+ 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/dns/dyndnsfree/dyndnsfree.toml b/providers/dns/dyndnsfree/dyndnsfree.toml
new file mode 100644
index 000000000..e64bb0080
--- /dev/null
+++ b/providers/dns/dyndnsfree/dyndnsfree.toml
@@ -0,0 +1,23 @@
+Name = "DynDnsFree.de"
+Description = ''''''
+URL = "https://www.dyndnsfree.de"
+Code = "dyndnsfree"
+Since = "v4.23.0"
+
+Example = '''
+DYNDNSFREE_USERNAME="xxx" \
+DYNDNSFREE_PASSWORD="yyy" \
+lego --dns dyndnsfree -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ DYNDNSFREE_USERNAME = "Username"
+ DYNDNSFREE_PASSWORD = "Password"
+ [Configuration.Additional]
+ DYNDNSFREE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ DYNDNSFREE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ DYNDNSFREE_HTTP_TIMEOUT = "Request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.dyndnsfree.de/user/hilfe.php?hsm=2"
diff --git a/providers/dns/dyndnsfree/dyndnsfree_test.go b/providers/dns/dyndnsfree/dyndnsfree_test.go
new file mode 100644
index 000000000..0b03bd27f
--- /dev/null
+++ b/providers/dns/dyndnsfree/dyndnsfree_test.go
@@ -0,0 +1,146 @@
+package dyndnsfree
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "secret",
+ },
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "secret",
+ },
+ expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "",
+ },
+ expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_USERNAME,DYNDNSFREE_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
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing username",
+ username: "",
+ password: "secret",
+ expected: "dyndnsfree: new client: credentials missing",
+ },
+ {
+ desc: "missing password",
+ username: "user",
+ password: "",
+ expected: "dyndnsfree: new client: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "dyndnsfree: new client: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Username = test.username
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ } 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/dns/dyndnsfree/internal/client.go b/providers/dns/dyndnsfree/internal/client.go
new file mode 100644
index 000000000..02a1f1607
--- /dev/null
+++ b/providers/dns/dyndnsfree/internal/client.go
@@ -0,0 +1,78 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://dynup.de/acme.php"
+
+type Client struct {
+ username string
+ password string
+
+ baseURL string
+ HTTPClient *http.Client
+}
+
+func NewClient(username, password string) (*Client, error) {
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ return &Client{
+ username: username,
+ password: password,
+ baseURL: defaultBaseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, zone, hostname, value string) error {
+ baseURL, err := url.Parse(c.baseURL)
+ if err != nil {
+ return err
+ }
+
+ query := baseURL.Query()
+ query.Set("username", c.username)
+ query.Set("password", c.password)
+ query.Set("hostname", zone)
+ query.Set("add_hostname", hostname)
+ query.Set("txt", value)
+ baseURL.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), http.NoBody)
+ if err != nil {
+ return err
+ }
+
+ 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 !bytes.Equal(raw, []byte("success")) {
+ return errors.New(string(raw))
+ }
+
+ return nil
+}
diff --git a/providers/dns/dyndnsfree/internal/client_test.go b/providers/dns/dyndnsfree/internal/client_test.go
new file mode 100644
index 000000000..d6f1d276b
--- /dev/null
+++ b/providers/dns/dyndnsfree/internal/client_test.go
@@ -0,0 +1,45 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func setupClient(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func TestAddTXTRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", servermock.RawStringResponse("success"),
+ servermock.CheckQueryParameter().Strict().
+ With("add_hostname", "sub.example.com").
+ With("hostname", "example.com").
+ With("password", "secret").
+ With("txt", "value").
+ With("username", "user")).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value")
+ require.NoError(t, err)
+}
+
+func TestAddTXTRecord_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", servermock.RawStringResponse("error: authentification failed")).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value")
+ require.EqualError(t, err, "error: authentification failed")
+}
diff --git a/providers/dns/dynu/dynu.go b/providers/dns/dynu/dynu.go
index af602ddfc..11df45281 100644
--- a/providers/dns/dynu/dynu.go
+++ b/providers/dns/dynu/dynu.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/dynu/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -86,7 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
client := internal.NewClient()
- client.HTTPClient = tr.Wrap(config.HTTPClient)
+
+ client.HTTPClient = clientdebug.Wrap(tr.Wrap(config.HTTPClient))
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/dynu/dynu.toml b/providers/dns/dynu/dynu.toml
index 7d12b428e..ae2367087 100644
--- a/providers/dns/dynu/dynu.toml
+++ b/providers/dns/dynu/dynu.toml
@@ -6,17 +6,17 @@ Since = "v3.5.0"
Example = '''
DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \
-lego --email you@example.com --dns dynu -d '*.example.com' -d example.com run
+lego --dns dynu -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
DYNU_API_KEY = "API key"
[Configuration.Additional]
- DYNU_POLLING_INTERVAL = "Time between DNS propagation check"
- DYNU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- DYNU_TTL = "The TTL of the TXT record used for the DNS challenge"
- DYNU_HTTP_TIMEOUT = "API request timeout"
+ DYNU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ DYNU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)"
+ DYNU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ DYNU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.dynu.com/en-US/Support/API"
diff --git a/providers/dns/dynu/dynu_test.go b/providers/dns/dynu/dynu_test.go
index fe2c22dfb..ffc7c3a00 100644
--- a/providers/dns/dynu/dynu_test.go
+++ b/providers/dns/dynu/dynu_test.go
@@ -38,6 +38,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -96,6 +97,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -109,6 +111,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/dynu/internal/auth.go b/providers/dns/dynu/internal/auth.go
index 7a21a10e8..0a91445d2 100644
--- a/providers/dns/dynu/internal/auth.go
+++ b/providers/dns/dynu/internal/auth.go
@@ -46,6 +46,7 @@ func (t *TokenTransport) transport() http.RoundTripper {
if t.Transport != nil {
return t.Transport
}
+
return http.DefaultTransport
}
diff --git a/providers/dns/dynu/internal/client.go b/providers/dns/dynu/internal/client.go
index 6821863b3..59e90d592 100644
--- a/providers/dns/dynu/internal/client.go
+++ b/providers/dns/dynu/internal/client.go
@@ -12,8 +12,9 @@ import (
"strconv"
"time"
- "github.com/cenkalti/backoff/v4"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/log"
+ "github.com/go-acme/lego/v4/platform/wait"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
@@ -34,7 +35,7 @@ func NewClient() *Client {
}
// GetRecords Get DNS records based on a hostname and resource record type.
-func (c Client) GetRecords(ctx context.Context, hostname, recordType string) ([]DNSRecord, error) {
+func (c *Client) GetRecords(ctx context.Context, hostname, recordType string) ([]DNSRecord, error) {
endpoint := c.baseURL.JoinPath("dns", "record", hostname)
query := endpoint.Query()
@@ -42,6 +43,7 @@ func (c Client) GetRecords(ctx context.Context, hostname, recordType string) ([]
endpoint.RawQuery = query.Encode()
apiResp := RecordsResponse{}
+
err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp)
if err != nil {
return nil, err
@@ -55,7 +57,7 @@ func (c Client) GetRecords(ctx context.Context, hostname, recordType string) ([]
}
// AddNewRecord Add a new DNS record for DNS service.
-func (c Client) AddNewRecord(ctx context.Context, domainID int64, record DNSRecord) error {
+func (c *Client) AddNewRecord(ctx context.Context, domainID int64, record DNSRecord) error {
endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record")
reqBody, err := json.Marshal(record)
@@ -64,6 +66,7 @@ func (c Client) AddNewRecord(ctx context.Context, domainID int64, record DNSReco
}
apiResp := RecordResponse{}
+
err = c.doRetry(ctx, http.MethodPost, endpoint.String(), reqBody, &apiResp)
if err != nil {
return err
@@ -77,10 +80,11 @@ func (c Client) AddNewRecord(ctx context.Context, domainID int64, record DNSReco
}
// DeleteRecord Remove a DNS record from DNS service.
-func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int64) error {
+func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int64) error {
endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record", strconv.FormatInt(recordID, 10))
apiResp := APIException{}
+
err := c.doRetry(ctx, http.MethodDelete, endpoint.String(), nil, &apiResp)
if err != nil {
return err
@@ -94,10 +98,11 @@ func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int64) erro
}
// GetRootDomain Get the root domain name based on a hostname.
-func (c Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostname, error) {
+func (c *Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostname, error) {
endpoint := c.baseURL.JoinPath("dns", "getroot", hostname)
apiResp := DNSHostname{}
+
err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp)
if err != nil {
return nil, err
@@ -111,7 +116,7 @@ func (c Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostnam
}
// 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 {
+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)
}
@@ -123,15 +128,10 @@ func (c Client) doRetry(ctx context.Context, method, uri string, body []byte, re
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 1 * time.Second
- err := backoff.RetryNotify(operation, bo, notify)
- if err != nil {
- return err
- }
-
- return nil
+ return wait.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithNotify(notify))
}
-func (c Client) do(ctx context.Context, method, uri string, body []byte, result any) error {
+func (c *Client) do(ctx context.Context, method, uri string, body []byte, result any) error {
var reqBody io.Reader
if len(body) > 0 {
reqBody = bytes.NewReader(body)
diff --git a/providers/dns/dynu/internal/client_test.go b/providers/dns/dynu/internal/client_test.go
index 7f33bc2c0..f70a8e377 100644
--- a/providers/dns/dynu/internal/client_test.go
+++ b/providers/dns/dynu/internal/client_test.go
@@ -1,53 +1,27 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient()
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- 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(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()
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestGetRootDomain(t *testing.T) {
@@ -65,9 +39,9 @@ func TestGetRootDomain(t *testing.T) {
}{
{
desc: "success",
- pattern: "/dns/getroot/test.lego.freeddns.org",
+ pattern: "GET /dns/getroot/test.lego.freeddns.org",
status: http.StatusOK,
- file: "./fixtures/get_root_domain.json",
+ file: "get_root_domain.json",
expected: expected{
domain: &DNSHostname{
APIException: &APIException{
@@ -82,9 +56,9 @@ func TestGetRootDomain(t *testing.T) {
},
{
desc: "invalid",
- pattern: "/dns/getroot/test.lego.freeddns.org",
+ pattern: "GET /dns/getroot/test.lego.freeddns.org",
status: http.StatusNotImplemented,
- file: "./fixtures/get_root_domain_invalid.json",
+ file: "get_root_domain_invalid.json",
expected: expected{
error: "API error: 501: Argument Exception: Invalid.",
},
@@ -95,9 +69,11 @@ func TestGetRootDomain(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file)
+ client := mockBuilder().
+ Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)).
+ Build(t)
- domain, err := client.GetRootDomain(context.Background(), "test.lego.freeddns.org")
+ domain, err := client.GetRootDomain(t.Context(), "test.lego.freeddns.org")
if test.expected.error != "" {
assert.EqualError(t, err, test.expected.error)
@@ -127,9 +103,9 @@ func TestGetRecords(t *testing.T) {
}{
{
desc: "success",
- pattern: "/dns/record/_acme-challenge.lego.freeddns.org",
+ pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org",
status: http.StatusOK,
- file: "./fixtures/get_records.json",
+ file: "get_records.json",
expected: expected{
records: []DNSRecord{
{
@@ -161,18 +137,18 @@ func TestGetRecords(t *testing.T) {
},
{
desc: "empty",
- pattern: "/dns/record/_acme-challenge.lego.freeddns.org",
+ pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org",
status: http.StatusOK,
- file: "./fixtures/get_records_empty.json",
+ file: "get_records_empty.json",
expected: expected{
records: []DNSRecord{},
},
},
{
desc: "invalid",
- pattern: "/dns/record/_acme-challenge.lego.freeddns.org",
+ pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org",
status: http.StatusNotImplemented,
- file: "./fixtures/get_records_invalid.json",
+ file: "get_records_invalid.json",
expected: expected{
error: "API error: 501: Argument Exception: Invalid.",
},
@@ -183,9 +159,13 @@ func TestGetRecords(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file)
+ client := mockBuilder().
+ Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status),
+ servermock.CheckQueryParameter().Strict().
+ With("recordType", "TXT")).
+ Build(t)
- records, err := client.GetRecords(context.Background(), "_acme-challenge.lego.freeddns.org", "TXT")
+ records, err := client.GetRecords(t.Context(), "_acme-challenge.lego.freeddns.org", "TXT")
if test.expected.error != "" {
assert.EqualError(t, err, test.expected.error)
@@ -214,15 +194,15 @@ func TestAddNewRecord(t *testing.T) {
}{
{
desc: "success",
- pattern: "/dns/9007481/record",
+ pattern: "POST /dns/9007481/record",
status: http.StatusOK,
- file: "./fixtures/add_new_record.json",
+ file: "add_new_record.json",
},
{
desc: "invalid",
- pattern: "/dns/9007481/record",
+ pattern: "POST /dns/9007481/record",
status: http.StatusNotImplemented,
- file: "./fixtures/add_new_record_invalid.json",
+ file: "add_new_record_invalid.json",
expected: expected{
error: "API error: 501: Argument Exception: Invalid.",
},
@@ -233,7 +213,10 @@ func TestAddNewRecord(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client := setupTest(t, http.MethodPost, test.pattern, test.status, test.file)
+ client := mockBuilder().
+ Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status),
+ servermock.CheckRequestJSONBodyFromFixture("add_new_record-request.json")).
+ Build(t)
record := DNSRecord{
Type: "TXT",
@@ -245,7 +228,7 @@ func TestAddNewRecord(t *testing.T) {
TTL: 300,
}
- err := client.AddNewRecord(context.Background(), 9007481, record)
+ err := client.AddNewRecord(t.Context(), 9007481, record)
if test.expected.error != "" {
assert.EqualError(t, err, test.expected.error)
@@ -271,15 +254,15 @@ func TestDeleteRecord(t *testing.T) {
}{
{
desc: "success",
- pattern: "/",
+ pattern: "DELETE /",
status: http.StatusOK,
- file: "./fixtures/delete_record.json",
+ file: "delete_record.json",
},
{
desc: "invalid",
- pattern: "/",
+ pattern: "DELETE /",
status: http.StatusNotImplemented,
- file: "./fixtures/delete_record_invalid.json",
+ file: "delete_record_invalid.json",
expected: expected{
error: "API error: 501: Argument Exception: Invalid.",
},
@@ -290,9 +273,11 @@ func TestDeleteRecord(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client := setupTest(t, http.MethodDelete, test.pattern, test.status, test.file)
+ client := mockBuilder().
+ Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), 9007481, 6041418)
+ err := client.DeleteRecord(t.Context(), 9007481, 6041418)
if test.expected.error != "" {
assert.EqualError(t, err, test.expected.error)
diff --git a/providers/dns/dynu/internal/fixtures/add_new_record-request.json b/providers/dns/dynu/internal/fixtures/add_new_record-request.json
new file mode 100644
index 000000000..f3c75ca36
--- /dev/null
+++ b/providers/dns/dynu/internal/fixtures/add_new_record-request.json
@@ -0,0 +1,9 @@
+{
+ "recordType": "TXT",
+ "domainName": "lego.freeddns.org",
+ "nodeName": "_acme-challenge",
+ "hostname": "_acme-challenge.lego.freeddns.org",
+ "state": true,
+ "textData": "txt_txt_txt_txt_txt_txt_txt_2",
+ "ttl": 300
+}
diff --git a/providers/dns/easydns/easydns.go b/providers/dns/easydns/easydns.go
index 7e5e219cb..205063e7b 100644
--- a/providers/dns/easydns/easydns.go
+++ b/providers/dns/easydns/easydns.go
@@ -16,6 +16,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/easydns/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -77,6 +78,7 @@ func NewDNSProvider() (*DNSProvider, error) {
if err != nil {
return nil, fmt.Errorf("easydns: %w", err)
}
+
config.Endpoint = endpoint
values, err := env.Get(EnvToken, EnvKey)
@@ -110,6 +112,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
if config.Endpoint != nil {
client.BaseURL = config.Endpoint
}
@@ -186,15 +190,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID)
-
- d.recordIDsMu.Lock()
- defer delete(d.recordIDs, key)
- d.recordIDsMu.Unlock()
-
if err != nil {
return fmt.Errorf("easydns: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, key)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/easydns/easydns.toml b/providers/dns/easydns/easydns.toml
index 4c775fb5a..307c86a09 100644
--- a/providers/dns/easydns/easydns.toml
+++ b/providers/dns/easydns/easydns.toml
@@ -7,7 +7,7 @@ Since = "v2.6.0"
Example = '''
EASYDNS_TOKEN=xxx \
EASYDNS_KEY=yyy \
-lego --email you@example.com --dns easydns -d '*.example.com' -d example.com run
+lego --dns easydns -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -20,11 +20,11 @@ To test with the sandbox environment set ```EASYDNS_ENDPOINT=https://sandbox.res
EASYDNS_KEY = "API Key"
[Configuration.Additional]
EASYDNS_ENDPOINT = "The endpoint URL of the API Server"
- EASYDNS_POLLING_INTERVAL = "Time between DNS propagation check"
- EASYDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- EASYDNS_SEQUENCE_INTERVAL = "Time between sequential requests"
- EASYDNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- EASYDNS_HTTP_TIMEOUT = "API request timeout"
+ EASYDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ EASYDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ EASYDNS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ EASYDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ EASYDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://docs.sandbox.rest.easydns.net"
diff --git a/providers/dns/easydns/easydns_test.go b/providers/dns/easydns/easydns_test.go
index 972ff8cda..5517928d7 100644
--- a/providers/dns/easydns/easydns_test.go
+++ b/providers/dns/easydns/easydns_test.go
@@ -2,7 +2,6 @@ package easydns
import (
"fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
@@ -10,12 +9,10 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/tester"
- "github.com/stretchr/testify/assert"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-const authorizationHeader = "Authorization"
-
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(
@@ -24,26 +21,27 @@ var envTest = tester.NewEnvTest(
EnvKey).
WithDomain(envDomain)
-func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ endpoint, err := url.Parse(server.URL)
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ config := NewDefaultConfig()
+ config.Token = "TOKEN"
+ config.Key = "SECRET"
+ config.Endpoint = endpoint
+ config.HTTPClient = server.Client()
- endpoint, err := url.Parse(server.URL)
- require.NoError(t, err)
-
- config := NewDefaultConfig()
- config.Token = "TOKEN"
- config.Key = "SECRET"
- config.Endpoint = endpoint
- config.HTTPClient = server.Client()
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- return provider, mux
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithAuthorization("Basic VE9LRU46U0VDUkVU"),
+ servermock.CheckQueryParameter().Strict().
+ With("format", "json"))
}
func TestNewDNSProvider(t *testing.T) {
@@ -78,6 +76,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -145,78 +144,50 @@ 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
+ provider := mockBuilder().
+ Route("GET /zones/records/all/example.com",
+ servermock.RawStringResponse(`{
+ "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
}
- })
-
- 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")
- assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
- assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader)
-
- reqBody, err := io.ReadAll(r.Body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- expectedReqBody := `{"domain":"example.com","host":"_acme-challenge","ttl":"120","prio":"0","type":"TXT","rdata":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"}
-`
- assert.Equal(t, expectedReqBody, string(reqBody))
-
- w.WriteHeader(http.StatusCreated)
- _, err = fmt.Fprintf(w, `{
- "msg": "OK",
- "tm": 1554681934,
- "data": {
- "host": "_acme-challenge",
- "geozone_id": 0,
- "ttl": "120",
- "prio": "0",
- "rdata": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM",
- "revoked": 0,
- "id": "123456789",
- "new_host": "_acme-challenge.example.com"
- },
- "status": 201
- }`)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ `),
+ servermock.CheckQueryParameter().Strict().
+ With("format", "json")).
+ Route("PUT /zones/records/add/example.com/TXT",
+ servermock.RawStringResponse(`{
+ "msg": "OK",
+ "tm": 1554681934,
+ "data": {
+ "host": "_acme-challenge",
+ "geozone_id": 0,
+ "ttl": "120",
+ "prio": "0",
+ "rdata": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM",
+ "revoked": 0,
+ "id": "123456789",
+ "new_host": "_acme-challenge.example.com"
+ },
+ "status": 201
+ }`),
+ servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"_acme-challenge","ttl":"120","prio":"0","type":"TXT","rdata":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"}
+`)).
+ Build(t)
err := provider.Present("example.com", "token", "keyAuth")
require.NoError(t, err)
@@ -224,163 +195,116 @@ func TestDNSProvider_Present(t *testing.T) {
}
func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(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
- }
- })
+ provider := mockBuilder().
+ Route("GET /zones/records/all/_acme-challenge.example.com",
+ servermock.RawStringResponse(`{
+ "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
+ }
+ `)).
+ Build(t)
err := provider.CleanUp("example.com", "token", "keyAuth")
require.NoError(t, err)
}
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")
- assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader)
-
- w.WriteHeader(http.StatusOK)
- _, err := fmt.Fprintf(w, `{
- "msg": "OK",
- "data": {
- "domain": "example.com",
- "id": "123456"
- },
- "status": 200
- }`)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ provider := mockBuilder().
+ Route("GET /zones/records/all/_acme-challenge.example.com",
+ servermock.RawStringResponse(`{
+ "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
+ }
+ `)).
+ Route("DELETE /zones/records/_acme-challenge.example.com/123456",
+ servermock.RawStringResponse(`{
+ "msg": "OK",
+ "data": {
+ "domain": "example.com",
+ "id": "123456"
+ },
+ "status": 200
+ }`)).
+ Build(t)
provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456"
+
err := provider.CleanUp("example.com", "token", "keyAuth")
require.NoError(t, err)
}
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,
"message": "Provided id is invalid or you do not have permission to access it."
}
}`
- 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")
- assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader)
- w.WriteHeader(http.StatusNotAcceptable)
- _, err := fmt.Fprint(w, errorMessage)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ provider := mockBuilder().
+ Route("GET /zones/records/all/example.com",
+ servermock.RawStringResponse(`{
+ "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
+}
+`)).
+ Route("DELETE /zones/records/example.com/123456",
+ servermock.RawStringResponse(errorMessage).
+ WithStatusCode(http.StatusNotAcceptable)).
+ Build(t)
provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456"
+
err := provider.CleanUp("example.com", "token", "keyAuth")
+
expectedError := fmt.Sprintf("easydns: unexpected status code: [status code: 406] body: %v", errorMessage)
require.EqualError(t, err, expectedError)
}
@@ -391,6 +315,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -404,6 +329,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/easydns/internal/client.go b/providers/dns/easydns/internal/client.go
index 3568eeea5..33d7c724e 100644
--- a/providers/dns/easydns/internal/client.go
+++ b/providers/dns/easydns/internal/client.go
@@ -26,7 +26,7 @@ type Client struct {
}
// NewClient Creates a new Client.
-func NewClient(token string, key string) *Client {
+func NewClient(token, key string) *Client {
baseURL, _ := url.Parse(DefaultBaseURL)
return &Client{
@@ -46,6 +46,7 @@ func (c *Client) ListZones(ctx context.Context, domain string) ([]ZoneRecord, er
}
response := &apiResponse[[]ZoneRecord]{}
+
err = c.do(req, response)
if err != nil {
return nil, err
@@ -67,6 +68,7 @@ func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord
}
response := &apiResponse[*ZoneRecord]{}
+
err = c.do(req, response)
if err != nil {
return "", err
diff --git a/providers/dns/easydns/internal/client_test.go b/providers/dns/easydns/internal/client_test.go
index 030b28f34..bf4e1e45b 100644
--- a/providers/dns/easydns/internal/client_test.go
+++ b/providers/dns/easydns/internal/client_test.go
@@ -1,76 +1,36 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("tok", "k")
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
- 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
- }
-
- token, key, ok := req.BasicAuth()
- if token != "tok" || key != "k" || !ok {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- if req.URL.Query().Get("format") != "json" {
- http.Error(rw, fmt.Sprintf("invalid format: %s", req.URL.Query().Get("format")), 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("tok", "k")
- client.HTTPClient = server.Client()
- client.BaseURL, _ = url.Parse(server.URL)
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("tok", "k"),
+ )
}
func TestClient_ListZones(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "list-zone.json")
+ client := mockBuilder().
+ Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("list-zone.json")).
+ Build(t)
- zones, err := client.ListZones(context.Background(), "example.com")
+ zones, err := client.ListZones(t.Context(), "example.com")
require.NoError(t, err)
expected := []ZoneRecord{{
@@ -88,14 +48,20 @@ func TestClient_ListZones(t *testing.T) {
}
func TestClient_ListZones_error(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "error1.json")
+ client := mockBuilder().
+ Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("error1.json")).
+ Build(t)
- _, err := client.ListZones(context.Background(), "example.com")
+ _, err := client.ListZones(t.Context(), "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")
+ client := mockBuilder().
+ Route("PUT /zones/records/add/example.com/TXT",
+ servermock.ResponseFromFixture("add-record.json").WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"test631","ttl":"300","prio":"0","type":"TXT","rdata":"txt"}`)).
+ Build(t)
record := ZoneRecord{
Domain: "example.com",
@@ -106,14 +72,17 @@ func TestClient_AddRecord(t *testing.T) {
Priority: "0",
}
- recordID, err := client.AddRecord(context.Background(), "example.com", record)
+ recordID, err := client.AddRecord(t.Context(), "example.com", record)
require.NoError(t, err)
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")
+ client := mockBuilder().
+ Route("PUT /zones/records/add/example.com/TXT",
+ servermock.ResponseFromFixture("error1.json").WithStatusCode(http.StatusCreated)).
+ Build(t)
record := ZoneRecord{
Domain: "example.com",
@@ -124,13 +93,15 @@ func TestClient_AddRecord_error(t *testing.T) {
Priority: "0",
}
- _, err := client.AddRecord(context.Background(), "example.com", record)
+ _, err := client.AddRecord(t.Context(), "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, "")
+ client := mockBuilder().
+ Route("DELETE /zones/records/example.com/xxx", nil).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "example.com", "xxx")
+ err := client.DeleteRecord(t.Context(), "example.com", "xxx")
require.NoError(t, err)
}
diff --git a/providers/dns/edgecenter/edgecenter.go b/providers/dns/edgecenter/edgecenter.go
new file mode 100644
index 000000000..cfc75b521
--- /dev/null
+++ b/providers/dns/edgecenter/edgecenter.go
@@ -0,0 +1,103 @@
+// Package edgecenter implements a DNS provider for solving the DNS-01 challenge using EdgeCenter.
+package edgecenter
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/gcore"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "EDGECENTER_"
+
+ EnvPermanentAPIToken = envNamespace + "PERMANENT_API_TOKEN"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultBaseURL = "https://api.edgecenter.ru/dns"
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config for DNSProvider.
+type Config = gcore.Config
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, gcore.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, gcore.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
+ },
+ }
+}
+
+// DNSProvider an implementation of challenge.Provider contract.
+type DNSProvider struct {
+ prv challenge.ProviderTimeout
+}
+
+// NewDNSProvider returns an instance of DNSProvider configured for G-Core DNS API.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvPermanentAPIToken)
+ if err != nil {
+ return nil, fmt.Errorf("edgecenter: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIToken = values[EnvPermanentAPIToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("edgecenter: the configuration of the DNS provider is nil")
+ }
+
+ provider, err := gcore.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("edgecenter: %w", err)
+ }
+
+ return &DNSProvider{prv: provider}, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("edgecenter: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("edgecenter: %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.prv.Timeout()
+}
diff --git a/providers/dns/edgecenter/edgecenter.toml b/providers/dns/edgecenter/edgecenter.toml
new file mode 100644
index 000000000..1c9e9b2a9
--- /dev/null
+++ b/providers/dns/edgecenter/edgecenter.toml
@@ -0,0 +1,22 @@
+Name = "EdgeCenter"
+Description = ''''''
+URL = "https://edgecenter.ru/dns"
+Code = "edgecenter"
+Since = "v4.29.0"
+
+Example = '''
+EDGECENTER_PERMANENT_API_TOKEN=xxxxx \
+lego --dns edgecenter -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ EDGECENTER_PERMANENT_API_TOKEN = "Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/)"
+ [Configuration.Additional]
+ EDGECENTER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)"
+ EDGECENTER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)"
+ EDGECENTER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ EDGECENTER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
+
+[Links]
+ API = "https://apidocs.edgecenter.ru/dns"
diff --git a/providers/dns/edgecenter/edgecenter_test.go b/providers/dns/edgecenter/edgecenter_test.go
new file mode 100644
index 000000000..e3ec43981
--- /dev/null
+++ b/providers/dns/edgecenter/edgecenter_test.go
@@ -0,0 +1,114 @@
+package edgecenter
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+var envTest = tester.NewEnvTest(EnvPermanentAPIToken).WithDomain(envNamespace + "DOMAIN")
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvPermanentAPIToken: "A",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvPermanentAPIToken: "",
+ },
+ expected: "edgecenter: some credentials information are missing: EDGECENTER_PERMANENT_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.prv)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "A",
+ },
+ {
+ desc: "missing credentials",
+ expected: "edgecenter: incomplete credentials provided",
+ },
+ }
+
+ 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.prv)
+ } 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/dns/edgedns/edgedns.go b/providers/dns/edgedns/edgedns.go
index d44d2eaf5..b5f4b99c9 100644
--- a/providers/dns/edgedns/edgedns.go
+++ b/providers/dns/edgedns/edgedns.go
@@ -2,14 +2,17 @@
package edgedns
import (
+ "context"
"errors"
"fmt"
+ "net/http"
"slices"
"strings"
"time"
- configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
- "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
+ edgegriddns "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/dns"
+ "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/edgegrid"
+ "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/session"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
@@ -20,19 +23,24 @@ import (
const (
envNamespace = "AKAMAI_"
- EnvEdgeRc = envNamespace + "EDGERC"
- EnvEdgeRcSection = envNamespace + "EDGERC_SECTION"
-
- EnvHost = envNamespace + "HOST"
- EnvClientToken = envNamespace + "CLIENT_TOKEN"
- EnvClientSecret = envNamespace + "CLIENT_SECRET"
- EnvAccessToken = envNamespace + "ACCESS_TOKEN"
+ EnvEdgeRc = envNamespace + "EDGERC"
+ EnvEdgeRcSection = envNamespace + "EDGERC_SECTION"
+ EnvAccountSwitchKey = envNamespace + "ACCOUNT_SWITCH_KEY"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
)
+// Test Environment variables names (unused).
+// TODO(ldez): must be moved into test files.
+const (
+ EnvHost = envNamespace + "HOST"
+ EnvClientToken = envNamespace + "CLIENT_TOKEN"
+ EnvClientSecret = envNamespace + "CLIENT_SECRET"
+ EnvAccessToken = envNamespace + "ACCESS_TOKEN"
+)
+
const (
defaultPropagationTimeout = 3 * time.Minute
defaultPollInterval = 15 * time.Second
@@ -44,7 +52,8 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- edgegrid.Config
+ *edgegrid.Config
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@@ -56,7 +65,7 @@ func NewDefaultConfig() *Config {
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollInterval),
- Config: edgegrid.Config{MaxBody: maxBody},
+ Config: &edgegrid.Config{MaxBody: maxBody},
}
}
@@ -71,22 +80,27 @@ type DNSProvider struct {
// 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`
// 3. .edgerc file located at `AKAMAI_EDGERC` (defaults to `~/.edgerc`, sections can be specified using `AKAMAI_EDGERC_SECTION`)
-// 4. Default environment variables: `AKAMAI_HOST`, `AKAMAI_ACCESS_TOKEN`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_CLIENT_SECRET`
//
// See also: https://developer.akamai.com/api/getting-started
func NewDNSProvider() (*DNSProvider, error) {
- config := NewDefaultConfig()
-
- rcPath := env.GetOrDefaultString(EnvEdgeRc, "")
- rcSection := env.GetOrDefaultString(EnvEdgeRcSection, "")
-
- conf, err := edgegrid.Init(rcPath, rcSection)
+ conf, err := edgegrid.New(
+ edgegrid.WithEnv(true),
+ edgegrid.WithFile(env.GetOrDefaultString(EnvEdgeRc, "~/.edgerc")),
+ edgegrid.WithSection(env.GetOrDefaultString(EnvEdgeRcSection, "default")),
+ )
if err != nil {
return nil, fmt.Errorf("edgedns: %w", err)
}
conf.MaxBody = maxBody
+ accountSwitchKey := env.GetOrDefaultString(EnvAccountSwitchKey, "")
+
+ if accountSwitchKey != "" {
+ conf.AccountKey = accountSwitchKey
+ }
+
+ config := NewDefaultConfig()
config.Config = conf
return NewDNSProviderConfig(config)
@@ -98,7 +112,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("edgedns: the configuration of the DNS provider is nil")
}
- configdns.Init(config.Config)
+ err := config.Validate()
+ if err != nil {
+ return nil, fmt.Errorf("edgedns: %w", err)
+ }
return &DNSProvider{config: config}, nil
}
@@ -111,14 +128,27 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// 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)
+ sess, err := session.New(session.WithSigner(d.config))
+ if err != nil {
+ return fmt.Errorf("edgedns: %w", err)
+ }
+
+ client := edgegriddns.Client(sess)
+
zone, err := getZone(info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("edgedns: %w", err)
}
- record, err := configdns.GetRecord(zone, info.EffectiveFQDN, "TXT")
+ record, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{
+ Zone: zone,
+ Name: info.EffectiveFQDN,
+ RecordType: "TXT",
+ })
if err != nil && !isNotFound(err) {
return fmt.Errorf("edgedns: %w", err)
}
@@ -138,7 +168,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
record.Target = append(record.Target, `"`+info.Value+`"`)
record.TTL = d.config.TTL
- err = record.Update(zone)
+ err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{
+ Record: &edgegriddns.RecordBody{
+ Name: record.Name,
+ RecordType: record.RecordType,
+ TTL: record.TTL,
+ Active: record.Active,
+ Target: record.Target,
+ },
+ Zone: zone,
+ })
if err != nil {
return fmt.Errorf("edgedns: %w", err)
}
@@ -146,14 +185,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return nil
}
- record = &configdns.RecordBody{
- Name: info.EffectiveFQDN,
- RecordType: "TXT",
- TTL: d.config.TTL,
- Target: []string{`"` + info.Value + `"`},
- }
-
- err = record.Save(zone)
+ err = client.CreateRecord(ctx, edgegriddns.CreateRecordRequest{
+ Record: &edgegriddns.RecordBody{
+ Name: info.EffectiveFQDN,
+ RecordType: "TXT",
+ TTL: d.config.TTL,
+ Target: []string{`"` + info.Value + `"`},
+ },
+ Zone: zone,
+ RecLock: nil,
+ })
if err != nil {
return fmt.Errorf("edgedns: %w", err)
}
@@ -163,18 +204,32 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// CleanUp removes the record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
info := dns01.GetChallengeInfo(domain, keyAuth)
+ sess, err := session.New(session.WithSigner(d.config))
+ if err != nil {
+ return fmt.Errorf("edgedns: %w", err)
+ }
+
+ client := edgegriddns.Client(sess)
+
zone, err := getZone(info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("edgedns: %w", err)
}
- existingRec, err := configdns.GetRecord(zone, info.EffectiveFQDN, "TXT")
+ existingRec, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{
+ Zone: zone,
+ Name: info.EffectiveFQDN,
+ RecordType: "TXT",
+ })
if err != nil {
if isNotFound(err) {
return nil
}
+
return fmt.Errorf("edgedns: %w", err)
}
@@ -190,19 +245,21 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
- var newRData []string
- for _, val := range existingRec.Target {
- val = strings.Trim(val, `"`)
- if val == info.Value {
- continue
- }
- newRData = append(newRData, val)
- }
+ newRData := filterRData(existingRec, info)
if len(newRData) > 0 {
existingRec.Target = newRData
- err = existingRec.Update(zone)
+ err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{
+ Record: &edgegriddns.RecordBody{
+ Name: existingRec.Name,
+ RecordType: existingRec.RecordType,
+ TTL: existingRec.TTL,
+ Active: existingRec.Active,
+ Target: existingRec.Target,
+ },
+ Zone: zone,
+ })
if err != nil {
return fmt.Errorf("edgedns: %w", err)
}
@@ -210,7 +267,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
- err = existingRec.Delete(zone)
+ err = client.DeleteRecord(ctx, edgegriddns.DeleteRecordRequest{
+ Zone: zone,
+ Name: existingRec.Name,
+ RecordType: "TXT",
+ RecLock: nil,
+ })
if err != nil {
return fmt.Errorf("edgedns: %w", err)
}
@@ -238,6 +300,22 @@ func isNotFound(err error) bool {
return false
}
- var e configdns.ConfigDNSError
- return errors.As(err, &e) && e.NotFound()
+ var e *edgegriddns.Error
+
+ return errors.As(err, &e) && e.StatusCode == http.StatusNotFound
+}
+
+func filterRData(existingRec *edgegriddns.GetRecordResponse, info dns01.ChallengeInfo) []string {
+ var newRData []string
+
+ for _, val := range existingRec.Target {
+ val = strings.Trim(val, `"`)
+ if val == info.Value {
+ continue
+ }
+
+ newRData = append(newRData, val)
+ }
+
+ return newRData
}
diff --git a/providers/dns/edgedns/edgedns.toml b/providers/dns/edgedns/edgedns.toml
index c01500112..7c7c5b3aa 100644
--- a/providers/dns/edgedns/edgedns.toml
+++ b/providers/dns/edgedns/edgedns.toml
@@ -12,7 +12,7 @@ AKAMAI_CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz1234567890ABCDEFG= \
AKAMAI_CLIENT_TOKEN=akab-mnbvcxzlkjhgfdsapoiuytrewq1234567 \
AKAMAI_HOST=akab-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.luna.akamaiapis.net \
AKAMAI_ACCESS_TOKEN=akab-1234567890qwerty-asdfghjklzxcvtnu \
-lego --email you@example.com --dns edgedns -d '*.example.com' -d example.com run
+lego --dns edgedns -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -42,6 +42,7 @@ See also:
- [.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/pkg/edgegrid/config.go#L118)
+- [Manage many accounts](https://techdocs.akamai.com/developer/docs/manage-many-accounts-with-one-api-client)
'''
[Configuration]
@@ -53,9 +54,10 @@ See also:
AKAMAI_EDGERC = "Path to the .edgerc file, managed by the Akamai EdgeGrid client"
AKAMAI_EDGERC_SECTION = "Configuration section, managed by the Akamai EdgeGrid client"
[Configuration.Additional]
- AKAMAI_POLLING_INTERVAL = "Time between DNS propagation check. Default: 15 seconds"
- AKAMAI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation. Default: 3 minutes"
- AKAMAI_TTL = "The TTL of the TXT record used for the DNS challenge"
+ AKAMAI_ACCOUNT_SWITCH_KEY = "Target account ID when the DNS zone and credentials belong to different accounts"
+ AKAMAI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)"
+ AKAMAI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)"
+ AKAMAI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
[Links]
API = "https://developer.akamai.com/api/cloud_security/edge_dns_zone_management/v2.html"
diff --git a/providers/dns/edgedns/edgedns_integration_test.go b/providers/dns/edgedns/edgedns_integration_test.go
index e1b3bb7cf..d20b8e5aa 100644
--- a/providers/dns/edgedns/edgedns_integration_test.go
+++ b/providers/dns/edgedns/edgedns_integration_test.go
@@ -1,11 +1,13 @@
package edgedns
import (
+ "context"
"fmt"
"testing"
"time"
- configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
+ edgegriddns "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/dns"
+ "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/session"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -17,6 +19,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -34,6 +37,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -69,10 +73,21 @@ func TestLiveTTL(t *testing.T) {
zone, err := getZone(fqdn)
require.NoError(t, err)
- resourceRecordSets, err := configdns.GetRecordList(zone, fqdn, "TXT")
+ ctx := context.Background()
+
+ sess, err := session.New(session.WithSigner(provider.config))
require.NoError(t, err)
- for i, rrset := range resourceRecordSets.Recordsets {
+ client := edgegriddns.Client(sess)
+
+ resourceRecordSets, err := client.GetRecordList(ctx, edgegriddns.GetRecordListRequest{
+ Zone: zone,
+ RecordType: "TXT",
+ })
+
+ require.NoError(t, err)
+
+ for i, rrset := range resourceRecordSets.RecordSets {
if rrset.Name != fqdn {
continue
}
diff --git a/providers/dns/edgedns/edgedns_test.go b/providers/dns/edgedns/edgedns_test.go
index 9bb76580b..a64efd6e2 100644
--- a/providers/dns/edgedns/edgedns_test.go
+++ b/providers/dns/edgedns/edgedns_test.go
@@ -1,12 +1,10 @@
package edgedns
import (
- "os"
"testing"
"time"
- configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
- "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
+ "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/edgegrid"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/stretchr/testify/require"
@@ -21,10 +19,14 @@ const (
)
var envTest = tester.NewEnvTest(
+ EnvTTL,
+ EnvPollingInterval,
+ EnvPropagationTimeout,
EnvHost,
EnvClientToken,
EnvClientSecret,
EnvAccessToken,
+ EnvAccountSwitchKey,
EnvEdgeRc,
EnvEdgeRcSection,
envTestHost,
@@ -34,7 +36,7 @@ var envTest = tester.NewEnvTest(
WithDomain(envDomain).
WithLiveTestRequirements(EnvHost, EnvClientToken, EnvClientSecret, EnvAccessToken, envDomain)
-func TestNewDNSProvider_FromEnv(t *testing.T) {
+func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
@@ -49,13 +51,31 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
EnvClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
EnvAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
},
- expectedConfig: &edgegrid.Config{
- Host: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net",
- ClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
- ClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
- AccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
- MaxBody: maxBody,
+ expectedConfig: newEdgeConfig(func(config *edgegrid.Config) {
+ config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net"
+ config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx"
+ config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx"
+ config.MaxBody = maxBody
+ }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null")),
+ },
+ {
+ desc: "with account switch key",
+ envVars: map[string]string{
+ EnvHost: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net",
+ EnvClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
+ EnvClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ EnvAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
+ EnvAccountSwitchKey: "F-AC-1234",
},
+ expectedConfig: newEdgeConfig(func(config *edgegrid.Config) {
+ config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net"
+ config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx"
+ config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx"
+ config.MaxBody = maxBody
+ config.AccountKey = "F-AC-1234"
+ }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null")),
},
{
desc: "with section",
@@ -66,17 +86,17 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
envTestClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
envTestAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
},
- expectedConfig: &edgegrid.Config{
- Host: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net",
- ClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
- ClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
- AccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx",
- MaxBody: maxBody,
- },
+ expectedConfig: newEdgeConfig(func(config *edgegrid.Config) {
+ config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net"
+ config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx"
+ config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx"
+ config.MaxBody = maxBody
+ }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null"), edgegrid.WithSection("test")),
},
{
desc: "missing credentials",
- expectedErr: "edgedns: Unable to create instance using environment or .edgerc file",
+ expectedErr: `edgedns: unable to load config from environment or .edgerc file`,
},
{
desc: "missing host",
@@ -86,7 +106,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
EnvClientSecret: "C",
EnvAccessToken: "D",
},
- expectedErr: "edgedns: Unable to create instance using environment or .edgerc file",
+ expectedErr: `edgedns: unable to load config from environment or .edgerc file`,
},
{
desc: "missing client token",
@@ -96,7 +116,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
EnvClientSecret: "C",
EnvAccessToken: "D",
},
- expectedErr: "edgedns: Fatal missing required environment variables: [AKAMAI_CLIENT_TOKEN]",
+ expectedErr: `edgedns: unable to load config from environment or .edgerc file`,
},
{
desc: "missing client secret",
@@ -106,7 +126,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
EnvClientSecret: "",
EnvAccessToken: "D",
},
- expectedErr: "edgedns: Fatal missing required environment variables: [AKAMAI_CLIENT_SECRET]",
+ expectedErr: `edgedns: unable to load config from environment or .edgerc file`,
},
{
desc: "missing access token",
@@ -116,18 +136,20 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
EnvClientSecret: "C",
EnvAccessToken: "",
},
- expectedErr: "edgedns: Fatal missing required environment variables: [AKAMAI_ACCESS_TOKEN]",
+ expectedErr: `edgedns: unable to load config from environment or .edgerc file`,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
if test.envVars == nil {
test.envVars = map[string]string{}
}
+
test.envVars[EnvEdgeRc] = "/dev/null"
envTest.Apply(test.envVars)
@@ -135,7 +157,7 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
p, err := NewDNSProvider()
if test.expectedErr != "" {
- require.EqualError(t, err, test.expectedErr)
+ require.ErrorContains(t, err, test.expectedErr)
return
}
@@ -144,13 +166,63 @@ func TestNewDNSProvider_FromEnv(t *testing.T) {
require.NotNil(t, p.config)
if test.expectedConfig != nil {
- require.Equal(t, *test.expectedConfig, configdns.Config)
+ require.Equal(t, test.expectedConfig, p.config.Config)
}
})
}
}
-func TestDNSProvider_findZone(t *testing.T) {
+func TestNewDefaultConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected *Config
+ }{
+ {
+ desc: "default configuration",
+ expected: &Config{
+ TTL: dns01.DefaultTTL,
+ PropagationTimeout: 3 * time.Minute,
+ PollingInterval: 15 * time.Second,
+ Config: &edgegrid.Config{
+ MaxBody: maxBody,
+ },
+ },
+ },
+ {
+ desc: "custom values",
+ envVars: map[string]string{
+ EnvTTL: "99",
+ EnvPropagationTimeout: "60",
+ EnvPollingInterval: "60",
+ },
+ expected: &Config{
+ TTL: 99,
+ PropagationTimeout: 60 * time.Second,
+ PollingInterval: 60 * time.Second,
+ Config: &edgegrid.Config{
+ MaxBody: maxBody,
+ },
+ },
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ config := NewDefaultConfig()
+
+ require.Equal(t, test.expected, config)
+ })
+ }
+}
+
+func Test_findZone(t *testing.T) {
testCases := []struct {
desc string
domain string
@@ -179,53 +251,7 @@ func TestDNSProvider_findZone(t *testing.T) {
}
}
-func TestNewDefaultConfig(t *testing.T) {
- defer envTest.RestoreEnv()
-
- testCases := []struct {
- desc string
- envVars map[string]string
- expected *Config
- }{
- {
- desc: "default configuration",
- expected: &Config{
- TTL: dns01.DefaultTTL,
- PropagationTimeout: 3 * time.Minute,
- PollingInterval: 15 * time.Second,
- Config: edgegrid.Config{
- MaxBody: maxBody,
- },
- },
- },
- {
- desc: "custom values",
- envVars: map[string]string{
- EnvTTL: "99",
- EnvPropagationTimeout: "60",
- EnvPollingInterval: "60",
- },
- expected: &Config{
- TTL: 99,
- PropagationTimeout: 60 * time.Second,
- PollingInterval: 60 * time.Second,
- Config: edgegrid.Config{
- MaxBody: maxBody,
- },
- },
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- envTest.ClearEnv()
- for key, value := range test.envVars {
- os.Setenv(key, value)
- }
-
- config := NewDefaultConfig()
-
- require.Equal(t, test.expected, config)
- })
- }
+func newEdgeConfig(opts ...edgegrid.Option) *edgegrid.Config {
+ config, _ := edgegrid.New(opts...)
+ return config
}
diff --git a/providers/dns/edgeone/edgeone.go b/providers/dns/edgeone/edgeone.go
new file mode 100644
index 000000000..6931c6715
--- /dev/null
+++ b/providers/dns/edgeone/edgeone.go
@@ -0,0 +1,203 @@
+// Package edgeone implements a DNS provider for solving the DNS-01 challenge using Tencent EdgeOne.
+package edgeone
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+ teo "github.com/go-acme/tencentedgdeone/v20220901"
+ "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
+ "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
+ "golang.org/x/net/idna"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "EDGEONE_"
+
+ EnvSecretID = envNamespace + "SECRET_ID"
+ EnvSecretKey = envNamespace + "SECRET_KEY"
+ EnvRegion = envNamespace + "REGION"
+ EnvSessionToken = envNamespace + "SESSION_TOKEN"
+ EnvZonesMapping = envNamespace + "ZONES_MAPPING"
+
+ 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 {
+ SecretID string
+ SecretKey string
+ Region string
+ SessionToken string
+
+ ZonesMapping map[string]string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPTimeout time.Duration
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, 60),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second),
+ HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *teo.Client
+
+ recordIDs map[string]*string
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Tencent EdgeOne.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvSecretID, EnvSecretKey)
+ if err != nil {
+ return nil, fmt.Errorf("edgeone: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.SecretID = values[EnvSecretID]
+ config.SecretKey = values[EnvSecretKey]
+ config.Region = env.GetOrDefaultString(EnvRegion, "")
+ config.SessionToken = env.GetOrDefaultString(EnvSessionToken, "")
+
+ mapping := env.GetOrDefaultString(EnvZonesMapping, "")
+ if mapping != "" {
+ config.ZonesMapping, err = env.ParsePairs(mapping)
+ if err != nil {
+ return nil, fmt.Errorf("edgeone: zones mapping: %w", err)
+ }
+ }
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Tencent EdgeOne.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("edgeone: the configuration of the DNS provider is nil")
+ }
+
+ var credential *common.Credential
+
+ switch {
+ case config.SecretID != "" && config.SecretKey != "" && config.SessionToken != "":
+ credential = common.NewTokenCredential(config.SecretID, config.SecretKey, config.SessionToken)
+ case config.SecretID != "" && config.SecretKey != "":
+ credential = common.NewCredential(config.SecretID, config.SecretKey)
+ default:
+ return nil, errors.New("edgeone: credentials missing")
+ }
+
+ cpf := profile.NewClientProfile()
+ cpf.HttpProfile.Endpoint = "teo.intl.tencentcloudapi.com"
+ cpf.HttpProfile.ReqTimeout = int(math.Round(config.HTTPTimeout.Seconds()))
+
+ client, err := teo.NewClient(credential, config.Region, cpf)
+ if err != nil {
+ return nil, fmt.Errorf("edgeone: %w", err)
+ }
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: map[string]*string{},
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx := context.Background()
+
+ zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("edgeone: failed to get hosted zone: %w", err)
+ }
+
+ punnyCoded, err := idna.ToASCII(dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("edgeone: fail to convert punycode: %w", err)
+ }
+
+ request := teo.NewCreateDnsRecordRequest()
+ request.Name = ptr.Pointer(punnyCoded)
+ request.ZoneId = zoneID
+ request.Type = ptr.Pointer("TXT")
+ request.Content = ptr.Pointer(info.Value)
+ request.TTL = ptr.Pointer(int64(d.config.TTL))
+
+ nr, err := teo.CreateDnsRecordWithContext(ctx, d.client, request)
+ if err != nil {
+ return fmt.Errorf("edgeone: API call failed: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = nr.Response.RecordId
+ 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)
+
+ ctx := context.Background()
+
+ zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("edgeone: failed to get hosted zone: %w", err)
+ }
+
+ // get the record's unique ID from when we created it
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("edgeone: unknown record ID for '%s'", info.EffectiveFQDN)
+ }
+
+ request := teo.NewDeleteDnsRecordsRequest()
+ request.ZoneId = zoneID
+ request.RecordIds = []*string{recordID}
+
+ _, err = teo.DeleteDnsRecordsWithContext(ctx, d.client, request)
+ if err != nil {
+ return fmt.Errorf("edgeone: delete record failed: %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/dns/edgeone/edgeone.toml b/providers/dns/edgeone/edgeone.toml
new file mode 100644
index 000000000..05b8bc516
--- /dev/null
+++ b/providers/dns/edgeone/edgeone.toml
@@ -0,0 +1,28 @@
+Name = "Tencent EdgeOne"
+Description = ''''''
+URL = "https://edgeone.ai"
+Code = "edgeone"
+Since = "v4.26.0"
+
+Example = '''
+EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \
+EDGEONE_SECRET_KEY=your-secret-key \
+lego --dns edgeone -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ EDGEONE_SECRET_ID = "Access key ID"
+ EDGEONE_SECRET_KEY = "Access Key secret"
+ [Configuration.Additional]
+ EDGEONE_SESSION_TOKEN = "Access Key token"
+ EDGEONE_REGION = "Region"
+ EDGEONE_ZONES_MAPPING = "Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2')"
+ EDGEONE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)"
+ EDGEONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)"
+ EDGEONE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ EDGEONE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://edgeone.ai/document/50454#dns-record-apis"
+ GoClient = "https://github.com/tencentcloud/tencentcloud-sdk-go"
diff --git a/providers/dns/edgeone/edgeone_test.go b/providers/dns/edgeone/edgeone_test.go
new file mode 100644
index 000000000..7bd4f6f6d
--- /dev/null
+++ b/providers/dns/edgeone/edgeone_test.go
@@ -0,0 +1,170 @@
+package edgeone
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvSecretID,
+ EnvSecretKey,
+ EnvZonesMapping,
+).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvSecretID: "123",
+ EnvSecretKey: "456",
+ },
+ },
+ {
+ desc: "success with zones mapping",
+ envVars: map[string]string{
+ EnvSecretID: "123",
+ EnvSecretKey: "456",
+ EnvZonesMapping: "example.org:id1,example.com:id2",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvSecretID: "",
+ EnvSecretKey: "",
+ },
+ expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_ID,EDGEONE_SECRET_KEY",
+ },
+ {
+ desc: "missing access id",
+ envVars: map[string]string{
+ EnvSecretID: "",
+ EnvSecretKey: "456",
+ },
+ expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_ID",
+ },
+ {
+ desc: "missing secret key",
+ envVars: map[string]string{
+ EnvSecretID: "123",
+ EnvSecretKey: "",
+ },
+ expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_KEY",
+ },
+ {
+ desc: "invalid mapping",
+ envVars: map[string]string{
+ EnvSecretID: "123",
+ EnvSecretKey: "456",
+ EnvZonesMapping: "example.org:id1,example.com",
+ },
+ expected: "edgeone: zones mapping: incorrect pair: example.com",
+ },
+ }
+
+ 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
+ secretID string
+ secretKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ secretID: "123",
+ secretKey: "456",
+ },
+ {
+ desc: "missing credentials",
+ expected: "edgeone: credentials missing",
+ },
+ {
+ desc: "missing secret id",
+ secretKey: "456",
+ expected: "edgeone: credentials missing",
+ },
+ {
+ desc: "missing secret key",
+ secretID: "123",
+ expected: "edgeone: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.SecretID = test.secretID
+ config.SecretKey = test.secretKey
+
+ 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/dns/edgeone/wrapper.go b/providers/dns/edgeone/wrapper.go
new file mode 100644
index 000000000..53fae9427
--- /dev/null
+++ b/providers/dns/edgeone/wrapper.go
@@ -0,0 +1,58 @@
+package edgeone
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+ teo "github.com/go-acme/tencentedgdeone/v20220901"
+)
+
+func (d *DNSProvider) getHostedZoneID(ctx context.Context, domain string) (*string, error) {
+ authZone, err := dns01.FindZoneByFqdn(domain)
+ if err != nil {
+ return nil, fmt.Errorf("could not find zone: %w", err)
+ }
+
+ if d.config.ZonesMapping != nil {
+ zoneID, ok := d.config.ZonesMapping[authZone]
+ if ok {
+ return ptr.Pointer(zoneID), nil
+ }
+ }
+
+ request := teo.NewDescribeZonesRequest()
+
+ var zones []*teo.Zone
+
+ for {
+ response, err := teo.DescribeZonesWithContext(ctx, d.client, request)
+ if err != nil {
+ return nil, fmt.Errorf("API call failed: %w", err)
+ }
+
+ zones = append(zones, response.Response.Zones...)
+
+ if int64(len(zones)) >= ptr.Deref(response.Response.TotalCount) {
+ break
+ }
+
+ request.Offset = ptr.Pointer(int64(len(zones)))
+ }
+
+ var hostedZone *teo.Zone
+
+ for _, zone := range zones {
+ unfqdn := dns01.UnFqdn(authZone)
+ if ptr.Deref(zone.ZoneName) == unfqdn {
+ hostedZone = zone
+ }
+ }
+
+ if hostedZone == nil {
+ return nil, fmt.Errorf("zone %s not found for domain %s", authZone, domain)
+ }
+
+ return hostedZone.ZoneId, nil
+}
diff --git a/providers/dns/efficientip/efficientip.go b/providers/dns/efficientip/efficientip.go
index 15fa579ed..81b4530b7 100644
--- a/providers/dns/efficientip/efficientip.go
+++ b/providers/dns/efficientip/efficientip.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/efficientip/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -91,12 +92,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
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")
}
@@ -113,6 +117,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/efficientip/efficientip.toml b/providers/dns/efficientip/efficientip.toml
index f03a8026f..6e1874319 100644
--- a/providers/dns/efficientip/efficientip.toml
+++ b/providers/dns/efficientip/efficientip.toml
@@ -9,7 +9,7 @@ EFFICIENTIP_USERNAME="user" \
EFFICIENTIP_PASSWORD="secret" \
EFFICIENTIP_HOSTNAME="ipam.example.org" \
EFFICIENTIP_DNS_NAME="dns.smart" \
-lego --email you@example.com --dns efficientip -d '*.example.com' -d example.com run
+lego --dns efficientip -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -21,7 +21,6 @@ lego --email you@example.com --dns efficientip -d '*.example.com' -d example.com
[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"
+ EFFICIENTIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ EFFICIENTIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ EFFICIENTIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
diff --git a/providers/dns/efficientip/efficientip_test.go b/providers/dns/efficientip/efficientip_test.go
index 3ee2da777..c2751a79b 100644
--- a/providers/dns/efficientip/efficientip_test.go
+++ b/providers/dns/efficientip/efficientip_test.go
@@ -83,6 +83,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -178,6 +179,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -191,6 +193,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/efficientip/internal/client.go b/providers/dns/efficientip/internal/client.go
index 2fea76a13..5ccdf3973 100644
--- a/providers/dns/efficientip/internal/client.go
+++ b/providers/dns/efficientip/internal/client.go
@@ -22,7 +22,7 @@ type Client struct {
password string
}
-func NewClient(hostname string, username string, password string) *Client {
+func NewClient(hostname, username, password string) *Client {
baseURL, _ := url.Parse(fmt.Sprintf("https://%s/rest/", hostname))
return &Client{
@@ -33,7 +33,7 @@ func NewClient(hostname string, username string, password string) *Client {
}
}
-func (c Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) {
+func (c *Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) {
endpoint := c.baseURL.JoinPath("dns_rr_list")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -51,7 +51,7 @@ func (c Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) {
return result, nil
}
-func (c Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, error) {
+func (c *Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, error) {
endpoint := c.baseURL.JoinPath("dns_rr_info")
query := endpoint.Query()
@@ -77,7 +77,7 @@ func (c Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, erro
return &result[0], nil
}
-func (c Client) AddRecord(ctx context.Context, record ResourceRecord) (*BaseOutput, error) {
+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)
@@ -99,7 +99,7 @@ func (c Client) AddRecord(ctx context.Context, record ResourceRecord) (*BaseOutp
return &result[0], nil
}
-func (c Client) DeleteRecord(ctx context.Context, params DeleteInputParameters) (*BaseOutput, error) {
+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)))
@@ -108,6 +108,7 @@ func (c Client) DeleteRecord(ctx context.Context, params DeleteInputParameters)
if err != nil {
return nil, fmt.Errorf("query parameters: %w", err)
}
+
endpoint.RawQuery = v.Encode()
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -129,7 +130,7 @@ func (c Client) DeleteRecord(ctx context.Context, params DeleteInputParameters)
return &result[0], nil
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
req.SetBasicAuth(c.username, c.password)
req.Header.Set("cache-control", "no-cache")
@@ -200,6 +201,7 @@ 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)
diff --git a/providers/dns/efficientip/internal/client_test.go b/providers/dns/efficientip/internal/client_test.go
index a766c9085..5d68b7d7f 100644
--- a/providers/dns/efficientip/internal/client_test.go
+++ b/providers/dns/efficientip/internal/client_test.go
@@ -1,80 +1,38 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ srvURL, _ := url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client := NewClient(srvURL.Host, "user", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("user", "secret"),
+ )
}
func TestListRecords(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/dns_rr_list", http.StatusOK, "dns_rr_list.json")
+ client := mockBuilder().
+ Route("GET /dns_rr_list", servermock.ResponseFromFixture("dns_rr_list.json")).
+ Build(t)
- ctx := context.Background()
-
- records, err := client.ListRecords(ctx)
+ records, err := client.ListRecords(t.Context())
require.NoError(t, err)
expected := []ResourceRecord{
@@ -337,11 +295,13 @@ func TestListRecords(t *testing.T) {
}
func TestGetRecord(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/dns_rr_info", http.StatusOK, "dns_rr_info.json")
+ client := mockBuilder().
+ Route("GET /dns_rr_info", servermock.ResponseFromFixture("dns_rr_info.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("rr_id", "239")).
+ Build(t)
- ctx := context.Background()
-
- record, err := client.GetRecord(ctx, "239")
+ record, err := client.GetRecord(t.Context(), "239")
require.NoError(t, err)
expected := &ResourceRecord{
@@ -384,9 +344,11 @@ func TestGetRecord(t *testing.T) {
}
func TestAddRecord(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/dns_rr_add", http.StatusCreated, "dns_rr_add.json")
-
- ctx := context.Background()
+ client := mockBuilder().
+ Route("POST /dns_rr_add",
+ servermock.ResponseFromFixture("dns_rr_add.json").WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"dns_name":"dns.smart","dnsview_name":"external","rr_name":"test.example.com","rr_type":"TXT","value1":"test"}`)).
+ Build(t)
r := ResourceRecord{
RRName: "test.example.com",
@@ -396,7 +358,7 @@ func TestAddRecord(t *testing.T) {
DNSViewName: "external",
}
- resp, err := client.AddRecord(ctx, r)
+ resp, err := client.AddRecord(t.Context(), r)
require.NoError(t, err)
expected := &BaseOutput{RetOID: "239"}
@@ -405,11 +367,13 @@ func TestAddRecord(t *testing.T) {
}
func TestDeleteRecord(t *testing.T) {
- client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusOK, "dns_rr_delete.json")
+ client := mockBuilder().
+ Route("DELETE /dns_rr_delete", servermock.ResponseFromFixture("dns_rr_delete.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("rr_id", "251")).
+ Build(t)
- ctx := context.Background()
-
- resp, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"})
+ resp, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"})
require.NoError(t, err)
expected := &BaseOutput{RetOID: "251"}
@@ -418,10 +382,11 @@ func TestDeleteRecord(t *testing.T) {
}
func TestDeleteRecord_error(t *testing.T) {
- client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusBadRequest, "dns_rr_delete-error.json")
+ client := mockBuilder().
+ Route("DELETE /dns_rr_delete",
+ servermock.ResponseFromFixture("dns_rr_delete-error.json").WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- ctx := context.Background()
-
- _, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"})
+ _, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"})
require.ErrorAs(t, err, &APIError{})
}
diff --git a/providers/dns/epik/epik.go b/providers/dns/epik/epik.go
index 58390faa9..ef5de3c4b 100644
--- a/providers/dns/epik/epik.go
+++ b/providers/dns/epik/epik.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/epik/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -86,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/epik/epik.toml b/providers/dns/epik/epik.toml
index d0f1fda03..faf453581 100644
--- a/providers/dns/epik/epik.toml
+++ b/providers/dns/epik/epik.toml
@@ -6,17 +6,17 @@ Since = "v4.5.0"
Example = '''
EPIK_SIGNATURE=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns epik -d '*.example.com' -d example.com run
+lego --dns epik -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
EPIK_SIGNATURE = "Epik API signature (https://registrar.epik.com/account/api-settings/)"
[Configuration.Additional]
- EPIK_POLLING_INTERVAL = "Time between DNS propagation check"
- EPIK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- EPIK_TTL = "The TTL of the TXT record used for the DNS challenge"
- EPIK_HTTP_TIMEOUT = "API request timeout"
+ EPIK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ EPIK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ EPIK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ EPIK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://docs-userapi.epik.com/v2/"
diff --git a/providers/dns/epik/epik_test.go b/providers/dns/epik/epik_test.go
index c0cd3d43b..b8b3c5c43 100644
--- a/providers/dns/epik/epik_test.go
+++ b/providers/dns/epik/epik_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/epik/internal/client.go b/providers/dns/epik/internal/client.go
index 9a5385453..2c3373953 100644
--- a/providers/dns/epik/internal/client.go
+++ b/providers/dns/epik/internal/client.go
@@ -37,7 +37,7 @@ func NewClient(signature string) *Client {
// GetDNSRecords gets DNS records for a domain.
// https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/getDnsRecord
-func (c Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, error) {
+func (c *Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, error) {
endpoint := c.createEndpoint(domain, url.Values{})
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -46,6 +46,7 @@ func (c Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, err
}
var data GetDNSRecordResponse
+
err = c.do(req, &data)
if err != nil {
return nil, err
@@ -56,7 +57,7 @@ func (c Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, err
// CreateHostRecord creates a record for a domain.
// https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/createHostRecord
-func (c Client) CreateHostRecord(ctx context.Context, domain string, record RecordRequest) (*Data, error) {
+func (c *Client) CreateHostRecord(ctx context.Context, domain string, record RecordRequest) (*Data, error) {
endpoint := c.createEndpoint(domain, url.Values{})
payload := CreateHostRecords{Payload: record}
@@ -67,6 +68,7 @@ func (c Client) CreateHostRecord(ctx context.Context, domain string, record Reco
}
var data Data
+
err = c.do(req, &data)
if err != nil {
return nil, err
@@ -77,7 +79,7 @@ func (c Client) CreateHostRecord(ctx context.Context, domain string, record Reco
// RemoveHostRecord removes a record for a domain.
// https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/removeHostRecord
-func (c Client) RemoveHostRecord(ctx context.Context, domain string, recordID string) (*Data, error) {
+func (c *Client) RemoveHostRecord(ctx context.Context, domain, recordID string) (*Data, error) {
params := url.Values{}
params.Set("ID", recordID)
@@ -89,6 +91,7 @@ func (c Client) RemoveHostRecord(ctx context.Context, domain string, recordID st
}
var data Data
+
err = c.do(req, &data)
if err != nil {
return nil, err
@@ -97,7 +100,7 @@ func (c Client) RemoveHostRecord(ctx context.Context, domain string, recordID st
return &data, nil
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
useragent.SetHeader(req.Header)
resp, err := c.HTTPClient.Do(req)
@@ -128,7 +131,7 @@ func (c Client) do(req *http.Request, result any) error {
return nil
}
-func (c Client) createEndpoint(domain string, params url.Values) *url.URL {
+func (c *Client) createEndpoint(domain string, params url.Values) *url.URL {
endpoint := c.baseURL.JoinPath("domains", domain, "records")
params.Set("SIGNATURE", c.signature)
@@ -165,6 +168,7 @@ 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)
diff --git a/providers/dns/epik/internal/client_test.go b/providers/dns/epik/internal/client_test.go
index 78c4452f0..b7c6f97df 100644
--- a/providers/dns/epik/internal/client_test.go
+++ b/providers/dns/epik/internal/client_test.go
@@ -1,40 +1,38 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("secret")
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestClient_GetDNSRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/example.com/records",
+ servermock.ResponseFromFixture("getDnsRecord.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("SIGNATURE", "secret")).
+ Build(t)
- mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusOK, "getDnsRecord.json"))
-
- records, err := client.GetDNSRecords(context.Background(), "example.com")
+ records, err := client.GetDNSRecords(t.Context(), "example.com")
require.NoError(t, err)
expected := []Record{
@@ -89,18 +87,25 @@ func TestClient_GetDNSRecords(t *testing.T) {
}
func TestClient_GetDNSRecords_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/example.com/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ servermock.CheckQueryParameter().Strict().
+ With("SIGNATURE", "secret")).
+ Build(t)
- mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
-
- _, err := client.GetDNSRecords(context.Background(), "example.com")
+ _, err := client.GetDNSRecords(t.Context(), "example.com")
require.Error(t, err)
}
func TestClient_CreateHostRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusOK, "createHostRecord.json"))
+ client := mockBuilder().
+ Route("POST /domains/example.com/records",
+ servermock.ResponseFromFixture("createHostRecord.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("SIGNATURE", "secret")).
+ Build(t)
record := RecordRequest{
Host: "www2",
@@ -110,7 +115,7 @@ func TestClient_CreateHostRecord(t *testing.T) {
TTL: 300,
}
- data, err := client.CreateHostRecord(context.Background(), "example.com", record)
+ data, err := client.CreateHostRecord(t.Context(), "example.com", record)
require.NoError(t, err)
expected := &Data{
@@ -122,9 +127,13 @@ func TestClient_CreateHostRecord(t *testing.T) {
}
func TestClient_CreateHostRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json"))
+ client := mockBuilder().
+ Route("POST /domains/example.com/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ servermock.CheckQueryParameter().Strict().
+ With("SIGNATURE", "secret")).
+ Build(t)
record := RecordRequest{
Host: "www2",
@@ -134,16 +143,20 @@ func TestClient_CreateHostRecord_error(t *testing.T) {
TTL: 300,
}
- _, err := client.CreateHostRecord(context.Background(), "example.com", record)
+ _, err := client.CreateHostRecord(t.Context(), "example.com", record)
require.Error(t, err)
}
func TestClient_RemoveHostRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/records",
+ servermock.ResponseFromFixture("removeHostRecord.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("ID", "abc123").
+ With("SIGNATURE", "secret")).
+ Build(t)
- mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusOK, "removeHostRecord.json"))
-
- data, err := client.RemoveHostRecord(context.Background(), "example.com", "abc123")
+ data, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123")
require.NoError(t, err)
expected := &Data{
@@ -155,45 +168,12 @@ func TestClient_RemoveHostRecord(t *testing.T) {
}
func TestClient_RemoveHostRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json"))
-
- _, err := client.RemoveHostRecord(context.Background(), "example.com", "abc123")
+ _, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123")
require.Error(t, err)
}
-
-func testHandler(method string, statusCode int, filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.URL.Query().Get("SIGNATURE")
- if auth != "secret" {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(statusCode)
-
- if statusCode == http.StatusNoContent {
- return
- }
-
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
- }
-}
diff --git a/providers/dns/eurodns/eurodns.go b/providers/dns/eurodns/eurodns.go
new file mode 100644
index 000000000..21ff3c3a9
--- /dev/null
+++ b/providers/dns/eurodns/eurodns.go
@@ -0,0 +1,197 @@
+// Package eurodns implements a DNS provider for solving the DNS-01 challenge using EuroDNS.
+package eurodns
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/eurodns/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "EURODNS_"
+
+ EnvApplicationID = envNamespace + "APP_ID"
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ ApplicationID string
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, internal.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for EuroDNS.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvApplicationID, EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("eurodns: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.ApplicationID = values[EnvApplicationID]
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for EuroDNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("eurodns: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.ApplicationID, config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("eurodns: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: %w", err)
+ }
+
+ authZone = dns01.UnFqdn(authZone)
+
+ zone, err := d.client.GetZone(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: get zone: %w", err)
+ }
+
+ zone.Records = append(zone.Records, internal.Record{
+ Type: "TXT",
+ Host: subDomain,
+ TTL: internal.TTLRounder(d.config.TTL),
+ RData: info.Value,
+ })
+
+ validation, err := d.client.ValidateZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: validate zone: %w", err)
+ }
+
+ if validation.Report != nil && !validation.Report.IsValid {
+ return fmt.Errorf("eurodns: validation report: %w", validation.Report)
+ }
+
+ err = d.client.SaveZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: save zone: %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: %w", err)
+ }
+
+ authZone = dns01.UnFqdn(authZone)
+
+ zone, err := d.client.GetZone(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("eurodns: get zone: %w", err)
+ }
+
+ var recordsToKeep []internal.Record
+
+ for _, record := range zone.Records {
+ if record.Type == "TXT" && record.Host == subDomain && record.RData == info.Value {
+ continue
+ }
+
+ recordsToKeep = append(recordsToKeep, record)
+ }
+
+ zone.Records = recordsToKeep
+
+ validation, err := d.client.ValidateZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: validate zone: %w", err)
+ }
+
+ if validation.Report != nil && !validation.Report.IsValid {
+ return fmt.Errorf("eurodns: validation report: %w", validation.Report)
+ }
+
+ err = d.client.SaveZone(ctx, authZone, zone)
+ if err != nil {
+ return fmt.Errorf("eurodns: save zone: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
diff --git a/providers/dns/eurodns/eurodns.toml b/providers/dns/eurodns/eurodns.toml
new file mode 100644
index 000000000..302b15d00
--- /dev/null
+++ b/providers/dns/eurodns/eurodns.toml
@@ -0,0 +1,24 @@
+Name = "EuroDNS"
+Description = ''''''
+URL = "https://www.eurodns.com/"
+Code = "eurodns"
+Since = "v4.33.0"
+
+Example = '''
+EURODNS_APP_ID="xxx" \
+EURODNS_API_KEY="yyy" \
+lego --dns eurodns -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ EURODNS_APP_ID = "Application ID"
+ EURODNS_API_KEY = "API key"
+ [Configuration.Additional]
+ EURODNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ EURODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ EURODNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ EURODNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://docapi.eurodns.com/"
diff --git a/providers/dns/eurodns/eurodns_test.go b/providers/dns/eurodns/eurodns_test.go
new file mode 100644
index 000000000..abbb4717e
--- /dev/null
+++ b/providers/dns/eurodns/eurodns_test.go
@@ -0,0 +1,215 @@
+package eurodns
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/eurodns/internal"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvApplicationID, EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvApplicationID: "abc",
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing application ID",
+ envVars: map[string]string{
+ EnvApplicationID: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "eurodns: some credentials information are missing: EURODNS_APP_ID",
+ },
+ {
+ desc: "missing API secret",
+ envVars: map[string]string{
+ EnvApplicationID: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "eurodns: some credentials information are missing: EURODNS_APP_ID",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "eurodns: some credentials information are missing: EURODNS_APP_ID,EURODNS_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ appID string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ appID: "abc",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing application ID",
+ expected: "eurodns: credentials missing",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing API secret",
+ expected: "eurodns: credentials missing",
+ appID: "abc",
+ },
+ {
+ desc: "missing credentials",
+ expected: "eurodns: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.ApplicationID = test.appID
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "secret"
+ config.ApplicationID = "abc"
+ config.HTTPClient = server.Client()
+
+ provider, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ provider.client.BaseURL, _ = url.Parse(server.URL)
+
+ return provider, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(internal.HeaderAppID, "abc").
+ With(internal.HeaderAPIKey, "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromInternal("zone_get.json"),
+ ).
+ Route("POST /example.com/check",
+ servermock.ResponseFromInternal("zone_add_validate_ok.json"),
+ servermock.CheckRequestJSONBodyFromInternal("zone_add.json"),
+ ).
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromInternal("zone_add.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromInternal("zone_add.json"),
+ ).
+ Route("POST /example.com/check",
+ servermock.ResponseFromInternal("zone_remove.json"),
+ servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"),
+ ).
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/eurodns/internal/client.go b/providers/dns/eurodns/internal/client.go
new file mode 100644
index 000000000..1ebf8d143
--- /dev/null
+++ b/providers/dns/eurodns/internal/client.go
@@ -0,0 +1,199 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://rest-api.eurodns.com/dns-zones/"
+
+const (
+ HeaderAppID = "X-APP-ID"
+ HeaderAPIKey = "X-API-KEY"
+)
+
+// Client the EuroDNS API client.
+type Client struct {
+ appID string
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(appID, apiKey string) (*Client, error) {
+ if appID == "" || apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ appID: appID,
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// GetZone gets a DNS Zone.
+// https://docapi.eurodns.com/#/dnsprovider/getdnszone
+func (c *Client) GetZone(ctx context.Context, domain string) (*Zone, error) {
+ endpoint := c.BaseURL.JoinPath(domain)
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &Zone{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// SaveZone saves a DNS Zone.
+// https://docapi.eurodns.com/#/dnsprovider/savednszone
+func (c *Client) SaveZone(ctx context.Context, domain string, zone *Zone) error {
+ endpoint := c.BaseURL.JoinPath(domain)
+
+ if len(zone.URLForwards) == 0 {
+ zone.URLForwards = make([]URLForward, 0)
+ }
+
+ if len(zone.MailForwards) == 0 {
+ zone.MailForwards = make([]MailForward, 0)
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPut, endpoint, zone)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+// ValidateZone validates DNS Zone.
+// https://docapi.eurodns.com/#/dnsprovider/checkdnszone
+func (c *Client) ValidateZone(ctx context.Context, domain string, zone *Zone) (*Zone, error) {
+ endpoint := c.BaseURL.JoinPath(domain, "check")
+
+ if len(zone.URLForwards) == 0 {
+ zone.URLForwards = make([]URLForward, 0)
+ }
+
+ if len(zone.MailForwards) == 0 {
+ zone.MailForwards = make([]MailForward, 0)
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zone)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &Zone{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Set(HeaderAppID, c.appID)
+ req.Header.Set(HeaderAPIKey, c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return fmt.Errorf("%d: %w", resp.StatusCode, &errAPI)
+}
+
+const DefaultTTL = 600
+
+// TTLRounder rounds the given TTL in seconds to the next accepted value.
+// Accepted TTL values are: 600, 900, 1800,3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800.
+func TTLRounder(ttl int) int {
+ for _, validTTL := range []int{DefaultTTL, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800} {
+ if ttl <= validTTL {
+ return validTTL
+ }
+ }
+
+ return DefaultTTL
+}
diff --git a/providers/dns/eurodns/internal/client_test.go b/providers/dns/eurodns/internal/client_test.go
new file mode 100644
index 000000000..68d1fda84
--- /dev/null
+++ b/providers/dns/eurodns/internal/client_test.go
@@ -0,0 +1,310 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "slices"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("abc", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(HeaderAppID, "abc").
+ With(HeaderAPIKey, "secret"),
+ )
+}
+
+func TestClient_GetZone(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromFixture("zone_get.json"),
+ ).
+ Build(t)
+
+ zone, err := client.GetZone(context.Background(), "example.com")
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: slices.Concat([]Record{fakeARecord()}),
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_GetZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ _, err := client.GetZone(context.Background(), "example.com")
+ require.Error(t, err)
+
+ require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key")
+}
+
+func TestClient_SaveZone(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ err := client.SaveZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+}
+
+func TestClient_SaveZone_emptyForwards(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /example.com",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add_empty_forwards.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: slices.Concat([]Record{fakeARecord(), record}),
+ }
+
+ err := client.SaveZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+}
+
+func TestClient_SaveZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord()},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ err := client.SaveZone(context.Background(), "example.com", zone)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key")
+}
+
+func TestClient_ValidateZone(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /example.com/check",
+ servermock.ResponseFromFixture("zone_add_validate_ok.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ zone, err := client.ValidateZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ Report: &Report{IsValid: true},
+ }
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_ValidateZone_report(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /example.com/check",
+ servermock.ResponseFromFixture("zone_add_validate_ko.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zone_add.json"),
+ ).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Host: "_acme-challenge",
+ RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 600,
+ }
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ zone, err := client.ValidateZone(context.Background(), "example.com", zone)
+ require.NoError(t, err)
+
+ expected := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord(), record},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ Report: fakeReport(),
+ }
+
+ assert.EqualError(t, zone.Report, `record error (ERROR): "120" is not a valid TTL, URL forward error (ERROR): string, mail forward error (ERROR): string, zone error (ERROR): string`)
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_ValidateZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /example.com/check",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ zone := &Zone{
+ Name: "example.com",
+ DomainConnect: true,
+ Records: []Record{fakeARecord()},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }
+
+ _, err := client.ValidateZone(context.Background(), "example.com", zone)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key")
+}
+
+func fakeARecord() Record {
+ return Record{
+ ID: 1000,
+ Type: "A",
+ Host: "@",
+ TTL: 600,
+ RData: "string",
+ Updated: ptr.Pointer(true),
+ Locked: ptr.Pointer(true),
+ IsDynDNS: ptr.Pointer(true),
+ Proxy: "ON",
+ }
+}
+
+func fakeURLForward() URLForward {
+ return URLForward{
+ ID: 2000,
+ ForwardType: "FRAME",
+ Host: "string",
+ URL: "string",
+ Title: "string",
+ Keywords: "string",
+ Description: "string",
+ Updated: ptr.Pointer(true),
+ }
+}
+
+func fakeMailForward() MailForward {
+ return MailForward{
+ ID: 3000,
+ Source: "string",
+ Destination: "string",
+ Updated: ptr.Pointer(true),
+ }
+}
+
+func fakeReport() *Report {
+ return &Report{
+ IsValid: false,
+ RecordErrors: []RecordError{{
+ Messages: []string{`"120" is not a valid TTL`},
+ Severity: "ERROR",
+ Record: fakeARecord(),
+ }},
+ URLForwardErrors: []URLForwardError{{
+ Messages: []string{"string"},
+ Severity: "ERROR",
+ URLForward: fakeURLForward(),
+ }},
+ MailForwardErrors: []MailForwardError{{
+ Messages: []string{"string"},
+ MailForward: fakeMailForward(),
+ Severity: "ERROR",
+ }},
+ ZoneErrors: []ZoneError{{
+ Message: "string",
+ Severity: "ERROR",
+ Records: []Record{fakeARecord()},
+ URLForwards: []URLForward{fakeURLForward()},
+ MailForwards: []MailForward{fakeMailForward()},
+ }},
+ }
+}
diff --git a/providers/dns/eurodns/internal/fixtures/error.json b/providers/dns/eurodns/internal/fixtures/error.json
new file mode 100644
index 000000000..82a334598
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/error.json
@@ -0,0 +1,8 @@
+{
+ "errors": [
+ {
+ "code": "INVALID_API_KEY",
+ "title": "Invalid API Key"
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add.json b/providers/dns/eurodns/internal/fixtures/zone_add.json
new file mode 100644
index 000000000..db8142357
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add.json
@@ -0,0 +1,46 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json b/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json
new file mode 100644
index 000000000..64f8530c9
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json
@@ -0,0 +1,28 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [],
+ "mailForwards": []
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json
new file mode 100644
index 000000000..e07d42299
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json
@@ -0,0 +1,139 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ],
+ "report": {
+ "isValid": false,
+ "recordErrors": [
+ {
+ "messages": [
+ "\"120\" is not a valid TTL"
+ ],
+ "record": {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ "severity": "ERROR"
+ }
+ ],
+ "urlForwardErrors": [
+ {
+ "messages": [
+ "string"
+ ],
+ "urlForward": {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ },
+ "severity": "ERROR"
+ }
+ ],
+ "mailForwardErrors": [
+ {
+ "messages": [
+ "string"
+ ],
+ "mailForward": {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ },
+ "severity": "ERROR"
+ }
+ ],
+ "zoneErrors": [
+ {
+ "message": "string",
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ],
+ "severity": "ERROR"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json
new file mode 100644
index 000000000..ba0ddfefb
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json
@@ -0,0 +1,49 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ },
+ {
+ "type": "TXT",
+ "host": "_acme-challenge",
+ "ttl": 600,
+ "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "updated": null,
+ "locked": null,
+ "isDynDns": null
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ],
+ "report": {
+ "isValid": true
+ }
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_get.json b/providers/dns/eurodns/internal/fixtures/zone_get.json
new file mode 100644
index 000000000..ebbc8593e
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_get.json
@@ -0,0 +1,37 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/fixtures/zone_remove.json b/providers/dns/eurodns/internal/fixtures/zone_remove.json
new file mode 100644
index 000000000..ebbc8593e
--- /dev/null
+++ b/providers/dns/eurodns/internal/fixtures/zone_remove.json
@@ -0,0 +1,37 @@
+{
+ "name": "example.com",
+ "domainConnect": true,
+ "records": [
+ {
+ "id": 1000,
+ "type": "A",
+ "host": "@",
+ "ttl": 600,
+ "rdata": "string",
+ "updated": true,
+ "locked": true,
+ "isDynDns": true,
+ "proxy": "ON"
+ }
+ ],
+ "urlForwards": [
+ {
+ "id": 2000,
+ "forwardType": "FRAME",
+ "host": "string",
+ "url": "string",
+ "title": "string",
+ "keywords": "string",
+ "description": "string",
+ "updated": true
+ }
+ ],
+ "mailForwards": [
+ {
+ "id": 3000,
+ "source": "string",
+ "destination": "string",
+ "updated": true
+ }
+ ]
+}
diff --git a/providers/dns/eurodns/internal/types.go b/providers/dns/eurodns/internal/types.go
new file mode 100644
index 000000000..891b02e14
--- /dev/null
+++ b/providers/dns/eurodns/internal/types.go
@@ -0,0 +1,136 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ Errors []Error `json:"errors"`
+}
+
+func (a *APIError) Error() string {
+ var msg []string
+
+ for _, e := range a.Errors {
+ msg = append(msg, fmt.Sprintf("%s: %s", e.Code, e.Title))
+ }
+
+ return strings.Join(msg, ", ")
+}
+
+type Error struct {
+ Code string `json:"code"`
+ Title string `json:"title"`
+}
+
+type Zone struct {
+ Name string `json:"name,omitempty"`
+ DomainConnect bool `json:"domainConnect,omitempty"`
+ Records []Record `json:"records"`
+ URLForwards []URLForward `json:"urlForwards"`
+ MailForwards []MailForward `json:"mailForwards"`
+ Report *Report `json:"report,omitempty"`
+}
+
+type Record struct {
+ ID int `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Host string `json:"host,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ RData string `json:"rdata,omitempty"`
+ Updated *bool `json:"updated"`
+ Locked *bool `json:"locked"`
+ IsDynDNS *bool `json:"isDynDns"`
+ Proxy string `json:"proxy,omitempty"`
+}
+
+type URLForward struct {
+ ID int `json:"id,omitempty"`
+ ForwardType string `json:"forwardType,omitempty"`
+ Host string `json:"host,omitempty"`
+ URL string `json:"url,omitempty"`
+ Title string `json:"title,omitempty"`
+ Keywords string `json:"keywords,omitempty"`
+ Description string `json:"description,omitempty"`
+ Updated *bool `json:"updated,omitempty"`
+}
+
+type MailForward struct {
+ ID int `json:"id,omitempty"`
+ Source string `json:"source,omitempty"`
+ Destination string `json:"destination,omitempty"`
+ Updated *bool `json:"updated,omitempty"`
+}
+
+type Report struct {
+ IsValid bool `json:"isValid,omitempty"`
+ RecordErrors []RecordError `json:"recordErrors,omitempty"`
+ URLForwardErrors []URLForwardError `json:"urlForwardErrors,omitempty"`
+ MailForwardErrors []MailForwardError `json:"mailForwardErrors,omitempty"`
+ ZoneErrors []ZoneError `json:"zoneErrors,omitempty"`
+}
+
+func (r *Report) Error() string {
+ var msg []string
+
+ for _, e := range r.RecordErrors {
+ msg = append(msg, e.Error())
+ }
+
+ for _, e := range r.URLForwardErrors {
+ msg = append(msg, e.Error())
+ }
+
+ for _, e := range r.MailForwardErrors {
+ msg = append(msg, e.Error())
+ }
+
+ for _, e := range r.ZoneErrors {
+ msg = append(msg, e.Error())
+ }
+
+ return strings.Join(msg, ", ")
+}
+
+type RecordError struct {
+ Messages []string `json:"messages,omitempty"`
+ Record Record `json:"record"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *RecordError) Error() string {
+ return fmt.Sprintf("record error (%s): %s", e.Severity, strings.Join(e.Messages, ", "))
+}
+
+type URLForwardError struct {
+ Messages []string `json:"messages,omitempty"`
+ URLForward URLForward `json:"urlForward"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *URLForwardError) Error() string {
+ return fmt.Sprintf("URL forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", "))
+}
+
+type MailForwardError struct {
+ Messages []string `json:"messages,omitempty"`
+ MailForward MailForward `json:"mailForward"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *MailForwardError) Error() string {
+ return fmt.Sprintf("mail forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", "))
+}
+
+type ZoneError struct {
+ Message string `json:"message,omitempty"`
+ Records []Record `json:"records,omitempty"`
+ URLForwards []URLForward `json:"urlForwards,omitempty"`
+ MailForwards []MailForward `json:"mailForwards,omitempty"`
+ Severity string `json:"severity,omitempty"`
+}
+
+func (e *ZoneError) Error() string {
+ return fmt.Sprintf("zone error (%s): %s", e.Severity, e.Message)
+}
diff --git a/providers/dns/excedo/excedo.go b/providers/dns/excedo/excedo.go
new file mode 100644
index 000000000..ae9128b94
--- /dev/null
+++ b/providers/dns/excedo/excedo.go
@@ -0,0 +1,176 @@
+// Package excedo implements a DNS provider for solving the DNS-01 challenge using Excedo.
+package excedo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/excedo/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "EXCEDO_"
+
+ EnvAPIURL = envNamespace + "API_URL"
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIURL string
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, 60),
+ 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
+
+ recordsMu sync.Mutex
+ records map[string]int64
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Excedo.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIURL, EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("excedo: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIURL = values[EnvAPIURL]
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Excedo.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("excedo: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIURL, config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("excedo: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ records: make(map[string]int64),
+ }, nil
+}
+
+// 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("excedo: %w", err)
+ }
+
+ record := internal.Record{
+ DomainName: dns01.UnFqdn(authZone),
+ Name: subDomain,
+ Type: "TXT",
+ Content: info.Value,
+ TTL: strconv.Itoa(d.config.TTL),
+ }
+
+ recordID, err := d.client.AddRecord(ctx, record)
+ if err != nil {
+ return fmt.Errorf("excedo: add record: %w", err)
+ }
+
+ d.recordsMu.Lock()
+ d.records[token] = recordID
+ d.recordsMu.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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err)
+ }
+
+ d.recordsMu.Lock()
+ recordID, ok := d.records[token]
+ d.recordsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("excedo: unknown record ID for '%s'", info.EffectiveFQDN)
+ }
+
+ err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), strconv.FormatInt(recordID, 10))
+ if err != nil {
+ return fmt.Errorf("excedo: delete record: %w", err)
+ }
+
+ d.recordsMu.Lock()
+ delete(d.records, token)
+ d.recordsMu.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/dns/excedo/excedo.toml b/providers/dns/excedo/excedo.toml
new file mode 100644
index 000000000..9f9874c62
--- /dev/null
+++ b/providers/dns/excedo/excedo.toml
@@ -0,0 +1,24 @@
+Name = "Excedo"
+Description = ''''''
+URL = "https://excedo.se/"
+Code = "excedo"
+Since = "v4.33.0"
+
+Example = '''
+EXCEDO_API_KEY=your-api-key \
+EXCEDO_API_URL=your-base-url \
+lego --dns excedo -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ EXCEDO_API_KEY = "API key"
+ EXCEDO_API_URL = "API base URL"
+ [Configuration.Additional]
+ EXCEDO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ EXCEDO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ EXCEDO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ EXCEDO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "none"
diff --git a/providers/dns/excedo/excedo_test.go b/providers/dns/excedo/excedo_test.go
new file mode 100644
index 000000000..f2350c035
--- /dev/null
+++ b/providers/dns/excedo/excedo_test.go
@@ -0,0 +1,210 @@
+package excedo
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIURL, EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIURL: "https://example.com",
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing the API key",
+ envVars: map[string]string{
+ EnvAPIURL: "https://example.com",
+ EnvAPIKey: "",
+ },
+ expected: "excedo: some credentials information are missing: EXCEDO_API_KEY",
+ },
+ {
+ desc: "missing the API URL",
+ envVars: map[string]string{
+ EnvAPIURL: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "excedo: some credentials information are missing: EXCEDO_API_URL",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "excedo: some credentials information are missing: EXCEDO_API_URL,EXCEDO_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiURL string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiURL: "https://example.com",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing the API key",
+ apiURL: "https://example.com",
+ expected: "excedo: credentials missing",
+ },
+ {
+ desc: "missing the API URL",
+ apiKey: "secret",
+ expected: "excedo: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "excedo: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIURL = test.apiURL
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIURL = server.URL
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ },
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromInternal("login.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret"),
+ ).
+ Route("POST /dns/addrecord/",
+ servermock.ResponseFromInternal("addrecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ servermock.CheckForm().Strict().
+ With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("domainName", "example.com").
+ With("name", "_acme-challenge").
+ With("ttl", "60").
+ With("type", "TXT"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromInternal("login.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret"),
+ ).
+ Route("POST /dns/deleterecord/",
+ servermock.ResponseFromInternal("deleterecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ ).
+ Build(t)
+
+ provider.records["abc"] = 19695822
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/excedo/internal/client.go b/providers/dns/excedo/internal/client.go
new file mode 100644
index 000000000..a5d8be88b
--- /dev/null
+++ b/providers/dns/excedo/internal/client.go
@@ -0,0 +1,205 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ querystring "github.com/google/go-querystring/query"
+)
+
+type responseChecker interface {
+ Check() error
+}
+
+// Client the Excedo API client.
+type Client struct {
+ apiKey string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+
+ token *ExpirableToken
+ muToken sync.Mutex
+}
+
+// NewClient creates a new Client.
+func NewClient(apiURL, apiKey string) (*Client, error) {
+ if apiURL == "" || apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, err := url.Parse(apiURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ apiKey: apiKey,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, record Record) (int64, error) {
+ payload, err := querystring.Values(record)
+ if err != nil {
+ return 0, err
+ }
+
+ endpoint := c.baseURL.JoinPath("/dns/addrecord/")
+
+ req, err := newFormRequest(ctx, http.MethodPost, endpoint, payload)
+ if err != nil {
+ return 0, err
+ }
+
+ result := new(AddRecordResponse)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return 0, err
+ }
+
+ return result.RecordID, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error {
+ endpoint := c.baseURL.JoinPath("/dns/deleterecord/")
+
+ data := map[string]string{
+ "domainname": dns01.UnFqdn(zone),
+ "recordid": recordID,
+ }
+
+ req, err := newMultipartRequest(ctx, http.MethodPost, endpoint, data)
+ if err != nil {
+ return err
+ }
+
+ result := new(BaseResponse)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) GetRecords(ctx context.Context, zone string) (map[string]Zone, error) {
+ endpoint := c.baseURL.JoinPath("/dns/getrecords/")
+
+ query := endpoint.Query()
+ query.Set("domainname", zone)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(GetRecordsResponse)
+
+ err = c.doAuthenticated(ctx, req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.DNS, nil
+}
+
+func (c *Client) do(req *http.Request, result responseChecker) error {
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ 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 result.Check()
+}
+
+func newMultipartRequest(ctx context.Context, method string, endpoint *url.URL, data map[string]string) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ writer := multipart.NewWriter(buf)
+
+ for k, v := range data {
+ err := writer.WriteField(k, v)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ err := writer.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ body := bytes.NewReader(buf.Bytes())
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ return req, nil
+}
+
+func newFormRequest(ctx context.Context, method string, endpoint *url.URL, form url.Values) (*http.Request, error) {
+ var body io.Reader
+
+ if len(form) > 0 {
+ body = bytes.NewReader([]byte(form.Encode()))
+ } else {
+ body = http.NoBody
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ if method == http.MethodPost {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+
+ return req, nil
+}
diff --git a/providers/dns/excedo/internal/client_test.go b/providers/dns/excedo/internal/client_test.go
new file mode 100644
index 000000000..f4fd52c00
--- /dev/null
+++ b/providers/dns/excedo/internal/client_test.go
@@ -0,0 +1,137 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ )
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/addrecord/",
+ servermock.ResponseFromFixture("addrecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ servermock.CheckForm().Strict().
+ With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("domainName", "example.com").
+ With("name", "_acme-challenge").
+ With("ttl", "60").
+ With("type", "TXT"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ record := Record{
+ DomainName: "example.com",
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: "60",
+ }
+
+ recordID, err := client.AddRecord(t.Context(), record)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, 19695822, recordID)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/addrecord/",
+ servermock.ResponseFromFixture("error.json"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ record := Record{
+ DomainName: "example.com",
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: "60",
+ }
+
+ _, err := client.AddRecord(t.Context(), record)
+ require.EqualError(t, err, "2003: Required parameter missing")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/deleterecord/",
+ servermock.ResponseFromFixture("deleterecord.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ err := client.DeleteRecord(t.Context(), "example.com", "19695822")
+ require.NoError(t, err)
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/getrecords/",
+ servermock.ResponseFromFixture("getrecords.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer session-token"),
+ servermock.CheckQueryParameter().Strict().
+ With("domainname", "example.com"),
+ ).
+ Build(t)
+
+ client.token = &ExpirableToken{
+ Token: "session-token",
+ Expires: time.Now().Add(6 * time.Hour),
+ }
+
+ zones, err := client.GetRecords(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := map[string]Zone{
+ "example.com": {
+ DNSType: "type",
+ Records: []Record{{
+ RecordID: "1234",
+ Name: "_acme-challenge.example.com",
+ Type: "TXT",
+ Content: "txt-value",
+ TTL: "60",
+ }},
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
diff --git a/providers/dns/excedo/internal/fixtures/addrecord.json b/providers/dns/excedo/internal/fixtures/addrecord.json
new file mode 100644
index 000000000..f1f7bf958
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/addrecord.json
@@ -0,0 +1,15 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "recordid": 19695822,
+ "session": {
+ "accID": "1234",
+ "usrID": "1234",
+ "status": "active",
+ "expire": {
+ "date": "2026-03-10 19:03:18",
+ "seconds": 5678
+ }
+ },
+ "runtime": 0.2852
+}
diff --git a/providers/dns/excedo/internal/fixtures/deleterecord.json b/providers/dns/excedo/internal/fixtures/deleterecord.json
new file mode 100644
index 000000000..5c2431b1c
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/deleterecord.json
@@ -0,0 +1,14 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "session": {
+ "accID": "1234",
+ "usrID": "1234",
+ "status": "active",
+ "expire": {
+ "date": "2026-03-10 19:03:18",
+ "seconds": 5678
+ }
+ },
+ "runtime": 0.2852
+}
diff --git a/providers/dns/excedo/internal/fixtures/error.json b/providers/dns/excedo/internal/fixtures/error.json
new file mode 100644
index 000000000..5a24ec247
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/error.json
@@ -0,0 +1,18 @@
+{
+ "code": 2003,
+ "desc": "Required parameter missing",
+ "missing": [
+ "domainname",
+ "recordid"
+ ],
+ "session": {
+ "accID": "1234",
+ "usrID": "1234",
+ "status": "active",
+ "expire": {
+ "date": "2026-03-10 19:03:18",
+ "seconds": 5485
+ }
+ },
+ "runtime": 0.0534
+}
diff --git a/providers/dns/excedo/internal/fixtures/getrecords.json b/providers/dns/excedo/internal/fixtures/getrecords.json
new file mode 100644
index 000000000..215a8abb2
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/getrecords.json
@@ -0,0 +1,23 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "dns": {
+ "example.com": {
+ "dnstype": "type",
+ "recordusage": {
+ "used": 74
+ },
+ "records": [
+ {
+ "recordid": "1234",
+ "name": "_acme-challenge.example.com",
+ "type": "TXT",
+ "content": "txt-value",
+ "ttl": "60",
+ "prio": null,
+ "change_date": null
+ }
+ ]
+ }
+ }
+}
diff --git a/providers/dns/excedo/internal/fixtures/login.json b/providers/dns/excedo/internal/fixtures/login.json
new file mode 100644
index 000000000..2defb9843
--- /dev/null
+++ b/providers/dns/excedo/internal/fixtures/login.json
@@ -0,0 +1,7 @@
+{
+ "code": 1000,
+ "desc": "Command completed successfully",
+ "parameters": {
+ "token": "session-token"
+ }
+}
diff --git a/providers/dns/excedo/internal/identity.go b/providers/dns/excedo/internal/identity.go
new file mode 100644
index 000000000..5c9ca119d
--- /dev/null
+++ b/providers/dns/excedo/internal/identity.go
@@ -0,0 +1,75 @@
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+type ExpirableToken struct {
+ Token string
+ Expires time.Time
+}
+
+func (t *ExpirableToken) IsExpired() bool {
+ return time.Now().After(t.Expires)
+}
+
+func (c *Client) Login(ctx context.Context) (string, error) {
+ endpoint := c.baseURL.JoinPath("/authenticate/login/")
+
+ req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return "", err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ result := new(LoginResponse)
+
+ err = c.do(req, result)
+ if err != nil {
+ return "", err
+ }
+
+ if result.Code != 1000 && result.Code != 1300 {
+ return "", fmt.Errorf("%d: %s", result.Code, result.Description)
+ }
+
+ return result.Parameters.Token, nil
+}
+
+func (c *Client) authenticate(ctx context.Context) (string, error) {
+ c.muToken.Lock()
+ defer c.muToken.Unlock()
+
+ if c.token == nil || c.token.IsExpired() {
+ token, err := c.Login(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ c.token = &ExpirableToken{
+ Token: token,
+ Expires: time.Now().Add(2*time.Hour - time.Minute),
+ }
+
+ return token, nil
+ }
+
+ return c.token.Token, nil
+}
+
+func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result responseChecker) error {
+ token, err := c.authenticate(ctx)
+ if err != nil {
+ return err
+ }
+
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+
+ return c.do(req, result)
+}
diff --git a/providers/dns/excedo/internal/identity_test.go b/providers/dns/excedo/internal/identity_test.go
new file mode 100644
index 000000000..86b7eb9d8
--- /dev/null
+++ b/providers/dns/excedo/internal/identity_test.go
@@ -0,0 +1,35 @@
+package internal
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClient_Login(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromFixture("login.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret"),
+ ).
+ Build(t)
+
+ token, err := client.Login(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, "session-token", token)
+}
+
+func TestClient_Login_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /authenticate/login/",
+ servermock.ResponseFromFixture("error.json"),
+ ).
+ Build(t)
+
+ _, err := client.Login(t.Context())
+ require.EqualError(t, err, "2003: Required parameter missing")
+}
diff --git a/providers/dns/excedo/internal/types.go b/providers/dns/excedo/internal/types.go
new file mode 100644
index 000000000..eb6ce8462
--- /dev/null
+++ b/providers/dns/excedo/internal/types.go
@@ -0,0 +1,65 @@
+package internal
+
+import "fmt"
+
+type BaseResponse struct {
+ Code int `json:"code"`
+ Description string `json:"desc"`
+}
+
+func (r BaseResponse) Check() error {
+ // Response codes:
+ // - 1000: Command completed successfully
+ // - 1300: Command completed successfully; no messages
+ // - 2001: Command syntax error
+ // - 2002: Command use error
+ // - 2003: Required parameter missing
+ // - 2004: Parameter value range error
+ // - 2104: Billing failure
+ // - 2200: Authentication error
+ // - 2201: Authorization error
+ // - 2303: Object does not exist
+ // - 2304: Object status prohibits operation
+ // - 2309: Object duplicate found
+ // - 2400: Command failed
+ // - 2500: Command failed; server closing connection
+ if r.Code != 1000 && r.Code != 1300 {
+ return fmt.Errorf("%d: %s", r.Code, r.Description)
+ }
+
+ return nil
+}
+
+type GetRecordsResponse struct {
+ BaseResponse
+
+ DNS map[string]Zone `json:"dns"`
+}
+
+type Zone struct {
+ DNSType string `json:"dnstype"`
+ Records []Record `json:"records"`
+}
+
+type Record struct {
+ DomainName string `json:"domainName,omitempty" url:"domainName,omitempty"`
+ RecordID string `json:"recordid,omitempty" url:"recordid,omitempty"`
+ Name string `json:"name,omitempty" url:"name,omitempty"`
+ Type string `json:"type,omitempty" url:"type,omitempty"`
+ Content string `json:"content,omitempty" url:"content,omitempty"`
+ TTL string `json:"ttl,omitempty" url:"ttl,omitempty"`
+}
+
+type AddRecordResponse struct {
+ BaseResponse
+
+ RecordID int64 `json:"recordid"`
+}
+
+type LoginResponse struct {
+ BaseResponse
+
+ Parameters struct {
+ Token string `json:"token"`
+ } `json:"parameters"`
+}
diff --git a/providers/dns/exec/exec.toml b/providers/dns/exec/exec.toml
index b5a68e36a..2f9c77c67 100644
--- a/providers/dns/exec/exec.toml
+++ b/providers/dns/exec/exec.toml
@@ -6,7 +6,7 @@ Since = "v0.5.0"
Example = '''
EXEC_PATH=/the/path/to/myscript.sh \
-lego --email you@example.com --dns exec -d '*.example.com' -d example.com run
+lego --dns exec -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -21,11 +21,11 @@ Additional = '''
## Additional Configuration
-| Environment Variable Name | Description |
-|----------------------------|-------------------------------------------|
-| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check. |
-| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation. |
-| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests. |
+| Environment Variable Name | Description |
+|----------------------------|--------------------------------------------------------------------|
+| `EXEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3). |
+| `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60). |
+| `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60). |
## Description
@@ -39,7 +39,7 @@ For example, requesting a certificate for the domain 'my.example.org' can be ach
```bash
EXEC_PATH=./update-dns.sh \
-lego --email you@example.com --dns exec --d my.example.org run
+lego --dns exec --d my.example.org run
```
It will then call the program './update-dns.sh' with like this:
@@ -59,7 +59,7 @@ If you want to use the raw domain, token, and keyAuth values with your program,
```bash
EXEC_MODE=RAW \
EXEC_PATH=./update-dns.sh \
-lego --email you@example.com --dns exec -d my.example.org run
+lego --dns exec -d my.example.org run
```
It will then call the program `./update-dns.sh` like this:
diff --git a/providers/dns/exec/exec_test.go b/providers/dns/exec/exec_test.go
index 3a2edbbf4..c1b6da55e 100644
--- a/providers/dns/exec/exec_test.go
+++ b/providers/dns/exec/exec_test.go
@@ -14,6 +14,7 @@ import (
func TestDNSProvider_Present(t *testing.T) {
backupLogger := log.Logger
+
defer func() {
log.Logger = backupLogger
}()
@@ -62,6 +63,7 @@ func TestDNSProvider_Present(t *testing.T) {
}
var message string
+
logRecorder.On("Println", mock.Anything).Run(func(args mock.Arguments) {
message = args.String(0)
fmt.Fprintln(os.Stdout, "XXX", message)
@@ -87,6 +89,7 @@ func TestDNSProvider_Present(t *testing.T) {
func TestDNSProvider_CleanUp(t *testing.T) {
backupLogger := log.Logger
+
defer func() {
log.Logger = backupLogger
}()
@@ -135,6 +138,7 @@ func TestDNSProvider_CleanUp(t *testing.T) {
}
var message string
+
logRecorder.On("Println", mock.Anything).Run(func(args mock.Arguments) {
message = args.String(0)
fmt.Fprintln(os.Stdout, "XXX", message)
diff --git a/providers/dns/exec/log_mock_test.go b/providers/dns/exec/log_mock_test.go
index 47935cc55..65753dcf8 100644
--- a/providers/dns/exec/log_mock_test.go
+++ b/providers/dns/exec/log_mock_test.go
@@ -6,26 +6,26 @@ type LogRecorder struct {
mock.Mock
}
-func (*LogRecorder) Fatal(args ...interface{}) {
+func (*LogRecorder) Fatal(args ...any) {
panic("implement me")
}
-func (*LogRecorder) Fatalln(args ...interface{}) {
+func (*LogRecorder) Fatalln(args ...any) {
panic("implement me")
}
-func (*LogRecorder) Fatalf(format string, args ...interface{}) {
+func (*LogRecorder) Fatalf(format string, args ...any) {
panic("implement me")
}
-func (*LogRecorder) Print(args ...interface{}) {
+func (*LogRecorder) Print(args ...any) {
panic("implement me")
}
-func (l *LogRecorder) Println(args ...interface{}) {
+func (l *LogRecorder) Println(args ...any) {
l.Called(args...)
}
-func (*LogRecorder) Printf(format string, args ...interface{}) {
+func (*LogRecorder) Printf(format string, args ...any) {
panic("implement me")
}
diff --git a/providers/dns/exoscale/exoscale.go b/providers/dns/exoscale/exoscale.go
index 4038ee4d4..05fcb6a6f 100644
--- a/providers/dns/exoscale/exoscale.go
+++ b/providers/dns/exoscale/exoscale.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strconv"
"time"
egoscale "github.com/exoscale/egoscale/v3"
@@ -13,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
)
@@ -88,7 +90,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client, err := egoscale.NewClient(
credentials.NewStaticCredentials(config.APIKey, config.APISecret),
egoscale.ClientOptWithEndpoint(egoscale.Endpoint(config.Endpoint)),
- egoscale.ClientOptWithHTTPClient(&http.Client{Timeout: config.HTTPTimeout}),
+ egoscale.ClientOptWithHTTPClient(clientdebug.Wrap(&http.Client{Timeout: config.HTTPTimeout})),
egoscale.ClientOptWithUserAgent(useragent.Get()),
)
if err != nil {
@@ -104,6 +106,7 @@ 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)
zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN)
@@ -111,10 +114,11 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("exoscale: %w", err)
}
- zone, err := d.findExistingZone(zoneName)
+ zone, err := d.findExistingZone(ctx, zoneName)
if err != nil {
return fmt.Errorf("exoscale: %w", err)
}
+
if zone == nil {
return fmt.Errorf("exoscale: zone %q not found", zoneName)
}
@@ -142,6 +146,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// CleanUp removes the record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
ctx := context.Background()
+
info := dns01.GetChallengeInfo(domain, keyAuth)
zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN)
@@ -149,15 +154,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("exoscale: %w", err)
}
- zone, err := d.findExistingZone(zoneName)
+ zone, err := d.findExistingZone(ctx, zoneName)
if err != nil {
return fmt.Errorf("exoscale: %w", err)
}
+
if zone == nil {
return fmt.Errorf("exoscale: zone %q not found", zoneName)
}
- recordID, err := d.findExistingRecordID(zone.ID, recordName, info.Value)
+ recordID, err := d.findExistingRecordID(ctx, zone.ID, recordName, info.Value)
if err != nil {
return err
}
@@ -187,9 +193,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// findExistingZone Query Exoscale to find an existing zone for this name.
// Returns nil result if no zone could be found.
-func (d *DNSProvider) findExistingZone(zoneName string) (*egoscale.DNSDomain, error) {
- ctx := context.Background()
-
+func (d *DNSProvider) findExistingZone(ctx context.Context, zoneName string) (*egoscale.DNSDomain, error) {
zones, err := d.client.ListDNSDomains(ctx)
if err != nil {
return nil, fmt.Errorf("error while retrieving DNS zones: %w", err)
@@ -206,16 +210,15 @@ func (d *DNSProvider) findExistingZone(zoneName string) (*egoscale.DNSDomain, er
// findExistingRecordID Query Exoscale to find an existing record for this name.
// Returns empty result if no record could be found.
-func (d *DNSProvider) findExistingRecordID(zoneID egoscale.UUID, recordName string, value string) (egoscale.UUID, error) {
- ctx := context.Background()
-
+func (d *DNSProvider) findExistingRecordID(ctx context.Context, zoneID egoscale.UUID, recordName, value string) (egoscale.UUID, error) {
records, err := d.client.ListDNSDomainRecords(ctx, zoneID)
if err != nil {
return "", fmt.Errorf("error while retrieving DNS records: %w", err)
}
for _, record := range records.DNSDomainRecords {
- if record.Name == recordName && record.Type == egoscale.DNSDomainRecordTypeTXT && record.Content == value {
+ if record.Name == recordName && record.Type == egoscale.DNSDomainRecordTypeTXT &&
+ (record.Content == value || record.Content == strconv.Quote(value)) {
return record.ID, nil
}
}
diff --git a/providers/dns/exoscale/exoscale.toml b/providers/dns/exoscale/exoscale.toml
index 28a756413..bcc912b07 100644
--- a/providers/dns/exoscale/exoscale.toml
+++ b/providers/dns/exoscale/exoscale.toml
@@ -7,7 +7,7 @@ Since = "v0.4.0"
Example = '''
EXOSCALE_API_KEY=abcdefghijklmnopqrstuvwx \
EXOSCALE_API_SECRET=xxxxxxx \
-lego --email you@example.com --dns exoscale -d '*.example.com' -d example.com run
+lego --dns exoscale -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,10 +16,10 @@ lego --email you@example.com --dns exoscale -d '*.example.com' -d example.com ru
EXOSCALE_API_SECRET = "API secret"
[Configuration.Additional]
EXOSCALE_ENDPOINT = "API endpoint URL"
- EXOSCALE_POLLING_INTERVAL = "Time between DNS propagation check"
- EXOSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- EXOSCALE_TTL = "The TTL of the TXT record used for the DNS challenge"
- EXOSCALE_HTTP_TIMEOUT = "API request timeout"
+ EXOSCALE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ EXOSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ EXOSCALE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ EXOSCALE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
[Links]
API = "https://openapi-v2.exoscale.com/#endpoint-dns"
diff --git a/providers/dns/exoscale/exoscale_test.go b/providers/dns/exoscale/exoscale_test.go
index fa58216a5..e9f6be602 100644
--- a/providers/dns/exoscale/exoscale_test.go
+++ b/providers/dns/exoscale/exoscale_test.go
@@ -58,6 +58,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -178,6 +179,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -195,6 +197,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/f5xc/f5xc.go b/providers/dns/f5xc/f5xc.go
new file mode 100644
index 000000000..76a6e0262
--- /dev/null
+++ b/providers/dns/f5xc/f5xc.go
@@ -0,0 +1,201 @@
+// Package f5xc implements a DNS provider for solving the DNS-01 challenge using F5 XC.
+package f5xc
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/cenkalti/backoff/v5"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/platform/wait"
+ "github.com/go-acme/lego/v4/providers/dns/f5xc/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "F5XC_"
+
+ EnvToken = envNamespace + "API_TOKEN"
+ EnvTenantName = envNamespace + "TENANT_NAME"
+ EnvServer = envNamespace + "SERVER"
+ EnvGroupName = envNamespace + "GROUP_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 {
+ APIToken string
+ TenantName string
+ Server string
+ GroupName string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for F5 XC.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvToken, EnvTenantName, EnvGroupName)
+ if err != nil {
+ return nil, fmt.Errorf("f5xc: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIToken = values[EnvToken]
+ config.TenantName = values[EnvTenantName]
+ config.GroupName = values[EnvGroupName]
+ config.Server = env.GetOrFile(EnvServer)
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for F5 XC.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("f5xc: the configuration of the DNS provider is nil")
+ }
+
+ if config.GroupName == "" {
+ return nil, errors.New("f5xc: missing group name")
+ }
+
+ client, err := internal.NewClient(config.APIToken, config.TenantName, config.Server)
+ if err != nil {
+ return nil, fmt.Errorf("f5xc: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("f5xc: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("f5xc: %w", err)
+ }
+
+ existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT")
+ if err != nil {
+ return fmt.Errorf("f5xc: get RR Set: %w", err)
+ }
+
+ // New RRSet.
+ if existingRRSet == nil || existingRRSet.RRSet.TXTRecord == nil {
+ rrSet := internal.RRSet{
+ Description: "lego",
+ TTL: d.config.TTL,
+ TXTRecord: &internal.TXTRecord{
+ Name: subDomain,
+ Values: []string{info.Value},
+ },
+ }
+
+ return d.waitFor(ctx, func() error {
+ _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, rrSet)
+ if err != nil {
+ return fmt.Errorf("create RR set: %w", err)
+ }
+
+ return nil
+ })
+ }
+
+ // Update RRSet.
+ existingRRSet.RRSet.TXTRecord.Values = append(existingRRSet.RRSet.TXTRecord.Values, info.Value)
+
+ return d.waitFor(ctx, func() error {
+ _, err = d.client.ReplaceRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT", existingRRSet.RRSet)
+ if err != nil {
+ return fmt.Errorf("replace RR set: %w", err)
+ }
+
+ return nil
+ })
+}
+
+func (d *DNSProvider) waitFor(ctx context.Context, operation func() error) error {
+ err := wait.Retry(ctx, operation,
+ backoff.WithBackOff(backoff.NewConstantBackOff(2*time.Second)),
+ backoff.WithMaxElapsedTime(60*time.Second),
+ )
+ if err != nil {
+ return fmt.Errorf("f5xc: %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("f5xc: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("f5xc: %w", err)
+ }
+
+ _, err = d.client.DeleteRRSet(context.Background(), dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT")
+ if err != nil {
+ return fmt.Errorf("f5xc: delete RR set: %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/dns/f5xc/f5xc.toml b/providers/dns/f5xc/f5xc.toml
new file mode 100644
index 000000000..6be604ddd
--- /dev/null
+++ b/providers/dns/f5xc/f5xc.toml
@@ -0,0 +1,28 @@
+Name = "F5 XC"
+Description = ''''''
+URL = "https://www.f5.com/products/distributed-cloud-services"
+Code = "f5xc"
+Since = "v4.23.0"
+
+Example = '''
+F5XC_API_TOKEN="xxx" \
+F5XC_TENANT_NAME="yyy" \
+F5XC_GROUP_NAME="zzz" \
+lego --dns f5xc -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ F5XC_API_TOKEN = "API token"
+ F5XC_TENANT_NAME = "XC Tenant shortname"
+ F5XC_GROUP_NAME = "Group name"
+ [Configuration.Additional]
+ F5XC_SERVER = "Server domain (Default: console.ves.volterra.io)"
+ F5XC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ F5XC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ F5XC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ F5XC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset"
+ Documentation = "https://my.f5.com/manage/s/article/K000147937"
diff --git a/providers/dns/f5xc/f5xc_test.go b/providers/dns/f5xc/f5xc_test.go
new file mode 100644
index 000000000..890a4cf09
--- /dev/null
+++ b/providers/dns/f5xc/f5xc_test.go
@@ -0,0 +1,174 @@
+package f5xc
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvToken,
+ EnvTenantName,
+ EnvServer,
+ EnvGroupName,
+).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ EnvTenantName: "shortname",
+ EnvGroupName: "group",
+ },
+ },
+ {
+ desc: "missing API token",
+ envVars: map[string]string{
+ EnvToken: "",
+ EnvTenantName: "shortname",
+ EnvGroupName: "group",
+ },
+ expected: "f5xc: some credentials information are missing: F5XC_API_TOKEN",
+ },
+ {
+ desc: "missing tenant name",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ EnvTenantName: "",
+ EnvGroupName: "group",
+ },
+ expected: "f5xc: some credentials information are missing: F5XC_TENANT_NAME",
+ },
+ {
+ desc: "missing group name",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ EnvTenantName: "shortname",
+ EnvGroupName: "",
+ },
+ expected: "f5xc: some credentials information are missing: F5XC_GROUP_NAME",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "f5xc: some credentials information are missing: F5XC_API_TOKEN,F5XC_TENANT_NAME,F5XC_GROUP_NAME",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ tenantName string
+ groupName string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "secret",
+ tenantName: "shortname",
+ groupName: "group",
+ },
+ {
+ desc: "missing API token",
+ tenantName: "shortname",
+ groupName: "group",
+ expected: "f5xc: credentials missing",
+ },
+ {
+ desc: "missing tenant name",
+ apiToken: "secret",
+ groupName: "group",
+ expected: "f5xc: missing tenant name",
+ },
+ {
+ desc: "missing group name",
+ apiToken: "secret",
+ tenantName: "shortname",
+ expected: "f5xc: missing group name",
+ },
+ {
+ desc: "missing credentials",
+ expected: "f5xc: missing group name",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIToken = test.apiToken
+ config.TenantName = test.tenantName
+ config.GroupName = test.groupName
+
+ 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/dns/f5xc/internal/client.go b/providers/dns/f5xc/internal/client.go
new file mode 100644
index 000000000..7beab0d03
--- /dev/null
+++ b/providers/dns/f5xc/internal/client.go
@@ -0,0 +1,224 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultServer = "console.ves.volterra.io"
+
+const authorizationHeader = "Authorization"
+
+// Client the F5 XC API client.
+type Client struct {
+ apiToken string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiToken, tenantName, server string) (*Client, error) {
+ if apiToken == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, err := createBaseURL(tenantName, server)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ apiToken: apiToken,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// CreateRRSet creates RRSet.
+// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Create
+func (c *Client) CreateRRSet(ctx context.Context, dnsZoneName, groupName string, rrSet RRSet) (*APIRRSet, error) {
+ endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName)
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, APIRRSet{
+ DNSZoneName: dnsZoneName,
+ GroupName: groupName,
+ RRSet: rrSet,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ result := &APIRRSet{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// GetRRSet gets RRSets.
+// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Get
+func (c *Client) GetRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string) (*APIRRSet, error) {
+ endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType)
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &APIRRSet{}
+
+ err = c.do(req, result)
+ if err != nil {
+ usce := &APIError{}
+ if errors.As(err, &usce) && usce.StatusCode == http.StatusNotFound {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteRRSet deletes RRSet.
+// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Delete
+func (c *Client) DeleteRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string) (*APIRRSet, error) {
+ endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &APIRRSet{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// ReplaceRRSet replaces RRSet.
+// https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Replace
+func (c *Client) ReplaceRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string, rrSet RRSet) (*APIRRSet, error) {
+ endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType)
+
+ req, err := newJSONRequest(ctx, http.MethodPut, endpoint, APIRRSet{
+ DNSZoneName: dnsZoneName,
+ GroupName: groupName,
+ RRSet: rrSet,
+ Type: recordType,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ result := &APIRRSet{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Set(authorizationHeader, "APIToken "+c.apiToken)
+
+ 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)
+
+ apiErr := APIError{StatusCode: resp.StatusCode}
+
+ err := json.Unmarshal(raw, &apiErr)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &apiErr
+}
+
+func createBaseURL(tenant, server string) (*url.URL, error) {
+ if tenant == "" {
+ return nil, errors.New("missing tenant name")
+ }
+
+ if server == "" {
+ server = defaultServer
+ }
+
+ baseURL, err := url.Parse(fmt.Sprintf("https://%s.%s", tenant, server))
+ if err != nil {
+ return nil, fmt.Errorf("parse base URL: %w", err)
+ }
+
+ return baseURL, nil
+}
diff --git a/providers/dns/f5xc/internal/client_test.go b/providers/dns/f5xc/internal/client_test.go
new file mode 100644
index 000000000..bb188ef3f
--- /dev/null
+++ b/providers/dns/f5xc/internal/client_test.go
@@ -0,0 +1,291 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret", "shortname", "")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("APIToken secret"))
+}
+
+func TestClient_CreateRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA",
+ servermock.ResponseFromFixture("create.json"),
+ servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)).
+ Build(t)
+
+ rrSet := RRSet{
+ Description: "lego",
+ TTL: 60,
+ TXTRecord: &TXTRecord{
+ Name: "wwww",
+ Values: []string{"txt"},
+ },
+ }
+
+ result, err := client.CreateRRSet(t.Context(), "example.com", "groupA", rrSet)
+ require.NoError(t, err)
+
+ expected := &APIRRSet{
+ DNSZoneName: "string",
+ GroupName: "string",
+ RRSet: RRSet{
+ Description: "string",
+ TXTRecord: &TXTRecord{
+ Name: "string",
+ Values: []string{"string"},
+ },
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_CreateRRSet_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ rrSet := RRSet{
+ Description: "lego",
+ TTL: 60,
+ TXTRecord: &TXTRecord{
+ Name: "wwww",
+ Values: []string{"txt"},
+ },
+ }
+
+ _, err := client.CreateRRSet(t.Context(), "example.com", "groupA", rrSet)
+ require.Error(t, err)
+}
+
+func TestClient_GetRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT",
+ servermock.ResponseFromFixture("get.json")).
+ Build(t)
+
+ result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT")
+ require.NoError(t, err)
+
+ expected := &APIRRSet{
+ DNSZoneName: "string",
+ GroupName: "string",
+ Namespace: "string",
+ RecordName: "string",
+ Type: "string",
+ RRSet: RRSet{
+ Description: "string",
+ TXTRecord: &TXTRecord{
+ Name: "string",
+ Values: []string{"string"},
+ },
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_GetRRSet_not_found(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT",
+ servermock.ResponseFromFixture("error_404.json").WithStatusCode(http.StatusNotFound)).
+ Build(t)
+
+ result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT")
+ require.NoError(t, err)
+
+ assert.Nil(t, result)
+}
+
+func TestClient_GetRRSet_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT")
+ require.Error(t, err)
+}
+
+func TestClient_DeleteRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT",
+ servermock.ResponseFromFixture("get.json")).
+ Build(t)
+
+ result, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT")
+ require.NoError(t, err)
+
+ expected := &APIRRSet{
+ DNSZoneName: "string",
+ GroupName: "string",
+ Namespace: "string",
+ RecordName: "string",
+ Type: "string",
+ RRSet: RRSet{
+ Description: "string",
+ TXTRecord: &TXTRecord{
+ Name: "string",
+ Values: []string{"string"},
+ },
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_DeleteRRSet_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT")
+ require.Error(t, err)
+}
+
+func TestClient_ReplaceRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT",
+ servermock.ResponseFromFixture("get.json"),
+ servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","type":"TXT","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)).
+ Build(t)
+
+ rrSet := RRSet{
+ Description: "lego",
+ TTL: 60,
+ TXTRecord: &TXTRecord{
+ Name: "wwww",
+ Values: []string{"txt"},
+ },
+ }
+
+ result, err := client.ReplaceRRSet(t.Context(), "example.com", "groupA", "www", "TXT", rrSet)
+ require.NoError(t, err)
+
+ expected := &APIRRSet{
+ DNSZoneName: "string",
+ GroupName: "string",
+ Namespace: "string",
+ RecordName: "string",
+ Type: "string",
+ RRSet: RRSet{
+ Description: "string",
+ TXTRecord: &TXTRecord{
+ Name: "string",
+ Values: []string{"string"},
+ },
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_ReplaceRRSet_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ rrSet := RRSet{
+ Description: "lego",
+ TTL: 60,
+ TXTRecord: &TXTRecord{
+ Name: "wwww",
+ Values: []string{"txt"},
+ },
+ }
+
+ _, err := client.ReplaceRRSet(t.Context(), "example.com", "groupA", "www", "TXT", rrSet)
+ require.Error(t, err)
+}
+
+func Test_createBaseURL(t *testing.T) {
+ testCases := []struct {
+ desc string
+ tenant string
+ server string
+ expected string
+ }{
+ {
+ desc: "only tenant",
+ tenant: "foo",
+ expected: "https://foo.console.ves.volterra.io",
+ },
+ {
+ desc: "custom server",
+ tenant: "foo",
+ server: "example.com",
+ expected: "https://foo.example.com",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ baseURL, err := createBaseURL(test.tenant, test.server)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expected, baseURL.String())
+ })
+ }
+}
+
+func Test_createBaseURL_error(t *testing.T) {
+ testCases := []struct {
+ desc string
+ tenant string
+ server string
+ expected string
+ }{
+ {
+ desc: "no tenant",
+ tenant: "",
+ expected: "missing tenant name",
+ },
+ {
+ desc: "invalid tenant",
+ tenant: "%31",
+ expected: `parse base URL: parse "https://%31.console.ves.volterra.io": invalid URL escape "%31"`,
+ },
+ {
+ desc: "invalid host",
+ tenant: "foo",
+ server: "192.168.0.%31",
+ expected: `parse base URL: parse "https://foo.192.168.0.%31": invalid URL escape "%31"`,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := createBaseURL(test.tenant, test.server)
+ require.EqualError(t, err, test.expected)
+ })
+ }
+}
diff --git a/providers/dns/f5xc/internal/fixtures/create.json b/providers/dns/f5xc/internal/fixtures/create.json
new file mode 100644
index 000000000..8c852304d
--- /dev/null
+++ b/providers/dns/f5xc/internal/fixtures/create.json
@@ -0,0 +1,204 @@
+{
+ "dns_zone_name": "string",
+ "group_name": "string",
+ "rrset": {
+ "a_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "aaaa_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "afsdb_record": {
+ "name": "string",
+ "values": [
+ {
+ "hostname": "string",
+ "subtype": "NONE"
+ }
+ ]
+ },
+ "alias_record": {
+ "value": "string"
+ },
+ "caa_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": 0,
+ "tag": "string",
+ "value": "string"
+ }
+ ]
+ },
+ "cds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "cert_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "RESERVEDALGORITHM",
+ "cert_key_tag": 0,
+ "cert_type": "INVALIDCERTTYPE",
+ "certificate": "string"
+ }
+ ]
+ },
+ "cname_record": {
+ "name": "string",
+ "value": "string"
+ },
+ "description": "string",
+ "ds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "eui48_record": {
+ "name": "string",
+ "value": "stringstringstrin"
+ },
+ "eui64_record": {
+ "name": "string",
+ "value": "stringstringstringstrin"
+ },
+ "lb_record": {
+ "name": "string",
+ "value": {
+ "name": "string",
+ "namespace": "string",
+ "tenant": "string"
+ }
+ },
+ "loc_record": {
+ "name": "string",
+ "values": [
+ {
+ "altitude": 0.1,
+ "horizontal_precision": 0.1,
+ "latitude_degree": 0,
+ "latitude_hemisphere": "N",
+ "latitude_minute": 0,
+ "latitude_second": 0.1,
+ "location_diameter": 0.1,
+ "longitude_degree": 0,
+ "longitude_hemisphere": "E",
+ "longitude_minute": 0,
+ "longitude_second": 0.1,
+ "vertical_precision": 0.1
+ }
+ ]
+ },
+ "mx_record": {
+ "name": "string",
+ "values": [
+ {
+ "domain": "string",
+ "priority": 0
+ }
+ ]
+ },
+ "naptr_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": "string",
+ "order": 0,
+ "preference": 0,
+ "regexp": "string",
+ "replacement": "string",
+ "service": "string"
+ }
+ ]
+ },
+ "ns_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "ptr_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "srv_record": {
+ "name": "string",
+ "values": [
+ {
+ "port": 0,
+ "priority": 0,
+ "target": "string",
+ "weight": 0
+ }
+ ]
+ },
+ "sshfp_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "UNSPECIFIEDALGORITHM",
+ "sha1_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstri"
+ },
+ "sha256_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri"
+ }
+ }
+ ]
+ },
+ "tlsa_record": {
+ "name": "string",
+ "values": [
+ {
+ "certificate_association_data": "string",
+ "certificate_usage": "CertificateAuthorityConstraint",
+ "matching_type": "NoHash",
+ "selector": "FullCertificate"
+ }
+ ]
+ },
+ "ttl": 0,
+ "txt_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ }
+ }
+}
diff --git a/providers/dns/f5xc/internal/fixtures/delete.json b/providers/dns/f5xc/internal/fixtures/delete.json
new file mode 100644
index 000000000..5c5143cae
--- /dev/null
+++ b/providers/dns/f5xc/internal/fixtures/delete.json
@@ -0,0 +1,207 @@
+{
+ "dns_zone_name": "string",
+ "group_name": "string",
+ "namespace": "string",
+ "record_name": "string",
+ "rrset": {
+ "a_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "aaaa_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "afsdb_record": {
+ "name": "string",
+ "values": [
+ {
+ "hostname": "string",
+ "subtype": "NONE"
+ }
+ ]
+ },
+ "alias_record": {
+ "value": "string"
+ },
+ "caa_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": 0,
+ "tag": "string",
+ "value": "string"
+ }
+ ]
+ },
+ "cds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "cert_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "RESERVEDALGORITHM",
+ "cert_key_tag": 0,
+ "cert_type": "INVALIDCERTTYPE",
+ "certificate": "string"
+ }
+ ]
+ },
+ "cname_record": {
+ "name": "string",
+ "value": "string"
+ },
+ "description": "string",
+ "ds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "eui48_record": {
+ "name": "string",
+ "value": "stringstringstrin"
+ },
+ "eui64_record": {
+ "name": "string",
+ "value": "stringstringstringstrin"
+ },
+ "lb_record": {
+ "name": "string",
+ "value": {
+ "name": "string",
+ "namespace": "string",
+ "tenant": "string"
+ }
+ },
+ "loc_record": {
+ "name": "string",
+ "values": [
+ {
+ "altitude": 0.1,
+ "horizontal_precision": 0.1,
+ "latitude_degree": 0,
+ "latitude_hemisphere": "N",
+ "latitude_minute": 0,
+ "latitude_second": 0.1,
+ "location_diameter": 0.1,
+ "longitude_degree": 0,
+ "longitude_hemisphere": "E",
+ "longitude_minute": 0,
+ "longitude_second": 0.1,
+ "vertical_precision": 0.1
+ }
+ ]
+ },
+ "mx_record": {
+ "name": "string",
+ "values": [
+ {
+ "domain": "string",
+ "priority": 0
+ }
+ ]
+ },
+ "naptr_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": "string",
+ "order": 0,
+ "preference": 0,
+ "regexp": "string",
+ "replacement": "string",
+ "service": "string"
+ }
+ ]
+ },
+ "ns_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "ptr_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "srv_record": {
+ "name": "string",
+ "values": [
+ {
+ "port": 0,
+ "priority": 0,
+ "target": "string",
+ "weight": 0
+ }
+ ]
+ },
+ "sshfp_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "UNSPECIFIEDALGORITHM",
+ "sha1_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstri"
+ },
+ "sha256_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri"
+ }
+ }
+ ]
+ },
+ "tlsa_record": {
+ "name": "string",
+ "values": [
+ {
+ "certificate_association_data": "string",
+ "certificate_usage": "CertificateAuthorityConstraint",
+ "matching_type": "NoHash",
+ "selector": "FullCertificate"
+ }
+ ]
+ },
+ "ttl": 0,
+ "txt_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ }
+ },
+ "type": "string"
+}
diff --git a/providers/dns/f5xc/internal/fixtures/error_404.json b/providers/dns/f5xc/internal/fixtures/error_404.json
new file mode 100644
index 000000000..4abd79dd4
--- /dev/null
+++ b/providers/dns/f5xc/internal/fixtures/error_404.json
@@ -0,0 +1,5 @@
+{
+ "code": 5,
+ "details": [],
+ "message": "the requested resource record was not found: (group,name,type) (acme-records,_acme-challenge,TXT)"
+}
diff --git a/providers/dns/f5xc/internal/fixtures/error_503.json b/providers/dns/f5xc/internal/fixtures/error_503.json
new file mode 100644
index 000000000..8d286a2a0
--- /dev/null
+++ b/providers/dns/f5xc/internal/fixtures/error_503.json
@@ -0,0 +1,5 @@
+{
+ "code": 14,
+ "details": [],
+ "message": "Previous DNS zone change is pending. Try again later"
+}
diff --git a/providers/dns/f5xc/internal/fixtures/get.json b/providers/dns/f5xc/internal/fixtures/get.json
new file mode 100644
index 000000000..5c5143cae
--- /dev/null
+++ b/providers/dns/f5xc/internal/fixtures/get.json
@@ -0,0 +1,207 @@
+{
+ "dns_zone_name": "string",
+ "group_name": "string",
+ "namespace": "string",
+ "record_name": "string",
+ "rrset": {
+ "a_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "aaaa_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "afsdb_record": {
+ "name": "string",
+ "values": [
+ {
+ "hostname": "string",
+ "subtype": "NONE"
+ }
+ ]
+ },
+ "alias_record": {
+ "value": "string"
+ },
+ "caa_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": 0,
+ "tag": "string",
+ "value": "string"
+ }
+ ]
+ },
+ "cds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "cert_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "RESERVEDALGORITHM",
+ "cert_key_tag": 0,
+ "cert_type": "INVALIDCERTTYPE",
+ "certificate": "string"
+ }
+ ]
+ },
+ "cname_record": {
+ "name": "string",
+ "value": "string"
+ },
+ "description": "string",
+ "ds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "eui48_record": {
+ "name": "string",
+ "value": "stringstringstrin"
+ },
+ "eui64_record": {
+ "name": "string",
+ "value": "stringstringstringstrin"
+ },
+ "lb_record": {
+ "name": "string",
+ "value": {
+ "name": "string",
+ "namespace": "string",
+ "tenant": "string"
+ }
+ },
+ "loc_record": {
+ "name": "string",
+ "values": [
+ {
+ "altitude": 0.1,
+ "horizontal_precision": 0.1,
+ "latitude_degree": 0,
+ "latitude_hemisphere": "N",
+ "latitude_minute": 0,
+ "latitude_second": 0.1,
+ "location_diameter": 0.1,
+ "longitude_degree": 0,
+ "longitude_hemisphere": "E",
+ "longitude_minute": 0,
+ "longitude_second": 0.1,
+ "vertical_precision": 0.1
+ }
+ ]
+ },
+ "mx_record": {
+ "name": "string",
+ "values": [
+ {
+ "domain": "string",
+ "priority": 0
+ }
+ ]
+ },
+ "naptr_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": "string",
+ "order": 0,
+ "preference": 0,
+ "regexp": "string",
+ "replacement": "string",
+ "service": "string"
+ }
+ ]
+ },
+ "ns_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "ptr_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "srv_record": {
+ "name": "string",
+ "values": [
+ {
+ "port": 0,
+ "priority": 0,
+ "target": "string",
+ "weight": 0
+ }
+ ]
+ },
+ "sshfp_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "UNSPECIFIEDALGORITHM",
+ "sha1_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstri"
+ },
+ "sha256_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri"
+ }
+ }
+ ]
+ },
+ "tlsa_record": {
+ "name": "string",
+ "values": [
+ {
+ "certificate_association_data": "string",
+ "certificate_usage": "CertificateAuthorityConstraint",
+ "matching_type": "NoHash",
+ "selector": "FullCertificate"
+ }
+ ]
+ },
+ "ttl": 0,
+ "txt_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ }
+ },
+ "type": "string"
+}
diff --git a/providers/dns/f5xc/internal/fixtures/replace.json b/providers/dns/f5xc/internal/fixtures/replace.json
new file mode 100644
index 000000000..e3e483df5
--- /dev/null
+++ b/providers/dns/f5xc/internal/fixtures/replace.json
@@ -0,0 +1,206 @@
+{
+ "dns_zone_name": "string",
+ "group_name": "string",
+ "record_name": "string",
+ "rrset": {
+ "a_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "aaaa_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "afsdb_record": {
+ "name": "string",
+ "values": [
+ {
+ "hostname": "string",
+ "subtype": "NONE"
+ }
+ ]
+ },
+ "alias_record": {
+ "value": "string"
+ },
+ "caa_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": 0,
+ "tag": "string",
+ "value": "string"
+ }
+ ]
+ },
+ "cds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "cert_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "RESERVEDALGORITHM",
+ "cert_key_tag": 0,
+ "cert_type": "INVALIDCERTTYPE",
+ "certificate": "string"
+ }
+ ]
+ },
+ "cname_record": {
+ "name": "string",
+ "value": "string"
+ },
+ "description": "string",
+ "ds_record": {
+ "name": "string",
+ "values": [
+ {
+ "ds_key_algorithm": "UNSPECIFIED",
+ "key_tag": 0,
+ "sha1_digest": {
+ "digest": "stringstringstringstringstringstringstri"
+ },
+ "sha256_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstri"
+ },
+ "sha384_digest": {
+ "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring"
+ }
+ }
+ ]
+ },
+ "eui48_record": {
+ "name": "string",
+ "value": "stringstringstrin"
+ },
+ "eui64_record": {
+ "name": "string",
+ "value": "stringstringstringstrin"
+ },
+ "lb_record": {
+ "name": "string",
+ "value": {
+ "name": "string",
+ "namespace": "string",
+ "tenant": "string"
+ }
+ },
+ "loc_record": {
+ "name": "string",
+ "values": [
+ {
+ "altitude": 0.1,
+ "horizontal_precision": 0.1,
+ "latitude_degree": 0,
+ "latitude_hemisphere": "N",
+ "latitude_minute": 0,
+ "latitude_second": 0.1,
+ "location_diameter": 0.1,
+ "longitude_degree": 0,
+ "longitude_hemisphere": "E",
+ "longitude_minute": 0,
+ "longitude_second": 0.1,
+ "vertical_precision": 0.1
+ }
+ ]
+ },
+ "mx_record": {
+ "name": "string",
+ "values": [
+ {
+ "domain": "string",
+ "priority": 0
+ }
+ ]
+ },
+ "naptr_record": {
+ "name": "string",
+ "values": [
+ {
+ "flags": "string",
+ "order": 0,
+ "preference": 0,
+ "regexp": "string",
+ "replacement": "string",
+ "service": "string"
+ }
+ ]
+ },
+ "ns_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "ptr_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ },
+ "srv_record": {
+ "name": "string",
+ "values": [
+ {
+ "port": 0,
+ "priority": 0,
+ "target": "string",
+ "weight": 0
+ }
+ ]
+ },
+ "sshfp_record": {
+ "name": "string",
+ "values": [
+ {
+ "algorithm": "UNSPECIFIEDALGORITHM",
+ "sha1_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstri"
+ },
+ "sha256_fingerprint": {
+ "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri"
+ }
+ }
+ ]
+ },
+ "tlsa_record": {
+ "name": "string",
+ "values": [
+ {
+ "certificate_association_data": "string",
+ "certificate_usage": "CertificateAuthorityConstraint",
+ "matching_type": "NoHash",
+ "selector": "FullCertificate"
+ }
+ ]
+ },
+ "ttl": 0,
+ "txt_record": {
+ "name": "string",
+ "values": [
+ "string"
+ ]
+ }
+ },
+ "type": "string"
+}
diff --git a/providers/dns/f5xc/internal/types.go b/providers/dns/f5xc/internal/types.go
new file mode 100644
index 000000000..346283fb7
--- /dev/null
+++ b/providers/dns/f5xc/internal/types.go
@@ -0,0 +1,48 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ StatusCode int `json:"-"`
+ Code int `json:"code"`
+ Details []string `json:"details"`
+ Message string `json:"message"`
+}
+
+func (a *APIError) Error() string {
+ var details string
+ if len(a.Details) > 0 {
+ details = " " + strings.Join(a.Details, ", ")
+ }
+
+ return fmt.Sprintf("code: %d, message: %s%s", a.Code, a.Message, details)
+}
+
+type APIRRSet struct {
+ DNSZoneName string `json:"dns_zone_name,omitempty"`
+ GroupName string `json:"group_name,omitempty"`
+ Namespace string `json:"namespace,omitempty"`
+ RecordName string `json:"record_name,omitempty"`
+ Type string `json:"type,omitempty"`
+ RRSet RRSet `json:"rrset"`
+}
+
+type RRSetRequest struct {
+ DNSZoneName string `json:"dns_zone_name,omitempty"`
+ GroupName string `json:"group_name,omitempty"`
+ RRSet RRSet `json:"rrset"`
+}
+
+type RRSet struct {
+ Description string `json:"description,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ TXTRecord *TXTRecord `json:"txt_record,omitempty"`
+}
+
+type TXTRecord struct {
+ Name string `json:"name,omitempty"`
+ Values []string `json:"values,omitempty"`
+}
diff --git a/providers/dns/freemyip/freemyip.go b/providers/dns/freemyip/freemyip.go
index 7613f2b8d..fb6202e25 100644
--- a/providers/dns/freemyip/freemyip.go
+++ b/providers/dns/freemyip/freemyip.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/nrdcg/freemyip"
)
@@ -88,6 +89,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
diff --git a/providers/dns/freemyip/freemyip.toml b/providers/dns/freemyip/freemyip.toml
index a71538ee3..adbf9e213 100644
--- a/providers/dns/freemyip/freemyip.toml
+++ b/providers/dns/freemyip/freemyip.toml
@@ -6,18 +6,18 @@ Since = "v4.5.0"
Example = '''
FREEMYIP_TOKEN=xxxxxx \
-lego --email you@example.com --dns freemyip -d '*.example.com' -d example.com run
+lego --dns freemyip -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
FREEMYIP_TOKEN = "Account token"
[Configuration.Additional]
- FREEMYIP_POLLING_INTERVAL = "Time between DNS propagation check"
- FREEMYIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- FREEMYIP_TTL = "The TTL of the TXT record used for the DNS challenge"
- FREEMYIP_HTTP_TIMEOUT = "API request timeout"
- FREEMYIP_SEQUENCE_INTERVAL = "Time between sequential requests"
+ FREEMYIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ FREEMYIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ FREEMYIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ FREEMYIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ FREEMYIP_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
[Links]
API = "https://freemyip.com/help"
diff --git a/providers/dns/freemyip/freemyip_test.go b/providers/dns/freemyip/freemyip_test.go
index dcf74dd6c..24d1b98f7 100644
--- a/providers/dns/freemyip/freemyip_test.go
+++ b/providers/dns/freemyip/freemyip_test.go
@@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -94,6 +95,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -107,6 +109,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/gandi/gandi.go b/providers/dns/gandi/gandi.go
index dd6622172..bb96a7d0f 100644
--- a/providers/dns/gandi/gandi.go
+++ b/providers/dns/gandi/gandi.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/gandi/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -109,6 +110,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
diff --git a/providers/dns/gandi/gandi.toml b/providers/dns/gandi/gandi.toml
index be5bc00d2..23d7de5db 100644
--- a/providers/dns/gandi/gandi.toml
+++ b/providers/dns/gandi/gandi.toml
@@ -6,17 +6,17 @@ Since = "v0.3.0"
Example = '''
GANDI_API_KEY=abcdefghijklmnopqrstuvwx \
-lego --email you@example.com --dns gandi -d '*.example.com' -d example.com run
+lego --dns gandi -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
GANDI_API_KEY = "API key"
[Configuration.Additional]
- GANDI_POLLING_INTERVAL = "Time between DNS propagation check"
- GANDI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- GANDI_TTL = "The TTL of the TXT record used for the DNS challenge"
- GANDI_HTTP_TIMEOUT = "API request timeout"
+ GANDI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)"
+ GANDI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2400)"
+ GANDI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ GANDI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
[Links]
API = "https://doc.rpc.gandi.net/index.html"
diff --git a/providers/dns/gandi/gandi_test.go b/providers/dns/gandi/gandi_test.go
index 36bc4ccd2..58c25d0db 100644
--- a/providers/dns/gandi/gandi_test.go
+++ b/providers/dns/gandi/gandi_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
@@ -38,6 +39,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -119,47 +121,52 @@ func TestDNSProvider(t *testing.T) {
cleanupDeleteZoneRequestMock: cleanupDeleteZoneResponseMock,
}
- fakeKeyAuth := "XXXX"
-
regexpDate := regexp.MustCompile(`\[ACME Challenge [^\]:]*:[^\]]*\]`)
- // start fake RPC server
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, "text/xml", r.Header.Get("Content-Type"), "invalid content type")
+ provider := servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.BaseURL = server.URL + "/"
+ config.HTTPClient = server.Client()
+ config.APIKey = "123412341234123412341234"
- req, errS := io.ReadAll(r.Body)
- require.NoError(t, errS)
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().WithContentType("text/xml"),
+ ).
+ Route("POST /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ require.Equal(t, "text/xml", req.Header.Get("Content-Type"), "invalid content type")
- req = regexpDate.ReplaceAllLiteral(req, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`))
- resp, ok := serverResponses[string(req)]
- require.Truef(t, ok, "Server response for request not found: %s", string(req))
+ body, errS := io.ReadAll(req.Body)
+ require.NoError(t, errS)
- _, errS = io.Copy(w, strings.NewReader(resp))
- require.NoError(t, errS)
- }))
- t.Cleanup(server.Close)
+ body = regexpDate.ReplaceAllLiteral(body, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`))
+ resp, ok := serverResponses[string(body)]
+ require.Truef(t, ok, "Server response for request not found: %s", string(body))
+
+ _, errS = io.Copy(rw, strings.NewReader(resp))
+ require.NoError(t, errS)
+ })).
+ Build(t)
+
+ fakeKeyAuth := "XXXX"
// define function to override findZoneByFqdn with
fakeFindZoneByFqdn := func(fqdn string) (string, error) {
return "example.com.", nil
}
- config := NewDefaultConfig()
- config.BaseURL = server.URL + "/"
- config.APIKey = "123412341234123412341234"
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
// override findZoneByFqdn function
savedFindZoneByFqdn := provider.findZoneByFqdn
+
t.Cleanup(func() {
provider.findZoneByFqdn = savedFindZoneByFqdn
})
+
provider.findZoneByFqdn = fakeFindZoneByFqdn
// run Present
- err = provider.Present("abc.def.example.com", "", fakeKeyAuth)
+ err := provider.Present("abc.def.example.com", "", fakeKeyAuth)
require.NoError(t, err)
// run CleanUp
diff --git a/providers/dns/gandi/internal/client.go b/providers/dns/gandi/internal/client.go
index 6dc09648c..6ca46d072 100644
--- a/providers/dns/gandi/internal/client.go
+++ b/providers/dns/gandi/internal/client.go
@@ -50,6 +50,7 @@ func (c *Client) GetZoneID(ctx context.Context, domain string) (int, error) {
}
var zoneID int
+
for _, member := range resp.StructMembers {
if member.Name == "zone_id" {
zoneID = member.ValueInt
@@ -59,6 +60,7 @@ func (c *Client) GetZoneID(ctx context.Context, domain string) (int, error) {
if zoneID == 0 {
return 0, fmt.Errorf("could not find zone_id for %s", domain)
}
+
return zoneID, nil
}
@@ -88,6 +90,7 @@ func (c *Client) CloneZone(ctx context.Context, zoneID int, name string) (int, e
}
var newZoneID int
+
for _, member := range resp.StructMembers {
if member.Name == "id" {
newZoneID = member.ValueInt
@@ -97,6 +100,7 @@ func (c *Client) CloneZone(ctx context.Context, zoneID int, name string) (int, e
if newZoneID == 0 {
return 0, errors.New("could not determine cloned zone_id")
}
+
return newZoneID, nil
}
@@ -119,6 +123,7 @@ func (c *Client) NewZoneVersion(ctx context.Context, zoneID int) (int, error) {
if resp.Value == 0 {
return 0, errors.New("could not create new zone version")
}
+
return resp.Value, nil
}
@@ -174,6 +179,7 @@ func (c *Client) SetZoneVersion(ctx context.Context, zoneID, version int) error
if !resp.Value {
return errors.New("could not set zone version")
}
+
return nil
}
@@ -195,6 +201,7 @@ func (c *Client) SetZone(ctx context.Context, domain string, zoneID int) error {
}
var respZoneID int
+
for _, member := range resp.StructMembers {
if member.Name == "zone_id" {
respZoneID = member.ValueInt
@@ -204,6 +211,7 @@ func (c *Client) SetZone(ctx context.Context, domain string, zoneID int) error {
if respZoneID != zoneID {
return fmt.Errorf("could not set new zone_id for %s", domain)
}
+
return nil
}
diff --git a/providers/dns/gandi/internal/client_test.go b/providers/dns/gandi/internal/client_test.go
new file mode 100644
index 000000000..a800767a2
--- /dev/null
+++ b/providers/dns/gandi/internal/client_test.go
@@ -0,0 +1,99 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithContentType("text/xml"),
+ )
+}
+
+func TestClient_GetZoneID(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("get_zone_id.xml"),
+ servermock.CheckRequestBodyFromFixture("get_zone_id-request.xml").IgnoreWhitespace()).
+ Build(t)
+
+ zoneID, err := client.GetZoneID(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ assert.Equal(t, 1, zoneID)
+}
+
+func TestClient_CloneZone(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("clone_zone.xml"),
+ servermock.CheckRequestBodyFromFixture("clone_zone-request.xml").IgnoreWhitespace()).
+ Build(t)
+
+ zoneID, err := client.CloneZone(t.Context(), 6, "foo")
+ require.NoError(t, err)
+
+ assert.Equal(t, 1, zoneID)
+}
+
+func TestClient_NewZoneVersion(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("new_zone_version.xml"),
+ servermock.CheckRequestBodyFromFixture("new_zone_version-request.xml").IgnoreWhitespace()).
+ Build(t)
+
+ zoneID, err := client.NewZoneVersion(t.Context(), 6)
+ require.NoError(t, err)
+
+ assert.Equal(t, 1, zoneID)
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("empty.xml"),
+ servermock.CheckRequestBodyFromFixture("add_txt_record-request.xml").IgnoreWhitespace()).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), 1, 123, "foo", "content", 120)
+ require.NoError(t, err)
+}
+
+func TestClient_SetZoneVersion(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("set_zone_version.xml"),
+ servermock.CheckRequestBodyFromFixture("set_zone_version-request.xml").IgnoreWhitespace()).
+ Build(t)
+
+ err := client.SetZoneVersion(t.Context(), 1, 123)
+ require.NoError(t, err)
+}
+
+func TestClient_SetZone(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("set_zone.xml"),
+ servermock.CheckRequestBodyFromFixture("set_zone-request.xml").IgnoreWhitespace()).
+ Build(t)
+
+ err := client.SetZone(t.Context(), "example.com", 1)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteZone(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("delete_zone.xml"),
+ servermock.CheckRequestBodyFromFixture("delete_zone-request.xml").IgnoreWhitespace()).
+ Build(t)
+
+ err := client.DeleteZone(t.Context(), 1)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml b/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml
new file mode 100644
index 000000000..001ee7a33
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml
@@ -0,0 +1,49 @@
+
+
+ domain.zone.record.add
+
+
+ secret
+
+
+
+
+ 1
+
+
+
+
+ 123
+
+
+
+
+
+
+ type
+
+ TXT
+
+
+
+ name
+
+ foo
+
+
+
+ value
+
+ content
+
+
+
+ ttl
+
+ 120
+
+
+
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/clone_zone-request.xml b/providers/dns/gandi/internal/fixtures/clone_zone-request.xml
new file mode 100644
index 000000000..40ee87c7e
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/clone_zone-request.xml
@@ -0,0 +1,31 @@
+
+
+ domain.zone.clone
+
+
+ secret
+
+
+
+
+ 6
+
+
+
+
+ 0
+
+
+
+
+
+
+ name
+
+ foo
+
+
+
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/clone_zone.xml b/providers/dns/gandi/internal/fixtures/clone_zone.xml
new file mode 100644
index 000000000..2af93526e
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/clone_zone.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ id
+
+ 1
+
+
+
+ foo
+
+ 2
+
+
+
+
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/delete_zone-request.xml b/providers/dns/gandi/internal/fixtures/delete_zone-request.xml
new file mode 100644
index 000000000..0ba9cb766
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/delete_zone-request.xml
@@ -0,0 +1,14 @@
+
+
+ domain.zone.delete
+
+
+ secret
+
+
+
+
+ 1
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/delete_zone.xml b/providers/dns/gandi/internal/fixtures/delete_zone.xml
new file mode 100644
index 000000000..28ba00dc5
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/delete_zone.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ true
+
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/empty.xml b/providers/dns/gandi/internal/fixtures/empty.xml
new file mode 100644
index 000000000..7843fd723
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/empty.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml b/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml
new file mode 100644
index 000000000..173a725d8
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml
@@ -0,0 +1,14 @@
+
+
+ domain.info
+
+
+ secret
+
+
+
+
+ example.com
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/get_zone_id.xml b/providers/dns/gandi/internal/fixtures/get_zone_id.xml
new file mode 100644
index 000000000..2a11e0dff
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/get_zone_id.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ zone_id
+
+ 1
+
+
+
+ foo
+
+ 2
+
+
+
+
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml b/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml
new file mode 100644
index 000000000..2fbac82de
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml
@@ -0,0 +1,14 @@
+
+
+ domain.zone.version.new
+
+
+ secret
+
+
+
+
+ 6
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/new_zone_version.xml b/providers/dns/gandi/internal/fixtures/new_zone_version.xml
new file mode 100644
index 000000000..feb84e486
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/new_zone_version.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ 1
+
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/set_zone-request.xml b/providers/dns/gandi/internal/fixtures/set_zone-request.xml
new file mode 100644
index 000000000..71ac843fd
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/set_zone-request.xml
@@ -0,0 +1,19 @@
+
+
+ domain.zone.set
+
+
+ secret
+
+
+
+
+ example.com
+
+
+
+
+ 1
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/set_zone.xml b/providers/dns/gandi/internal/fixtures/set_zone.xml
new file mode 100644
index 000000000..2a11e0dff
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/set_zone.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ zone_id
+
+ 1
+
+
+
+ foo
+
+ 2
+
+
+
+
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml b/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml
new file mode 100644
index 000000000..68a021446
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml
@@ -0,0 +1,19 @@
+
+
+ domain.zone.version.set
+
+
+ secret
+
+
+
+
+ 1
+
+
+
+
+ 123
+
+
+
diff --git a/providers/dns/gandi/internal/fixtures/set_zone_version.xml b/providers/dns/gandi/internal/fixtures/set_zone_version.xml
new file mode 100644
index 000000000..28ba00dc5
--- /dev/null
+++ b/providers/dns/gandi/internal/fixtures/set_zone_version.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ true
+
+
+
+
diff --git a/providers/dns/gandi/internal/types.go b/providers/dns/gandi/internal/types.go
index cdcd0a658..2cde62b53 100644
--- a/providers/dns/gandi/internal/types.go
+++ b/providers/dns/gandi/internal/types.go
@@ -69,6 +69,7 @@ func (r responseFault) faultString() string { return r.FaultString }
type responseStruct struct {
responseFault
+
StructMembers []struct {
Name string `xml:"name"`
ValueInt int `xml:"value>int"`
@@ -77,11 +78,13 @@ type responseStruct struct {
type responseInt struct {
responseFault
+
Value int `xml:"params>param>value>int"`
}
type responseBool struct {
responseFault
+
Value bool `xml:"params>param>value>boolean"`
}
diff --git a/providers/dns/gandiv5/gandiv5.go b/providers/dns/gandiv5/gandiv5.go
index 3c35245de..15014e207 100644
--- a/providers/dns/gandiv5/gandiv5.go
+++ b/providers/dns/gandiv5/gandiv5.go
@@ -15,6 +15,7 @@ import (
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/gandiv5/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -113,6 +114,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if err != nil {
return nil, fmt.Errorf("gandiv5: %w", err)
}
+
client.BaseURL = baseURL
}
@@ -120,6 +122,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -160,6 +164,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
authZone: authZone,
fieldName: subDomain,
}
+
return nil
}
@@ -170,6 +175,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
// acquire lock and retrieve authZone
d.inProgressMu.Lock()
defer d.inProgressMu.Unlock()
+
if _, ok := d.inProgressFQDNs[info.EffectiveFQDN]; !ok {
// if there is no cleanup information then just return
return nil
@@ -184,6 +190,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("gandiv5: %w", err)
}
+
return nil
}
diff --git a/providers/dns/gandiv5/gandiv5.toml b/providers/dns/gandiv5/gandiv5.toml
index ebeef84b8..31568e89b 100644
--- a/providers/dns/gandiv5/gandiv5.toml
+++ b/providers/dns/gandiv5/gandiv5.toml
@@ -6,7 +6,7 @@ Since = "v0.5.0"
Example = '''
GANDIV5_PERSONAL_ACCESS_TOKEN=abcdefghijklmnopqrstuvwx \
-lego --email you@example.com --dns gandiv5 -d '*.example.com' -d example.com run
+lego --dns gandiv5 -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns gandiv5 -d '*.example.com' -d example.com run
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"
- GANDIV5_TTL = "The TTL of the TXT record used for the DNS challenge"
- GANDIV5_HTTP_TIMEOUT = "API request timeout"
+ GANDIV5_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)"
+ GANDIV5_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)"
+ GANDIV5_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ GANDIV5_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://api.gandi.net/docs/livedns/"
diff --git a/providers/dns/gandiv5/gandiv5_test.go b/providers/dns/gandiv5/gandiv5_test.go
index 57fed032e..d6f077243 100644
--- a/providers/dns/gandiv5/gandiv5_test.go
+++ b/providers/dns/gandiv5/gandiv5_test.go
@@ -1,15 +1,11 @@
package gandiv5
import (
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "regexp"
"testing"
- "github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
@@ -39,6 +35,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -95,90 +92,44 @@ func TestNewDNSProviderConfig(t *testing.T) {
// TestDNSProvider runs Present and CleanUp against a fake Gandi RPC
// Server, whose responses are predetermined for particular requests.
func TestDNSProvider(t *testing.T) {
- // serverResponses is the JSON Request->Response map used by the
- // fake JSON server.
- serverResponses := map[string]map[string]string{
- http.MethodGet: {
- ``: `{"rrset_ttl":300,"rrset_values":[],"rrset_name":"_acme-challenge.abc.def","rrset_type":"TXT"}`,
+ provider := servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.PersonalAccessToken = "123412341234123412341234"
+ config.BaseURL = server.URL
+ config.HTTPClient = server.Client()
+
+ return NewDNSProviderConfig(config)
},
- http.MethodPut: {
- `{"rrset_ttl":300,"rrset_values":["TOKEN"]}`: `{"message": "Zone Record Created"}`,
- },
- http.MethodDelete: {
- ``: ``,
- },
- }
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer 123412341234123412341234"),
+ ).
+ Route("GET /domains/example.com/records/_acme-challenge.abc.def/TXT",
+ servermock.RawStringResponse(`{"rrset_ttl":300,"rrset_values":[],"rrset_name":"_acme-challenge.abc.def","rrset_type":"TXT"}`)).
+ Route("PUT /domains/example.com/records/_acme-challenge.abc.def/TXT",
+ servermock.RawStringResponse(`{"message": "Zone Record Created"}`),
+ servermock.CheckRequestJSONBody(`{"rrset_ttl":300,"rrset_values":["ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ"]}`)).
+ Route("DELETE /domains/example.com/records/_acme-challenge.abc.def/TXT", nil).
+ Build(t)
fakeKeyAuth := "XXXX"
- regexpToken := regexp.MustCompile(`"rrset_values":\[".+"\]`)
-
- // start fake RPC server
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- 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("Authorization") != "Bearer 123412341234123412341234" {
- http.Error(rw, `{"message": "missing or malformed Authorization"}`, http.StatusUnauthorized)
- return
- }
-
- if req.Method == http.MethodPost && req.Header.Get("Content-Type") != "application/json" {
- http.Error(rw, `{"message": "invalid content type"}`, http.StatusBadRequest)
- return
- }
-
- body, errS := io.ReadAll(req.Body)
- if errS != nil {
- http.Error(rw, fmt.Sprintf(`{"message": "read body error: %v"}`, errS), http.StatusInternalServerError)
- return
- }
-
- body = regexpToken.ReplaceAllLiteral(body, []byte(`"rrset_values":["TOKEN"]`))
-
- responses, ok := serverResponses[req.Method]
- if !ok {
- http.Error(rw, fmt.Sprintf(`{"message": "Server response for request not found: %#q"}`, string(body)), http.StatusInternalServerError)
- return
- }
-
- resp := responses[string(body)]
-
- _, errS = rw.Write([]byte(resp))
- if errS != nil {
- http.Error(rw, fmt.Sprintf(`{"message": "failed to write response: %v"}`, errS), http.StatusInternalServerError)
- return
- }
- })
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- log.Infof("request: %s %s", req.Method, req.URL)
- http.Error(rw, fmt.Sprintf(`{"message": "URL doesn't match: %s"}`, req.URL), http.StatusNotFound)
- })
-
// define function to override findZoneByFqdn with
fakeFindZoneByFqdn := func(fqdn string) (string, error) {
return "example.com.", nil
}
- config := NewDefaultConfig()
- config.PersonalAccessToken = "123412341234123412341234"
- config.BaseURL = server.URL
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
// override findZoneByFqdn function
savedFindZoneByFqdn := provider.findZoneByFqdn
+
defer func() {
provider.findZoneByFqdn = savedFindZoneByFqdn
}()
+
provider.findZoneByFqdn = fakeFindZoneByFqdn
// run Present
- err = provider.Present("abc.def.example.com", "", fakeKeyAuth)
+ err := provider.Present("abc.def.example.com", "", fakeKeyAuth)
require.NoError(t, err)
// run CleanUp
diff --git a/providers/dns/gandiv5/internal/client.go b/providers/dns/gandiv5/internal/client.go
index 57de9d615..bfb71c9f6 100644
--- a/providers/dns/gandiv5/internal/client.go
+++ b/providers/dns/gandiv5/internal/client.go
@@ -15,10 +15,7 @@ import (
)
// defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp.
-const defaultBaseURL = "https://dns.api.gandi.net/api/v5"
-
-// APIKeyHeader API key header.
-const APIKeyHeader = "X-Api-Key"
+const defaultBaseURL = "https://api.gandi.net/v5/livedns"
// Related to Personal Access Token.
const authorizationHeader = "Authorization"
@@ -78,6 +75,7 @@ func (c *Client) getTXTRecord(ctx context.Context, domain, name string) (*Record
}
txtRecord := &Record{}
+
err = c.do(req, txtRecord)
if err != nil {
return nil, fmt.Errorf("unable to get TXT records for domain %s and name %s: %w", domain, name, err)
@@ -95,6 +93,7 @@ func (c *Client) addTXTRecord(ctx context.Context, domain, name string, newRecor
}
message := apiResponse{}
+
err = c.do(req, &message)
if err != nil {
return fmt.Errorf("unable to create TXT record for domain %s and name %s: %w", domain, name, err)
@@ -116,6 +115,7 @@ func (c *Client) DeleteTXTRecord(ctx context.Context, domain, name string) error
}
message := apiResponse{}
+
err = c.do(req, &message)
if err != nil {
return fmt.Errorf("unable to delete TXT record for domain %s and name %s: %w", domain, name, err)
@@ -130,7 +130,7 @@ func (c *Client) DeleteTXTRecord(ctx context.Context, domain, name string) error
func (c *Client) do(req *http.Request, result any) error {
if c.apiKey != "" {
- req.Header.Set(APIKeyHeader, c.apiKey)
+ req.Header.Set(authorizationHeader, "Apikey "+c.apiKey)
}
if c.pat != "" {
@@ -208,6 +208,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
response := apiResponse{}
+
err := json.Unmarshal(raw, &response)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/gandiv5/internal/client_test.go b/providers/dns/gandiv5/internal/client_test.go
new file mode 100644
index 000000000..6a4158dcb
--- /dev/null
+++ b/providers/dns/gandiv5/internal/client_test.go
@@ -0,0 +1,54 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder(apiKey, pat string) *servermock.Builder[*Client] {
+ checkHeaders := servermock.CheckHeader().WithJSONHeaders()
+
+ if apiKey != "" {
+ checkHeaders = checkHeaders.WithAuthorization("Apikey secret-apikey")
+ } else {
+ checkHeaders = checkHeaders.WithAuthorization("Bearer secret-pat")
+ }
+
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(apiKey, pat)
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ checkHeaders,
+ )
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := mockBuilder("secret-apikey", "").
+ Route("GET /domains/example.com/records/foo/TXT",
+ servermock.ResponseFromFixture("add_txt_record_get.json")).
+ Route("PUT /domains/example.com/records/foo/TXT",
+ servermock.ResponseFromFixture("api_response.json"),
+ servermock.CheckRequestJSONBody(`{"rrset_ttl":120,"rrset_values":["content","value1"]}`)).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example.com", "foo", "content", 120)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+ client := mockBuilder("", "secret-pat").
+ Route("DELETE /domains/example.com/records/foo/TXT",
+ servermock.ResponseFromFixture("api_response.json")).
+ Build(t)
+
+ err := client.DeleteTXTRecord(t.Context(), "example.com", "foo")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json b/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json
new file mode 100644
index 000000000..fead6ab0a
--- /dev/null
+++ b/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json
@@ -0,0 +1,8 @@
+{
+ "rrset_ttl": 120,
+ "rrset_values": [
+ "value1"
+ ],
+ "rrset_name": "foo",
+ "rrset_type": "TXT"
+}
diff --git a/providers/dns/gandiv5/internal/fixtures/api_response.json b/providers/dns/gandiv5/internal/fixtures/api_response.json
new file mode 100644
index 000000000..47f4352ff
--- /dev/null
+++ b/providers/dns/gandiv5/internal/fixtures/api_response.json
@@ -0,0 +1,4 @@
+{
+ "message": "test",
+ "uuid": "123456789"
+}
diff --git a/providers/dns/gcloud/gcloud.toml b/providers/dns/gcloud/gcloud.toml
index ed12a75dc..63d22bed3 100644
--- a/providers/dns/gcloud/gcloud.toml
+++ b/providers/dns/gcloud/gcloud.toml
@@ -5,9 +5,29 @@ Code = "gcloud"
Since = "v0.3.0"
Example = '''
+# Using a service account file
GCE_PROJECT="gc-project-id" \
GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \
-lego --email you@email.com --dns gcloud -d '*.example.com' -d example.com run
+lego --dns gcloud -d '*.example.com' -d example.com run
+
+# Using default credentials with impersonation
+GCE_PROJECT="gc-project-id" \
+GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \
+lego --dns gcloud -d '*.example.com' -d example.com run
+
+# Using service account key with impersonation
+GCE_PROJECT="gc-project-id" \
+GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \
+GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \
+lego --dns gcloud -d '*.example.com' -d example.com run
+'''
+
+Additional = '''
+Supports service account impersonation to access Google Cloud DNS resources across different projects or with restricted permissions.
+
+When using impersonation, the source service account must have:
+1. The "Service Account Token Creator" role on the source service account
+2. The "https://www.googleapis.com/auth/cloud-platform" scope
'''
[Configuration]
@@ -19,9 +39,10 @@ lego --email you@email.com --dns gcloud -d '*.example.com' -d example.com run
[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"
+ GCE_IMPERSONATE_SERVICE_ACCOUNT = "Service account email to impersonate"
+ GCE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ GCE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)"
+ GCE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
[Links]
API = "https://cloud.google.com/dns/api/v1/"
diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go
index 99c716b62..61e8ee66f 100644
--- a/providers/dns/gcloud/googlecloud.go
+++ b/providers/dns/gcloud/googlecloud.go
@@ -2,6 +2,7 @@
package gcloud
import (
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -11,15 +12,19 @@ import (
"time"
"cloud.google.com/go/compute/metadata"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/platform/wait"
- "golang.org/x/net/context"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/miekg/dns"
+ "golang.org/x/oauth2"
"golang.org/x/oauth2/google"
- "google.golang.org/api/dns/v1"
+ gdns "google.golang.org/api/dns/v1"
"google.golang.org/api/googleapi"
+ "google.golang.org/api/impersonate"
"google.golang.org/api/option"
)
@@ -27,11 +32,12 @@ import (
const (
envNamespace = "GCE_"
- EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT"
- EnvProject = envNamespace + "PROJECT"
- EnvZoneID = envNamespace + "ZONE_ID"
- EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE"
- EnvDebug = envNamespace + "DEBUG"
+ EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT"
+ EnvProject = envNamespace + "PROJECT"
+ EnvZoneID = envNamespace + "ZONE_ID"
+ EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE"
+ EnvDebug = envNamespace + "DEBUG"
+ EnvImpersonateServiceAccount = envNamespace + "IMPERSONATE_SERVICE_ACCOUNT"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -44,32 +50,34 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- Debug bool
- Project string
- ZoneID string
- AllowPrivateZone bool
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
+ Debug bool
+ Project string
+ ZoneID string
+ AllowPrivateZone bool
+ ImpersonateServiceAccount 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{
- 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),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
+ Debug: env.GetOrDefaultBool(EnvDebug, false),
+ ZoneID: env.GetOrDefaultString(EnvZoneID, ""),
+ AllowPrivateZone: env.GetOrDefaultBool(EnvAllowPrivateZone, false),
+ ImpersonateServiceAccount: env.GetOrDefaultString(EnvImpersonateServiceAccount, ""),
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
- client *dns.Service
+ client *gdns.Service
}
// NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS.
@@ -85,6 +93,7 @@ func NewDNSProvider() (*DNSProvider, error) {
// Use default credentials.
project := env.GetOrDefaultString(EnvProject, autodetectProjectID(context.Background()))
+
return NewDNSProviderCredentials(project)
}
@@ -95,14 +104,15 @@ func NewDNSProviderCredentials(project string) (*DNSProvider, error) {
return nil, errors.New("googlecloud: project name missing")
}
- client, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope)
- if err != nil {
- return nil, fmt.Errorf("googlecloud: unable to get Google Cloud client: %w", err)
- }
-
config := NewDefaultConfig()
config.Project = project
- config.HTTPClient = client
+
+ var err error
+
+ config.HTTPClient, err = newClientFromCredentials(context.Background(), config)
+ if err != nil {
+ return nil, fmt.Errorf("googlecloud: %w", err)
+ }
return NewDNSProviderConfig(config)
}
@@ -122,22 +132,24 @@ func NewDNSProviderServiceAccountKey(saKey []byte) (*DNSProvider, error) {
var datJSON struct {
ProjectID string `json:"project_id"`
}
+
err := json.Unmarshal(saKey, &datJSON)
if err != nil || datJSON.ProjectID == "" {
return nil, errors.New("googlecloud: project ID not found in Google Cloud Service Account file")
}
+
project = datJSON.ProjectID
}
- conf, err := google.JWTConfigFromJSON(saKey, dns.NdevClouddnsReadwriteScope)
- if err != nil {
- return nil, fmt.Errorf("googlecloud: unable to acquire config: %w", err)
- }
- client := conf.Client(context.Background())
-
config := NewDefaultConfig()
config.Project = project
- config.HTTPClient = client
+
+ var err error
+
+ config.HTTPClient, err = newClientFromServiceAccountKey(context.Background(), config, saKey)
+ if err != nil {
+ return nil, fmt.Errorf("googlecloud: %w", err)
+ }
return NewDNSProviderConfig(config)
}
@@ -162,11 +174,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("googlecloud: the configuration of the DNS provider is nil")
}
+
if config.HTTPClient == nil {
return nil, errors.New("googlecloud: unable to create Google Cloud DNS service: client is nil")
}
- svc, err := dns.NewService(context.Background(), option.WithHTTPClient(config.HTTPClient))
+ svc, err := gdns.NewService(context.Background(), option.WithHTTPClient(clientdebug.Wrap(config.HTTPClient)))
if err != nil {
return nil, fmt.Errorf("googlecloud: unable to create Google Cloud DNS service: %w", err)
}
@@ -176,6 +189,8 @@ 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)
zone, err := d.getHostedZone(info.EffectiveFQDN)
@@ -191,6 +206,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
for _, rrSet := range existingRrSet {
var rrd []string
+
for _, rr := range rrSet.Rrdatas {
data := mustUnquote(rr)
rrd = append(rrd, data)
@@ -200,17 +216,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return nil
}
}
+
rrSet.Rrdatas = rrd
}
// Attempt to delete the existing records before adding the new one.
if len(existingRrSet) > 0 {
- if err = d.applyChanges(zone, &dns.Change{Deletions: existingRrSet}); err != nil {
+ if err = d.applyChanges(ctx, zone, &gdns.Change{Deletions: existingRrSet}); err != nil {
return fmt.Errorf("googlecloud: %w", err)
}
}
- rec := &dns.ResourceRecordSet{
+ rec := &gdns.ResourceRecordSet{
Name: info.EffectiveFQDN,
Rrdatas: []string{info.Value},
Ttl: int64(d.config.TTL),
@@ -226,18 +243,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
}
}
- change := &dns.Change{
- Additions: []*dns.ResourceRecordSet{rec},
+ change := &gdns.Change{
+ Additions: []*gdns.ResourceRecordSet{rec},
}
- if err = d.applyChanges(zone, change); err != nil {
+ if err = d.applyChanges(ctx, zone, change); err != nil {
return fmt.Errorf("googlecloud: %w", err)
}
return nil
}
-func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error {
+func (d *DNSProvider) applyChanges(ctx context.Context, zone string, change *gdns.Change) error {
if d.config.Debug {
data, _ := json.Marshal(change)
log.Printf("change (Create): %s", string(data))
@@ -251,6 +268,7 @@ func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error {
}
data, _ := json.Marshal(change)
+
return fmt.Errorf("failed to perform changes [zone %s, change %s]: %w", zone, string(data), err)
}
@@ -261,24 +279,28 @@ func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error {
chgID := chg.Id
// wait for change to be acknowledged
- return wait.For("apply change", 30*time.Second, 3*time.Second, func() (bool, error) {
- if d.config.Debug {
- data, _ := json.Marshal(change)
- log.Printf("change (Get): %s", string(data))
- }
+ return wait.Retry(ctx,
+ func() error {
+ if d.config.Debug {
+ data, _ := json.Marshal(change)
+ log.Printf("change (Get): %s", string(data))
+ }
- chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do()
- if err != nil {
- data, _ := json.Marshal(change)
- return false, fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err)
- }
+ chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do()
+ if err != nil {
+ data, _ := json.Marshal(change)
+ return fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err)
+ }
- if chg.Status == changeStatusDone {
- return true, nil
- }
+ if chg.Status != changeStatusDone {
+ return fmt.Errorf("status: %s", chg.Status)
+ }
- return false, fmt.Errorf("status: %s", chg.Status)
- })
+ return nil
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(3*time.Second)),
+ backoff.WithMaxElapsedTime(30*time.Second),
+ )
}
// CleanUp removes the TXT record matching the specified parameters.
@@ -299,10 +321,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
- _, err = d.client.Changes.Create(d.config.Project, zone, &dns.Change{Deletions: records}).Do()
+ _, err = d.client.Changes.Create(d.config.Project, zone, &gdns.Change{Deletions: records}).Do()
if err != nil {
return fmt.Errorf("googlecloud: %w", err)
}
+
return nil
}
@@ -348,7 +371,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) {
// (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) {
+func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*gdns.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()
@@ -356,10 +379,10 @@ func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZ
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
+ return zone.DnsName, []*gdns.ManagedZone{zone}, nil
}
- authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
+ authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(domain))
if err != nil {
return "", nil, fmt.Errorf("could not find zone: %w", err)
}
@@ -375,7 +398,7 @@ func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZ
return authZone, zones.ManagedZones, nil
}
-func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) {
+func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*gdns.ResourceRecordSet, error) {
recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do()
if err != nil {
return nil, err
@@ -384,11 +407,60 @@ func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSe
return recs.Rrsets, nil
}
+func newClientFromCredentials(ctx context.Context, config *Config) (*http.Client, error) {
+ if config.ImpersonateServiceAccount != "" {
+ ts, err := google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/cloud-platform")
+ if err != nil {
+ return nil, fmt.Errorf("unable to get default token source: %w", err)
+ }
+
+ return newImpersonateClient(ctx, config.ImpersonateServiceAccount, ts)
+ }
+
+ client, err := google.DefaultClient(ctx, gdns.NdevClouddnsReadwriteScope)
+ if err != nil {
+ return nil, fmt.Errorf("unable to get Google Cloud client: %w", err)
+ }
+
+ return client, nil
+}
+
+func newClientFromServiceAccountKey(ctx context.Context, config *Config, saKey []byte) (*http.Client, error) {
+ if config.ImpersonateServiceAccount != "" {
+ conf, err := google.JWTConfigFromJSON(saKey, "https://www.googleapis.com/auth/cloud-platform")
+ if err != nil {
+ return nil, fmt.Errorf("unable to acquire config: %w", err)
+ }
+
+ return newImpersonateClient(ctx, config.ImpersonateServiceAccount, conf.TokenSource(ctx))
+ }
+
+ conf, err := google.JWTConfigFromJSON(saKey, gdns.NdevClouddnsReadwriteScope)
+ if err != nil {
+ return nil, fmt.Errorf("unable to acquire config: %w", err)
+ }
+
+ return conf.Client(ctx), nil
+}
+
+func newImpersonateClient(ctx context.Context, impersonateServiceAccount string, ts oauth2.TokenSource) (*http.Client, error) {
+ impersonatedTS, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
+ TargetPrincipal: impersonateServiceAccount,
+ Scopes: []string{gdns.NdevClouddnsReadwriteScope},
+ }, option.WithTokenSource(ts))
+ if err != nil {
+ return nil, fmt.Errorf("unable to create impersonated credentials: %w", err)
+ }
+
+ return oauth2.NewClient(ctx, impersonatedTS), nil
+}
+
func mustUnquote(raw string) string {
clean, err := strconv.Unquote(raw)
if err != nil {
return raw
}
+
return clean
}
diff --git a/providers/dns/gcloud/googlecloud_test.go b/providers/dns/gcloud/googlecloud_test.go
index 453fdd5ed..28b08a2f9 100644
--- a/providers/dns/gcloud/googlecloud_test.go
+++ b/providers/dns/gcloud/googlecloud_test.go
@@ -1,6 +1,7 @@
package gcloud
import (
+ "context"
"encoding/json"
"fmt"
"net/http"
@@ -10,8 +11,8 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
- "golang.org/x/net/context"
"golang.org/x/oauth2/google"
"google.golang.org/api/dns/v1"
)
@@ -30,7 +31,8 @@ var envTest = tester.NewEnvTest(
envServiceAccountFile,
envGoogleApplicationCredentials,
envMetadataHost,
- EnvServiceAccount).
+ EnvServiceAccount,
+ EnvImpersonateServiceAccount).
WithDomain(envDomain).
WithLiveTestExtra(func() bool {
_, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope)
@@ -50,7 +52,7 @@ func TestNewDNSProvider(t *testing.T) {
envServiceAccountFile: "",
// as Travis run on GCE, we have to alter env
envGoogleApplicationCredentials: "not-a-secret-file",
- envMetadataHost: "http://lego.wtf", // defined here to avoid the client cache.
+ envMetadataHost: "http://example.com", // defined here to avoid the client cache.
},
// the error message varies according to the OS used.
expected: "googlecloud: unable to get Google Cloud client: google: error getting credentials using GOOGLE_APPLICATION_CREDENTIALS environment variable: ",
@@ -61,7 +63,7 @@ func TestNewDNSProvider(t *testing.T) {
EnvProject: "",
envServiceAccountFile: "",
// as Travis run on GCE, we have to alter env
- envMetadataHost: "http://lego.wtf",
+ envMetadataHost: "http://example.com",
},
expected: "googlecloud: project name missing",
},
@@ -84,6 +86,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -123,6 +126,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
config := NewDefaultConfig()
@@ -143,245 +147,162 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
func TestPresentNoExistingRR(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ provider := mockBuilder().
+ // getHostedZone
+ Route("GET /dns/v1/projects/manhattan/managedZones",
+ servermock.JSONEncode(&dns.ManagedZonesListResponse{
+ ManagedZones: []*dns.ManagedZone{
+ {Name: "test", Visibility: "public"},
+ },
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("dnsName", "example.com.").
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ // findTxtRecords
+ Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets",
+ servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{
+ Rrsets: []*dns.ResourceRecordSet{},
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge.example.com.").
+ With("type", "TXT").
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ // applyChanges [Create]
+ Route("POST /dns/v1/projects/manhattan/managedZones/test/changes",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ var chgReq dns.Change
+ if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
- // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf.
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+ chgResp := chgReq
+ chgResp.Status = changeStatusDone
- mzlrs := &dns.ManagedZonesListResponse{
- ManagedZones: []*dns.ManagedZone{
- {Name: "test", Visibility: "public"},
- },
- }
+ if err := json.NewEncoder(rw).Encode(chgResp); err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ Build(t)
- err := json.NewEncoder(w).Encode(mzlrs)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ domain := "example.com"
- // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- rrslr := &dns.ResourceRecordSetsListResponse{
- Rrsets: []*dns.ResourceRecordSet{},
- }
-
- err := json.NewEncoder(w).Encode(rrslr)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- // applyChanges [Create]: /manhattan/managedZones/test/changes?alt=json
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/changes", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- var chgReq dns.Change
- if err := json.NewDecoder(r.Body).Decode(&chgReq); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- chgResp := chgReq
- chgResp.Status = changeStatusDone
-
- if err := json.NewEncoder(w).Encode(chgResp); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- config := NewDefaultConfig()
- config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
- config.Project = "manhattan"
-
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- p.client.BasePath = server.URL
-
- domain := "lego.wtf"
-
- err = p.Present(domain, "", "")
+ err := provider.Present(domain, "", "")
require.NoError(t, err)
}
func TestPresentWithExistingRR(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf.
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- mzlrs := &dns.ManagedZonesListResponse{
- ManagedZones: []*dns.ManagedZone{
- {Name: "test", Visibility: "public"},
- },
- }
-
- err := json.NewEncoder(w).Encode(mzlrs)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- rrslr := &dns.ResourceRecordSetsListResponse{
- Rrsets: []*dns.ResourceRecordSet{{
- Name: "_acme-challenge.lego.wtf.",
- Rrdatas: []string{`"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`},
- Ttl: 120,
- Type: "TXT",
- }},
- }
-
- err := json.NewEncoder(w).Encode(rrslr)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- // applyChanges [Create]: /manhattan/managedZones/test/changes?alt=json
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/changes", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- var chgReq dns.Change
- if err := json.NewDecoder(r.Body).Decode(&chgReq); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- if len(chgReq.Additions) > 0 {
- sort.Strings(chgReq.Additions[0].Rrdatas)
- }
-
- var prevVal string
- for _, addition := range chgReq.Additions {
- for _, value := range addition.Rrdatas {
- if prevVal == value {
- http.Error(w, fmt.Sprintf("The resource %s already exists", value), http.StatusConflict)
+ provider := mockBuilder().
+ // getHostedZone
+ Route("GET /dns/v1/projects/manhattan/managedZones",
+ servermock.JSONEncode(&dns.ManagedZonesListResponse{
+ ManagedZones: []*dns.ManagedZone{
+ {Name: "test", Visibility: "public"},
+ },
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("dnsName", "example.com.").
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ // findTxtRecords
+ Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets",
+ servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{
+ Rrsets: []*dns.ResourceRecordSet{{
+ Name: "_acme-challenge.example.com.",
+ Rrdatas: []string{`"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`},
+ Ttl: 120,
+ Type: "TXT",
+ }},
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge.example.com.").
+ With("type", "TXT").
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ // applyChanges [Create]
+ Route("POST /dns/v1/projects/manhattan/managedZones/test/changes",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ var chgReq dns.Change
+ if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
- prevVal = value
- }
- }
- chgResp := chgReq
- chgResp.Status = changeStatusDone
+ if len(chgReq.Additions) > 0 {
+ sort.Strings(chgReq.Additions[0].Rrdatas)
+ }
- if err := json.NewEncoder(w).Encode(chgResp); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ var prevVal string
- config := NewDefaultConfig()
- config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
- config.Project = "manhattan"
+ for _, addition := range chgReq.Additions {
+ for _, value := range addition.Rrdatas {
+ if prevVal == value {
+ http.Error(rw, fmt.Sprintf("The resource %s already exists", value), http.StatusConflict)
+ return
+ }
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
+ prevVal = value
+ }
+ }
- p.client.BasePath = server.URL
+ chgResp := chgReq
+ chgResp.Status = changeStatusDone
- domain := "lego.wtf"
+ if err := json.NewEncoder(rw).Encode(chgResp); err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ Build(t)
- err = p.Present(domain, "", "")
+ domain := "example.com"
+
+ err := provider.Present(domain, "", "")
require.NoError(t, err)
}
func TestPresentSkipExistingRR(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ provider := mockBuilder().
+ // getHostedZone
+ Route("GET /dns/v1/projects/manhattan/managedZones",
+ servermock.JSONEncode(&dns.ManagedZonesListResponse{
+ ManagedZones: []*dns.ManagedZone{
+ {Name: "test", Visibility: "public"},
+ },
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("dnsName", "example.com.").
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ // findTxtRecords
+ Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets",
+ servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{
+ Rrsets: []*dns.ResourceRecordSet{{
+ Name: "_acme-challenge.example.com.",
+ Rrdatas: []string{`"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`},
+ Ttl: 120,
+ Type: "TXT",
+ }},
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge.example.com.").
+ With("type", "TXT").
+ With("prettyPrint", "false").
+ With("alt", "json")).
+ Build(t)
- // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf.
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+ domain := "example.com"
- mzlrs := &dns.ManagedZonesListResponse{
- ManagedZones: []*dns.ManagedZone{
- {Name: "test", Visibility: "public"},
- },
- }
-
- err := json.NewEncoder(w).Encode(mzlrs)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT
- mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- rrslr := &dns.ResourceRecordSetsListResponse{
- Rrsets: []*dns.ResourceRecordSet{{
- Name: "_acme-challenge.lego.wtf.",
- Rrdatas: []string{`"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`},
- Ttl: 120,
- Type: "TXT",
- }},
- }
-
- err := json.NewEncoder(w).Encode(rrslr)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- config := NewDefaultConfig()
- config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
- config.Project = "manhattan"
-
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- p.client.BasePath = server.URL
-
- domain := "lego.wtf"
-
- err = p.Present(domain, "", "")
+ err := provider.Present(domain, "", "")
require.NoError(t, err)
}
@@ -431,3 +352,20 @@ func TestLiveCleanUp(t *testing.T) {
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.Project = "manhattan"
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BasePath = server.URL
+
+ return p, err
+ })
+}
diff --git a/providers/dns/gcore/gcore.go b/providers/dns/gcore/gcore.go
index 646c5ab1c..9b98f28d4 100644
--- a/providers/dns/gcore/gcore.go
+++ b/providers/dns/gcore/gcore.go
@@ -1,17 +1,16 @@
+// Package gcore implements a DNS provider for solving the DNS-01 challenge using G-Core.
package gcore
import (
- "context"
"errors"
"fmt"
"net/http"
- "strings"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/gcore/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/gcore"
)
// Environment variables names.
@@ -26,28 +25,17 @@ const (
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
-const (
- defaultPropagationTimeout = 360 * time.Second
- defaultPollingInterval = 20 * time.Second
-)
-
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config for DNSProvider.
-type Config struct {
- APIToken string
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = gcore.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, gcore.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, gcore.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
},
@@ -56,8 +44,7 @@ func NewDefaultConfig() *Config {
// DNSProvider an implementation of challenge.Provider contract.
type DNSProvider struct {
- config *Config
- client *internal.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns an instance of DNSProvider configured for G-Core DNS API.
@@ -79,91 +66,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("gcore: the configuration of the DNS provider is nil")
}
- if config.APIToken == "" {
- return nil, errors.New("gcore: incomplete credentials provided")
+ provider, err := gcore.NewDNSProviderConfig(config, "")
+ if err != nil {
+ return nil, fmt.Errorf("gcore: %w", err)
}
- client := internal.NewClient(config.APIToken)
-
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- return &DNSProvider{
- config: config,
- client: client,
- }, nil
+ return &DNSProvider{prv: provider}, nil
}
-// Present creates a TXT record to fulfill the dns-01 challenge.
-func (d *DNSProvider) Present(domain, _, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- zone, err := d.guessZone(ctx, info.EffectiveFQDN)
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("gcore: %w", err)
}
- err = d.client.AddRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL)
- if err != nil {
- return fmt.Errorf("gcore: add txt record: %w", err)
- }
-
return nil
}
-// CleanUp removes the record matching the specified parameters.
-func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- zone, err := d.guessZone(ctx, info.EffectiveFQDN)
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("gcore: %w", err)
}
- err = d.client.DeleteRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN))
- if err != nil {
- return fmt.Errorf("gcore: remove txt 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
-}
-
-func (d *DNSProvider) guessZone(ctx context.Context, fqdn string) (string, error) {
- var lastErr error
-
- for _, zone := range extractAllZones(fqdn) {
- dnsZone, err := d.client.GetZone(ctx, zone)
- if err == nil {
- return dnsZone.Name, nil
- }
-
- lastErr = err
- }
-
- return "", fmt.Errorf("zone %q not found: %w", fqdn, lastErr)
-}
-
-func extractAllZones(fqdn string) []string {
- parts := strings.Split(dns01.UnFqdn(fqdn), ".")
- if len(parts) < 3 {
- return nil
- }
-
- var zones []string
- for i := 1; i < len(parts)-1; i++ {
- zones = append(zones, strings.Join(parts[i:], "."))
- }
-
- return zones
+ return d.prv.Timeout()
}
diff --git a/providers/dns/gcore/gcore.toml b/providers/dns/gcore/gcore.toml
index bd514ac78..983c35f8a 100644
--- a/providers/dns/gcore/gcore.toml
+++ b/providers/dns/gcore/gcore.toml
@@ -6,17 +6,17 @@ Since = "v4.5.0"
Example = '''
GCORE_PERMANENT_API_TOKEN=xxxxx \
-lego --email you@example.com --dns gcore -d '*.example.com' -d example.com run
+lego --dns gcore -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
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"
- GCORE_TTL = "The TTL of the TXT record used for the DNS challenge"
- GCORE_HTTP_TIMEOUT = "API request timeout"
+ GCORE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)"
+ GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)"
+ GCORE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ GCORE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://api.gcore.com/docs/dns#tag/zones"
diff --git a/providers/dns/gcore/gcore_test.go b/providers/dns/gcore/gcore_test.go
index a5eddee7c..6f8e38c12 100644
--- a/providers/dns/gcore/gcore_test.go
+++ b/providers/dns/gcore/gcore_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"
)
@@ -34,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -43,8 +43,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.client)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -78,8 +77,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.client)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -93,6 +91,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -106,36 +105,10 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
-
-func Test_extractAllZones(t *testing.T) {
- testCases := []struct {
- desc string
- fqdn string
- expected []string
- }{
- {
- desc: "success",
- fqdn: "_acme-challenge.my.test.domain.com.",
- expected: []string{"my.test.domain.com", "test.domain.com", "domain.com"},
- },
- {
- desc: "empty",
- fqdn: "_acme-challenge.com.",
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
-
- got := extractAllZones(test.fqdn)
- assert.Equal(t, test.expected, got)
- })
- }
-}
diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/gcore/internal/client_test.go
deleted file mode 100644
index f414b33e1..000000000
--- a/providers/dns/gcore/internal/client_test.go
+++ /dev/null
@@ -1,256 +0,0 @@
-package internal
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "net/url"
- "reflect"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-const (
- testToken = "test"
- testRecordContent = "acme"
- testRecordContent2 = "foo"
- testTTL = 10
-)
-
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(testToken)
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func TestClient_GetZone(t *testing.T) {
- client, mux := setupTest(t)
-
- expected := Zone{Name: "example.com"}
-
- mux.Handle("/v2/zones/example.com", validationHandler{
- method: http.MethodGet,
- next: handleJSONResponse(expected),
- })
-
- zone, err := client.GetZone(context.Background(), "example.com")
- require.NoError(t, err)
-
- assert.Equal(t, expected, zone)
-}
-
-func TestClient_GetZone_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.Handle("/v2/zones/example.com", validationHandler{
- method: http.MethodGet,
- next: handleAPIError(),
- })
-
- _, err := client.GetZone(context.Background(), "example.com")
- require.Error(t, err)
-}
-
-func TestClient_GetRRSet(t *testing.T) {
- client, mux := setupTest(t)
-
- expected := RRSet{
- TTL: testTTL,
- Records: []Records{
- {Content: []string{testRecordContent}},
- },
- }
-
- mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{
- method: http.MethodGet,
- next: handleJSONResponse(expected),
- })
-
- rrSet, err := client.GetRRSet(context.Background(), "example.com", "foo.example.com")
- require.NoError(t, err)
-
- assert.Equal(t, expected, rrSet)
-}
-
-func TestClient_GetRRSet_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{
- method: http.MethodGet,
- next: handleAPIError(),
- })
-
- _, err := client.GetRRSet(context.Background(), "example.com", "foo.example.com")
- require.Error(t, err)
-}
-
-func TestClient_DeleteRRSet(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType,
- validationHandler{method: http.MethodDelete})
-
- err := client.DeleteRRSet(context.Background(), "test.example.com", "my.test.example.com.")
- require.NoError(t, err)
-}
-
-func TestClient_DeleteRRSet_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, validationHandler{
- method: http.MethodDelete,
- next: handleAPIError(),
- })
-
- err := client.DeleteRRSet(context.Background(), "test.example.com", "my.test.example.com.")
- require.NoError(t, err)
-}
-
-func TestClient_AddRRSet(t *testing.T) {
- testCases := []struct {
- desc string
- zone string
- recordName string
- value string
- handledDomain string
- handlers map[string]http.Handler
- wantErr bool
- }{
- {
- desc: "success add",
- zone: "test.example.com",
- recordName: "my.test.example.com",
- value: testRecordContent,
- handlers: map[string]http.Handler{
- // createRRSet
- "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: validationHandler{
- method: http.MethodPost,
- next: handleAddRRSet([]Records{{Content: []string{testRecordContent}}}),
- },
- },
- },
- {
- desc: "success update",
- zone: "test.example.com",
- recordName: "my.test.example.com",
- value: testRecordContent,
- handlers: map[string]http.Handler{
- "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
- switch req.Method {
- case http.MethodGet: // GetRRSet
- data := RRSet{
- TTL: testTTL,
- Records: []Records{{Content: []string{testRecordContent2}}},
- }
- handleJSONResponse(data).ServeHTTP(rw, req)
- case http.MethodPut: // updateRRSet
- expected := []Records{
- {Content: []string{testRecordContent}},
- {Content: []string{testRecordContent2}},
- }
- handleAddRRSet(expected).ServeHTTP(rw, req)
- default:
- http.Error(rw, "wrong method", http.StatusMethodNotAllowed)
- }
- }),
- },
- },
- {
- desc: "not in the zone",
- zone: "test.example.com",
- recordName: "notfound.example.com",
- value: testRecordContent,
- wantErr: true,
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- cl, mux := setupTest(t)
-
- for pattern, handler := range test.handlers {
- mux.Handle(pattern, handler)
- }
-
- err := cl.AddRRSet(context.Background(), test.zone, test.recordName, test.value, testTTL)
- if test.wantErr {
- require.Error(t, err)
- return
- }
-
- require.NoError(t, err)
- })
- }
-}
-
-type validationHandler struct {
- method string
- next http.Handler
-}
-
-func (v validationHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
- if req.Header.Get(authorizationHeader) != fmt.Sprintf("%s %s", tokenTypeHeader, testToken) {
- rw.WriteHeader(http.StatusForbidden)
- _ = json.NewEncoder(rw).Encode(APIError{Message: "token up for parsing was not passed through the context"})
- return
- }
-
- if req.Method != v.method {
- http.Error(rw, "wrong method", http.StatusMethodNotAllowed)
- return
- }
-
- if v.next != nil {
- v.next.ServeHTTP(rw, req)
- }
-}
-
-func handleAPIError() http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- rw.WriteHeader(http.StatusInternalServerError)
- _ = json.NewEncoder(rw).Encode(APIError{Message: "oops"})
- }
-}
-
-func handleJSONResponse(data interface{}) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- err := json.NewEncoder(rw).Encode(data)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- }
-}
-
-func handleAddRRSet(expected []Records) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- body := RRSet{}
-
- err := json.NewDecoder(req.Body).Decode(&body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if body.TTL != testTTL {
- http.Error(rw, "wrong ttl", http.StatusInternalServerError)
- return
- }
-
- if !reflect.DeepEqual(body.Records, expected) {
- http.Error(rw, "wrong resource records", http.StatusInternalServerError)
- return
- }
- }
-}
diff --git a/providers/dns/gigahostno/gigahostno.go b/providers/dns/gigahostno/gigahostno.go
new file mode 100644
index 000000000..b9ed23f3f
--- /dev/null
+++ b/providers/dns/gigahostno/gigahostno.go
@@ -0,0 +1,233 @@
+// Package gigahostno implements a DNS provider for solving the DNS-01 challenge using Gigahost.no.
+package gigahostno
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/gigahostno/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "GIGAHOSTNO_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+ EnvSecret = envNamespace + "SECRET"
+
+ 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 {
+ Username string
+ Password string
+ Secret string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+
+ identifier *internal.Identifier
+ client *internal.Client
+
+ tokenMu sync.Mutex
+ token *internal.Token
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Gigahost.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("gigahostno: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+ config.Secret = env.GetOrFile(EnvSecret)
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Gigahost.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("gigahostno: the configuration of the DNS provider is nil")
+ }
+
+ identifier, err := internal.NewIdentifier(config.Username, config.Password, config.Secret)
+ if err != nil {
+ return nil, fmt.Errorf("gigahostno: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ identifier.HTTPClient = config.HTTPClient
+ }
+
+ identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient)
+
+ client := internal.NewClient()
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ identifier: identifier,
+ client: client,
+ }, nil
+}
+
+// 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)
+
+ err := d.authenticate(ctx)
+ if err != nil {
+ return fmt.Errorf("gigahostno: %w", err)
+ }
+
+ ctx = internal.WithContext(ctx, d.token.Token)
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("gigahostno: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name)
+ if err != nil {
+ return fmt.Errorf("gigahostno: %w", err)
+ }
+
+ record := internal.Record{
+ Name: subDomain,
+ Type: "TXT",
+ Value: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ err = d.client.CreateNewRecord(ctx, zone.ID, record)
+ if err != nil {
+ return fmt.Errorf("gigahostno: create new 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)
+
+ err := d.authenticate(ctx)
+ if err != nil {
+ return fmt.Errorf("gigahostno: %w", err)
+ }
+
+ ctx = internal.WithContext(ctx, d.token.Token)
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("gigahostno: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name)
+ if err != nil {
+ return fmt.Errorf("gigahostno: %w", err)
+ }
+
+ records, err := d.client.GetZoneRecords(ctx, zone.ID)
+ if err != nil {
+ return fmt.Errorf("gigahostno: get zone records: %w", err)
+ }
+
+ for _, record := range records {
+ if record.Type == "TXT" && record.Name == subDomain && record.Value == info.Value {
+ err := d.client.DeleteRecord(ctx, zone.ID, record.ID, record.Name, record.Type)
+ if err != nil {
+ return fmt.Errorf("gigahostno: delete record: %w", err)
+ }
+
+ break
+ }
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) authenticate(ctx context.Context) error {
+ d.tokenMu.Lock()
+ defer d.tokenMu.Unlock()
+
+ if !d.token.IsExpired() {
+ return nil
+ }
+
+ tok, err := d.identifier.Authenticate(ctx)
+ if err != nil {
+ return fmt.Errorf("authenticate: %w", err)
+ }
+
+ d.token = tok
+
+ return nil
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.Zone, error) {
+ zones, err := d.client.GetZones(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get zones: %w", err)
+ }
+
+ for d := range dns01.UnFqdnDomainsSeq(fqdn) {
+ for _, zone := range zones {
+ if zone.Name == d && zone.Active == "1" {
+ return &zone, nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("zone not found for %q", fqdn)
+}
diff --git a/providers/dns/gigahostno/gigahostno.toml b/providers/dns/gigahostno/gigahostno.toml
new file mode 100644
index 000000000..b8d3fad2b
--- /dev/null
+++ b/providers/dns/gigahostno/gigahostno.toml
@@ -0,0 +1,25 @@
+Name = "Gigahost.no"
+Description = ''''''
+URL = "https://gigahost.no/"
+Code = "gigahostno"
+Since = "v4.29.0"
+
+Example = '''
+GIGAHOSTNO_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \
+GIGAHOSTNO_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \
+lego --dns gigahostno -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ GIGAHOSTNO_USERNAME = "Username"
+ GIGAHOSTNO_PASSWORD = "Password"
+ [Configuration.Additional]
+ GIGAHOSTNO_SECRET = "TOTP secret"
+ GIGAHOSTNO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ GIGAHOSTNO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ GIGAHOSTNO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ GIGAHOSTNO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://gigahost.no/api-dokumentasjon"
diff --git a/providers/dns/gigahostno/gigahostno_test.go b/providers/dns/gigahostno/gigahostno_test.go
new file mode 100644
index 000000000..7aaac0159
--- /dev/null
+++ b/providers/dns/gigahostno/gigahostno_test.go
@@ -0,0 +1,277 @@
+package gigahostno
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/gigahostno/internal"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvUsername,
+ EnvPassword,
+ EnvSecret,
+).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",
+ EnvSecret: "super-secret",
+ },
+ },
+ {
+ desc: "missing GIGAHOSTNO_USERNAME",
+ envVars: map[string]string{
+ EnvPassword: "secret",
+ },
+ expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_USERNAME",
+ },
+ {
+ desc: "missing GIGAHOSTNO_PASSWORD",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ },
+ expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_USERNAME,GIGAHOSTNO_PASSWORD",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ username string
+ password string
+ secret string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "user",
+ password: "secret",
+ secret: "super-secret",
+ },
+ {
+ desc: "missing username",
+ password: "secret",
+ expected: "gigahostno: credentials missing",
+ },
+ {
+ desc: "missing password",
+ username: "user",
+ expected: "gigahostno: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "gigahostno: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Username = test.username
+ config.Password = test.password
+ 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)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Username = "user"
+ config.Password = "secret"
+ config.Secret = "JBSWY3DPEHPK3PXP"
+
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+ p.identifier.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /authenticate",
+ servermock.ResponseFromInternal("authenticate.json")).
+ Route("GET /dns/zones",
+ servermock.ResponseFromInternal("zones.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secrettoken")).
+ Route("POST /dns/zones/123/records",
+ servermock.ResponseFromInternal("create_record.json"),
+ servermock.CheckRequestJSONBodyFromInternal("create_record-request.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secrettoken")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_Present_token_not_expired(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/zones",
+ servermock.ResponseFromInternal("zones.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret-token")).
+ Route("POST /dns/zones/123/records",
+ servermock.ResponseFromInternal("create_record.json"),
+ servermock.CheckRequestJSONBodyFromInternal("create_record-request.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret-token")).
+ Build(t)
+
+ provider.token = &internal.Token{
+ Token: "secret-token",
+ TokenExpire: 65322892800, // 2040-01-01
+ CustomerID: "123",
+ }
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /authenticate",
+ servermock.ResponseFromInternal("authenticate.json")).
+ Route("GET /dns/zones",
+ servermock.ResponseFromInternal("zones.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secrettoken")).
+ Route("GET /dns/zones/123/records",
+ servermock.ResponseFromInternal("zone_records.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secrettoken")).
+ Route("DELETE /dns/zones/123/records/jkl012",
+ servermock.ResponseFromInternal("delete_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge").
+ With("type", "TXT"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secrettoken")).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_token_not_expired(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/zones",
+ servermock.ResponseFromInternal("zones.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret-token")).
+ Route("GET /dns/zones/123/records",
+ servermock.ResponseFromInternal("zone_records.json"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret-token")).
+ Route("DELETE /dns/zones/123/records/jkl012",
+ servermock.ResponseFromInternal("delete_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge").
+ With("type", "TXT"),
+ servermock.CheckHeader().
+ WithAuthorization("Bearer secret-token")).
+ Build(t)
+
+ provider.token = &internal.Token{
+ Token: "secret-token",
+ TokenExpire: 65322892800, // 2040-01-01
+ CustomerID: "123",
+ }
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/gigahostno/internal/client.go b/providers/dns/gigahostno/internal/client.go
new file mode 100644
index 000000000..cfff3a7b8
--- /dev/null
+++ b/providers/dns/gigahostno/internal/client.go
@@ -0,0 +1,172 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.gigahost.no/api/v0"
+
+const authorizationHeader = "Authorization"
+
+// Client the Gigahost.no API client.
+type Client struct {
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient() *Client {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+// GetZones returns all zones.
+func (c *Client) GetZones(ctx context.Context) ([]Zone, error) {
+ endpoint := c.BaseURL.JoinPath("dns", "zones")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[[]Zone]
+
+ err = c.do(ctx, req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Data, nil
+}
+
+// GetZoneRecords returns all records for a zone.
+func (c *Client) GetZoneRecords(ctx context.Context, zoneID string) ([]Record, error) {
+ endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[[]Record]
+
+ err = c.do(ctx, req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Data, nil
+}
+
+// CreateNewRecord creates a new record.
+func (c *Client) CreateNewRecord(ctx context.Context, zoneID string, record Record) error {
+ endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(ctx, req, nil)
+}
+
+// DeleteRecord deletes a record.
+func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID, name, recordType string) error {
+ endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records", recordID)
+
+ query := endpoint.Query()
+ query.Set("name", name)
+ query.Set("type", recordType)
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(ctx, req, nil)
+}
+
+func (c *Client) do(ctx context.Context, req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set(authorizationHeader, "Bearer "+getToken(ctx))
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/gigahostno/internal/client_test.go b/providers/dns/gigahostno/internal/client_test.go
new file mode 100644
index 000000000..8d1298947
--- /dev/null
+++ b/providers/dns/gigahostno/internal/client_test.go
@@ -0,0 +1,149 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient()
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
+}
+
+func TestClient_GetZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/zones",
+ servermock.ResponseFromFixture("zones.json")).
+ Build(t)
+
+ zones, err := client.GetZones(mockContext(t))
+ require.NoError(t, err)
+
+ expected := []Zone{
+ {
+ ID: "123",
+ Name: "example.com",
+ NameDisplay: "example.com",
+ Type: "NATIVE",
+ Active: "1",
+ },
+ {
+ ID: "226",
+ Name: "example.org",
+ NameDisplay: "example.org",
+ Type: "NATIVE",
+ Active: "1",
+ },
+ {
+ ID: "229",
+ Name: "example.xn--zckzah",
+ NameDisplay: "example.テスト",
+ Type: "NATIVE",
+ Active: "1",
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_GetZones_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/zones",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.GetZones(mockContext(t))
+ require.EqualError(t, err, "401: 401 Unauthorized: 401 Unauthorized")
+}
+
+func TestClient_GetZoneRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/zones/123/records",
+ servermock.ResponseFromFixture("zone_records.json")).
+ Build(t)
+
+ zones, err := client.GetZoneRecords(mockContext(t), "123")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {
+ ID: "abc123",
+ Name: "@",
+ Type: "A",
+ Value: "185.125.168.166",
+ TTL: 3600,
+ },
+ {
+ ID: "def456",
+ Name: "www",
+ Type: "A",
+ Value: "185.125.168.166",
+ TTL: 3600,
+ },
+ {
+ ID: "ghi789",
+ Name: "@",
+ Type: "MX",
+ Value: "mail.example.no",
+ TTL: 3600,
+ },
+ {
+ ID: "jkl012",
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_CreateNewRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/zones/example.com/records",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ }
+
+ err := client.CreateNewRecord(mockContext(t), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("/dns/zones/123/records/abc123",
+ servermock.ResponseFromFixture("delete_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge").
+ With("type", "TXT")).
+ Build(t)
+
+ err := client.DeleteRecord(mockContext(t), "123", "abc123", "_acme-challenge", "TXT")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/authenticate-request.json b/providers/dns/gigahostno/internal/fixtures/authenticate-request.json
new file mode 100644
index 000000000..c641cd3e5
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/authenticate-request.json
@@ -0,0 +1,4 @@
+{
+ "username": "user",
+ "password": "secret"
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/authenticate.json b/providers/dns/gigahostno/internal/fixtures/authenticate.json
new file mode 100644
index 000000000..2c43ccbfe
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/authenticate.json
@@ -0,0 +1,23 @@
+{
+ "meta": {
+ "status": 200,
+ "status_message": "200 OK",
+ "maintenance": false
+ },
+ "data": {
+ "token": "secrettoken",
+ "token_expire": 1577836800,
+ "customer_id": "16030",
+ "contact_id": "15182",
+ "customer_name": "Cloudline AS",
+ "contact_username": "test@example.com",
+ "contact_access_level": "admin",
+ "customer_address": "Grønland 14",
+ "customer_zipcode": "5918",
+ "customer_city": "Frekhaug",
+ "customer_province": "Vestland",
+ "ga_secret": "ga_secret",
+ "ga_enabled": "1",
+ "vat": 1
+ }
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/create_record-request.json b/providers/dns/gigahostno/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..f8f0b5b11
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/create_record-request.json
@@ -0,0 +1,6 @@
+{
+ "record_name": "_acme-challenge",
+ "record_type": "TXT",
+ "record_value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "record_ttl": 120
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/create_record.json b/providers/dns/gigahostno/internal/fixtures/create_record.json
new file mode 100644
index 000000000..9232677d7
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/create_record.json
@@ -0,0 +1,7 @@
+{
+ "meta": {
+ "status": 201,
+ "status_message": "201 Created",
+ "message": "Record created successfully."
+ }
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/delete_record.json b/providers/dns/gigahostno/internal/fixtures/delete_record.json
new file mode 100644
index 000000000..9d87f2f42
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/delete_record.json
@@ -0,0 +1,7 @@
+{
+ "meta": {
+ "status": 200,
+ "status_message": "200 OK",
+ "message": "Record deleted successfully."
+ }
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/error.json b/providers/dns/gigahostno/internal/fixtures/error.json
new file mode 100644
index 000000000..f2fcfd437
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/error.json
@@ -0,0 +1,9 @@
+{
+ "meta": {
+ "status": 401,
+ "status_message": "401 Unauthorized",
+ "maintenance": false,
+ "message": "401 Unauthorized"
+ },
+ "data": []
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/zone_records.json b/providers/dns/gigahostno/internal/fixtures/zone_records.json
new file mode 100644
index 000000000..e67ff83f4
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/zone_records.json
@@ -0,0 +1,39 @@
+{
+ "meta": {
+ "status": 200,
+ "status_message": "200 OK"
+ },
+ "data": [
+ {
+ "record_id": "abc123",
+ "record_name": "@",
+ "record_type": "A",
+ "record_value": "185.125.168.166",
+ "record_ttl": 3600,
+ "record_priority": null
+ },
+ {
+ "record_id": "def456",
+ "record_name": "www",
+ "record_type": "A",
+ "record_value": "185.125.168.166",
+ "record_ttl": 3600,
+ "record_priority": null
+ },
+ {
+ "record_id": "ghi789",
+ "record_name": "@",
+ "record_type": "MX",
+ "record_value": "mail.example.no",
+ "record_ttl": 3600,
+ "record_priority": 10
+ },
+ {
+ "record_id": "jkl012",
+ "record_name": "_acme-challenge",
+ "record_type": "TXT",
+ "record_value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "record_ttl": 120
+ }
+ ]
+}
diff --git a/providers/dns/gigahostno/internal/fixtures/zones.json b/providers/dns/gigahostno/internal/fixtures/zones.json
new file mode 100644
index 000000000..d45b0ac49
--- /dev/null
+++ b/providers/dns/gigahostno/internal/fixtures/zones.json
@@ -0,0 +1,97 @@
+{
+ "meta": {
+ "status": 200,
+ "status_message": "200 OK",
+ "maintenance": false,
+ "message": "200 OK"
+ },
+ "data": [
+ {
+ "zone_id": "123",
+ "cust_id": "16030",
+ "order_id": "26117",
+ "zone_name": "example.com",
+ "zone_type": "NATIVE",
+ "zone_active": "1",
+ "zone_protected": "1",
+ "zone_is_registered": "1",
+ "domain_registrar": "norid",
+ "domain_status": "active",
+ "domain_registered_date": "2025-11-23 15:17:38",
+ "domain_expiry_date": "2026-11-23 15:17:38",
+ "domain_updated_date": "2025-11-23 16:17:38",
+ "domain_auto_renew": "1",
+ "domain_epp_id": "LEG2175D-NORID",
+ "domain_registrant_id": "CA19777O",
+ "domain_tech_id": "GH295R",
+ "domain_auth_info": "XXXXXXXXXXXXXXX",
+ "domain_locked": "0",
+ "domain_dnssec": "0",
+ "domain_dnssec_data": null,
+ "domain_protected_email": null,
+ "zone_created": "2025-11-23 16:17:29",
+ "zone_updated": 1700000000,
+ "external_dns": "0",
+ "record_count": 4,
+ "zone_name_display": "example.com"
+ },
+ {
+ "zone_id": "226",
+ "cust_id": "16030",
+ "order_id": "26114",
+ "zone_name": "example.org",
+ "zone_type": "NATIVE",
+ "zone_active": "1",
+ "zone_protected": "1",
+ "zone_is_registered": "1",
+ "domain_registrar": "norid",
+ "domain_status": "active",
+ "domain_registered_date": "2025-11-23 14:15:01",
+ "domain_expiry_date": "2026-11-23 14:15:01",
+ "domain_updated_date": "2025-11-23 15:15:02",
+ "domain_auto_renew": "1",
+ "domain_epp_id": "TEO218D-NORID",
+ "domain_registrant_id": "CA19774O",
+ "domain_tech_id": "GH295R",
+ "domain_auth_info": "XXXXXXXXXXXXXX",
+ "domain_locked": "0",
+ "domain_dnssec": "0",
+ "domain_dnssec_data": null,
+ "domain_protected_email": null,
+ "zone_created": "2025-11-23 15:13:27",
+ "zone_updated": 1700000000,
+ "external_dns": "0",
+ "record_count": 5,
+ "zone_name_display": "example.org"
+ },
+ {
+ "zone_id": "229",
+ "cust_id": "16030",
+ "order_id": "26119",
+ "zone_name": "example.xn--zckzah",
+ "zone_type": "NATIVE",
+ "zone_active": "1",
+ "zone_protected": "1",
+ "zone_is_registered": "1",
+ "domain_registrar": "norid",
+ "domain_status": "active",
+ "domain_registered_date": "2014-12-01 12:40:48",
+ "domain_expiry_date": "2026-12-01 12:40:48",
+ "domain_updated_date": "2025-11-23 15:37:36",
+ "domain_auto_renew": "1",
+ "domain_epp_id": "DIT1003D-NORID",
+ "domain_registrant_id": "DCA822O",
+ "domain_tech_id": "GH295R",
+ "domain_auth_info": "XXXXXXXXXXXXXX",
+ "domain_locked": "0",
+ "domain_dnssec": "0",
+ "domain_dnssec_data": null,
+ "domain_protected_email": null,
+ "zone_created": "2025-11-23 16:37:15",
+ "zone_updated": 1700000000,
+ "external_dns": "0",
+ "record_count": 4,
+ "zone_name_display": "example.\u30C6\u30B9\u30C8"
+ }
+ ]
+}
diff --git a/providers/dns/gigahostno/internal/identity.go b/providers/dns/gigahostno/internal/identity.go
new file mode 100644
index 000000000..262dfabdd
--- /dev/null
+++ b/providers/dns/gigahostno/internal/identity.go
@@ -0,0 +1,122 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ "github.com/pquerna/otp/totp"
+)
+
+type token string
+
+const tokenKey token = "token"
+
+type Identifier struct {
+ username string
+ password string
+ Secret string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+func NewIdentifier(username, password, secret string) (*Identifier, error) {
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Identifier{
+ username: username,
+ password: password,
+ Secret: secret,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Identifier) Authenticate(ctx context.Context) (*Token, error) {
+ endpoint := c.BaseURL.JoinPath("authenticate")
+
+ auth := Auth{Username: c.username, Password: c.password}
+
+ if c.Secret != "" {
+ tan, err := totp.GenerateCode(c.Secret, time.Now())
+ if err != nil {
+ return nil, fmt.Errorf("generate TOTP: %w", err)
+ }
+
+ auth.Code, err = strconv.Atoi(tan)
+ if err != nil {
+ return nil, fmt.Errorf("parse TOTP: %w", err)
+ }
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, auth)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[*Token]
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Data, nil
+}
+
+func (c *Identifier) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func WithContext(ctx context.Context, credential string) context.Context {
+ return context.WithValue(ctx, tokenKey, credential)
+}
+
+func getToken(ctx context.Context) string {
+ credential, ok := ctx.Value(tokenKey).(string)
+ if !ok {
+ return ""
+ }
+
+ return credential
+}
diff --git a/providers/dns/gigahostno/internal/identity_test.go b/providers/dns/gigahostno/internal/identity_test.go
new file mode 100644
index 000000000..09d72746a
--- /dev/null
+++ b/providers/dns/gigahostno/internal/identity_test.go
@@ -0,0 +1,108 @@
+package internal
+
+import (
+ "context"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupIdentifierClient(server *httptest.Server) (*Identifier, error) {
+ client, err := NewIdentifier("user", "secret", "")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func mockContext(t *testing.T) context.Context {
+ t.Helper()
+
+ return context.WithValue(t.Context(), tokenKey, "secret")
+}
+
+func TestIdentifier_Authenticate(t *testing.T) {
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifierClient).
+ Route("POST /authenticate",
+ servermock.ResponseFromFixture("authenticate.json"),
+ servermock.CheckRequestJSONBodyFromFixture("authenticate-request.json")).
+ Build(t)
+
+ token, err := identifier.Authenticate(context.Background())
+ require.NoError(t, err)
+
+ expected := &Token{
+ Token: "secrettoken",
+ TokenExpire: 1577836800,
+ CustomerID: "16030",
+ ContactID: "15182",
+ CustomerName: "Cloudline AS",
+ ContactUsername: "test@example.com",
+ ContactAccessLevel: "admin",
+ CustomerAddress: "Grønland 14",
+ CustomerZipcode: "5918",
+ CustomerCity: "Frekhaug",
+ CustomerProvince: "Vestland",
+ GASecret: "ga_secret",
+ GAEnabled: "1",
+ VAT: 1,
+ }
+
+ assert.Equal(t, expected, token)
+}
+
+func TestToken_IsExpired(t *testing.T) {
+ testCases := []struct {
+ desc string
+ token *Token
+ assert assert.BoolAssertionFunc
+ }{
+ {
+ desc: "nil",
+ assert: assert.True,
+ },
+ {
+ desc: "empty",
+ token: &Token{},
+ assert: assert.True,
+ },
+ {
+ desc: "not expired",
+ token: &Token{
+ TokenExpire: 65322892800, // 2040-01-01
+ },
+ assert: assert.False,
+ },
+ {
+ desc: "now",
+ token: &Token{
+ TokenExpire: time.Now().Unix(),
+ },
+ assert: assert.True,
+ },
+ {
+ desc: "now + 2 minutes",
+ token: &Token{
+ TokenExpire: time.Now().Add(2 * time.Minute).Unix(),
+ },
+ assert: assert.False,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ test.assert(t, test.token.IsExpired())
+ })
+ }
+}
diff --git a/providers/dns/gigahostno/internal/types.go b/providers/dns/gigahostno/internal/types.go
new file mode 100644
index 000000000..e998dc084
--- /dev/null
+++ b/providers/dns/gigahostno/internal/types.go
@@ -0,0 +1,73 @@
+package internal
+
+import (
+ "fmt"
+ "time"
+)
+
+type APIError struct {
+ Meta MetaData `json:"meta"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%d: %s: %s", a.Meta.Status, a.Meta.StatusMessage, a.Meta.Message)
+}
+
+type MetaData struct {
+ Status int `json:"status,omitempty"`
+ StatusMessage string `json:"status_message,omitempty"`
+ Maintenance bool `json:"maintenance"`
+ Message string `json:"message,omitempty"`
+}
+
+type APIResponse[T any] struct {
+ Meta MetaData `json:"meta"`
+ Data T `json:"data,omitempty"`
+}
+
+type Zone struct {
+ ID string `json:"zone_id,omitempty"`
+ Name string `json:"zone_name,omitempty"`
+ NameDisplay string `json:"zone_name_display,omitempty"`
+ Type string `json:"zone_type,omitempty"`
+ Active string `json:"zone_active,omitempty"`
+}
+
+type Record struct {
+ ID string `json:"record_id,omitempty"`
+ Name string `json:"record_name,omitempty"`
+ Type string `json:"record_type,omitempty"`
+ Value string `json:"record_value,omitempty"`
+ TTL int `json:"record_ttl,omitempty"`
+}
+
+type Auth struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Code int `json:"code,omitempty"`
+}
+
+type Token struct {
+ Token string `json:"token,omitempty"`
+ TokenExpire int64 `json:"token_expire,omitempty"`
+ CustomerID string `json:"customer_id,omitempty"`
+ ContactID string `json:"contact_id,omitempty"`
+ CustomerName string `json:"customer_name,omitempty"`
+ ContactUsername string `json:"contact_username,omitempty"`
+ ContactAccessLevel string `json:"contact_access_level,omitempty"`
+ CustomerAddress string `json:"customer_address,omitempty"`
+ CustomerZipcode string `json:"customer_zipcode,omitempty"`
+ CustomerCity string `json:"customer_city,omitempty"`
+ CustomerProvince string `json:"customer_province,omitempty"`
+ GASecret string `json:"ga_secret,omitempty"`
+ GAEnabled string `json:"ga_enabled,omitempty"`
+ VAT int `json:"vat,omitempty"`
+}
+
+func (t *Token) IsExpired() bool {
+ if t == nil {
+ return true
+ }
+
+ return time.Now().UTC().Add(1 * time.Minute).After(time.Unix(t.TokenExpire, 0).UTC())
+}
diff --git a/providers/dns/glesys/glesys.go b/providers/dns/glesys/glesys.go
index 4b0d545ed..729756235 100644
--- a/providers/dns/glesys/glesys.go
+++ b/providers/dns/glesys/glesys.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/glesys/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -99,6 +100,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -133,6 +136,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// save data necessary for CleanUp
d.activeRecords[info.EffectiveFQDN] = recordID
+
return nil
}
@@ -143,6 +147,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
// acquire lock and retrieve authZone
d.inProgressMu.Lock()
defer d.inProgressMu.Unlock()
+
if _, ok := d.activeRecords[info.EffectiveFQDN]; !ok {
// if there is no cleanup information then just return
return nil
diff --git a/providers/dns/glesys/glesys.toml b/providers/dns/glesys/glesys.toml
index 146b24517..c0e2613b8 100644
--- a/providers/dns/glesys/glesys.toml
+++ b/providers/dns/glesys/glesys.toml
@@ -7,7 +7,7 @@ Since = "v0.5.0"
Example = '''
GLESYS_API_USER=xxxxx \
GLESYS_API_KEY=yyyyy \
-lego --email you@example.com --dns glesys -d '*.example.com' -d example.com run
+lego --dns glesys -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns glesys -d '*.example.com' -d example.com run
GLESYS_API_USER = "API user"
GLESYS_API_KEY = "API key"
[Configuration.Additional]
- GLESYS_POLLING_INTERVAL = "Time between DNS propagation check"
- GLESYS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- GLESYS_TTL = "The TTL of the TXT record used for the DNS challenge"
- GLESYS_HTTP_TIMEOUT = "API request timeout"
+ GLESYS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)"
+ GLESYS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)"
+ GLESYS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ GLESYS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://github.com/GleSYS/API/wiki/API-Documentation"
diff --git a/providers/dns/glesys/glesys_test.go b/providers/dns/glesys/glesys_test.go
index d5fdf36da..f2d65e514 100644
--- a/providers/dns/glesys/glesys_test.go
+++ b/providers/dns/glesys/glesys_test.go
@@ -56,6 +56,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -130,6 +131,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -143,6 +145,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/glesys/internal/client.go b/providers/dns/glesys/internal/client.go
index 038c6f0d5..ee6ebc058 100644
--- a/providers/dns/glesys/internal/client.go
+++ b/providers/dns/glesys/internal/client.go
@@ -24,7 +24,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(apiUser string, apiKey string) *Client {
+func NewClient(apiUser, apiKey string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
@@ -102,6 +102,7 @@ func (c *Client) do(req *http.Request) (*apiResponse, error) {
}
var response apiResponse
+
err = json.Unmarshal(raw, &response)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/glesys/internal/client_test.go b/providers/dns/glesys/internal/client_test.go
index 7e8ca9724..cd71757ff 100644
--- a/providers/dns/glesys/internal/client_test.go
+++ b/providers/dns/glesys/internal/client_test.go
@@ -1,79 +1,49 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- 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
- }
-
- apiUser, apiKey, ok := req.BasicAuth()
- if apiUser != "user" || apiKey != "secret" || !ok {
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("user", "secret"),
+ )
}
func TestClient_AddTXTRecord(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/domain/addrecord", http.StatusOK, "add-record.json")
+ client := mockBuilder().
+ Route("POST /domain/addrecord",
+ servermock.ResponseFromFixture("add-record.json"),
+ servermock.CheckRequestJSONBody(`{"domainname":"example.com","host":"foo","type":"TXT","data":"txt","ttl":120}`)).
+ Build(t)
- recordID, err := client.AddTXTRecord(context.Background(), "example.com", "foo", "txt", 120)
+ recordID, err := client.AddTXTRecord(t.Context(), "example.com", "foo", "txt", 120)
require.NoError(t, err)
assert.Equal(t, 123, recordID)
}
func TestClient_DeleteTXTRecord(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/domain/deleterecord", http.StatusOK, "delete-record.json")
+ client := mockBuilder().
+ Route("POST /domain/deleterecord",
+ servermock.ResponseFromFixture("delete-record.json"),
+ servermock.CheckRequestJSONBody(`{"recordid":123}`)).
+ Build(t)
- err := client.DeleteTXTRecord(context.Background(), 123)
+ err := client.DeleteTXTRecord(t.Context(), 123)
require.NoError(t, err)
}
diff --git a/providers/dns/godaddy/godaddy.go b/providers/dns/godaddy/godaddy.go
index bc0f42339..1603bb57e 100644
--- a/providers/dns/godaddy/godaddy.go
+++ b/providers/dns/godaddy/godaddy.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/godaddy/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -46,7 +47,7 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -95,6 +96,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -128,6 +131,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
}
var newRecords []internal.DNSRecord
+
for _, record := range existingRecords {
if record.Data != "" {
newRecords = append(newRecords, record)
@@ -174,6 +178,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
var recordsToKeep []internal.DNSRecord
+
for _, record := range existingRecords {
if record.Data != info.Value && record.Data != "" {
recordsToKeep = append(recordsToKeep, record)
diff --git a/providers/dns/godaddy/godaddy.toml b/providers/dns/godaddy/godaddy.toml
index aa835d087..b906605b3 100644
--- a/providers/dns/godaddy/godaddy.toml
+++ b/providers/dns/godaddy/godaddy.toml
@@ -7,7 +7,7 @@ Since = "v0.5.0"
Example = '''
GODADDY_API_KEY=xxxxxxxx \
GODADDY_API_SECRET=yyyyyyyy \
-lego --email you@example.com --dns godaddy -d '*.example.com' -d example.com run
+lego --dns godaddy -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -24,10 +24,10 @@ https://community.letsencrypt.org/t/getting-unauthorized-url-error-while-trying-
GODADDY_API_KEY = "API key"
GODADDY_API_SECRET = "API secret"
[Configuration.Additional]
- GODADDY_POLLING_INTERVAL = "Time between DNS propagation check"
- GODADDY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- GODADDY_TTL = "The TTL of the TXT record used for the DNS challenge"
- GODADDY_HTTP_TIMEOUT = "API request timeout"
+ GODADDY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ GODADDY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ GODADDY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ GODADDY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developer.godaddy.com/doc/endpoint/domains"
diff --git a/providers/dns/godaddy/godaddy_test.go b/providers/dns/godaddy/godaddy_test.go
index 4cb5f2721..38b39672e 100644
--- a/providers/dns/godaddy/godaddy_test.go
+++ b/providers/dns/godaddy/godaddy_test.go
@@ -56,6 +56,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -126,6 +127,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -139,6 +141,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/godaddy/internal/client.go b/providers/dns/godaddy/internal/client.go
index 1902fc1fd..9dd337ddc 100644
--- a/providers/dns/godaddy/internal/client.go
+++ b/providers/dns/godaddy/internal/client.go
@@ -26,7 +26,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(apiKey string, apiSecret string) *Client {
+func NewClient(apiKey, apiSecret string) *Client {
baseURL, _ := url.Parse(DefaultBaseURL)
return &Client{
@@ -48,6 +48,7 @@ func (c *Client) GetRecords(ctx context.Context, domainZone, rType, recordName s
}
var records []DNSRecord
+
err = c.do(req, &records)
if err != nil {
return nil, err
@@ -141,6 +142,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/godaddy/internal/client_test.go b/providers/dns/godaddy/internal/client_test.go
index 50d193bdb..694a16565 100644
--- a/providers/dns/godaddy/internal/client_test.go
+++ b/providers/dns/godaddy/internal/client_test.go
@@ -1,40 +1,35 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("key", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("key", "secret")
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("sso-key key:secret"))
}
func TestClient_GetRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/example.com/records/TXT/", servermock.ResponseFromFixture("getrecords.json")).
+ Build(t)
- mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusOK, "getrecords.json"))
-
- records, err := client.GetRecords(context.Background(), "example.com", "TXT", "")
+ records, err := client.GetRecords(t.Context(), "example.com", "TXT", "")
require.NoError(t, err)
expected := []DNSRecord{
@@ -50,30 +45,21 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecords_errors(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /v1/domains/example.com/records/TXT/",
+ servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity)).
+ Build(t)
- mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusUnprocessableEntity, "errors.json"))
-
- records, err := client.GetRecords(context.Background(), "example.com", "TXT", "")
+ records, err := client.GetRecords(t.Context(), "example.com", "TXT", "")
require.EqualError(t, err, "[status code: 422] INVALID_BODY: Request body doesn't fulfill schema, see details in `fields`")
assert.Nil(t, records)
}
func TestClient_UpdateTxtRecords(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPut {
- http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "sso-key key:secret" {
- http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized)
- return
- }
- })
+ client := mockBuilder().
+ Route("PUT /v1/domains/example.com/records/TXT/lego", nil,
+ servermock.CheckRequestJSONBodyFromFixture("update_records-request.json")).
+ Build(t)
records := []DNSRecord{
{Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600},
@@ -84,15 +70,16 @@ func TestClient_UpdateTxtRecords(t *testing.T) {
{Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600},
}
- err := client.UpdateTxtRecords(context.Background(), records, "example.com", "lego")
+ err := client.UpdateTxtRecords(t.Context(), records, "example.com", "lego")
require.NoError(t, err)
}
func TestClient_UpdateTxtRecords_errors(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/domains/example.com/records/TXT/lego",
- testHandler(http.MethodPut, http.StatusUnprocessableEntity, "errors.json"))
+ client := mockBuilder().
+ Route("PUT /v1/domains/example.com/records/TXT/lego",
+ servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity),
+ servermock.CheckRequestJSONBodyFromFixture("update_records-request.json")).
+ Build(t)
records := []DNSRecord{
{Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600},
@@ -103,59 +90,26 @@ func TestClient_UpdateTxtRecords_errors(t *testing.T) {
{Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600},
}
- err := client.UpdateTxtRecords(context.Background(), records, "example.com", "lego")
+ err := client.UpdateTxtRecords(t.Context(), records, "example.com", "lego")
require.EqualError(t, err, "[status code: 422] INVALID_BODY: Request body doesn't fulfill schema, see details in `fields`")
}
func TestClient_DeleteTxtRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /v1/domains/example.com/records/TXT/foo",
+ servermock.Noop().WithStatusCode(http.StatusNoContent)).
+ Build(t)
- mux.HandleFunc("/v1/domains/example.com/records/TXT/foo", testHandler(http.MethodDelete, http.StatusNoContent, ""))
-
- err := client.DeleteTxtRecords(context.Background(), "example.com", "foo")
+ err := client.DeleteTxtRecords(t.Context(), "example.com", "foo")
require.NoError(t, err)
}
func TestClient_DeleteTxtRecords_errors(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /v1/domains/example.com/records/TXT/foo",
+ servermock.ResponseFromFixture("error-extended.json").WithStatusCode(http.StatusConflict)).
+ Build(t)
- mux.HandleFunc("/v1/domains/example.com/records/TXT/foo", testHandler(http.MethodDelete, http.StatusConflict, "error-extended.json"))
-
- err := client.DeleteTxtRecords(context.Background(), "example.com", "foo")
+ err := client.DeleteTxtRecords(t.Context(), "example.com", "foo")
require.EqualError(t, err, "[status code: 409] ACCESS_DENIED: Authenticated user is not allowed access [test: content (path=/foo) (pathRelated=/bar)]")
}
-
-func testHandler(method string, statusCode int, filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "sso-key key:secret" {
- http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(statusCode)
-
- if statusCode == http.StatusNoContent {
- return
- }
-
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
- }
-}
diff --git a/providers/dns/godaddy/internal/fixtures/update_records-request.json b/providers/dns/godaddy/internal/fixtures/update_records-request.json
new file mode 100644
index 000000000..969afb2dc
--- /dev/null
+++ b/providers/dns/godaddy/internal/fixtures/update_records-request.json
@@ -0,0 +1,38 @@
+[
+ {
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "data": " ",
+ "ttl": 600
+ },
+ {
+ "name": "_acme-challenge.example",
+ "type": "TXT",
+ "data": "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU",
+ "ttl": 600
+ },
+ {
+ "name": "_acme-challenge.example",
+ "type": "TXT",
+ "data": "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek",
+ "ttl": 600
+ },
+ {
+ "name": "_acme-challenge.lego",
+ "type": "TXT",
+ "data": " ",
+ "ttl": 600
+ },
+ {
+ "name": "_acme-challenge.lego",
+ "type": "TXT",
+ "data": "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A",
+ "ttl": 600
+ },
+ {
+ "name": "_acme-challenge.lego",
+ "type": "TXT",
+ "data": "acme",
+ "ttl": 600
+ }
+]
diff --git a/providers/dns/godaddy/internal/types.go b/providers/dns/godaddy/internal/types.go
index a97a97896..3bd5c9560 100644
--- a/providers/dns/godaddy/internal/types.go
+++ b/providers/dns/godaddy/internal/types.go
@@ -1,6 +1,9 @@
package internal
-import "fmt"
+import (
+ "fmt"
+ "strings"
+)
// DNSRecord a DNS record.
type DNSRecord struct {
@@ -23,13 +26,16 @@ type APIError struct {
}
func (a APIError) Error() string {
- msg := fmt.Sprintf("%s: %s", a.Code, a.Message)
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "%s: %s", a.Code, a.Message)
for _, field := range a.Fields {
- msg += " " + field.String()
+ msg.WriteString(" ")
+ msg.WriteString(field.String())
}
- return msg
+ return msg.String()
}
type Field struct {
diff --git a/providers/dns/googledomains/googledomains.go b/providers/dns/googledomains/googledomains.go
index 933929147..b5eed0b03 100644
--- a/providers/dns/googledomains/googledomains.go
+++ b/providers/dns/googledomains/googledomains.go
@@ -2,17 +2,12 @@
package googledomains
import (
- "context"
"errors"
- "fmt"
"net/http"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
- "github.com/go-acme/lego/v4/platform/config/env"
- "google.golang.org/api/acmedns/v1"
- "google.golang.org/api/option"
)
// Environment variables names.
@@ -37,103 +32,29 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
- return &Config{
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
- HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
- },
- }
+ return &Config{}
}
+type DNSProvider struct{}
+
// NewDNSProvider returns the Google Domains DNS provider with a default configuration.
func NewDNSProvider() (*DNSProvider, error) {
- values, err := env.Get(EnvAccessToken)
- if err != nil {
- return nil, fmt.Errorf("googledomains: %w", err)
- }
-
- config := NewDefaultConfig()
- config.AccessToken = values[EnvAccessToken]
-
- return NewDNSProviderConfig(config)
+ return NewDNSProviderConfig(&Config{})
}
// NewDNSProviderConfig returns the Google Domains DNS provider with the provided config.
-func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
- if config == nil {
- return nil, errors.New("googledomains: the configuration of the DNS provider is nil")
- }
-
- if config.AccessToken == "" {
- return nil, errors.New("googledomains: access token is missing")
- }
-
- service, err := acmedns.NewService(context.Background(), option.WithHTTPClient(config.HTTPClient))
- if err != nil {
- return nil, fmt.Errorf("googledomains: error creating acme dns service: %w", err)
- }
-
- return &DNSProvider{
- config: config,
- acmedns: service,
- }, nil
+func NewDNSProviderConfig(_ *Config) (*DNSProvider, error) {
+ return nil, errors.New("googledomains: provider has shut down")
}
-type DNSProvider struct {
- config *Config
- acmedns *acmedns.Service
-}
-
-func (d *DNSProvider) Present(domain, token, keyAuth string) error {
- zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
- if err != nil {
- return fmt.Errorf("googledomains: could not find zone for domain %q: %w", domain, err)
- }
-
- rotateReq := acmedns.RotateChallengesRequest{
- AccessToken: d.config.AccessToken,
- RecordsToAdd: []*acmedns.AcmeTxtRecord{getAcmeTxtRecord(domain, keyAuth)},
- KeepExpiredRecords: false,
- }
-
- call := d.acmedns.AcmeChallengeSets.RotateChallenges(zone, &rotateReq)
- _, err = call.Do()
- if err != nil {
- return fmt.Errorf("googledomains: error adding challenge for domain %s: %w", domain, err)
- }
+func (d *DNSProvider) Present(_, _, _ string) error {
return nil
}
-func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
- zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
- if err != nil {
- return fmt.Errorf("googledomains: could not find zone for domain %q: %w", domain, err)
- }
-
- rotateReq := acmedns.RotateChallengesRequest{
- AccessToken: d.config.AccessToken,
- RecordsToRemove: []*acmedns.AcmeTxtRecord{getAcmeTxtRecord(domain, keyAuth)},
- KeepExpiredRecords: false,
- }
-
- call := d.acmedns.AcmeChallengeSets.RotateChallenges(zone, &rotateReq)
- _, err = call.Do()
- if err != nil {
- return fmt.Errorf("googledomains: error cleaning up challenge for domain %s: %w", domain, err)
- }
+func (d *DNSProvider) CleanUp(_, _, _ string) error {
return nil
}
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
- return d.config.PropagationTimeout, d.config.PollingInterval
-}
-
-func getAcmeTxtRecord(domain, keyAuth string) *acmedns.AcmeTxtRecord {
- challengeInfo := dns01.GetChallengeInfo(domain, keyAuth)
-
- return &acmedns.AcmeTxtRecord{
- Fqdn: challengeInfo.EffectiveFQDN,
- Digest: challengeInfo.Value,
- }
+ return dns01.DefaultPropagationTimeout, dns01.DefaultPollingInterval
}
diff --git a/providers/dns/googledomains/googledomains.toml b/providers/dns/googledomains/googledomains.toml
index 97e5452cc..52330795d 100644
--- a/providers/dns/googledomains/googledomains.toml
+++ b/providers/dns/googledomains/googledomains.toml
@@ -1,21 +1,23 @@
Name = "Google Domains"
-Description = ''''''
-URL = "https://domains.google"
+Description = '''
+The Google Domains DNS provider has shut down.
+'''
+URL = "https://github.com/go-acme/lego/issues/2553"
Code = "googledomains"
Since = "v4.11.0"
Example = '''
GOOGLE_DOMAINS_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns googledomains -d '*.example.com' -d example.com run
+lego --dns googledomains -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
GOOGLE_DOMAINS_ACCESS_TOKEN = "Access token"
[Configuration.Additional]
- GOOGLE_DOMAINS_POLLING_INTERVAL = "Time between DNS propagation check"
- GOOGLE_DOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- GOOGLE_DOMAINS_HTTP_TIMEOUT = "API request timeout"
+ GOOGLE_DOMAINS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ GOOGLE_DOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ GOOGLE_DOMAINS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
GoClient = "https://github.com/googleapis/google-api-go-client"
diff --git a/providers/dns/gravity/gravity.go b/providers/dns/gravity/gravity.go
new file mode 100644
index 000000000..b0bbb2fcb
--- /dev/null
+++ b/providers/dns/gravity/gravity.go
@@ -0,0 +1,209 @@
+// Package gravity implements a DNS provider for solving the DNS-01 challenge using Gravity.
+package gravity
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/gravity/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/google/uuid"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "GRAVITY_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+ EnvServerURL = envNamespace + "SERVER_URL"
+
+ 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.
+type Config struct {
+ Username string
+ Password string
+ ServerURL string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ SequenceInterval 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),
+ SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 1*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
+
+ records map[string]internal.Record
+ recordsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Gravity.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword, EnvServerURL)
+ if err != nil {
+ return nil, fmt.Errorf("gravity: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+ config.ServerURL = values[EnvServerURL]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Gravity.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("gravity: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.ServerURL, config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("gravity: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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 {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ _, err := d.client.Login(ctx)
+ if err != nil {
+ return fmt.Errorf("gravity: login: %w", err)
+ }
+
+ zone, err := d.findZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("gravity: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
+ if err != nil {
+ return fmt.Errorf("gravity: %w", err)
+ }
+
+ id := uuid.New()
+
+ record := internal.Record{
+ Data: info.Value,
+ Hostname: subDomain,
+ Type: "TXT",
+ UID: id.String(),
+ }
+
+ err = d.client.CreateDNSRecord(ctx, zone, record)
+ if err != nil {
+ return fmt.Errorf("gravity: create DNS record: %w", err)
+ }
+
+ d.recordsMu.Lock()
+
+ record.Fqdn = zone
+ d.records[token] = record
+ d.recordsMu.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)
+
+ d.recordsMu.Lock()
+ record, ok := d.records[token]
+ d.recordsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("gravity: unknown record for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err := d.client.DeleteDNSRecord(context.Background(), record.Fqdn, record)
+ if err != nil {
+ return fmt.Errorf("gravity: delete record: %w", err)
+ }
+
+ d.recordsMu.Lock()
+ delete(d.records, token)
+ d.recordsMu.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
+}
+
+// Sequential implements the [dns01.sequential] interface.
+// It changes the behavior of the provider to resolve DNS challenges sequentially.
+// Returns the interval between each iteration.
+//
+// Gravity supports adding multiple records for the same domain, but the DNS server doesn't work as expected:
+// if you call the DNS server, it will answer only the latest record instead of all of them.
+func (d *DNSProvider) Sequential() time.Duration {
+ return d.config.SequenceInterval
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, effectiveFQDN string) (string, error) {
+ var zone string
+
+ for fqdn := range dns01.DomainsSeq(effectiveFQDN) {
+ zones, err := d.client.GetDNSZones(ctx, fqdn)
+ if err != nil {
+ return "", fmt.Errorf("get DNS zones: %w", err)
+ }
+
+ if len(zones) != 0 {
+ zone = zones[0].Name
+ break
+ }
+ }
+
+ if zone == "" {
+ return "", fmt.Errorf("could not find zone for %q", effectiveFQDN)
+ }
+
+ return zone, nil
+}
diff --git a/providers/dns/gravity/gravity.toml b/providers/dns/gravity/gravity.toml
new file mode 100644
index 000000000..87a303839
--- /dev/null
+++ b/providers/dns/gravity/gravity.toml
@@ -0,0 +1,26 @@
+Name = "Gravity"
+Description = ''''''
+URL = "https://gravity.beryju.io/"
+Code = "gravity"
+Since = "v4.30.0"
+
+Example = '''
+GRAVITY_SERVER_URL="https://example.org:1234" \
+GRAVITY_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \
+GRAVITY_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \
+lego --dns gravity -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ GRAVITY_SERVER_URL = "URL of the server"
+ GRAVITY_USERNAME = "Username"
+ GRAVITY_PASSWORD = "Password"
+ [Configuration.Additional]
+ GRAVITY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ GRAVITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ GRAVITY_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 1)"
+ GRAVITY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://gravity.beryju.io/docs/api/reference/"
diff --git a/providers/dns/gravity/gravity_test.go b/providers/dns/gravity/gravity_test.go
new file mode 100644
index 000000000..b59b856fe
--- /dev/null
+++ b/providers/dns/gravity/gravity_test.go
@@ -0,0 +1,254 @@
+package gravity
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/gravity/internal"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvUsername,
+ EnvPassword,
+ EnvServerURL,
+).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",
+ EnvServerURL: "https://example.org:1234",
+ },
+ },
+ {
+ desc: "missing EnvUsername",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "secret",
+ EnvServerURL: "https://example.org:1234",
+ },
+ expected: "gravity: some credentials information are missing: GRAVITY_USERNAME",
+ },
+ {
+ desc: "missing EnvPassword",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "",
+ EnvServerURL: "https://example.org:1234",
+ },
+ expected: "gravity: some credentials information are missing: GRAVITY_PASSWORD",
+ },
+ {
+ desc: "missing EnvServerURL",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "secret",
+ EnvServerURL: "",
+ },
+ expected: "gravity: some credentials information are missing: GRAVITY_SERVER_URL",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "gravity: some credentials information are missing: GRAVITY_USERNAME,GRAVITY_PASSWORD,GRAVITY_SERVER_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 == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ username string
+ password string
+ serverURL string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "user",
+ password: "secret",
+ serverURL: "https://example.org:1234",
+ },
+ {
+ desc: "missing username",
+ username: "",
+ password: "secret",
+ serverURL: "https://example.org:1234",
+ expected: "gravity: credentials missing",
+ },
+ {
+ desc: "missing password",
+ username: "user",
+ password: "",
+ serverURL: "https://example.org:1234",
+ expected: "gravity: credentials missing",
+ },
+ {
+ desc: "missing server URL",
+ username: "user",
+ password: "secret",
+ serverURL: "",
+ expected: "gravity: server URL missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "gravity: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Username = test.username
+ config.Password = test.password
+ config.ServerURL = test.serverURL
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+
+ config.Username = "user"
+ config.Password = "secret"
+ config.ServerURL = server.URL
+
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /api/v1/auth/login",
+ servermock.ResponseFromInternal("login.json"),
+ servermock.CheckRequestJSONBodyFromInternal("login-request.json")).
+ Route("GET /api/v1/dns/",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ if req.URL.Query().Get("name") != "example.com." {
+ servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req)
+ return
+ }
+
+ servermock.ResponseFromInternal("zones_empty.json").ServeHTTP(rw, req)
+ }),
+ ).
+ Route("POST /api/v1/dns/zones/records",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckQueryParameter().Strict().
+ With("zone", "example.com.").
+ WithRegexp("uid", `\w{8}-\w{4}-\w{4}-\w{4}-\w{12}`).
+ With("hostname", "_acme-challenge")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /api/v1/dns/zones/records",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckQueryParameter().Strict().
+ With("zone", "example.com.").
+ With("uid", "123").
+ With("type", "TXT").
+ With("hostname", "_acme-challenge")).
+ Build(t)
+
+ provider.records["abc"] = internal.Record{
+ Fqdn: "example.com.",
+ Hostname: "_acme-challenge",
+ Type: "TXT",
+ UID: "123",
+ }
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/gravity/internal/client.go b/providers/dns/gravity/internal/client.go
new file mode 100644
index 000000000..41c6294c3
--- /dev/null
+++ b/providers/dns/gravity/internal/client.go
@@ -0,0 +1,234 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ "golang.org/x/net/publicsuffix"
+)
+
+// Client the Gravity API client.
+type Client struct {
+ username string
+ password string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(serverURL, username, password string) (*Client, error) {
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ if serverURL == "" {
+ return nil, errors.New("server URL missing")
+ }
+
+ baseURL, err := url.Parse(serverURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ username: username,
+ password: password,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) Login(ctx context.Context) (*Auth, error) {
+ jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
+ if err != nil {
+ return nil, err
+ }
+
+ c.HTTPClient.Jar = jar
+
+ login := Login{
+ Username: c.username,
+ Password: c.password,
+ }
+
+ endpoint := c.baseURL.JoinPath("api", "v1", "auth", "login")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, login)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &Auth{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) Me(ctx context.Context) (*UserInfo, error) {
+ endpoint := c.baseURL.JoinPath("api", "v1", "auth", "me")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &UserInfo{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, err
+}
+
+func (c *Client) GetDNSZones(ctx context.Context, name string) ([]Zone, error) {
+ endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones")
+
+ if name != "" {
+ query := endpoint.Query()
+ query.Set("name", name)
+ endpoint.RawQuery = query.Encode()
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := Zones{}
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Zones, nil
+}
+
+func (c *Client) CreateDNSRecord(ctx context.Context, zone string, record Record) error {
+ endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones", "records")
+
+ query := endpoint.Query()
+
+ query.Set("zone", zone)
+ query.Set("hostname", record.Hostname)
+
+ // When the UID is the same as an existing one, the record is updated, else a new record is created.
+ // An explicit UID is not required to create a record.
+ if record.UID != "" {
+ query.Set("uid", record.UID)
+ }
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) DeleteDNSRecord(ctx context.Context, zone string, record Record) error {
+ endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones", "records")
+
+ query := endpoint.Query()
+
+ query.Set("zone", zone)
+ query.Set("hostname", record.Hostname)
+ query.Set("uid", record.UID)
+ query.Set("type", record.Type)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/gravity/internal/client_test.go b/providers/dns/gravity/internal/client_test.go
new file mode 100644
index 000000000..98b17c59e
--- /dev/null
+++ b/providers/dns/gravity/internal/client_test.go
@@ -0,0 +1,160 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestClient_Login(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/v1/auth/login",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ http.SetCookie(rw, &http.Cookie{
+ Name: "gravity_session",
+ Value: "session_id",
+ Path: "/",
+ })
+
+ servermock.ResponseFromFixture("login.json").ServeHTTP(rw, req)
+ }),
+ servermock.CheckRequestJSONBodyFromFixture("login-request.json")).
+ Build(t)
+
+ auth, err := client.Login(t.Context())
+ require.NoError(t, err)
+
+ cookies := client.HTTPClient.Jar.Cookies(client.baseURL)
+
+ require.Len(t, cookies, 1)
+
+ assert.Equal(t, "gravity_session", cookies[0].Name)
+ assert.Equal(t, "session_id", cookies[0].Value)
+
+ expected := &Auth{Successful: true}
+
+ assert.Equal(t, expected, auth)
+}
+
+func TestClient_Login_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/v1/auth/login",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.Login(t.Context())
+ require.EqualError(t, err, "status: UNAUTHENTICATED, error: unauthenticated, additionalProp1: string")
+}
+
+func TestClient_Me(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/v1/auth/me",
+ servermock.ResponseFromFixture("me.json")).
+ Build(t)
+
+ info, err := client.Me(t.Context())
+ require.NoError(t, err)
+
+ expected := &UserInfo{
+ Username: "admin",
+ Authenticated: true,
+ Permissions: []Permission{{
+ Methods: []string{"GET", "POST", "PUT", "HEAD", "DELETE"},
+ Path: "/*",
+ }},
+ }
+
+ assert.Equal(t, expected, info)
+}
+
+func TestClient_GetDNSZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/v1/dns/",
+ servermock.ResponseFromFixture("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.")).
+ Build(t)
+
+ zones, err := client.GetDNSZones(t.Context(), "example.com.")
+ require.NoError(t, err)
+
+ expected := []Zone{{
+ Name: "example.com.",
+ HandlerConfigs: []HandlerConfig{
+ {Type: "memory"},
+ {Type: "etcd"},
+ },
+ DefaultTTL: 86400,
+ RecordCount: 1,
+ }}
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_CreateDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /api/v1/dns/zones/records",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("zone", "example.com.").
+ With("uid", "123").
+ With("hostname", "_acme-challenge")).
+ Build(t)
+
+ record := Record{
+ Data: "txtTXTtxt",
+ Hostname: "_acme-challenge",
+ Type: "TXT",
+ UID: "123",
+ }
+
+ err := client.CreateDNSRecord(t.Context(), "example.com.", record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /api/v1/dns/zones/records",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckQueryParameter().Strict().
+ With("zone", "example.com.").
+ With("uid", "123").
+ With("type", "TXT").
+ With("hostname", "_acme-challenge")).
+ Build(t)
+
+ record := Record{
+ Data: "txtTXTtxt",
+ Hostname: "_acme-challenge",
+ Type: "TXT",
+ UID: "123",
+ }
+
+ err := client.DeleteDNSRecord(t.Context(), "example.com.", record)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/gravity/internal/fixtures/create_record-request.json b/providers/dns/gravity/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..d671d1342
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/create_record-request.json
@@ -0,0 +1,6 @@
+{
+ "data": "txtTXTtxt",
+ "hostname": "_acme-challenge",
+ "type": "TXT",
+ "uid": "123"
+}
diff --git a/providers/dns/gravity/internal/fixtures/error.json b/providers/dns/gravity/internal/fixtures/error.json
new file mode 100644
index 000000000..38b78fcca
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/error.json
@@ -0,0 +1,8 @@
+{
+ "code": 0,
+ "context": {
+ "additionalProp1": "string"
+ },
+ "error": "unauthenticated",
+ "status": "UNAUTHENTICATED"
+}
diff --git a/providers/dns/gravity/internal/fixtures/login-request.json b/providers/dns/gravity/internal/fixtures/login-request.json
new file mode 100644
index 000000000..c641cd3e5
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/login-request.json
@@ -0,0 +1,4 @@
+{
+ "username": "user",
+ "password": "secret"
+}
diff --git a/providers/dns/gravity/internal/fixtures/login.json b/providers/dns/gravity/internal/fixtures/login.json
new file mode 100644
index 000000000..b9ae7145f
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/login.json
@@ -0,0 +1,3 @@
+{
+ "successful": true
+}
diff --git a/providers/dns/gravity/internal/fixtures/me.json b/providers/dns/gravity/internal/fixtures/me.json
new file mode 100644
index 000000000..881a2ca5f
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/me.json
@@ -0,0 +1,16 @@
+{
+ "username": "admin",
+ "authenticated": true,
+ "permissions": [
+ {
+ "path": "/*",
+ "methods": [
+ "GET",
+ "POST",
+ "PUT",
+ "HEAD",
+ "DELETE"
+ ]
+ }
+ ]
+}
diff --git a/providers/dns/gravity/internal/fixtures/me_unauthenticated.json b/providers/dns/gravity/internal/fixtures/me_unauthenticated.json
new file mode 100644
index 000000000..67698b8e2
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/me_unauthenticated.json
@@ -0,0 +1,5 @@
+{
+ "username": "",
+ "authenticated": false,
+ "permissions": null
+}
diff --git a/providers/dns/gravity/internal/fixtures/zones.json b/providers/dns/gravity/internal/fixtures/zones.json
new file mode 100644
index 000000000..53a8df6c1
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/zones.json
@@ -0,0 +1,19 @@
+{
+ "zones": [
+ {
+ "name": "example.com.",
+ "handlerConfigs": [
+ {
+ "type": "memory"
+ },
+ {
+ "type": "etcd"
+ }
+ ],
+ "defaultTTL": 86400,
+ "authoritative": false,
+ "hook": "",
+ "recordCount": 1
+ }
+ ]
+}
diff --git a/providers/dns/gravity/internal/fixtures/zones_empty.json b/providers/dns/gravity/internal/fixtures/zones_empty.json
new file mode 100644
index 000000000..d8b70b45e
--- /dev/null
+++ b/providers/dns/gravity/internal/fixtures/zones_empty.json
@@ -0,0 +1,3 @@
+{
+ "zones": null
+}
diff --git a/providers/dns/gravity/internal/types.go b/providers/dns/gravity/internal/types.go
new file mode 100644
index 000000000..872bc070f
--- /dev/null
+++ b/providers/dns/gravity/internal/types.go
@@ -0,0 +1,82 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ Status string `json:"status"`
+ ErrorMsg string `json:"error"`
+ Code int `json:"code"`
+ Context map[string]string `json:"context"`
+}
+
+func (a *APIError) Error() string {
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "status: %s, error: %s", a.Status, a.ErrorMsg)
+
+ if a.Code != 0 {
+ _, _ = fmt.Fprintf(msg, ", code: %d", a.Code)
+ }
+
+ if len(a.Context) != 0 {
+ for k, v := range a.Context {
+ _, _ = fmt.Fprintf(msg, ", %s: %s", k, v)
+ }
+ }
+
+ return msg.String()
+}
+
+type Login struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+type Auth struct {
+ Successful bool `json:"successful"`
+}
+
+type UserInfo struct {
+ Username string `json:"username"`
+ Authenticated bool `json:"authenticated"`
+ Permissions []Permission `json:"permissions"`
+}
+
+type Permission struct {
+ Methods []string `json:"methods"`
+ Path string `json:"path"`
+}
+
+type Zones struct {
+ Zones []Zone `json:"zones"`
+}
+
+type Zone struct {
+ Name string `json:"name"`
+ HandlerConfigs []HandlerConfig `json:"handlerConfigs"`
+ DefaultTTL int `json:"defaultTTL"`
+ Authoritative bool `json:"authoritative"`
+ Hook string `json:"hook"`
+ RecordCount int `json:"recordCount"`
+}
+
+type HandlerConfig struct {
+ Type string `json:"type"`
+ CacheTTL int `json:"cache_ttl,omitempty"`
+ To []string `json:"to,omitempty"`
+}
+
+type Record struct {
+ Data string `json:"data,omitempty"`
+ Fqdn string `json:"fqdn,omitempty"`
+ Hostname string `json:"hostname,omitempty"`
+ MxPreference int `json:"mxPreference,omitempty"`
+ SrvPort int `json:"srvPort,omitempty"`
+ SrvPriority int `json:"srvPriority,omitempty"`
+ SrvWeight int `json:"srvWeight,omitempty"`
+ Type string `json:"type,omitempty"`
+ UID string `json:"uid,omitempty"`
+}
diff --git a/providers/dns/hetzner/hetzner.go b/providers/dns/hetzner/hetzner.go
index e5c5ca266..bae985b3e 100644
--- a/providers/dns/hetzner/hetzner.go
+++ b/providers/dns/hetzner/hetzner.go
@@ -2,28 +2,28 @@
package hetzner
import (
- "context"
"errors"
- "fmt"
"net/http"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/hetzner/internal"
+ "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1"
+ "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy"
)
// Environment variables names.
const (
- envNamespace = "HETZNER_"
+ // Deprecated: use EnvAPIToken instead.
+ EnvAPIKey = legacy.EnvAPIKey
+ EnvAPIToken = hetznerv1.EnvAPIToken
- EnvAPIKey = envNamespace + "API_KEY"
-
- EnvTTL = envNamespace + "TTL"
- EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
- EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
- EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+ EnvTTL = hetznerv1.EnvTTL
+ EnvPropagationTimeout = hetznerv1.EnvPropagationTimeout
+ EnvPollingInterval = hetznerv1.EnvPollingInterval
+ EnvHTTPTimeout = hetznerv1.EnvHTTPTimeout
)
const minTTL = 60
@@ -32,7 +32,11 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- APIKey string
+ // Deprecated: use APIToken instead
+ APIKey string
+
+ APIToken string
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@@ -44,7 +48,7 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -53,22 +57,41 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *internal.Client
+ provider challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for hetzner.
-// Credentials must be passed in the environment variable: HETZNER_API_KEY.
func NewDNSProvider() (*DNSProvider, error) {
- values, err := env.Get(EnvAPIKey)
- if err != nil {
- return nil, fmt.Errorf("hetzner: %w", err)
+ foundAPIToken := env.GetOrFile(EnvAPIToken) != ""
+ foundAPIKey := env.GetOrFile(EnvAPIKey) != ""
+
+ switch {
+ case foundAPIToken:
+ provider, err := hetznerv1.NewDNSProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ return &DNSProvider{provider: provider}, nil
+
+ case foundAPIKey:
+ log.Warnf("APIKey (legacy Hetzner DNS API) is deprecated, please use APIToken (Hetzner Cloud API) instead.")
+
+ provider, err := legacy.NewDNSProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ return &DNSProvider{provider: provider}, nil
+
+ default:
+ provider, err := hetznerv1.NewDNSProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ return &DNSProvider{provider: provider}, nil
}
-
- config := NewDefaultConfig()
- config.APIKey = values[EnvAPIKey]
-
- return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for hetzner.
@@ -77,98 +100,57 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("hetzner: the configuration of the DNS provider is nil")
}
- if config.APIKey == "" {
- return nil, errors.New("hetzner: credentials missing")
+ switch {
+ case config.APIToken != "":
+ cfg := &hetznerv1.Config{
+ APIToken: config.APIToken,
+ PropagationTimeout: config.PropagationTimeout,
+ PollingInterval: config.PollingInterval,
+ TTL: config.TTL,
+ HTTPClient: config.HTTPClient,
+ }
+
+ provider, err := hetznerv1.NewDNSProviderConfig(cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ return &DNSProvider{provider: provider}, nil
+
+ case config.APIKey != "":
+ log.Warnf("%s (legacy Hetzner DNS API) is deprecated, please use %s (Hetzner Cloud API) instead.", EnvAPIKey, EnvAPIToken)
+
+ cfg := &legacy.Config{
+ APIKey: config.APIKey,
+ PropagationTimeout: config.PropagationTimeout,
+ PollingInterval: config.PollingInterval,
+ TTL: config.TTL,
+ HTTPClient: config.HTTPClient,
+ }
+
+ provider, err := legacy.NewDNSProviderConfig(cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ return &DNSProvider{provider: provider}, nil
}
- if config.TTL < minTTL {
- return nil, fmt.Errorf("hetzner: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
- }
-
- client := internal.NewClient(config.APIKey)
-
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- return &DNSProvider{config: config, client: client}, nil
+ return nil, errors.New("hetzner: credentials missing")
}
// 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
+ return d.provider.Timeout()
}
// 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("hetzner: could not find zone for domain %q: %w", domain, err)
- }
-
- zone := dns01.UnFqdn(authZone)
-
- ctx := context.Background()
-
- zoneID, err := d.client.GetZoneID(ctx, zone)
- if err != nil {
- return fmt.Errorf("hetzner: %w", err)
- }
-
- subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
- if err != nil {
- return fmt.Errorf("hetzner: %w", err)
- }
-
- record := internal.DNSRecord{
- Type: "TXT",
- Name: subDomain,
- Value: info.Value,
- TTL: d.config.TTL,
- ZoneID: zoneID,
- }
-
- if err := d.client.CreateRecord(ctx, record); err != nil {
- return fmt.Errorf("hetzner: failed to add TXT record: fqdn=%s, zoneID=%s: %w", info.EffectiveFQDN, zoneID, err)
- }
-
- return nil
+ 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 {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
- if err != nil {
- return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err)
- }
-
- zone := dns01.UnFqdn(authZone)
-
- ctx := context.Background()
-
- zoneID, err := d.client.GetZoneID(ctx, zone)
- if err != nil {
- return fmt.Errorf("hetzner: %w", err)
- }
-
- subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
- if err != nil {
- return fmt.Errorf("hetzner: %w", err)
- }
-
- record, err := d.client.GetTxtRecord(ctx, subDomain, info.Value, zoneID)
- if err != nil {
- return fmt.Errorf("hetzner: %w", err)
- }
-
- if err := d.client.DeleteRecord(ctx, record.ID); err != nil {
- return fmt.Errorf("hetzner: failed to delete TXT record: id=%s, name=%s: %w", record.ID, record.Name, err)
- }
-
- return nil
+ return d.provider.CleanUp(domain, token, keyAuth)
}
diff --git a/providers/dns/hetzner/hetzner.toml b/providers/dns/hetzner/hetzner.toml
index 77d23acb8..40d4cea72 100644
--- a/providers/dns/hetzner/hetzner.toml
+++ b/providers/dns/hetzner/hetzner.toml
@@ -5,18 +5,18 @@ Code = "hetzner"
Since = "v3.7.0"
Example = '''
-HETZNER_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
-lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run
+HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns hetzner -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
- HETZNER_API_KEY = "API key"
+ HETZNER_API_TOKEN = "API token"
[Configuration.Additional]
- HETZNER_POLLING_INTERVAL = "Time between DNS propagation check"
- HETZNER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- HETZNER_TTL = "The TTL of the TXT record used for the DNS challenge"
- HETZNER_HTTP_TIMEOUT = "API request timeout"
+ HETZNER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HETZNER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ HETZNER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ HETZNER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
- API = "https://dns.hetzner.com/api-docs"
+ API = "https://docs.hetzner.cloud/reference/cloud#dns"
diff --git a/providers/dns/hetzner/hetzner_test.go b/providers/dns/hetzner/hetzner_test.go
index d028fd06b..430f0270b 100644
--- a/providers/dns/hetzner/hetzner_test.go
+++ b/providers/dns/hetzner/hetzner_test.go
@@ -3,52 +3,72 @@ package hetzner
import (
"testing"
+ "github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1"
+ "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-const envDomain = envNamespace + "DOMAIN"
-
-var envTest = tester.NewEnvTest(
- EnvAPIKey).
- WithDomain(envDomain)
+var envTest = tester.NewEnvTest(EnvAPIKey, EnvAPIToken)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
- desc string
- envVars map[string]string
- expected string
+ desc string
+ envVars map[string]string
+
+ expectedProvider challenge.ProviderTimeout
+ expectedError string
}{
{
- desc: "success",
+ desc: "success (v1)",
+ envVars: map[string]string{
+ EnvAPIToken: "123",
+ },
+ expectedProvider: &hetznerv1.DNSProvider{},
+ },
+ {
+ desc: "success (legacy)",
envVars: map[string]string{
EnvAPIKey: "123",
},
+ expectedProvider: &legacy.DNSProvider{},
+ },
+ {
+ desc: "success (both)",
+ envVars: map[string]string{
+ EnvAPIKey: "123",
+ EnvAPIToken: "123",
+ },
+ expectedProvider: &hetznerv1.DNSProvider{},
},
{
desc: "missing credentials",
envVars: map[string]string{
- EnvAPIKey: "",
+ EnvAPIKey: "",
+ EnvAPIToken: "",
},
- expected: "hetzner: some credentials information are missing: HETZNER_API_KEY",
+ expectedError: "hetzner: some credentials information are missing: HETZNER_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 == "" {
+ if test.expectedError == "" {
require.NoError(t, err)
+ assert.IsType(t, test.expectedProvider, p.provider)
require.NotNil(t, p)
- require.NotNil(t, p.config)
} else {
- require.EqualError(t, err, test.expected)
+ require.EqualError(t, err, test.expectedError)
}
})
}
@@ -58,68 +78,53 @@ func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
apiKey string
+ apiToken string
ttl int
- expected string
+
+ expectedProvider challenge.ProviderTimeout
+ expectedError string
}{
{
- desc: "success",
- ttl: minTTL,
- apiKey: "123",
+ desc: "success (v1)",
+ ttl: minTTL,
+ apiToken: "123",
+ expectedProvider: &hetznerv1.DNSProvider{},
},
{
- desc: "missing credentials",
- ttl: minTTL,
- expected: "hetzner: credentials missing",
+ desc: "success (legacy)",
+ ttl: minTTL,
+ apiKey: "456",
+ expectedProvider: &legacy.DNSProvider{},
},
{
- desc: "invalid TTL",
- apiKey: "123",
- ttl: 10,
- expected: "hetzner: invalid TTL, TTL (10) must be greater than 60",
+ desc: "success (both)",
+ ttl: minTTL,
+ apiToken: "123",
+ apiKey: "456",
+ expectedProvider: &hetznerv1.DNSProvider{},
+ },
+ {
+ desc: "missing credentials",
+ ttl: minTTL,
+ expectedError: "hetzner: credentials missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
+ config.APIToken = test.apiToken
config.APIKey = test.apiKey
config.TTL = test.ttl
p, err := NewDNSProviderConfig(config)
- if test.expected == "" {
+ if test.expectedError == "" {
require.NoError(t, err)
- require.NotNil(t, p)
- require.NotNil(t, p.config)
+ assert.IsType(t, test.expectedProvider, p.provider)
} else {
- require.EqualError(t, err, test.expected)
+ require.EqualError(t, err, test.expectedError)
}
})
}
}
-
-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/dns/hetzner/internal/client_test.go b/providers/dns/hetzner/internal/client_test.go
deleted file mode 100644
index aa2175409..000000000
--- a/providers/dns/hetzner/internal/client_test.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package internal
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(apiKey)
- client.baseURL, _ = url.Parse(server.URL)
- client.HTTPClient = server.Client()
-
- return client, mux
-}
-
-func TestClient_GetTxtRecord(t *testing.T) {
- const zoneID = "zoneA"
- const apiKey = "myKeyA"
-
- client, mux := setupTest(t, apiKey)
-
- mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authHeader)
- if auth != apiKey {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
-
- zID := req.URL.Query().Get("zone_id")
- if zID != zoneID {
- http.Error(rw, fmt.Sprintf("invalid zone ID: %s", zID), http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/get_txt_record.json")
- 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
- }
- })
-
- record, err := client.GetTxtRecord(context.Background(), "test1", "txttxttxt", zoneID)
- require.NoError(t, err)
-
- fmt.Println(record)
-}
-
-func TestClient_CreateRecord(t *testing.T) {
- const zoneID = "zoneA"
- const apiKey = "myKeyB"
-
- client, mux := setupTest(t, apiKey)
-
- mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authHeader)
- if auth != apiKey {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
-
- file, err := os.Open("./fixtures/create_txt_record.json")
- 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
- }
- })
-
- record := DNSRecord{
- Name: "test",
- Type: "TXT",
- Value: "txttxttxt",
- TTL: 600,
- ZoneID: zoneID,
- }
-
- err := client.CreateRecord(context.Background(), record)
- require.NoError(t, err)
-}
-
-func TestClient_DeleteRecord(t *testing.T) {
- const apiKey = "myKeyC"
-
- client, mux := setupTest(t, apiKey)
-
- mux.HandleFunc("/api/v1/records/recordID", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authHeader)
- if auth != apiKey {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
- })
-
- err := client.DeleteRecord(context.Background(), "recordID")
- require.NoError(t, err)
-}
-
-func TestClient_GetZoneID(t *testing.T) {
- const apiKey = "myKeyD"
-
- client, mux := setupTest(t, apiKey)
-
- mux.HandleFunc("/api/v1/zones", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authHeader)
- if auth != apiKey {
- http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
- return
- }
-
- file, err := os.Open("./fixtures/get_zone_id.json")
- 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
- }
- })
-
- zoneID, err := client.GetZoneID(context.Background(), "example.com")
- require.NoError(t, err)
-
- assert.Equal(t, "zoneA", zoneID)
-}
diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records-request.json
new file mode 100644
index 000000000..210f84435
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records-request.json
@@ -0,0 +1,8 @@
+{
+ "ttl": 120,
+ "records": [
+ {
+ "value": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\""
+ }
+ ]
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records.json
new file mode 100644
index 000000000..2341c7e6e
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records.json
@@ -0,0 +1,17 @@
+{
+ "action": {
+ "id": 1,
+ "command": "add_rrset_records",
+ "status": "running",
+ "progress": 50,
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": null,
+ "resources": [
+ {
+ "id": 42,
+ "type": "zone"
+ }
+ ],
+ "error": null
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_error.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_error.json
new file mode 100644
index 000000000..2a4472f67
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_error.json
@@ -0,0 +1,20 @@
+{
+ "action": {
+ "id": 1,
+ "command": "remove_rrset_records",
+ "status": "error",
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": "2016-01-30T23:55:00+00:00",
+ "progress": 50,
+ "resources": [
+ {
+ "id": 42,
+ "type": "zone"
+ }
+ ],
+ "error": {
+ "code": "action_failed",
+ "message": "Action failed"
+ }
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_running.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_running.json
new file mode 100644
index 000000000..dcec6c2cd
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_running.json
@@ -0,0 +1,16 @@
+{
+ "action": {
+ "id": 1,
+ "command": "remove_rrset_records",
+ "status": "running",
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": "2016-01-30T23:55:00+00:00",
+ "progress": 50,
+ "resources": [
+ {
+ "id": 42,
+ "type": "zone"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_success.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_success.json
new file mode 100644
index 000000000..6b7267c07
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_success.json
@@ -0,0 +1,16 @@
+{
+ "action": {
+ "id": 1,
+ "command": "remove_rrset_records",
+ "status": "success",
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": "2016-01-30T23:55:00+00:00",
+ "progress": 100,
+ "resources": [
+ {
+ "id": 42,
+ "type": "zone"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records-request.json
new file mode 100644
index 000000000..982273b67
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records-request.json
@@ -0,0 +1,7 @@
+{
+ "records": [
+ {
+ "value": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\""
+ }
+ ]
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records.json
new file mode 100644
index 000000000..1b10dfd5e
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records.json
@@ -0,0 +1,17 @@
+{
+ "action": {
+ "id": 1,
+ "command": "remove_rrset_records",
+ "status": "running",
+ "progress": 50,
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": null,
+ "resources": [
+ {
+ "id": 42,
+ "type": "zone"
+ }
+ ],
+ "error": null
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go b/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go
new file mode 100644
index 000000000..b31c766ce
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go
@@ -0,0 +1,209 @@
+// Package hetznerv1 implements a DNS provider for solving the DNS-01 challenge using Hetzner.
+package hetznerv1
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/cenkalti/backoff/v5"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/platform/wait"
+ "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "golang.org/x/net/idna"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "HETZNER_"
+
+ 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, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Hetzner.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIToken)
+ if err != nil {
+ return nil, fmt.Errorf("hetzner: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIToken = values[EnvAPIToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Hetzner.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("hetzner: the configuration of the DNS provider is nil")
+ }
+
+ if config.APIToken == "" {
+ return nil, errors.New("hetzner: credentials missing")
+ }
+
+ client, err := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.APIToken),
+ ),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("hetzner: %w", err)
+ }
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("hetzner: %w", err)
+ }
+
+ subDomainPunnycoded, err := idna.ToASCII(dns01.UnFqdn(subDomain))
+ if err != nil {
+ return fmt.Errorf("hetzner: %w", err)
+ }
+
+ zone, err := idna.ToASCII(dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("hetzner: %w", err)
+ }
+
+ records := []internal.Record{{Value: strconv.Quote(info.Value)}}
+
+ action, err := d.client.AddRRSetRecords(ctx, zone, "TXT", subDomainPunnycoded, d.config.TTL, records)
+ if err != nil {
+ return fmt.Errorf("hetzner: add RRSet records: %w", err)
+ }
+
+ err = d.waitAction(ctx, action.ID)
+ if err != nil {
+ return fmt.Errorf("hetzner: wait (add RRSet records): %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("hetzner: %w", err)
+ }
+
+ subDomainPunnycoded, err := idna.ToASCII(dns01.UnFqdn(subDomain))
+ if err != nil {
+ return fmt.Errorf("hetzner: %w", err)
+ }
+
+ zone, err := idna.ToASCII(dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("hetzner: %w", err)
+ }
+
+ records := []internal.Record{{Value: strconv.Quote(info.Value)}}
+
+ action, err := d.client.RemoveRRSetRecords(ctx, zone, "TXT", subDomainPunnycoded, records)
+ if err != nil {
+ return fmt.Errorf("hetzner: remove RRSet records: %w", err)
+ }
+
+ err = d.waitAction(ctx, action.ID)
+ if err != nil {
+ return fmt.Errorf("hetzner: wait (remove RRSet records): %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) waitAction(ctx context.Context, actionID int64) error {
+ return wait.Retry(ctx,
+ func() error {
+ result, err := d.client.GetAction(ctx, actionID)
+ if err != nil {
+ return backoff.Permanent(fmt.Errorf("get action %d: %w", actionID, err))
+ }
+
+ switch result.Status {
+ case internal.StatusRunning:
+ return fmt.Errorf("action %d is %s", actionID, internal.StatusRunning)
+
+ case internal.StatusError:
+ return backoff.Permanent(fmt.Errorf("action %d: %s: %w", actionID, internal.StatusError, result.ErrorInfo))
+
+ default:
+ return nil
+ }
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
+ backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
+ )
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/hetznerv1_test.go b/providers/dns/hetzner/internal/hetznerv1/hetznerv1_test.go
new file mode 100644
index 000000000..bf52baa35
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/hetznerv1_test.go
@@ -0,0 +1,232 @@
+package hetznerv1
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "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: "secret",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "hetzner: some credentials information are missing: HETZNER_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)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "hetzner: credentials 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)
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIToken = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records",
+ servermock.ResponseFromFixture("add_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")).
+ Route("GET /actions/1",
+ servermock.ResponseFromFixture("get_action_success.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "", "foobar")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_Present_error(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records",
+ servermock.ResponseFromFixture("add_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")).
+ Route("GET /actions/1",
+ servermock.ResponseFromFixture("get_action_error.json")).
+ Build(t)
+
+ provider.config.PollingInterval = 20 * time.Millisecond
+ provider.config.PropagationTimeout = 1 * time.Second
+
+ err := provider.Present("example.com", "", "foobar")
+ require.EqualError(t, err, "hetzner: wait (add RRSet records): action 1: error: action_failed: Action failed")
+}
+
+func TestDNSProvider_Present_running(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records",
+ servermock.ResponseFromFixture("add_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")).
+ Route("GET /actions/1",
+ servermock.ResponseFromFixture("get_action_running.json")).
+ Build(t)
+
+ provider.config.PollingInterval = 20 * time.Millisecond
+ provider.config.PropagationTimeout = 1 * time.Second
+
+ err := provider.Present("example.com", "", "foobar")
+ require.EqualError(t, err, "hetzner: wait (add RRSet records): action 1 is running")
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records",
+ servermock.ResponseFromFixture("remove_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")).
+ Route("GET /actions/1",
+ servermock.ResponseFromFixture("get_action_success.json")).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "foobar")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_error(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records",
+ servermock.ResponseFromFixture("remove_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")).
+ Route("GET /actions/1",
+ servermock.ResponseFromFixture("get_action_error.json")).
+ Build(t)
+
+ provider.config.PollingInterval = 20 * time.Millisecond
+ provider.config.PropagationTimeout = 1 * time.Second
+
+ err := provider.CleanUp("example.com", "", "foobar")
+ require.EqualError(t, err, "hetzner: wait (remove RRSet records): action 1: error: action_failed: Action failed")
+}
+
+func TestDNSProvider_CleanUp_running(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records",
+ servermock.ResponseFromFixture("remove_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")).
+ Route("GET /actions/1",
+ servermock.ResponseFromFixture("get_action_running.json")).
+ Build(t)
+
+ provider.config.PollingInterval = 20 * time.Millisecond
+ provider.config.PropagationTimeout = 1 * time.Second
+
+ err := provider.CleanUp("example.com", "", "foobar")
+ require.EqualError(t, err, "hetzner: wait (remove RRSet records): action 1 is running")
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/client.go b/providers/dns/hetzner/internal/hetznerv1/internal/client.go
new file mode 100644
index 000000000..2f29f642a
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/client.go
@@ -0,0 +1,183 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "golang.org/x/oauth2"
+)
+
+const defaultBaseURL = "https://api.hetzner.cloud/v1"
+
+const (
+ StatusRunning = "running"
+ StatusSuccess = "success"
+ StatusError = "error"
+)
+
+// Client the Hetzner API client.
+type Client struct {
+ BaseURL *url.URL
+ httpClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(hc *http.Client) (*Client, error) {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ if hc == nil {
+ hc = &http.Client{Timeout: 10 * time.Second}
+ }
+
+ return &Client{
+ BaseURL: baseURL,
+ httpClient: hc,
+ }, nil
+}
+
+// AddRRSetRecords adds records to an RRSet.
+// https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset
+func (c *Client) AddRRSetRecords(ctx context.Context, zoneIDName, recordType, recordName string, ttl int, records []Record) (*Action, error) {
+ endpoint := c.BaseURL.JoinPath("zones", zoneIDName, "rrsets", recordName, recordType, "actions", "add_records")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, RRSet{TTL: ttl, Records: records})
+ if err != nil {
+ return nil, err
+ }
+
+ var result ActionResponse
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Action, nil
+}
+
+// RemoveRRSetRecords removes records from an RRSet.
+// https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset
+func (c *Client) RemoveRRSetRecords(ctx context.Context, zoneIDName, recordType, recordName string, records []Record) (*Action, error) {
+ endpoint := c.BaseURL.JoinPath("zones", zoneIDName, "rrsets", recordName, recordType, "actions", "remove_records")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, RRSet{Records: records})
+ if err != nil {
+ return nil, err
+ }
+
+ var result ActionResponse
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Action, nil
+}
+
+// GetAction gets an action.
+// https://docs.hetzner.cloud/reference/cloud#actions-get-an-action
+func (c *Client) GetAction(ctx context.Context, id int64) (*Action, error) {
+ endpoint := c.BaseURL.JoinPath("actions", strconv.FormatInt(id, 10))
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result ActionResponse
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Action, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ 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: 5 * time.Second}
+ }
+
+ client.Transport = &oauth2.Transport{
+ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+ Base: client.Transport,
+ }
+
+ return client
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go b/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go
new file mode 100644
index 000000000..6fd3d77a7
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go
@@ -0,0 +1,154 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
+}
+
+func TestClient_AddRRSetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records",
+ servermock.ResponseFromFixture("add_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")).
+ Build(t)
+
+ records := []Record{{
+ Value: "198.51.100.1",
+ Comment: "My web server at Hetzner Cloud.",
+ }}
+
+ result, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 3600, records)
+ require.NoError(t, err)
+
+ expected := &Action{
+ ID: 1,
+ Command: "add_rrset_records",
+ Status: "running",
+ Progress: 50,
+ Resources: []Resources{{ID: 590000000000000, Type: "zone"}},
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_AddRRSetRecords_error_invalid_input(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records",
+ servermock.ResponseFromFixture("error-invalid_input.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ records := []Record{{
+ Value: "198.51.100.1",
+ Comment: "My web server at Hetzner Cloud.",
+ }}
+
+ _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records)
+ require.EqualError(t, err, "invalid_input: invalid input in field 'broken_field': is too longfield: broken_field: is too long")
+}
+
+func TestClient_AddRRSetRecords_error_resource_limit_exceeded(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records",
+ servermock.ResponseFromFixture("error-resource_limit_exceeded.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ records := []Record{{
+ Value: "198.51.100.1",
+ Comment: "My web server at Hetzner Cloud.",
+ }}
+
+ _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records)
+ require.EqualError(t, err, "resource_limit_exceeded: project limit exceededlimit: project_limit")
+}
+
+func TestClient_AddRRSetRecords_error_deprecated_api_endpoint(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records",
+ servermock.ResponseFromFixture("error-deprecated_api_endpoint.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ records := []Record{{
+ Value: "198.51.100.1",
+ Comment: "My web server at Hetzner Cloud.",
+ }}
+
+ _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records)
+ require.EqualError(t, err, "deprecated_api_endpoint: API functionality was removed: https://docs.hetzner.cloud/changelog#2023-07-20-foo-endpoint-is-deprecated")
+}
+
+func TestClient_RemoveRRSetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/example.com/rrsets/www/TXT/actions/remove_records",
+ servermock.ResponseFromFixture("remove_rrset_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")).
+ Build(t)
+
+ records := []Record{{
+ Value: "198.51.100.1",
+ Comment: "My web server at Hetzner Cloud.",
+ }}
+
+ result, err := client.RemoveRRSetRecords(t.Context(), "example.com", "TXT", "www", records)
+ require.NoError(t, err)
+
+ expected := &Action{
+ ID: 1,
+ Command: "remove_rrset_records",
+ Status: "running",
+ Progress: 50,
+ Resources: []Resources{{ID: 42, Type: "zone"}},
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_GetAction(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /actions/123", servermock.ResponseFromFixture("get_action.json")).
+ Route("/", servermock.DumpRequest()).
+ Build(t)
+
+ result, err := client.GetAction(t.Context(), 123)
+ require.NoError(t, err)
+
+ expected := &Action{
+ ID: 590000000000000,
+ Command: "start_resource",
+ Status: "running",
+ Progress: 100,
+ Resources: []Resources{{ID: 590000000000000, Type: "server"}},
+ ErrorInfo: &ErrorInfo{
+ Code: "action_failed",
+ Message: "Action failed",
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records-request.json
new file mode 100644
index 000000000..cba0f34d3
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records-request.json
@@ -0,0 +1,9 @@
+{
+ "ttl": 3600,
+ "records": [
+ {
+ "value": "198.51.100.1",
+ "comment": "My web server at Hetzner Cloud."
+ }
+ ]
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json
new file mode 100644
index 000000000..7267b02cb
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json
@@ -0,0 +1,17 @@
+{
+ "action": {
+ "id": 1,
+ "command": "add_rrset_records",
+ "status": "running",
+ "progress": 50,
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": null,
+ "resources": [
+ {
+ "id": 590000000000000,
+ "type": "zone"
+ }
+ ],
+ "error": null
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-deprecated_api_endpoint.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-deprecated_api_endpoint.json
new file mode 100644
index 000000000..4d8fb945d
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-deprecated_api_endpoint.json
@@ -0,0 +1,9 @@
+{
+ "error": {
+ "code": "deprecated_api_endpoint",
+ "message": "API functionality was removed",
+ "details": {
+ "announcement": "https://docs.hetzner.cloud/changelog#2023-07-20-foo-endpoint-is-deprecated"
+ }
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-invalid_input.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-invalid_input.json
new file mode 100644
index 000000000..e05bf7a3e
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-invalid_input.json
@@ -0,0 +1,16 @@
+{
+ "error": {
+ "code": "invalid_input",
+ "message": "invalid input in field 'broken_field': is too long",
+ "details": {
+ "fields": [
+ {
+ "name": "broken_field",
+ "messages": [
+ "is too long"
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-resource_limit_exceeded.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-resource_limit_exceeded.json
new file mode 100644
index 000000000..9072d10e3
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-resource_limit_exceeded.json
@@ -0,0 +1,13 @@
+{
+ "error": {
+ "code": "resource_limit_exceeded",
+ "message": "project limit exceeded",
+ "details": {
+ "limits": [
+ {
+ "name": "project_limit"
+ }
+ ]
+ }
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json
new file mode 100644
index 000000000..19278fc51
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json
@@ -0,0 +1,20 @@
+{
+ "action": {
+ "id": 590000000000000,
+ "command": "start_resource",
+ "status": "running",
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": "2016-01-30T23:55:00+00:00",
+ "progress": 100,
+ "resources": [
+ {
+ "id": 590000000000000,
+ "type": "server"
+ }
+ ],
+ "error": {
+ "code": "action_failed",
+ "message": "Action failed"
+ }
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records-request.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records-request.json
new file mode 100644
index 000000000..778e051b4
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records-request.json
@@ -0,0 +1,8 @@
+{
+ "records": [
+ {
+ "value": "198.51.100.1",
+ "comment": "My web server at Hetzner Cloud."
+ }
+ ]
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records.json
new file mode 100644
index 000000000..1b10dfd5e
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records.json
@@ -0,0 +1,17 @@
+{
+ "action": {
+ "id": 1,
+ "command": "remove_rrset_records",
+ "status": "running",
+ "progress": 50,
+ "started": "2016-01-30T23:55:00+00:00",
+ "finished": null,
+ "resources": [
+ {
+ "id": 42,
+ "type": "zone"
+ }
+ ],
+ "error": null
+ }
+}
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/types.go b/providers/dns/hetzner/internal/hetznerv1/internal/types.go
new file mode 100644
index 000000000..2b38a8a8c
--- /dev/null
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/types.go
@@ -0,0 +1,98 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ ErrorInfo ErrorInfo `json:"error"`
+}
+
+type ErrorInfo struct {
+ Code string `json:"code,omitempty"`
+ Message string `json:"message,omitempty"`
+ Details ErrorDetails `json:"details"`
+}
+
+func (i *ErrorInfo) Error() string {
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "%s: %s", i.Code, i.Message)
+
+ if i.Details.Announcement != "" {
+ _, _ = fmt.Fprintf(msg, ": %s", i.Details.Announcement)
+ }
+
+ for _, limit := range i.Details.Limits {
+ _, _ = fmt.Fprintf(msg, "limit: %s", limit.Name)
+ }
+
+ for _, field := range i.Details.Fields {
+ _, _ = fmt.Fprintf(msg, "field: %s: %s", field.Name, strings.Join(field.Messages, ", "))
+ }
+
+ return msg.String()
+}
+
+type ErrorDetails struct {
+ Announcement string `json:"announcement,omitempty"`
+ Limits []LimitError `json:"limits,omitempty"`
+ Fields []FieldError `json:"fields,omitempty"`
+}
+
+type FieldError struct {
+ Name string `json:"name,omitempty"`
+ Messages []string `json:"messages,omitempty"`
+}
+
+type LimitError struct {
+ Name string `json:"name,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ return a.ErrorInfo.Error()
+}
+
+type RRSet struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Labels map[string]string `json:"labels,omitempty"`
+ Protection *Protection `json:"protection,omitempty"`
+ Records []Record `json:"records,omitempty"`
+ ZoneID int `json:"zone,omitempty"`
+}
+
+type Protection struct {
+ Change bool `json:"change,omitempty"`
+}
+
+type Record struct {
+ Value string `json:"value,omitempty"`
+ Comment string `json:"comment,omitempty"`
+}
+
+type ActionResponse struct {
+ Action *Action `json:"action,omitempty"`
+}
+
+type Action struct {
+ ID int64 `json:"id,omitempty"`
+ Command string `json:"command,omitempty"`
+
+ // It can be: `running`, `success`, `error`.
+ // https://docs.hetzner.cloud/reference/cloud#zone-actions-get-an-action
+ // https://docs.hetzner.cloud/reference/cloud#zone-actions
+ Status string `json:"status,omitempty"`
+ Progress int `json:"progress,omitempty"`
+
+ Resources []Resources `json:"resources,omitempty"`
+ ErrorInfo *ErrorInfo `json:"error,omitempty"`
+}
+
+type Resources struct {
+ ID int64 `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+}
diff --git a/providers/dns/hetzner/internal/legacy/hetzner.go b/providers/dns/hetzner/internal/legacy/hetzner.go
new file mode 100644
index 000000000..393a3d671
--- /dev/null
+++ b/providers/dns/hetzner/internal/legacy/hetzner.go
@@ -0,0 +1,177 @@
+// Package legacy implements a DNS provider for solving the DNS-01 challenge using Hetzner DNS.
+package legacy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "HETZNER_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const minTTL = 60
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for hetzner.
+// Credentials must be passed in the environment variable: HETZNER_API_KEY.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("hetzner (legacy): %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for hetzner.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("hetzner (legacy): the configuration of the DNS provider is nil")
+ }
+
+ if config.APIKey == "" {
+ return nil, errors.New("hetzner (legacy): credentials missing")
+ }
+
+ if config.TTL < minTTL {
+ return nil, fmt.Errorf("hetzner (legacy): invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
+ }
+
+ client := internal.NewClient(config.APIKey)
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("hetzner (legacy): could not find zone for domain %q: %w", domain, err)
+ }
+
+ zone := dns01.UnFqdn(authZone)
+
+ ctx := context.Background()
+
+ zoneID, err := d.client.GetZoneID(ctx, zone)
+ if err != nil {
+ return fmt.Errorf("hetzner (legacy): %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
+ if err != nil {
+ return fmt.Errorf("hetzner (legacy): %w", err)
+ }
+
+ record := internal.DNSRecord{
+ Type: "TXT",
+ Name: subDomain,
+ Value: info.Value,
+ TTL: d.config.TTL,
+ ZoneID: zoneID,
+ }
+
+ if err := d.client.CreateRecord(ctx, record); err != nil {
+ return fmt.Errorf("hetzner (legacy): failed to add TXT record: fqdn=%s, zoneID=%s: %w", info.EffectiveFQDN, zoneID, 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("hetzner (legacy): could not find zone for domain %q: %w", domain, err)
+ }
+
+ zone := dns01.UnFqdn(authZone)
+
+ ctx := context.Background()
+
+ zoneID, err := d.client.GetZoneID(ctx, zone)
+ if err != nil {
+ return fmt.Errorf("hetzner (legacy): %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
+ if err != nil {
+ return fmt.Errorf("hetzner (legacy): %w", err)
+ }
+
+ record, err := d.client.GetTxtRecord(ctx, subDomain, info.Value, zoneID)
+ if err != nil {
+ return fmt.Errorf("hetzner (legacy): %w", err)
+ }
+
+ if err := d.client.DeleteRecord(ctx, record.ID); err != nil {
+ return fmt.Errorf("hetzner (legacy): failed to delete TXT record: id=%s, name=%s: %w", record.ID, record.Name, err)
+ }
+
+ return nil
+}
diff --git a/providers/dns/hetzner/internal/legacy/hetzner_test.go b/providers/dns/hetzner/internal/legacy/hetzner_test.go
new file mode 100644
index 000000000..c9258ecf8
--- /dev/null
+++ b/providers/dns/hetzner/internal/legacy/hetzner_test.go
@@ -0,0 +1,128 @@
+package legacy
+
+import (
+ "testing"
+
+ "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 credentials",
+ envVars: map[string]string{
+ EnvAPIKey: "",
+ },
+ expected: "hetzner (legacy): some credentials information are missing: HETZNER_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
+ ttl int
+ expected string
+ }{
+ {
+ desc: "success",
+ ttl: minTTL,
+ apiKey: "123",
+ },
+ {
+ desc: "missing credentials",
+ ttl: minTTL,
+ expected: "hetzner (legacy): credentials missing",
+ },
+ {
+ desc: "invalid TTL",
+ apiKey: "123",
+ ttl: 10,
+ expected: "hetzner (legacy): invalid TTL, TTL (10) must be greater than 60",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+ config.TTL = test.ttl
+
+ 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)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/hetzner/internal/client.go b/providers/dns/hetzner/internal/legacy/internal/client.go
similarity index 99%
rename from providers/dns/hetzner/internal/client.go
rename to providers/dns/hetzner/internal/legacy/internal/client.go
index 381922264..cd187f6e5 100644
--- a/providers/dns/hetzner/internal/client.go
+++ b/providers/dns/hetzner/internal/legacy/internal/client.go
@@ -83,6 +83,7 @@ func (c *Client) getRecords(ctx context.Context, zoneID string) (*DNSRecords, er
}
records := &DNSRecords{}
+
err = json.Unmarshal(raw, records)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -190,6 +191,7 @@ func (c *Client) getZones(ctx context.Context, name string) (*Zones, error) {
}
zones := &Zones{}
+
err = json.Unmarshal(raw, zones)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/hetzner/internal/legacy/internal/client_test.go b/providers/dns/hetzner/internal/legacy/internal/client_test.go
new file mode 100644
index 000000000..ade312a90
--- /dev/null
+++ b/providers/dns/hetzner/internal/legacy/internal/client_test.go
@@ -0,0 +1,89 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder(apiKey string) *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(apiKey)
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With(authHeader, apiKey))
+}
+
+func TestClient_GetTxtRecord(t *testing.T) {
+ const zoneID = "zoneA"
+
+ client := mockBuilder("myKeyA").
+ Route("GET /api/v1/records", servermock.ResponseFromFixture("get_txt_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("zone_id", zoneID)).
+ Build(t)
+
+ record, err := client.GetTxtRecord(t.Context(), "test1", "txttxttxt", zoneID)
+ require.NoError(t, err)
+
+ expected := &DNSRecord{
+ ID: "1b",
+ Name: "test1",
+ Type: "TXT",
+ Value: "txttxttxt",
+ Priority: 0,
+ TTL: 600,
+ ZoneID: "zoneA",
+ }
+
+ assert.Equal(t, expected, record)
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ const zoneID = "zoneA"
+
+ client := mockBuilder("myKeyB").
+ Route("POST /api/v1/records", servermock.ResponseFromFixture("create_txt_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_txt_record-request.json")).
+ Build(t)
+
+ record := DNSRecord{
+ Name: "test",
+ Type: "TXT",
+ Value: "txttxttxt",
+ TTL: 600,
+ ZoneID: zoneID,
+ }
+
+ err := client.CreateRecord(t.Context(), record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder("myKeyC").
+ Route("DELETE /api/v1/records/recordID", nil).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "recordID")
+ require.NoError(t, err)
+}
+
+func TestClient_GetZoneID(t *testing.T) {
+ client := mockBuilder("myKeyD").
+ Route("GET /api/v1/zones", servermock.ResponseFromFixture("get_zone_id.json")).
+ Build(t)
+
+ zoneID, err := client.GetZoneID(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ assert.Equal(t, "zoneA", zoneID)
+}
diff --git a/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record-request.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record-request.json
new file mode 100644
index 000000000..894d81886
--- /dev/null
+++ b/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record-request.json
@@ -0,0 +1,7 @@
+{
+ "name": "test",
+ "type": "TXT",
+ "value": "txttxttxt",
+ "ttl": 600,
+ "zone_id": "zoneA"
+}
diff --git a/providers/dns/hetzner/internal/fixtures/create_txt_record.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record.json
similarity index 100%
rename from providers/dns/hetzner/internal/fixtures/create_txt_record.json
rename to providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record.json
diff --git a/providers/dns/hetzner/internal/fixtures/get_txt_record.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/get_txt_record.json
similarity index 100%
rename from providers/dns/hetzner/internal/fixtures/get_txt_record.json
rename to providers/dns/hetzner/internal/legacy/internal/fixtures/get_txt_record.json
diff --git a/providers/dns/hetzner/internal/fixtures/get_zone_id.json b/providers/dns/hetzner/internal/legacy/internal/fixtures/get_zone_id.json
similarity index 100%
rename from providers/dns/hetzner/internal/fixtures/get_zone_id.json
rename to providers/dns/hetzner/internal/legacy/internal/fixtures/get_zone_id.json
diff --git a/providers/dns/hetzner/internal/types.go b/providers/dns/hetzner/internal/legacy/internal/types.go
similarity index 91%
rename from providers/dns/hetzner/internal/types.go
rename to providers/dns/hetzner/internal/legacy/internal/types.go
index d0e284511..3b332cc8f 100644
--- a/providers/dns/hetzner/internal/types.go
+++ b/providers/dns/hetzner/internal/legacy/internal/types.go
@@ -25,12 +25,12 @@ type Zone struct {
// Zones a set of DNS zones.
type Zones struct {
Zones []Zone `json:"zones"`
- Meta Meta `json:"meta,omitempty"`
+ Meta Meta `json:"meta"`
}
// Meta response metadata.
type Meta struct {
- Pagination Pagination `json:"pagination,omitempty"`
+ Pagination Pagination `json:"pagination"`
}
// Pagination information about pagination.
diff --git a/providers/dns/hostingde/hostingde.go b/providers/dns/hostingde/hostingde.go
index 67c4661bd..1e022b630 100644
--- a/providers/dns/hostingde/hostingde.go
+++ b/providers/dns/hostingde/hostingde.go
@@ -2,11 +2,9 @@
package hostingde
import (
- "context"
"errors"
"fmt"
"net/http"
- "sync"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -31,14 +29,7 @@ const (
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// 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
-}
+type Config = hostingde.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
@@ -46,7 +37,7 @@ func NewDefaultConfig() *Config {
ZoneName: env.GetOrFile(EnvZoneName),
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -55,11 +46,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *hostingde.Client
-
- recordIDs map[string]string
- recordIDsMu sync.Mutex
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for hosting.de.
@@ -83,140 +70,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("hostingde: the configuration of the DNS provider is nil")
}
- if config.APIKey == "" {
- return nil, errors.New("hostingde: API key missing")
+ provider, err := hostingde.NewDNSProviderConfig(config, "")
+ if err != nil {
+ return nil, fmt.Errorf("hostingde: %w", err)
}
- return &DNSProvider{
- config: config,
- client: hostingde.NewClient(config.APIKey),
- recordIDs: make(map[string]string),
- }, nil
+ return &DNSProvider{prv: provider}, 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.
+// Present creates a TXT record using the specified parameters.
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("hostingde: 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)
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("hostingde: %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("hostingde: %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("hostingde: 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("hostingde: 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)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("hostingde: %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("hostingde: %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 for %s: %w", fqdn, err)
- }
-
- if zoneName == "" {
- return "", errors.New("empty zone name")
- }
-
- return dns01.UnFqdn(zoneName), 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.prv.Timeout()
}
diff --git a/providers/dns/hostingde/hostingde.toml b/providers/dns/hostingde/hostingde.toml
index 39e7ab0f9..502a7fe9e 100644
--- a/providers/dns/hostingde/hostingde.toml
+++ b/providers/dns/hostingde/hostingde.toml
@@ -6,7 +6,7 @@ Since = "v1.1.0"
Example = '''
HOSTINGDE_API_KEY=xxxxxxxx \
-lego --email you@example.com --dns hostingde -d '*.example.com' -d example.com run
+lego --dns hostingde -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns hostingde -d '*.example.com' -d example.com r
HOSTINGDE_API_KEY = "API key"
[Configuration.Additional]
HOSTINGDE_ZONE_NAME = "Zone name in ACE format"
- HOSTINGDE_POLLING_INTERVAL = "Time between DNS propagation check"
- HOSTINGDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- HOSTINGDE_TTL = "The TTL of the TXT record used for the DNS challenge"
- HOSTINGDE_HTTP_TIMEOUT = "API request timeout"
+ HOSTINGDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HOSTINGDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ HOSTINGDE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ HOSTINGDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.hosting.de/api/#dns"
diff --git a/providers/dns/hostingde/hostingde_test.go b/providers/dns/hostingde/hostingde_test.go
index d7681f953..a92006f81 100644
--- a/providers/dns/hostingde/hostingde_test.go
+++ b/providers/dns/hostingde/hostingde_test.go
@@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -58,8 +59,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.recordIDs)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -101,8 +101,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.recordIDs)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -116,6 +115,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -129,6 +129,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/hostinger/hostinger.go b/providers/dns/hostinger/hostinger.go
new file mode 100644
index 000000000..13d9ed0f8
--- /dev/null
+++ b/providers/dns/hostinger/hostinger.go
@@ -0,0 +1,211 @@
+// Package hostinger implements a DNS provider for solving the DNS-01 challenge using Hostinger.
+package hostinger
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/hostinger/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "HOSTINGER_"
+
+ 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, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Hostinger.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIToken)
+ if err != nil {
+ return nil, fmt.Errorf("hostinger: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIToken = values[EnvAPIToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Hostinger.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("hostinger: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIToken)
+ if err != nil {
+ return nil, fmt.Errorf("hostinger: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("hostinger: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("hostinger: %w", err)
+ }
+
+ ctx := context.Background()
+
+ request := internal.ZoneRequest{
+ Overwrite: false,
+ Zone: []internal.RecordSet{{
+ Name: subDomain,
+ Type: "TXT",
+ TTL: d.config.TTL,
+ Records: []internal.Record{
+ {Content: info.Value},
+ },
+ }},
+ }
+
+ err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request)
+ if err != nil {
+ return fmt.Errorf("hostinger: update DNS records (add): %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("hostinger: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("hostinger: %w", err)
+ }
+
+ ctx := context.Background()
+
+ recordSet, err := d.findRecordSet(ctx, authZone, subDomain)
+ if err != nil {
+ return fmt.Errorf("hostinger: %w", err)
+ }
+
+ var newRecords []internal.Record
+
+ for _, record := range recordSet.Records {
+ if record.Content == info.Value || record.Content == strconv.Quote(info.Value) {
+ continue
+ }
+
+ newRecords = append(newRecords, record)
+ }
+
+ recordSet.Records = newRecords
+
+ if len(recordSet.Records) > 0 {
+ request := internal.ZoneRequest{
+ Overwrite: true,
+ Zone: []internal.RecordSet{recordSet},
+ }
+
+ err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request)
+ if err != nil {
+ return fmt.Errorf("hostinger: update DNS records (delete): %w", err)
+ }
+
+ return nil
+ }
+
+ filters := []internal.Filter{{
+ Name: subDomain,
+ Type: "TXT",
+ }}
+
+ err = d.client.DeleteDNSRecords(ctx, dns01.UnFqdn(authZone), filters)
+ if err != nil {
+ return fmt.Errorf("hostinger: delete DNS records: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findRecordSet(ctx context.Context, authZone, subDomain string) (internal.RecordSet, error) {
+ recordSets, err := d.client.GetDNSRecords(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return internal.RecordSet{}, fmt.Errorf("get DNS records: %w", err)
+ }
+
+ for _, recordSet := range recordSets {
+ if recordSet.Name != subDomain || recordSet.Type != "TXT" {
+ continue
+ }
+
+ return recordSet, nil
+ }
+
+ return internal.RecordSet{}, fmt.Errorf("no record found for domain %q and subdomain %q", authZone, subDomain)
+}
diff --git a/providers/dns/hostinger/hostinger.toml b/providers/dns/hostinger/hostinger.toml
new file mode 100644
index 000000000..a6f152e73
--- /dev/null
+++ b/providers/dns/hostinger/hostinger.toml
@@ -0,0 +1,22 @@
+Name = "Hostinger"
+Description = ''''''
+URL = "https://www.hostinger.com/"
+Code = "hostinger"
+Since = "v4.27.0"
+
+Example = '''
+HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns hostinger -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ HOSTINGER_API_TOKEN = "API Token"
+ [Configuration.Additional]
+ HOSTINGER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HOSTINGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ HOSTINGER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ HOSTINGER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://developers.hostinger.com/#tag/dns-zone"
diff --git a/providers/dns/hostinger/hostinger_test.go b/providers/dns/hostinger/hostinger_test.go
new file mode 100644
index 000000000..90ecba529
--- /dev/null
+++ b/providers/dns/hostinger/hostinger_test.go
@@ -0,0 +1,180 @@
+package hostinger
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "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: "secret",
+ },
+ },
+ {
+ desc: "missing API token",
+ envVars: map[string]string{
+ EnvAPIToken: "",
+ },
+ expected: "hostinger: some credentials information are missing: HOSTINGER_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)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "secret",
+ },
+ {
+ desc: "missing API token",
+ expected: "hostinger: credentials 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)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIToken = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("PUT /api/dns/v1/zones/example.com",
+ servermock.ResponseFromInternal("update_dns_records.json"),
+ servermock.CheckRequestJSONBodyFromInternal("update_dns_records-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_update(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /api/dns/v1/zones/example.com",
+ servermock.ResponseFromInternal("get_dns_records_acme.json")).
+ Route("PUT /api/dns/v1/zones/example.com",
+ servermock.ResponseFromInternal("update_dns_records.json"),
+ servermock.CheckRequestJSONBodyFromInternal("update_dns_records_base-request.json")).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_delete(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /api/dns/v1/zones/example.com",
+ servermock.ResponseFromInternal("get_dns_records_empty.json")).
+ Route("DELETE /api/dns/v1/zones/example.com",
+ servermock.ResponseFromInternal("delete_dns_records.json"),
+ servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+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/dns/hostinger/internal/client.go b/providers/dns/hostinger/internal/client.go
new file mode 100644
index 000000000..9da712d61
--- /dev/null
+++ b/providers/dns/hostinger/internal/client.go
@@ -0,0 +1,156 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://developers.hostinger.com"
+
+const authorizationHeader = "Authorization"
+
+// Client the Hostinger API client.
+type Client struct {
+ token string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(token string) (*Client, error) {
+ if token == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ token: token,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// GetDNSRecords retrieves DNS zone records for a specific domain.
+// https://developers.hostinger.com/#tag/dns-zone/get/api/dns/v1/zones/{domain}
+func (c *Client) GetDNSRecords(ctx context.Context, domain string) ([]RecordSet, error) {
+ endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain)
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result []RecordSet
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// UpdateDNSRecords updates DNS records for the selected domain.
+// https://developers.hostinger.com/#tag/dns-zone/put/api/dns/v1/zones/{domain}
+func (c *Client) UpdateDNSRecords(ctx context.Context, domain string, zone ZoneRequest) error {
+ endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain)
+
+ req, err := newJSONRequest(ctx, http.MethodPut, endpoint, zone)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+// DeleteDNSRecords deletes DNS records for the selected domain.
+// https://developers.hostinger.com/#tag/dns-zone/delete/api/dns/v1/zones/{domain}
+func (c *Client) DeleteDNSRecords(ctx context.Context, domain string, filters []Filter) error {
+ endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, Filters{Filters: filters})
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Set(authorizationHeader, "Bearer "+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)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/hostinger/internal/client_test.go b/providers/dns/hostinger/internal/client_test.go
new file mode 100644
index 000000000..69cab5587
--- /dev/null
+++ b/providers/dns/hostinger/internal/client_test.go
@@ -0,0 +1,154 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With("Authorization", "Bearer secret"),
+ )
+}
+
+func TestClient_GetDNSRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/dns/v1/zones/example.com",
+ servermock.ResponseFromFixture("get_dns_records.json")).
+ Build(t)
+
+ records, err := client.GetDNSRecords(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := []RecordSet{
+ {
+ Name: "_acme-challenge",
+ Records: []Record{{
+ Content: "aaa",
+ }},
+ TTL: 14400,
+ Type: "TXT",
+ },
+ {
+ Name: "_acme-challenge",
+ Records: []Record{{
+ Content: "example.com.",
+ }},
+ TTL: 14400,
+ Type: "A",
+ },
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_GetDNSRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/dns/v1/zones/example.com",
+ servermock.ResponseFromFixture("error_401.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.GetDNSRecords(t.Context(), "example.com")
+
+ require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: Unauthenticated")
+}
+
+func TestClient_UpdateDNSRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /api/dns/v1/zones/example.com",
+ servermock.ResponseFromFixture("update_dns_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("update_dns_records-request.json")).
+ Build(t)
+
+ zone := ZoneRequest{
+ Overwrite: false,
+ Zone: []RecordSet{
+ {
+ Name: "_acme-challenge",
+ Records: []Record{
+ {Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
+ },
+ TTL: 120,
+ Type: "TXT",
+ },
+ },
+ }
+
+ err := client.UpdateDNSRecords(t.Context(), "example.com", zone)
+ require.NoError(t, err)
+}
+
+func TestClient_UpdateDNSRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /api/dns/v1/zones/example.com",
+ servermock.ResponseFromFixture("error_422.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ zone := ZoneRequest{
+ Zone: []RecordSet{{
+ Name: "_acme-challenge",
+ Records: []Record{{
+ Content: "aaa",
+ }},
+ TTL: 14400,
+ Type: "TXT",
+ }},
+ }
+
+ err := client.UpdateDNSRecords(t.Context(), "example.com", zone)
+
+ require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: The name field is required. (and 1 more error): field_1: The field_1 field is required., The field_1 must be a number.")
+}
+
+func TestClient_DeleteDNSRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /api/dns/v1/zones/example.com",
+ servermock.ResponseFromFixture("delete_dns_records.json"),
+ servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)).
+ Build(t)
+
+ filters := []Filter{{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ }}
+
+ err := client.DeleteDNSRecords(t.Context(), "example.com", filters)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteDNSRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /api/dns/v1/zones/example.com",
+ servermock.ResponseFromFixture("error_401.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ filters := []Filter{{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ }}
+
+ err := client.DeleteDNSRecords(t.Context(), "example.com", filters)
+
+ require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: Unauthenticated")
+}
diff --git a/providers/dns/hostinger/internal/fixtures/delete_dns_records.json b/providers/dns/hostinger/internal/fixtures/delete_dns_records.json
new file mode 100644
index 000000000..11d2582b4
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/delete_dns_records.json
@@ -0,0 +1,3 @@
+{
+ "message": "Request accepted"
+}
diff --git a/providers/dns/hostinger/internal/fixtures/error_401.json b/providers/dns/hostinger/internal/fixtures/error_401.json
new file mode 100644
index 000000000..1b7381ff6
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/error_401.json
@@ -0,0 +1,4 @@
+{
+ "message": "Unauthenticated",
+ "correlation_id": "26a91bd9-f8c8-4a83-9df9-83e23d696fe3"
+}
diff --git a/providers/dns/hostinger/internal/fixtures/error_422.json b/providers/dns/hostinger/internal/fixtures/error_422.json
new file mode 100644
index 000000000..6ec286823
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/error_422.json
@@ -0,0 +1,10 @@
+{
+ "message": "The name field is required. (and 1 more error)",
+ "errors": {
+ "field_1": [
+ "The field_1 field is required.",
+ "The field_1 must be a number."
+ ]
+ },
+ "correlation_id": "26a91bd9-f8c8-4a83-9df9-83e23d696fe3"
+}
diff --git a/providers/dns/hostinger/internal/fixtures/get_dns_records.json b/providers/dns/hostinger/internal/fixtures/get_dns_records.json
new file mode 100644
index 000000000..e51edd4dc
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/get_dns_records.json
@@ -0,0 +1,24 @@
+[
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "aaa",
+ "is_disabled": false
+ }
+ ],
+ "ttl": 14400,
+ "type": "TXT"
+ },
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "example.com.",
+ "is_disabled": false
+ }
+ ],
+ "ttl": 14400,
+ "type": "A"
+ }
+]
diff --git a/providers/dns/hostinger/internal/fixtures/get_dns_records_acme.json b/providers/dns/hostinger/internal/fixtures/get_dns_records_acme.json
new file mode 100644
index 000000000..99a574514
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/get_dns_records_acme.json
@@ -0,0 +1,27 @@
+[
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "aaa",
+ "is_disabled": false
+ },
+ {
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ }
+ ],
+ "ttl": 14400,
+ "type": "TXT"
+ },
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "example.com.",
+ "is_disabled": false
+ }
+ ],
+ "ttl": 14400,
+ "type": "A"
+ }
+]
diff --git a/providers/dns/hostinger/internal/fixtures/get_dns_records_empty.json b/providers/dns/hostinger/internal/fixtures/get_dns_records_empty.json
new file mode 100644
index 000000000..9989a3fc4
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/get_dns_records_empty.json
@@ -0,0 +1,23 @@
+[
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ }
+ ],
+ "ttl": 14400,
+ "type": "TXT"
+ },
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "example.com.",
+ "is_disabled": false
+ }
+ ],
+ "ttl": 14400,
+ "type": "A"
+ }
+]
diff --git a/providers/dns/hostinger/internal/fixtures/update_dns_records-request.json b/providers/dns/hostinger/internal/fixtures/update_dns_records-request.json
new file mode 100644
index 000000000..6f287b3fc
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/update_dns_records-request.json
@@ -0,0 +1,15 @@
+{
+ "overwrite": false,
+ "zone": [
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ }
+ ],
+ "ttl": 120,
+ "type": "TXT"
+ }
+ ]
+}
diff --git a/providers/dns/hostinger/internal/fixtures/update_dns_records.json b/providers/dns/hostinger/internal/fixtures/update_dns_records.json
new file mode 100644
index 000000000..11d2582b4
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/update_dns_records.json
@@ -0,0 +1,3 @@
+{
+ "message": "Request accepted"
+}
diff --git a/providers/dns/hostinger/internal/fixtures/update_dns_records_base-request.json b/providers/dns/hostinger/internal/fixtures/update_dns_records_base-request.json
new file mode 100644
index 000000000..c42ddc6d7
--- /dev/null
+++ b/providers/dns/hostinger/internal/fixtures/update_dns_records_base-request.json
@@ -0,0 +1,15 @@
+{
+ "overwrite": true,
+ "zone": [
+ {
+ "name": "_acme-challenge",
+ "records": [
+ {
+ "content": "aaa"
+ }
+ ],
+ "ttl": 14400,
+ "type": "TXT"
+ }
+ ]
+}
diff --git a/providers/dns/hostinger/internal/types.go b/providers/dns/hostinger/internal/types.go
new file mode 100644
index 000000000..c1a02ff8c
--- /dev/null
+++ b/providers/dns/hostinger/internal/types.go
@@ -0,0 +1,50 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ Message string `json:"message,omitempty"`
+ Errors map[string][]string `json:"errors,omitempty"`
+ CorrelationID string `json:"correlation_id,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "%s: %s", a.CorrelationID, a.Message)
+
+ for field, values := range a.Errors {
+ _, _ = fmt.Fprintf(msg, ": %s: %s", field, strings.Join(values, ", "))
+ }
+
+ return msg.String()
+}
+
+type ZoneRequest struct {
+ Overwrite bool `json:"overwrite"`
+ Zone []RecordSet `json:"zone,omitempty"`
+}
+
+type RecordSet struct {
+ Name string `json:"name,omitempty"`
+ Records []Record `json:"records,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Type string `json:"type,omitempty"`
+}
+
+type Record struct {
+ Content string `json:"content,omitempty"`
+ IsDisabled bool `json:"is_disabled,omitempty"`
+}
+
+type Filters struct {
+ Filters []Filter `json:"filters"`
+}
+
+type Filter struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
diff --git a/providers/dns/hostingnl/hostingnl.go b/providers/dns/hostingnl/hostingnl.go
new file mode 100644
index 000000000..a49941817
--- /dev/null
+++ b/providers/dns/hostingnl/hostingnl.go
@@ -0,0 +1,168 @@
+// Package hostingnl implements a DNS provider for solving the DNS-01 challenge using hosting.nl.
+package hostingnl
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/hostingnl/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "HOSTINGNL_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+ HTTPClient *http.Client
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ 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, 120*time.Second),
+ 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
+
+ recordIDs map[string]string
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for hosting.nl.
+// Credentials must be passed in the environment variables:
+// HOSTINGNL_APIKEY.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("hostingnl: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for hosting.nl.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("hostingnl: the configuration of the DNS provider is nil")
+ }
+
+ if config.APIKey == "" {
+ return nil, errors.New("hostingnl: APIKey is missing")
+ }
+
+ client := internal.NewClient(config.APIKey)
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]string),
+ }, 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("hostingnl: could not find zone for domain %q: %w", domain, err)
+ }
+
+ record := internal.Record{
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
+ Type: "TXT",
+ Content: strconv.Quote(info.Value),
+ TTL: d.config.TTL,
+ Priority: 0,
+ }
+
+ newRecord, err := d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("hostingnl: failed to create TXT record, fqdn=%s: %w", info.EffectiveFQDN, err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = newRecord.ID
+ d.recordIDsMu.Unlock()
+
+ return nil
+}
+
+// CleanUp removes the TXT records matching the specified parameters.
+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("hostingnl: could not find zone for domain %q: %w", domain, err)
+ }
+
+ // gets the record's unique ID
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("hostingnl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
+ if err != nil {
+ return fmt.Errorf("hostingnl: failed to delete TXT record, id=%s: %w", recordID, err)
+ }
+
+ // deletes record ID from map
+ 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/dns/hostingnl/hostingnl.toml b/providers/dns/hostingnl/hostingnl.toml
new file mode 100644
index 000000000..943264ed3
--- /dev/null
+++ b/providers/dns/hostingnl/hostingnl.toml
@@ -0,0 +1,22 @@
+Name = "Hosting.nl"
+Description = ''''''
+URL = "https://hosting.nl"
+Code = "hostingnl"
+Since = "v4.30.0"
+
+Example = '''
+HOSTINGNL_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns hostingnl -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ HOSTINGNL_API_KEY = "The API key"
+ [Configuration.Additional]
+ HOSTINGNL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HOSTINGNL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ HOSTINGNL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ HOSTINGNL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
+
+[Links]
+ API = "https://api.hosting.nl/api/documentation"
diff --git a/providers/dns/hostingnl/hostingnl_test.go b/providers/dns/hostingnl/hostingnl_test.go
new file mode 100644
index 000000000..cef754c7c
--- /dev/null
+++ b/providers/dns/hostingnl/hostingnl_test.go
@@ -0,0 +1,167 @@
+package hostingnl
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "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: "key",
+ },
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{},
+ expected: "hostingnl: some credentials information are missing: HOSTINGNL_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "key",
+ },
+ {
+ desc: "missing API key",
+ expected: "hostingnl: APIKey is missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ provider, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ provider.client.BaseURL, _ = url.Parse(server.URL)
+
+ return provider, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("API-TOKEN", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /domains/example.com/dns",
+ servermock.ResponseFromInternal("add_record.json"),
+ servermock.CheckQueryParameter().Strict(),
+ servermock.CheckRequestJSONBodyFromInternal("add_record-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /domains/example.com/dns",
+ servermock.ResponseFromInternal("delete_record.json"),
+ servermock.CheckQueryParameter().Strict(),
+ servermock.CheckRequestJSONBodyFromInternal("delete_record-request.json")).
+ Build(t)
+
+ provider.recordIDs["abc"] = "12345"
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/hostingnl/internal/client.go b/providers/dns/hostingnl/internal/client.go
new file mode 100644
index 000000000..f2d7b5346
--- /dev/null
+++ b/providers/dns/hostingnl/internal/client.go
@@ -0,0 +1,144 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.hosting.nl"
+
+type Client struct {
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+func NewClient(apiKey string) *Client {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 5 * time.Second},
+ }
+}
+
+func (c Client) AddRecord(ctx context.Context, domain string, record Record) (*Record, error) {
+ endpoint := c.BaseURL.JoinPath("domains", domain, "dns")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, []Record{record})
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[Record]
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(result.Data) != 1 {
+ return nil, fmt.Errorf("unexpected response data: %v", result.Data)
+ }
+
+ return &result.Data[0], nil
+}
+
+func (c Client) DeleteRecord(ctx context.Context, domain, recordID string) error {
+ endpoint := c.BaseURL.JoinPath("domains", domain, "dns")
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, []Record{{ID: recordID}})
+ if err != nil {
+ return err
+ }
+
+ var result APIResponse[Record]
+
+ err = c.do(req, &result)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set("API-TOKEN", c.apiKey)
+
+ 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 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 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)
+}
+
+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/dns/hostingnl/internal/client_test.go b/providers/dns/hostingnl/internal/client_test.go
new file mode 100644
index 000000000..efdb98980
--- /dev/null
+++ b/providers/dns/hostingnl/internal/client_test.go
@@ -0,0 +1,92 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("API-TOKEN", "secret"),
+ )
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/example.com/dns",
+ servermock.ResponseFromFixture("add_record.json"),
+ servermock.CheckQueryParameter().Strict(),
+ servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge.example.com",
+ Type: "TXT",
+ Content: strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"),
+ TTL: 120,
+ }
+
+ newRecord, err := client.AddRecord(context.Background(), "example.com", record)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: "12345",
+ Name: "_acme-challenge.example.com",
+ Type: "TXT",
+ Content: strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"),
+ TTL: 120,
+ }
+
+ assert.Equal(t, expected, newRecord)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/dns",
+ servermock.ResponseFromFixture("delete_record.json"),
+ servermock.CheckQueryParameter().Strict(),
+ servermock.CheckRequestJSONBodyFromFixture("delete_record-request.json")).
+ Build(t)
+
+ err := client.DeleteRecord(context.Background(), "example.com", "12345")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/dns",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ err := client.DeleteRecord(context.Background(), "example.com", "12345")
+ require.EqualError(t, err, "[status code: 401] Something went wrong")
+}
+
+func TestClient_DeleteRecord_error_other(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/dns",
+ servermock.ResponseFromFixture("error_other.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
+
+ err := client.DeleteRecord(context.Background(), "example.com", "12345")
+ require.EqualError(t, err, "[status code: 404] Resource not found")
+}
diff --git a/providers/dns/hostingnl/internal/fixtures/add_record-request.json b/providers/dns/hostingnl/internal/fixtures/add_record-request.json
new file mode 100644
index 000000000..6b68ec3c6
--- /dev/null
+++ b/providers/dns/hostingnl/internal/fixtures/add_record-request.json
@@ -0,0 +1,8 @@
+[
+ {
+ "name": "_acme-challenge.example.com",
+ "type": "TXT",
+ "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"",
+ "ttl": 120
+ }
+]
diff --git a/providers/dns/hostingnl/internal/fixtures/add_record.json b/providers/dns/hostingnl/internal/fixtures/add_record.json
new file mode 100644
index 000000000..a822a4f8d
--- /dev/null
+++ b/providers/dns/hostingnl/internal/fixtures/add_record.json
@@ -0,0 +1,13 @@
+{
+ "success": true,
+ "data": [
+ {
+ "id": "12345",
+ "type": "TXT",
+ "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"",
+ "name": "_acme-challenge.example.com",
+ "prio": 0,
+ "ttl": 120
+ }
+ ]
+}
diff --git a/providers/dns/hostingnl/internal/fixtures/delete_record-request.json b/providers/dns/hostingnl/internal/fixtures/delete_record-request.json
new file mode 100644
index 000000000..cfc26d2b9
--- /dev/null
+++ b/providers/dns/hostingnl/internal/fixtures/delete_record-request.json
@@ -0,0 +1,5 @@
+[
+ {
+ "id": "12345"
+ }
+]
diff --git a/providers/dns/hostingnl/internal/fixtures/delete_record.json b/providers/dns/hostingnl/internal/fixtures/delete_record.json
new file mode 100644
index 000000000..c041c1f6d
--- /dev/null
+++ b/providers/dns/hostingnl/internal/fixtures/delete_record.json
@@ -0,0 +1,8 @@
+{
+ "success": true,
+ "data": [
+ {
+ "id": "12345"
+ }
+ ]
+}
diff --git a/providers/dns/hostingnl/internal/fixtures/error.json b/providers/dns/hostingnl/internal/fixtures/error.json
new file mode 100644
index 000000000..170587246
--- /dev/null
+++ b/providers/dns/hostingnl/internal/fixtures/error.json
@@ -0,0 +1,5 @@
+{
+ "errors": {
+ "message": "Something went wrong"
+ }
+}
diff --git a/providers/dns/hostingnl/internal/fixtures/error_other.json b/providers/dns/hostingnl/internal/fixtures/error_other.json
new file mode 100644
index 000000000..ca7ecab28
--- /dev/null
+++ b/providers/dns/hostingnl/internal/fixtures/error_other.json
@@ -0,0 +1,3 @@
+{
+ "error": "Resource not found"
+}
diff --git a/providers/dns/hostingnl/internal/types.go b/providers/dns/hostingnl/internal/types.go
new file mode 100644
index 000000000..f324665fe
--- /dev/null
+++ b/providers/dns/hostingnl/internal/types.go
@@ -0,0 +1,36 @@
+package internal
+
+type Record struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Priority int `json:"prio,omitempty"`
+}
+
+type APIResponse[T any] struct {
+ Success bool `json:"success"`
+ Data []T `json:"data"`
+}
+
+type APIError struct {
+ ErrorMsg string `json:"error"`
+ Errors Error `json:"errors"`
+}
+
+func (e APIError) Error() string {
+ if e.ErrorMsg != "" {
+ return e.ErrorMsg
+ }
+
+ return e.Errors.Error()
+}
+
+type Error struct {
+ Message string `json:"message"`
+}
+
+func (e Error) Error() string {
+ return e.Message
+}
diff --git a/providers/dns/hosttech/hosttech.go b/providers/dns/hosttech/hosttech.go
index 22d3be7bd..73346f6cb 100644
--- a/providers/dns/hosttech/hosttech.go
+++ b/providers/dns/hosttech/hosttech.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/hosttech/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -84,7 +85,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("hosttech: missing credentials")
}
- client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey))
+ client := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey),
+ ),
+ )
return &DNSProvider{
config: config,
@@ -159,6 +164,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("hosttech: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
@@ -168,5 +174,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("hosttech: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/hosttech/hosttech.toml b/providers/dns/hosttech/hosttech.toml
index 89d495b0c..52c01fd31 100644
--- a/providers/dns/hosttech/hosttech.toml
+++ b/providers/dns/hosttech/hosttech.toml
@@ -6,7 +6,7 @@ Since = "v4.5.0"
Example = '''
HOSTTECH_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns hosttech -d '*.example.com' -d example.com run
+lego --dns hosttech -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns hosttech -d '*.example.com' -d example.com ru
HOSTTECH_API_KEY = "API login"
HOSTTECH_PASSWORD = "API password"
[Configuration.Additional]
- HOSTTECH_POLLING_INTERVAL = "Time between DNS propagation check"
- HOSTTECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- HOSTTECH_TTL = "The TTL of the TXT record used for the DNS challenge"
- HOSTTECH_HTTP_TIMEOUT = "API request timeout"
+ HOSTTECH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HOSTTECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ HOSTTECH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ HOSTTECH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.ns1.hosttech.eu/api/documentation"
diff --git a/providers/dns/hosttech/hosttech_test.go b/providers/dns/hosttech/hosttech_test.go
index 6f0d0bd3e..042b73353 100644
--- a/providers/dns/hosttech/hosttech_test.go
+++ b/providers/dns/hosttech/hosttech_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/hosttech/internal/client.go b/providers/dns/hosttech/internal/client.go
index 78b594558..557d54298 100644
--- a/providers/dns/hosttech/internal/client.go
+++ b/providers/dns/hosttech/internal/client.go
@@ -36,7 +36,7 @@ func NewClient(hc *http.Client) *Client {
// GetZones Get a list of all zones.
// https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones
-func (c Client) GetZones(ctx context.Context, query string, limit, offset int) ([]Zone, error) {
+func (c *Client) GetZones(ctx context.Context, query string, limit, offset int) ([]Zone, error) {
endpoint := c.baseURL.JoinPath("user", "v1", "zones")
values := endpoint.Query()
@@ -58,6 +58,7 @@ func (c Client) GetZones(ctx context.Context, query string, limit, offset int) (
}
result := apiResponse[[]Zone]{}
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -68,7 +69,7 @@ func (c Client) GetZones(ctx context.Context, query string, limit, offset int) (
// GetZone Get a single zone.
// https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones__zoneId_
-func (c Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) {
+func (c *Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) {
endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -77,6 +78,7 @@ func (c Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) {
}
result := apiResponse[*Zone]{}
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -87,7 +89,7 @@ func (c Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) {
// GetRecords Returns a list of all records for the given zone.
// https://api.ns1.hosttech.eu/api/documentation/#/Records/get_api_user_v1_zones__zoneId__records
-func (c Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Record, error) {
+func (c *Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Record, error) {
endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records")
values := endpoint.Query()
@@ -104,6 +106,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Re
}
result := apiResponse[[]Record]{}
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -114,7 +117,7 @@ func (c Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Re
// AddRecord Adds a new record to the zone and returns the newly created record.
// https://api.ns1.hosttech.eu/api/documentation/#/Records/post_api_user_v1_zones__zoneId__records
-func (c Client) AddRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
+func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
@@ -123,6 +126,7 @@ func (c Client) AddRecord(ctx context.Context, zoneID string, record Record) (*R
}
result := apiResponse[*Record]{}
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -133,7 +137,7 @@ func (c Client) AddRecord(ctx context.Context, zoneID string, record Record) (*R
// DeleteRecord Deletes a single record for the given id.
// https://api.ns1.hosttech.eu/api/documentation/#/Records/delete_api_user_v1_zones__zoneId__records__recordId_
-func (c Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
+func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records", recordID)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -144,7 +148,7 @@ func (c Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error
return c.do(req, nil)
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
resp, errD := c.httpClient.Do(req)
if errD != nil {
return errutils.NewHTTPDoError(req, errD)
@@ -202,6 +206,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errAPI := &APIError{StatusCode: resp.StatusCode}
+
err := json.Unmarshal(raw, errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/hosttech/internal/client_test.go b/providers/dns/hosttech/internal/client_test.go
index bf90acc9f..223a0d9cf 100644
--- a/providers/dns/hosttech/internal/client_test.go
+++ b/providers/dns/hosttech/internal/client_test.go
@@ -1,26 +1,40 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAPIKey = "secret"
-func TestClient_GetZones(t *testing.T) {
- client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusOK, "zones.json"))
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey))
+ client.baseURL, _ = url.Parse(server.URL)
- zones, err := client.GetZones(context.Background(), "", 100, 0)
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer secret"))
+}
+
+func TestClient_GetZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /user/v1/zones",
+ servermock.ResponseFromFixture("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("limit", "100").
+ With("query", "")).
+ Build(t)
+
+ zones, err := client.GetZones(t.Context(), "", 100, 0)
require.NoError(t, err)
expected := []Zone{
@@ -39,16 +53,23 @@ func TestClient_GetZones(t *testing.T) {
}
func TestClient_GetZones_error(t *testing.T) {
- client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
+ client := mockBuilder().
+ Route("GET /user/v1/zones",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- _, err := client.GetZones(context.Background(), "", 100, 0)
+ _, err := client.GetZones(t.Context(), "", 100, 0)
require.Error(t, err)
}
func TestClient_GetZone(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusOK, "zone.json"))
+ client := mockBuilder().
+ Route("GET /user/v1/zones/123",
+ servermock.ResponseFromFixture("zone.json")).
+ Build(t)
- zone, err := client.GetZone(context.Background(), "123")
+ zone, err := client.GetZone(t.Context(), "123")
require.NoError(t, err)
expected := &Zone{
@@ -65,16 +86,25 @@ func TestClient_GetZone(t *testing.T) {
}
func TestClient_GetZone_error(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
+ client := mockBuilder().
+ Route("GET /user/v1/zones/123",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- _, err := client.GetZone(context.Background(), "123")
- require.Error(t, err)
+ _, err := client.GetZone(t.Context(), "123")
+ require.EqualError(t, err, "401: Unauthenticated.")
}
func TestClient_GetRecords(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusOK, "records.json"))
+ client := mockBuilder().
+ Route("GET /user/v1/zones/123/records",
+ servermock.ResponseFromFixture("records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("type", "TXT")).
+ Build(t)
- records, err := client.GetRecords(context.Background(), "123", "TXT")
+ records, err := client.GetRecords(t.Context(), "123", "TXT")
require.NoError(t, err)
expected := []Record{
@@ -152,14 +182,22 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecords_error(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
+ client := mockBuilder().
+ Route("GET /user/v1/zones/123/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- _, err := client.GetRecords(context.Background(), "123", "TXT")
- require.Error(t, err)
+ _, err := client.GetRecords(t.Context(), "123", "TXT")
+ require.EqualError(t, err, "401: Unauthenticated.")
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodPost, http.StatusCreated, "record.json"))
+ client := mockBuilder().
+ Route("POST /user/v1/zones/123/records",
+ servermock.ResponseFromFixture("record.json").
+ WithStatusCode(http.StatusCreated)).
+ Build(t)
record := Record{
Type: "TXT",
@@ -169,7 +207,7 @@ func TestClient_AddRecord(t *testing.T) {
Comment: "example",
}
- newRecord, err := client.AddRecord(context.Background(), "123", record)
+ newRecord, err := client.AddRecord(t.Context(), "123", record)
require.NoError(t, err)
expected := &Record{
@@ -185,7 +223,11 @@ func TestClient_AddRecord(t *testing.T) {
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodPost, http.StatusUnauthorized, "error-details.json"))
+ client := mockBuilder().
+ Route("POST /user/v1/zones/123/records",
+ servermock.ResponseFromFixture("error-details.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
record := Record{
Type: "TXT",
@@ -195,69 +237,28 @@ func TestClient_AddRecord_error(t *testing.T) {
Comment: "example",
}
- _, err := client.AddRecord(context.Background(), "123", record)
- require.Error(t, err)
+ _, err := client.AddRecord(t.Context(), "123", record)
+ require.EqualError(t, err, "401: The given data was invalid. type: [Darf nicht leer sein.]")
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json"))
+ client := mockBuilder().
+ Route("DELETE /user/v1/zones/123/records/6",
+ servermock.Noop().WithStatusCode(http.StatusNoContent).
+ WithStatusCode(http.StatusCreated)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "123", "6")
+ err := client.DeleteRecord(t.Context(), "123", "6")
require.Error(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusNoContent, ""))
+ client := mockBuilder().
+ Route("DELETE /user/v1/zones/123/records/6",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "123", "6")
- require.NoError(t, err)
-}
-
-func setupTest(t *testing.T, path string, handler http.Handler) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.Handle(path, handler)
-
- client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey))
- client.baseURL, _ = url.Parse(server.URL)
-
- return client
-}
-
-func testHandler(method string, statusCode int, filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- if req.Header.Get("Authorization") != "Bearer "+testAPIKey {
- http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(statusCode)
-
- if statusCode == http.StatusNoContent {
- return
- }
-
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
- }
+ err := client.DeleteRecord(t.Context(), "123", "6")
+ require.EqualError(t, err, "401: Unauthenticated.")
}
diff --git a/providers/dns/hosttech/internal/types.go b/providers/dns/hosttech/internal/types.go
index bf86964f7..a4b5b564d 100644
--- a/providers/dns/hosttech/internal/types.go
+++ b/providers/dns/hosttech/internal/types.go
@@ -2,6 +2,7 @@ package internal
import (
"fmt"
+ "strings"
)
type apiResponse[T any] struct {
@@ -15,11 +16,15 @@ type APIError struct {
}
func (a APIError) Error() string {
- msg := fmt.Sprintf("%d: %s", a.StatusCode, a.Message)
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "%d: %s", a.StatusCode, a.Message)
+
for k, v := range a.Errors {
- msg += fmt.Sprintf(" %s: %v", k, v)
+ _, _ = fmt.Fprintf(msg, " %s: %v", k, v)
}
- return msg
+
+ return msg.String()
}
type Zone struct {
diff --git a/providers/dns/httpnet/httpnet.go b/providers/dns/httpnet/httpnet.go
index 41f4ffbf8..4a88f1092 100644
--- a/providers/dns/httpnet/httpnet.go
+++ b/providers/dns/httpnet/httpnet.go
@@ -2,12 +2,9 @@
package httpnet
import (
- "context"
"errors"
"fmt"
"net/http"
- "net/url"
- "sync"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -29,17 +26,12 @@ const (
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
+const defaultBaseURL = "https://partner.http.net/api/dns/v1/json"
+
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// 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
-}
+type Config = hostingde.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
@@ -47,7 +39,7 @@ func NewDefaultConfig() *Config {
ZoneName: env.GetOrFile(EnvZoneName),
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -56,11 +48,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *hostingde.Client
-
- recordIDs map[string]string
- recordIDsMu sync.Mutex
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for http.net.
@@ -84,143 +72,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("httpnet: the configuration of the DNS provider is nil")
}
- if config.APIKey == "" {
- return nil, errors.New("httpnet: API key missing")
+ provider, err := hostingde.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("httpnet: %w", err)
}
- client := hostingde.NewClient(config.APIKey)
- client.BaseURL, _ = url.Parse(hostingde.DefaultHTTPNetBaseURL)
-
- return &DNSProvider{
- config: config,
- client: client,
- recordIDs: make(map[string]string),
- }, nil
+ return &DNSProvider{prv: provider}, 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.
+// Present creates a TXT record using the specified parameters.
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)
+ err := d.prv.Present(domain, token, keyAuth)
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)
+ err := d.prv.CleanUp(domain, token, keyAuth)
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 for %s: %w", fqdn, err)
- }
-
- if zoneName == "" {
- return "", errors.New("empty zone name")
- }
-
- return dns01.UnFqdn(zoneName), 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.prv.Timeout()
}
diff --git a/providers/dns/httpnet/httpnet.toml b/providers/dns/httpnet/httpnet.toml
index baf170973..3dd581204 100644
--- a/providers/dns/httpnet/httpnet.toml
+++ b/providers/dns/httpnet/httpnet.toml
@@ -6,7 +6,7 @@ Since = "v4.15.0"
Example = '''
HTTPNET_API_KEY=xxxxxxxx \
-lego --email you@example.com --dns httpnet -d '*.example.com' -d example.com run
+lego --dns httpnet -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns httpnet -d '*.example.com' -d example.com run
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"
+ HTTPNET_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HTTPNET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ HTTPNET_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ HTTPNET_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.http.net/docs/api/#dns"
diff --git a/providers/dns/httpnet/httpnet_test.go b/providers/dns/httpnet/httpnet_test.go
index a9bc527ad..ef1d2a1b7 100644
--- a/providers/dns/httpnet/httpnet_test.go
+++ b/providers/dns/httpnet/httpnet_test.go
@@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -58,8 +59,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.recordIDs)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -101,8 +101,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.recordIDs)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -116,6 +115,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -129,6 +129,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/httpreq/httpreq.go b/providers/dns/httpreq/httpreq.go
index 8f8311e0a..591e9b5e1 100644
--- a/providers/dns/httpreq/httpreq.go
+++ b/providers/dns/httpreq/httpreq.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
@@ -88,6 +89,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config.Username = env.GetOrFile(EnvUsername)
config.Password = env.GetOrFile(EnvPassword)
config.Endpoint = endpoint
+
return NewDNSProviderConfig(config)
}
@@ -101,6 +103,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("httpreq: the endpoint is missing")
}
+ config.HTTPClient = clientdebug.Wrap(config.HTTPClient)
+
return &DNSProvider{config: config}, nil
}
@@ -125,6 +129,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("httpreq: %w", err)
}
+
return nil
}
@@ -138,6 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("httpreq: %w", err)
}
+
return nil
}
@@ -156,6 +162,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("httpreq: %w", err)
}
+
return nil
}
@@ -169,11 +176,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("httpreq: %w", err)
}
+
return nil
}
func (d *DNSProvider) doPost(ctx context.Context, uri string, msg any) error {
reqBody := new(bytes.Buffer)
+
err := json.NewEncoder(reqBody).Encode(msg)
if err != nil {
return fmt.Errorf("failed to create request JSON body: %w", err)
diff --git a/providers/dns/httpreq/httpreq.toml b/providers/dns/httpreq/httpreq.toml
index 43f3e4f62..d64d61a6c 100644
--- a/providers/dns/httpreq/httpreq.toml
+++ b/providers/dns/httpreq/httpreq.toml
@@ -6,7 +6,7 @@ Since = "v2.0.0"
Example = '''
HTTPREQ_ENDPOINT=http://my.server.com:9090 \
-lego --email you@example.com --dns httpreq -d '*.example.com' -d example.com run
+lego --dns httpreq -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -56,6 +56,6 @@ Basic authentication (optional) can be set with some environment variables:
[Configuration.Additional]
HTTPREQ_USERNAME = "Basic authentication username"
HTTPREQ_PASSWORD = "Basic authentication password"
- HTTPREQ_POLLING_INTERVAL = "Time between DNS propagation check"
- HTTPREQ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- HTTPREQ_HTTP_TIMEOUT = "API request timeout"
+ HTTPREQ_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HTTPREQ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ HTTPREQ_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
diff --git a/providers/dns/httpreq/httpreq_test.go b/providers/dns/httpreq/httpreq_test.go
index 8dc36ccc6..108d6a565 100644
--- a/providers/dns/httpreq/httpreq_test.go
+++ b/providers/dns/httpreq/httpreq_test.go
@@ -1,15 +1,12 @@
package httpreq
import (
- "encoding/json"
- "fmt"
- "net/http"
"net/http/httptest"
"net/url"
- "path"
"testing"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
@@ -46,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -102,75 +100,60 @@ func TestNewDNSProvider_Present(t *testing.T) {
testCases := []struct {
desc string
- mode string
- username string
- password string
- pathPrefix string
- handler http.HandlerFunc
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "success",
- handler: successHandler,
+ desc: "success",
+ builder: mockBuilder("").
+ Route("/present",
+ servermock.RawStringResponse("lego"),
+ servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)),
},
{
- desc: "success with path prefix",
- handler: successHandler,
- pathPrefix: "/api/acme/",
+ desc: "success with path prefix",
+ builder: mockBuilderWithPathPrefix("", "/api/acme/").
+ Route("/api/acme/present",
+ servermock.RawStringResponse("lego"),
+ servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)),
},
{
desc: "error",
- handler: http.NotFound,
+ builder: mockBuilder(""),
expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
},
{
- desc: "success raw mode",
- mode: "RAW",
- handler: successRawModeHandler,
+ desc: "success raw mode",
+ builder: mockBuilder("RAW").
+ Route("/present",
+ servermock.RawStringResponse("lego"),
+ servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)),
},
{
desc: "error raw mode",
- mode: "RAW",
- handler: http.NotFound,
+ builder: mockBuilder("RAW"),
expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
},
{
- desc: "basic auth",
- username: "bar",
- password: "foo",
- handler: func(rw http.ResponseWriter, req *http.Request) {
- username, password, ok := req.BasicAuth()
- if username != "bar" || password != "foo" || !ok {
- rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and password."))
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- fmt.Fprint(rw, "lego")
- },
+ desc: "basic auth fail",
+ builder: mockBuilderWithBasicAuth("nope", "nope").
+ Route("/present", servermock.Noop()),
+ expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "nope", password: "nope"], want [username: "user", password: "secret"]`,
+ },
+ {
+ desc: "basic auth success",
+ builder: mockBuilderWithBasicAuth("user", "secret").
+ Route("/present",
+ servermock.RawStringResponse("lego"),
+ servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)),
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
+ p := test.builder.Build(t)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc(path.Join("/", test.pathPrefix, "present"), test.handler)
-
- config := NewDefaultConfig()
- config.Endpoint = mustParse(server.URL + test.pathPrefix)
- config.Mode = test.mode
- config.Username = test.username
- config.Password = test.password
-
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- err = p.Present("domain", "token", "key")
+ err := p.Present("domain", "token", "key")
if test.expectedError == "" {
require.NoError(t, err)
} else {
@@ -185,68 +168,53 @@ func TestNewDNSProvider_Cleanup(t *testing.T) {
testCases := []struct {
desc string
- mode string
- username string
- password string
- handler http.HandlerFunc
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "success",
- handler: successHandler,
+ desc: "success",
+ builder: mockBuilder("").
+ Route("/cleanup",
+ servermock.RawStringResponse("lego"),
+ servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)),
},
{
desc: "error",
- handler: http.NotFound,
+ builder: mockBuilder(""),
expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
},
{
- desc: "success raw mode",
- mode: "RAW",
- handler: successRawModeHandler,
+ desc: "success raw mode",
+ builder: mockBuilder("RAW").
+ Route("/cleanup",
+ servermock.RawStringResponse("lego"),
+ servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)),
},
{
desc: "error raw mode",
- mode: "RAW",
- handler: http.NotFound,
+ builder: mockBuilder("RAW"),
expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
},
{
- desc: "basic auth",
- username: "bar",
- password: "foo",
- handler: func(rw http.ResponseWriter, req *http.Request) {
- username, password, ok := req.BasicAuth()
- if username != "bar" || password != "foo" || !ok {
- rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and password."))
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
- fmt.Fprint(rw, "lego")
- },
+ desc: "basic auth fail",
+ builder: mockBuilderWithBasicAuth("test", "example").
+ Route("/cleanup", servermock.Noop()),
+ expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "test", password: "example"], want [username: "user", password: "secret"]`,
+ },
+ {
+ desc: "basic auth success",
+ builder: mockBuilderWithBasicAuth("user", "secret").
+ Route("/cleanup",
+ servermock.RawStringResponse("lego"),
+ servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)),
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
+ p := test.builder.Build(t)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/cleanup", test.handler)
-
- config := NewDefaultConfig()
- config.Endpoint = mustParse(server.URL)
- config.Mode = test.mode
- config.Username = test.username
- config.Password = test.password
-
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- err = p.CleanUp("domain", "token", "key")
+ err := p.CleanUp("domain", "token", "key")
if test.expectedError == "" {
require.NoError(t, err)
} else {
@@ -256,36 +224,42 @@ func TestNewDNSProvider_Cleanup(t *testing.T) {
}
}
-func successHandler(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+func mockBuilder(mode string) *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.Endpoint, _ = url.Parse(server.URL)
+ config.Mode = mode
- msg := &message{}
- err := json.NewDecoder(req.Body).Decode(msg)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- fmt.Fprint(rw, "lego")
+ return NewDNSProviderConfig(config)
+ })
}
-func successRawModeHandler(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+func mockBuilderWithPathPrefix(mode, prefix string) *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.Endpoint, _ = url.Parse(server.URL + prefix)
+ config.Mode = mode
- msg := &messageRaw{}
- err := json.NewDecoder(req.Body).Decode(msg)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
+ return NewDNSProviderConfig(config)
+ })
+}
- fmt.Fprint(rw, "lego")
+func mockBuilderWithBasicAuth(username, password string) *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.Endpoint, _ = url.Parse(server.URL)
+ config.Username = username
+ config.Password = password
+
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().WithBasicAuth("user", "secret"))
}
func mustParse(rawURL string) *url.URL {
@@ -293,5 +267,6 @@ func mustParse(rawURL string) *url.URL {
if err != nil {
panic(err)
}
+
return uri
}
diff --git a/providers/dns/huaweicloud/huaweicloud.go b/providers/dns/huaweicloud/huaweicloud.go
index 9d20c27ab..e47f3e2b5 100644
--- a/providers/dns/huaweicloud/huaweicloud.go
+++ b/providers/dns/huaweicloud/huaweicloud.go
@@ -2,6 +2,7 @@
package huaweicloud
import (
+ "context"
"errors"
"fmt"
"strconv"
@@ -9,10 +10,13 @@ import (
"sync"
"time"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/platform/wait"
+ "github.com/go-acme/lego/v4/providers/dns/huaweicloud/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
hwauthbasic "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic"
hwconfig "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/config"
hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2"
@@ -61,7 +65,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
- client *hwdns.DnsClient
+ client *internal.DnsClient
recordIDs map[string]string
recordIDsMu sync.Mutex
@@ -118,7 +122,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return &DNSProvider{
config: config,
- client: hwdns.NewDnsClient(client),
+ client: internal.NewDnsClient(client),
recordIDs: map[string]string{},
}, nil
}
@@ -146,19 +150,27 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
d.recordIDs[token] = recordSetID
d.recordIDsMu.Unlock()
- err = wait.For("record set sync on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) {
- rs, errShow := d.client.ShowRecordSet(&hwmodel.ShowRecordSetRequest{
- ZoneId: zoneID,
- RecordsetId: recordSetID,
- })
- if errShow != nil {
- return false, fmt.Errorf("show record set: %w", errShow)
- }
+ err = wait.Retry(context.Background(),
+ func() error {
+ rs, errShow := d.client.ShowRecordSet(&hwmodel.ShowRecordSetRequest{
+ ZoneId: zoneID,
+ RecordsetId: recordSetID,
+ })
+ if errShow != nil {
+ return fmt.Errorf("show record set: %w", errShow)
+ }
- return !strings.HasSuffix(deref(rs.Status), "PENDING_"), nil
- })
+ if !strings.HasSuffix(ptr.Deref(rs.Status), "PENDING_") {
+ return nil
+ }
+
+ return fmt.Errorf("status: %s", ptr.Deref(rs.Status))
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
+ backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
+ )
if err != nil {
- return fmt.Errorf("huaweicloud: %w", err)
+ return fmt.Errorf("huaweicloud: record set sync on %s: %w", domain, err)
}
return nil
@@ -172,6 +184,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("huaweicloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
@@ -196,6 +209,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("huaweicloud: delete record: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
@@ -208,7 +225,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.ChallengeInfo) (string, error) {
records, err := d.client.ListRecordSetsByZone(&hwmodel.ListRecordSetsByZoneRequest{
ZoneId: zoneID,
- Name: pointer(info.EffectiveFQDN),
+ Name: ptr.Pointer(info.EffectiveFQDN),
})
if err != nil {
return "", fmt.Errorf("record list: unable to get record %s for zone %s: %w", info.EffectiveFQDN, domain, err)
@@ -216,8 +233,8 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C
var existingRecordSet *hwmodel.ListRecordSets
- for _, record := range deref(records.Recordsets) {
- if deref(record.Type) == "TXT" && deref(record.Name) == info.EffectiveFQDN {
+ for _, record := range ptr.Deref(records.Recordsets) {
+ if ptr.Deref(record.Type) == "TXT" && ptr.Deref(record.Name) == info.EffectiveFQDN {
existingRecordSet = &record
}
}
@@ -229,9 +246,9 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C
ZoneId: zoneID,
Body: &hwmodel.CreateRecordSetRequestBody{
Name: info.EffectiveFQDN,
- Description: pointer("Added TXT record for ACME dns-01 challenge using lego client"),
+ Description: ptr.Pointer("Added TXT record for ACME dns-01 challenge using lego client"),
Type: "TXT",
- Ttl: pointer(d.config.TTL),
+ Ttl: ptr.Pointer(d.config.TTL),
Records: []string{value},
},
}
@@ -241,18 +258,18 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C
return "", fmt.Errorf("create record set: %w", errCreate)
}
- return deref(resp.Id), nil
+ return ptr.Deref(resp.Id), nil
}
updateRequest := &hwmodel.UpdateRecordSetRequest{
ZoneId: zoneID,
- RecordsetId: deref(existingRecordSet.Id),
+ RecordsetId: ptr.Deref(existingRecordSet.Id),
Body: &hwmodel.UpdateRecordSetReq{
Name: existingRecordSet.Name,
Description: existingRecordSet.Description,
Type: existingRecordSet.Type,
Ttl: existingRecordSet.Ttl,
- Records: pointer(append(deref(existingRecordSet.Records), value)),
+ Records: ptr.Pointer(append(ptr.Deref(existingRecordSet.Records), value)),
},
}
@@ -261,7 +278,7 @@ func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.C
return "", fmt.Errorf("update record set: %w", err)
}
- return deref(resp.Id), nil
+ return ptr.Deref(resp.Id), nil
}
func (d *DNSProvider) getZoneID(authZone string) (string, error) {
@@ -270,22 +287,11 @@ func (d *DNSProvider) getZoneID(authZone string) (string, error) {
return "", fmt.Errorf("unable to get zone: %w", err)
}
- for _, zone := range deref(zones.Zones) {
- if deref(zone.Name) == authZone {
- return deref(zone.Id), nil
+ for _, zone := range ptr.Deref(zones.Zones) {
+ if ptr.Deref(zone.Name) == authZone {
+ return ptr.Deref(zone.Id), nil
}
}
return "", fmt.Errorf("zone %q not found", authZone)
}
-
-func pointer[T any](v T) *T { return &v }
-
-func deref[T any](v *T) T {
- if v == nil {
- var zero T
- return zero
- }
-
- return *v
-}
diff --git a/providers/dns/huaweicloud/huaweicloud.toml b/providers/dns/huaweicloud/huaweicloud.toml
index 423dd9d7d..e8d417c11 100644
--- a/providers/dns/huaweicloud/huaweicloud.toml
+++ b/providers/dns/huaweicloud/huaweicloud.toml
@@ -8,7 +8,7 @@ Example = '''
HUAWEICLOUD_ACCESS_KEY_ID=your-access-key-id \
HUAWEICLOUD_SECRET_ACCESS_KEY=your-secret-access-key \
HUAWEICLOUD_REGION=cn-south-1 \
-lego --email you@example.com --dns huaweicloud -d '*.example.com' -d example.com run
+lego --dns huaweicloud -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -18,10 +18,10 @@ lego --email you@example.com --dns huaweicloud -d '*.example.com' -d example.com
HUAWEICLOUD_REGION = "Region"
[Configuration.Additional]
- HUAWEICLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- HUAWEICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- HUAWEICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
- HUAWEICLOUD_HTTP_TIMEOUT = "API request timeout"
+ HUAWEICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HUAWEICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ HUAWEICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ HUAWEICLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://console-intl.huaweicloud.com/apiexplorer/#/openapi/DNS/doc?locale=en-us"
diff --git a/providers/dns/huaweicloud/huaweicloud_test.go b/providers/dns/huaweicloud/huaweicloud_test.go
index 02ba1576d..25e295da7 100644
--- a/providers/dns/huaweicloud/huaweicloud_test.go
+++ b/providers/dns/huaweicloud/huaweicloud_test.go
@@ -20,18 +20,7 @@ func TestNewDNSProvider(t *testing.T) {
envVars map[string]string
expected string
}{
- {
- desc: "success",
- envVars: map[string]string{
- EnvAccessKeyID: "123",
- EnvSecretAccessKey: "456",
- EnvRegion: hwregion.CN_EAST_2.Id,
- },
- // The "success" cannot be tested because there is an API call that require a valid authentication.
- // Also, there is a bug during the error message creation:
- // https://github.com/huaweicloud/huaweicloud-sdk-go-v3/pull/81
- expected: "huaweicloud: client build: runtime error: invalid memory address or nil pointer dereference",
- },
+ // The "success" cannot be tested because there is an API call that require a valid authentication.
{
desc: "missing credentials",
envVars: map[string]string{
@@ -73,6 +62,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -99,16 +89,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
region string
expected string
}{
- {
- desc: "success",
- accessKeyID: "123",
- secretAccessKey: "456",
- region: hwregion.CN_EAST_2.Id,
- // The "success" cannot be tested because there is an API call that require a valid authentication.
- // Also, there is a bug during the error message creation:
- // https://github.com/huaweicloud/huaweicloud-sdk-go-v3/pull/81
- expected: "huaweicloud: client build: runtime error: invalid memory address or nil pointer dereference",
- },
+ // The "success" cannot be tested because there is an API call that require a valid authentication.
{
desc: "missing credentials",
expected: "huaweicloud: credentials missing",
@@ -160,6 +141,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -173,6 +155,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/huaweicloud/internal/client.go b/providers/dns/huaweicloud/internal/client.go
new file mode 100644
index 000000000..f10cf2dff
--- /dev/null
+++ b/providers/dns/huaweicloud/internal/client.go
@@ -0,0 +1,92 @@
+/*
+Copyright (c) Huawei Technologies Co., Ltd. 2020-present. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package internal is a partial copy of https://github.com/huaweicloud/huaweicloud-sdk-go-v3/blob/v0.1.159/services/dns/v2/dns_client.go
+package internal
+
+import (
+ httpclient "github.com/huaweicloud/huaweicloud-sdk-go-v3/core"
+ hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2"
+ "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/model"
+)
+
+type DnsClient struct {
+ HcClient *httpclient.HcHttpClient
+}
+
+func NewDnsClient(hcClient *httpclient.HcHttpClient) *DnsClient {
+ return &DnsClient{HcClient: hcClient}
+}
+
+func (c *DnsClient) ShowRecordSet(request *model.ShowRecordSetRequest) (*model.ShowRecordSetResponse, error) {
+ requestDef := hwdns.GenReqDefForShowRecordSet()
+
+ if resp, err := c.HcClient.Sync(request, requestDef); err != nil {
+ return nil, err
+ } else {
+ return resp.(*model.ShowRecordSetResponse), nil
+ }
+}
+
+func (c *DnsClient) CreateRecordSet(request *model.CreateRecordSetRequest) (*model.CreateRecordSetResponse, error) {
+ requestDef := hwdns.GenReqDefForCreateRecordSet()
+
+ if resp, err := c.HcClient.Sync(request, requestDef); err != nil {
+ return nil, err
+ } else {
+ return resp.(*model.CreateRecordSetResponse), nil
+ }
+}
+
+func (c *DnsClient) UpdateRecordSet(request *model.UpdateRecordSetRequest) (*model.UpdateRecordSetResponse, error) {
+ requestDef := hwdns.GenReqDefForUpdateRecordSet()
+
+ if resp, err := c.HcClient.Sync(request, requestDef); err != nil {
+ return nil, err
+ } else {
+ return resp.(*model.UpdateRecordSetResponse), nil
+ }
+}
+
+func (c *DnsClient) DeleteRecordSet(request *model.DeleteRecordSetRequest) (*model.DeleteRecordSetResponse, error) {
+ requestDef := hwdns.GenReqDefForDeleteRecordSet()
+
+ if resp, err := c.HcClient.Sync(request, requestDef); err != nil {
+ return nil, err
+ } else {
+ return resp.(*model.DeleteRecordSetResponse), nil
+ }
+}
+
+func (c *DnsClient) ListRecordSetsByZone(request *model.ListRecordSetsByZoneRequest) (*model.ListRecordSetsByZoneResponse, error) {
+ requestDef := hwdns.GenReqDefForListRecordSetsByZone()
+
+ if resp, err := c.HcClient.Sync(request, requestDef); err != nil {
+ return nil, err
+ } else {
+ return resp.(*model.ListRecordSetsByZoneResponse), nil
+ }
+}
+
+func (c *DnsClient) ListPublicZones(request *model.ListPublicZonesRequest) (*model.ListPublicZonesResponse, error) {
+ requestDef := hwdns.GenReqDefForListPublicZones()
+
+ if resp, err := c.HcClient.Sync(request, requestDef); err != nil {
+ return nil, err
+ } else {
+ return resp.(*model.ListPublicZonesResponse), nil
+ }
+}
diff --git a/providers/dns/hurricane/hurricane.go b/providers/dns/hurricane/hurricane.go
index e2054d38d..b23528bb0 100644
--- a/providers/dns/hurricane/hurricane.go
+++ b/providers/dns/hurricane/hurricane.go
@@ -5,13 +5,13 @@ import (
"errors"
"fmt"
"net/http"
- "strings"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/hurricane/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -58,14 +58,15 @@ type DNSProvider struct {
// NewDNSProvider returns a DNSProvider instance configured for Hurricane Electric.
func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
+
values, err := env.Get(EnvTokens)
if err != nil {
return nil, fmt.Errorf("hurricane: %w", err)
}
- credentials, err := parseCredentials(values[EnvTokens])
+ credentials, err := env.ParsePairs(values[EnvTokens])
if err != nil {
- return nil, fmt.Errorf("hurricane: %w", err)
+ return nil, fmt.Errorf("hurricane: credentials: %w", err)
}
config.Credentials = credentials
@@ -84,6 +85,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := internal.NewClient(config.Credentials)
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -122,19 +129,3 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) Sequential() time.Duration {
return d.config.SequenceInterval
}
-
-func parseCredentials(raw string) (map[string]string, error) {
- credentials := make(map[string]string)
-
- credStrings := strings.Split(strings.TrimSuffix(raw, ","), ",")
- for _, credPair := range credStrings {
- data := strings.Split(credPair, ":")
- if len(data) != 2 {
- return nil, fmt.Errorf("incorrect credential pair: %s", credPair)
- }
-
- credentials[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1])
- }
-
- return credentials, nil
-}
diff --git a/providers/dns/hurricane/hurricane.toml b/providers/dns/hurricane/hurricane.toml
index 88e73dea9..10b370e4f 100644
--- a/providers/dns/hurricane/hurricane.toml
+++ b/providers/dns/hurricane/hurricane.toml
@@ -6,10 +6,10 @@ Since = "v4.3.0"
Example = '''
HURRICANE_TOKENS=example.org:token \
-lego --email you@example.com --dns hurricane -d '*.example.com' -d example.com run
+lego --dns hurricane -d '*.example.com' -d example.com run
HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \
-lego --email you@example.com --dns hurricane -d my.example.org -d demo.example.org
+lego --dns hurricane -d my.example.org -d demo.example.org
'''
Additional = """
@@ -39,10 +39,10 @@ HURRICANE_TOKENS=example.org:token
[Configuration.Credentials]
HURRICANE_TOKENS = "TXT record names and tokens"
[Configuration.Additional]
- HURRICANE_POLLING_INTERVAL = "Time between DNS propagation checks"
- HURRICANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)"
- HURRICANE_SEQUENCE_INTERVAL = "Time between sequential requests"
- HURRICANE_HTTP_TIMEOUT = "API request timeout"
+ HURRICANE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ HURRICANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation (Default: 300)"
+ HURRICANE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ HURRICANE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://dns.he.net/"
diff --git a/providers/dns/hurricane/hurricane_test.go b/providers/dns/hurricane/hurricane_test.go
index 12217c790..2bbd638fa 100644
--- a/providers/dns/hurricane/hurricane_test.go
+++ b/providers/dns/hurricane/hurricane_test.go
@@ -34,14 +34,14 @@ func TestNewDNSProvider(t *testing.T) {
envVars: map[string]string{
EnvTokens: ",",
},
- expected: "hurricane: incorrect credential pair: ",
+ expected: "hurricane: credentials: incorrect pair: ",
},
{
desc: "invalid credentials, partial",
envVars: map[string]string{
EnvTokens: "example.org:123,example.net",
},
- expected: "hurricane: incorrect credential pair: example.net",
+ expected: "hurricane: credentials: incorrect pair: example.net",
},
{
desc: "missing credentials",
@@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -120,6 +121,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -133,6 +135,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/hurricane/internal/client.go b/providers/dns/hurricane/internal/client.go
index 62ca76159..b758ec166 100644
--- a/providers/dns/hurricane/internal/client.go
+++ b/providers/dns/hurricane/internal/client.go
@@ -52,7 +52,7 @@ func NewClient(credentials map[string]string) *Client {
}
// UpdateTxtRecord updates a TXT record.
-func (c *Client) UpdateTxtRecord(ctx context.Context, hostname string, txt string) error {
+func (c *Client) UpdateTxtRecord(ctx context.Context, hostname, txt string) error {
domain := strings.TrimPrefix(hostname, "_acme-challenge.")
c.credMu.Lock()
@@ -101,7 +101,7 @@ func (c *Client) UpdateTxtRecord(ctx context.Context, hostname string, txt strin
return evaluateBody(string(bytes.TrimSpace(raw)), hostname)
}
-func evaluateBody(body string, hostname string) error {
+func evaluateBody(body, hostname string) error {
code, _, _ := strings.Cut(body, " ")
switch code {
diff --git a/providers/dns/hurricane/internal/client_test.go b/providers/dns/hurricane/internal/client_test.go
index 2862c2481..d93f3e0ed 100644
--- a/providers/dns/hurricane/internal/client_test.go
+++ b/providers/dns/hurricane/internal/client_test.go
@@ -1,15 +1,21 @@
package internal
import (
- "context"
- "fmt"
- "net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
)
+func setupClient(server *httptest.Server) (*Client, error) {
+ client := NewClient(map[string]string{"example.com": "secret"})
+ client.baseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
func TestClient_UpdateTxtRecord(t *testing.T) {
testCases := []struct {
code string
@@ -49,33 +55,16 @@ func TestClient_UpdateTxtRecord(t *testing.T) {
t.Run(test.code, func(t *testing.T) {
t.Parallel()
- handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
+ client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()).
+ Route("POST /",
+ servermock.RawStringResponse(test.code),
+ servermock.CheckForm().Strict().
+ With("hostname", "_acme-challenge.example.com").
+ With("password", "secret").
+ With("txt", "foo")).
+ Build(t)
- if err := req.ParseForm(); err != nil {
- http.Error(rw, "failed to parse form data", http.StatusBadRequest)
- return
- }
-
- if req.PostForm.Encode() != "hostname=_acme-challenge.example.com&password=secret&txt=foo" {
- http.Error(rw, "invalid form data", http.StatusBadRequest)
- return
- }
-
- _, _ = rw.Write([]byte(test.code))
- })
-
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- client := NewClient(map[string]string{"example.com": "secret"})
- client.baseURL = server.URL
- client.HTTPClient = server.Client()
-
- err := client.UpdateTxtRecord(context.Background(), "_acme-challenge.example.com", "foo")
+ err := client.UpdateTxtRecord(t.Context(), "_acme-challenge.example.com", "foo")
test.expected(t, err)
})
}
diff --git a/providers/dns/hyperone/hyperone.go b/providers/dns/hyperone/hyperone.go
index 890f9f627..3cdad8e68 100644
--- a/providers/dns/hyperone/hyperone.go
+++ b/providers/dns/hyperone/hyperone.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/hyperone/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Environment variables names.
@@ -76,6 +77,7 @@ func NewDNSProvider() (*DNSProvider, error) {
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config.PassportLocation == "" {
var err error
+
config.PassportLocation, err = GetDefaultPassportLocation()
if err != nil {
return nil, fmt.Errorf("hyperone: %w", err)
@@ -96,6 +98,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{client: client, config: config}, nil
}
@@ -163,6 +167,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
if err != nil {
return fmt.Errorf("hyperone: %w", err)
}
+
if len(records) == 1 {
if records[0].Content != info.Value {
return fmt.Errorf("hyperone: record with content %s not found: fqdn=%s", info.Value, info.EffectiveFQDN)
diff --git a/providers/dns/hyperone/hyperone.toml b/providers/dns/hyperone/hyperone.toml
index bebde3185..88814356f 100644
--- a/providers/dns/hyperone/hyperone.toml
+++ b/providers/dns/hyperone/hyperone.toml
@@ -5,7 +5,7 @@ Code = "hyperone"
Since = "v3.9.0"
Example = '''
-lego --email you@example.com --dns hyperone -d '*.example.com' -d example.com run
+lego --dns hyperone -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -41,9 +41,10 @@ All required permissions are available via platform role `tool.lego`.
HYPERONE_PASSPORT_LOCATION = "Allows to pass custom passport file location (default ~/.h1/passport.json)"
HYPERONE_API_URL = "Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)"
HYPERONE_LOCATION_ID = "Specifies location (region) to be used in API calls. (default pl-waw-1)"
- HYPERONE_TTL = "The TTL of the TXT record used for the DNS challenge"
- HYPERONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- HYPERONE_POLLING_INTERVAL = "Time between DNS propagation check"
+ HYPERONE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ HYPERONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2)"
+ HYPERONE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)"
+ HYPERONE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.hyperone.com/v2/docs"
diff --git a/providers/dns/hyperone/hyperone_test.go b/providers/dns/hyperone/hyperone_test.go
index 1222d1c74..675a1fe19 100644
--- a/providers/dns/hyperone/hyperone_test.go
+++ b/providers/dns/hyperone/hyperone_test.go
@@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -124,6 +125,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -137,6 +139,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/hyperone/internal/client.go b/providers/dns/hyperone/internal/client.go
index 09fa68768..cf9ab2a37 100644
--- a/providers/dns/hyperone/internal/client.go
+++ b/providers/dns/hyperone/internal/client.go
@@ -132,7 +132,7 @@ func (c *Client) CreateRecordset(ctx context.Context, zoneID, recordType, name,
// DeleteRecordset deletes a recordset.
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_delete
-func (c *Client) DeleteRecordset(ctx context.Context, zoneID string, recordsetID string) error {
+func (c *Client) DeleteRecordset(ctx context.Context, zoneID, recordsetID string) error {
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}
endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID)
@@ -146,7 +146,7 @@ func (c *Client) DeleteRecordset(ctx context.Context, zoneID string, recordsetID
// GetRecords gets all records within specified recordset.
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_list
-func (c *Client) GetRecords(ctx context.Context, zoneID string, recordsetID string) ([]Record, error) {
+func (c *Client) GetRecords(ctx context.Context, zoneID, recordsetID string) ([]Record, error) {
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record")
diff --git a/providers/dns/hyperone/internal/client_test.go b/providers/dns/hyperone/internal/client_test.go
index e3a1073e0..aa087c4f2 100644
--- a/providers/dns/hyperone/internal/client_test.go
+++ b/providers/dns/hyperone/internal/client_test.go
@@ -1,17 +1,10 @@
package internal
import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -22,10 +15,34 @@ func (s signerMock) GetJWT() (string, error) {
return "", nil
}
-func TestClient_FindRecordset(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/zone321/recordset", respFromFile("recordset.json"))
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ passport := &Passport{
+ SubjectID: "/iam/project/proj123/sa/xxxxxxx",
+ }
- recordset, err := client.FindRecordset(context.Background(), "zone321", "SOA", "example.com.")
+ client, err := NewClient(server.URL, "loc123", passport)
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.signer = signerMock{}
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer"))
+}
+
+func TestClient_FindRecordset(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/loc123/project/proj123/zone/zone321/recordset",
+ servermock.ResponseFromFixture("recordset.json")).
+ Build(t)
+
+ recordset, err := client.FindRecordset(t.Context(), "zone321", "SOA", "example.com.")
require.NoError(t, err)
expected := &Recordset{
@@ -46,10 +63,13 @@ func TestClient_CreateRecordset(t *testing.T) {
Record: &Record{Content: "value"},
}
- client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/zone123/recordset",
- hasReqBody(expectedReqBody), respFromFile("createRecordset.json"))
+ client := mockBuilder().
+ Route("POST /dns/loc123/project/proj123/zone/zone123/recordset",
+ servermock.ResponseFromFixture("createRecordset.json"),
+ servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)).
+ Build(t)
- rs, err := client.CreateRecordset(context.Background(), "zone123", "TXT", "test.example.com.", "value", 3600)
+ rs, err := client.CreateRecordset(t.Context(), "zone123", "TXT", "test.example.com.", "value", 3600)
require.NoError(t, err)
expected := &Recordset{RecordType: "TXT", Name: "test.example.com.", TTL: 3600, ID: "1234567890qwertyuiop"}
@@ -57,16 +77,21 @@ func TestClient_CreateRecordset(t *testing.T) {
}
func TestClient_DeleteRecordset(t *testing.T) {
- client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/zone321/recordset/rs322")
+ client := mockBuilder().
+ Route("DELETE /dns/loc123/project/proj123/zone/zone321/recordset/rs322", nil).
+ Build(t)
- err := client.DeleteRecordset(context.Background(), "zone321", "rs322")
+ err := client.DeleteRecordset(t.Context(), "zone321", "rs322")
require.NoError(t, err)
}
func TestClient_GetRecords(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/321/recordset/322/record", respFromFile("record.json"))
+ client := mockBuilder().
+ Route("GET /dns/loc123/project/proj123/zone/321/recordset/322/record",
+ servermock.ResponseFromFixture("record.json")).
+ Build(t)
- records, err := client.GetRecords(context.Background(), "321", "322")
+ records, err := client.GetRecords(t.Context(), "321", "322")
require.NoError(t, err)
expected := []Record{
@@ -85,10 +110,13 @@ func TestClient_CreateRecord(t *testing.T) {
Content: "value",
}
- client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/z123/recordset/rs325/record",
- hasReqBody(expectedReqBody), respFromFile("createRecord.json"))
+ client := mockBuilder().
+ Route("POST /dns/loc123/project/proj123/zone/z123/recordset/rs325/record",
+ servermock.ResponseFromFixture("createRecord.json"),
+ servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)).
+ Build(t)
- rs, err := client.CreateRecord(context.Background(), "z123", "rs325", "value")
+ rs, err := client.CreateRecord(t.Context(), "z123", "rs325", "value")
require.NoError(t, err)
expected := &Record{ID: "123321qwerqwewqerq", Content: "value", Enabled: true}
@@ -96,16 +124,22 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/321/recordset/322/record/323")
+ client := mockBuilder().
+ Route("DELETE /dns/loc123/project/proj123/zone/321/recordset/322/record/323",
+ servermock.ResponseFromFixture("createRecord.json")).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "321", "322", "323")
+ err := client.DeleteRecord(t.Context(), "321", "322", "323")
require.NoError(t, err)
}
func TestClient_FindZone(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json"))
+ client := mockBuilder().
+ Route("GET /dns/loc123/project/proj123/zone",
+ servermock.ResponseFromFixture("zones.json")).
+ Build(t)
- zone, err := client.FindZone(context.Background(), "example.com")
+ zone, err := client.FindZone(t.Context(), "example.com")
require.NoError(t, err)
expected := &Zone{
@@ -120,9 +154,12 @@ func TestClient_FindZone(t *testing.T) {
}
func TestClient_GetZones(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json"))
+ client := mockBuilder().
+ Route("GET /dns/loc123/project/proj123/zone",
+ servermock.ResponseFromFixture("zones.json")).
+ Build(t)
- zones, err := client.GetZones(context.Background())
+ zones, err := client.GetZones(t.Context())
require.NoError(t, err)
expected := []Zone{
@@ -144,77 +181,3 @@ func TestClient_GetZones(t *testing.T) {
assert.Equal(t, expected, zones)
}
-
-func setupTest(t *testing.T, method, path string, handlers ...assertHandler) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.Handle(path, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- if len(handlers) != 0 {
- for _, handler := range handlers {
- code, err := handler(rw, req)
- if err != nil {
- http.Error(rw, err.Error(), code)
- return
- }
- }
- }
- }))
-
- passport := &Passport{
- SubjectID: "/iam/project/proj123/sa/xxxxxxx",
- }
-
- client, err := NewClient(server.URL, "loc123", passport)
- require.NoError(t, err)
-
- client.signer = signerMock{}
-
- return client
-}
-
-type assertHandler func(http.ResponseWriter, *http.Request) (int, error)
-
-func hasReqBody(v interface{}) assertHandler {
- return func(rw http.ResponseWriter, req *http.Request) (int, error) {
- reqBody, err := io.ReadAll(req.Body)
- if err != nil {
- return http.StatusBadRequest, err
- }
-
- marshal, err := json.Marshal(v)
- if err != nil {
- return http.StatusInternalServerError, err
- }
-
- if !bytes.Equal(marshal, bytes.TrimSpace(reqBody)) {
- return http.StatusBadRequest, fmt.Errorf("invalid request body, got: %s, expect: %s", string(reqBody), string(marshal))
- }
-
- return http.StatusOK, nil
- }
-}
-
-func respFromFile(fixtureName string) assertHandler {
- return func(rw http.ResponseWriter, req *http.Request) (int, error) {
- file, err := os.Open(filepath.Join(".", "fixtures", fixtureName))
- if err != nil {
- return http.StatusInternalServerError, err
- }
-
- _, err = io.Copy(rw, file)
- if err != nil {
- return http.StatusInternalServerError, err
- }
-
- return http.StatusOK, nil
- }
-}
diff --git a/providers/dns/hyperone/internal/passport.go b/providers/dns/hyperone/internal/passport.go
index b63236c3b..d1503d893 100644
--- a/providers/dns/hyperone/internal/passport.go
+++ b/providers/dns/hyperone/internal/passport.go
@@ -25,6 +25,7 @@ func LoadPassportFile(location string) (*Passport, error) {
defer func() { _ = file.Close() }()
var passport Passport
+
err = json.NewDecoder(file).Decode(&passport)
if err != nil {
return nil, fmt.Errorf("failed to parse passport file: %w", err)
diff --git a/providers/dns/hyperone/internal/token_test.go b/providers/dns/hyperone/internal/token_test.go
index 243e015e8..34b4cc573 100644
--- a/providers/dns/hyperone/internal/token_test.go
+++ b/providers/dns/hyperone/internal/token_test.go
@@ -1,31 +1,18 @@
package internal
import (
+ "crypto/rand"
+ "crypto/rsa"
"encoding/base64"
"encoding/json"
"strings"
"testing"
+ "github.com/go-acme/lego/v4/certcrypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-const privateKey = `-----BEGIN RSA PRIVATE KEY-----
-MIICWgIBAAKBgGFfgMY+DuO8l0RYrMLhcl6U/NigNIiOVhoo/xnYyoQALpWxBaBR
-+iVJiBUYunQjKA33yAiY0AasCfSn1JB6asayQvGGn73xztLjkeCVLT+9e4nJ0A/o
-dK8SOKBg9FFe70KJrWjJd626el0aVDJjtCE+QxJExA0UZbQp+XIyveQXAgMBAAEC
-gYBHcL1XNWLRPaWx9GlUVfoGYMMd4HSKl/ueF+QKP59dt5B2LTnWhS7FOqzH5auu
-17hkfx3ZCNzfeEuZn6T6F4bMtsQ6A5iT/DeRlG8tOPiCVZ/L0j6IFM78iIUT8XyA
-miwnSy1xGSBA67yUmsLxFg2DtGCjamAkY0C5pccadaB7oQJBAKsIPpMXMni+Oo1I
-kVxRyoIZgDxsMJiihG2YLVqo8rPtdErl+Lyg3ziVyg9KR6lFMaTBkYBTLoCPof3E
-AB/jyucCQQCRv1cVnYNx+bfnXsBlcsCFDV2HkEuLTpxj7hauD4P3GcyLidSsUkn1
-PiPunZqKpsQaIoxc/BzTOCcP19ifgqdRAkBJ8Cp9FE4xfKt7YJ/WtVVCoRubA3qO
-wdNWPa99vgQOXN0lc/3wLevSXo8XxRjtyIgJndT1EQDNe0qglhcnsiaJAkBziAcR
-/VAq0tZys2szf6kYTyXqxfj8Lo5NsHeN9oKXJ346xkEtb/VsT5vQFGJishsU1HoL
-Y1W+IO7l4iW3G6xhAkACNwtqxSRRbVsNCUMENpKmYhsyN8QXJ8V+o2A9s+pl21Kz
-HIIm179mUYCgO6iAHmkqxlFHFwprUBKdPrmP8qF9
------END RSA PRIVATE KEY-----`
-
type Header struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
@@ -33,7 +20,10 @@ type Header struct {
}
func TestPayload_buildToken(t *testing.T) {
- signer, err := getRSASigner(privateKey, "sampleKeyId")
+ key, err := rsa.GenerateKey(rand.Reader, 1024)
+ require.NoError(t, err)
+
+ signer, err := getRSASigner(string(certcrypto.PEMEncode(key)), "sampleKeyId")
require.NoError(t, err)
payload := Payload{IssuedAt: 1234, Expiry: 4321, Audience: "api.url", Issuer: "issuer", Subject: "subject"}
@@ -48,6 +38,7 @@ func TestPayload_buildToken(t *testing.T) {
require.NoError(t, err)
var headerStruct Header
+
err = json.Unmarshal(headerString, &headerStruct)
require.NoError(t, err)
@@ -55,6 +46,7 @@ func TestPayload_buildToken(t *testing.T) {
require.NoError(t, err)
var payloadStruct Payload
+
err = json.Unmarshal(payloadString, &payloadStruct)
require.NoError(t, err)
diff --git a/providers/dns/ibmcloud/ibmcloud.toml b/providers/dns/ibmcloud/ibmcloud.toml
index 270995465..01088f09b 100644
--- a/providers/dns/ibmcloud/ibmcloud.toml
+++ b/providers/dns/ibmcloud/ibmcloud.toml
@@ -7,18 +7,18 @@ Since = "v4.5.0"
Example = '''
SOFTLAYER_USERNAME=xxxxx \
SOFTLAYER_API_KEY=yyyyy \
-lego --email you@example.com --dns ibmcloud -d '*.example.com' -d example.com run
+lego --dns ibmcloud -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
- SOFTLAYER_USERNAME = "Username (IBM Cloud is _)"
+ SOFTLAYER_USERNAME = "Username (IBM Cloud is {accountID}_{emailAddress})"
SOFTLAYER_API_KEY = "Classic Infrastructure API key"
[Configuration.Additional]
- SOFTLAYER_POLLING_INTERVAL = "Time between DNS propagation check"
- SOFTLAYER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SOFTLAYER_TTL = "The TTL of the TXT record used for the DNS challenge"
- SOFTLAYER_TIMEOUT = "API request timeout"
+ SOFTLAYER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ SOFTLAYER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ SOFTLAYER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SOFTLAYER_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://cloud.ibm.com/docs/dns?topic=dns-getting-started-with-the-dns-api"
diff --git a/providers/dns/ibmcloud/ibmcloud_test.go b/providers/dns/ibmcloud/ibmcloud_test.go
index a000e3e59..6ca7cd81b 100644
--- a/providers/dns/ibmcloud/ibmcloud_test.go
+++ b/providers/dns/ibmcloud/ibmcloud_test.go
@@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -127,6 +128,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -140,6 +142,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/iij/iij.go b/providers/dns/iij/iij.go
index 9beb411ed..1d098bde2 100644
--- a/providers/dns/iij/iij.go
+++ b/providers/dns/iij/iij.go
@@ -6,7 +6,6 @@ import (
"fmt"
"slices"
"strconv"
- "strings"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -14,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/iij/doapi"
"github.com/iij/doapi/protocol"
+ "github.com/miekg/dns"
)
// Environment variables names.
@@ -98,6 +98,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("iij: %w", err)
}
+
return nil
}
@@ -110,6 +111,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("iij: %w", err)
}
+
return nil
}
@@ -226,26 +228,20 @@ func (d *DNSProvider) listZones() ([]string, error) {
}
func splitDomain(domain string, zones []string) (string, string, error) {
- parts := strings.Split(strings.Trim(domain, "."), ".")
+ base := dns01.UnFqdn(domain)
- var owner string
- var zone string
+ for _, index := range dns.Split(base) {
+ zone := base[index:]
- for i := range len(parts) - 1 {
- zone = strings.Join(parts[i:], ".")
if slices.Contains(zones, zone) {
- baseOwner := strings.Join(parts[0:i], ".")
+ baseOwner := base[:index]
if baseOwner != "" {
baseOwner = "." + baseOwner
}
- owner = "_acme-challenge" + baseOwner
- break
+
+ return "_acme-challenge" + dns01.UnFqdn(baseOwner), zone, nil
}
}
- if owner == "" {
- return "", "", fmt.Errorf("%s not found", domain)
- }
-
- return owner, zone, nil
+ return "", "", fmt.Errorf("%s not found", domain)
}
diff --git a/providers/dns/iij/iij.toml b/providers/dns/iij/iij.toml
index da7590dd9..95355200a 100644
--- a/providers/dns/iij/iij.toml
+++ b/providers/dns/iij/iij.toml
@@ -8,7 +8,7 @@ Example = '''
IIJ_API_ACCESS_KEY=xxxxxxxx \
IIJ_API_SECRET_KEY=yyyyyy \
IIJ_DO_SERVICE_CODE=zzzzzz \
-lego --email you@example.com --dns iij -d '*.example.com' -d example.com run
+lego --dns iij -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,9 +17,9 @@ lego --email you@example.com --dns iij -d '*.example.com' -d example.com run
IIJ_API_SECRET_KEY = "API secret key"
IIJ_DO_SERVICE_CODE = "DO service code"
[Configuration.Additional]
- IIJ_POLLING_INTERVAL = "Time between DNS propagation check"
- IIJ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- IIJ_TTL = "The TTL of the TXT record used for the DNS challenge"
+ IIJ_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)"
+ IIJ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)"
+ IIJ_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
[Links]
API = "https://manual.iij.jp/p2/pubapi/"
diff --git a/providers/dns/iij/iij_test.go b/providers/dns/iij/iij_test.go
index 936dd9b8d..bd8140532 100644
--- a/providers/dns/iij/iij_test.go
+++ b/providers/dns/iij/iij_test.go
@@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -161,31 +162,31 @@ func TestSplitDomain(t *testing.T) {
}{
{
desc: "domain equals zone",
- domain: "domain.com",
- zones: []string{"domain.com"},
+ domain: "example.com",
+ zones: []string{"example.com"},
expectedOwner: "_acme-challenge",
- expectedZone: "domain.com",
+ expectedZone: "example.com",
},
{
desc: "with a subdomain",
- domain: "my.domain.com",
- zones: []string{"domain.com"},
+ domain: "my.example.com",
+ zones: []string{"example.com"},
expectedOwner: "_acme-challenge.my",
- expectedZone: "domain.com",
+ expectedZone: "example.com",
},
{
desc: "with a subdomain in a zone",
- domain: "my.sub.domain.com",
- zones: []string{"sub.domain.com", "domain.com"},
+ domain: "my.sub.example.com",
+ zones: []string{"sub.example.com", "example.com"},
expectedOwner: "_acme-challenge.my",
- expectedZone: "sub.domain.com",
+ expectedZone: "sub.example.com",
},
{
desc: "with a sub-subdomain",
- domain: "my.sub.domain.com",
- zones: []string{"domain1.com", "domain.com"},
+ domain: "my.sub.example.com",
+ zones: []string{"domain1.com", "example.com"},
expectedOwner: "_acme-challenge.my.sub",
- expectedZone: "domain.com",
+ expectedZone: "example.com",
},
}
@@ -202,12 +203,43 @@ func TestSplitDomain(t *testing.T) {
}
}
+func TestSplitDomain_error(t *testing.T) {
+ testCases := []struct {
+ desc string
+ domain string
+ zones []string
+ expectedOwner string
+ expectedZone string
+ }{
+ {
+ desc: "no zone",
+ domain: "example.com",
+ zones: nil,
+ },
+ {
+ desc: "domain does not contain zone",
+ domain: "example.com",
+ zones: []string{"example.org"},
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := splitDomain(test.domain, test.zones)
+ require.Error(t, err)
+ })
+ }
+}
+
func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -221,6 +253,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/iijdpf/iijdpf.toml b/providers/dns/iijdpf/iijdpf.toml
index 297866e2b..650285f95 100644
--- a/providers/dns/iijdpf/iijdpf.toml
+++ b/providers/dns/iijdpf/iijdpf.toml
@@ -7,7 +7,7 @@ Since = "v4.7.0"
Example = '''
IIJ_DPF_API_TOKEN=xxxxxxxx \
IIJ_DPF_DPM_SERVICE_CODE=yyyyyy \
-lego --email you@example.com --dns iijdpf -d '*.example.com' -d example.com run
+lego --dns iijdpf -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,9 +16,9 @@ lego --email you@example.com --dns iijdpf -d '*.example.com' -d example.com run
IIJ_DPF_DPM_SERVICE_CODE = "IIJ Managed DNS Service's service code"
[Configuration.Additional]
IIJ_DPF_API_ENDPOINT = "API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1"
- IIJ_DPF_POLLING_INTERVAL = "Time between DNS propagation check, defaults to 5 second"
- IIJ_DPF_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation, defaults to 660 second"
- IIJ_DPF_TTL = "The TTL of the TXT record used for the DNS challenge, default to 300"
+ IIJ_DPF_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ IIJ_DPF_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 660)"
+ IIJ_DPF_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
[Links]
API = "https://manual.iij.jp/dpf/dpfapi/"
diff --git a/providers/dns/iijdpf/iijdpf_test.go b/providers/dns/iijdpf/iijdpf_test.go
index a4fa8b8f6..fbcf3e1f5 100644
--- a/providers/dns/iijdpf/iijdpf_test.go
+++ b/providers/dns/iijdpf/iijdpf_test.go
@@ -43,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -115,6 +116,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -128,6 +130,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/iijdpf/wrapper.go b/providers/dns/iijdpf/wrapper.go
index 12b09a30c..0ab26cdcd 100644
--- a/providers/dns/iijdpf/wrapper.go
+++ b/providers/dns/iijdpf/wrapper.go
@@ -51,6 +51,7 @@ func (d *DNSProvider) deleteTxtRecord(ctx context.Context, zoneID, fqdn, rdata s
// empty target rrset
return nil
}
+
return err
}
@@ -66,11 +67,13 @@ func (d *DNSProvider) deleteTxtRecord(ctx context.Context, zoneID, fqdn, rdata s
// delete rdata
rdataSlice := dpfzones.RecordRDATASlice{}
+
for _, v := range r.RData {
if v.Value != rdata {
rdataSlice = append(rdataSlice, v)
}
}
+
r.RData = rdataSlice
_, _, err = dpfapiutils.SyncUpdate(ctx, d.client, r, nil)
diff --git a/providers/dns/infoblox/infoblox.go b/providers/dns/infoblox/infoblox.go
index 6aefd0bc1..054f13679 100644
--- a/providers/dns/infoblox/infoblox.go
+++ b/providers/dns/infoblox/infoblox.go
@@ -12,20 +12,21 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
- infoblox "github.com/infobloxopen/infoblox-go-client"
+ infoblox "github.com/infobloxopen/infoblox-go-client/v2"
)
// Environment variables names.
const (
envNamespace = "INFOBLOX_"
- EnvHost = envNamespace + "HOST"
- EnvPort = envNamespace + "PORT"
- EnvUsername = envNamespace + "USERNAME"
- EnvPassword = envNamespace + "PASSWORD"
- EnvDNSView = envNamespace + "DNS_VIEW"
- EnvWApiVersion = envNamespace + "WAPI_VERSION"
- EnvSSLVerify = envNamespace + "SSL_VERIFY"
+ EnvHost = envNamespace + "HOST"
+ EnvPort = envNamespace + "PORT"
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+ EnvDNSView = envNamespace + "DNS_VIEW"
+ EnvWApiVersion = envNamespace + "WAPI_VERSION"
+ EnvSSLVerify = envNamespace + "SSL_VERIFY"
+ EnvCACertificate = envNamespace + "CA_CERTIFICATE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -57,6 +58,9 @@ type Config struct {
// SSLVerify is whether or not to verify the ssl of the server being hit.
SSLVerify bool
+ // CACertificate is the path to the CA certificate (PEM encoded).
+ CACertificate string
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@@ -66,10 +70,11 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- DNSView: env.GetOrDefaultString(EnvDNSView, "External"),
- WapiVersion: env.GetOrDefaultString(EnvWApiVersion, "2.11"),
- Port: env.GetOrDefaultString(EnvPort, "443"),
- SSLVerify: env.GetOrDefaultBool(EnvSSLVerify, true),
+ DNSView: env.GetOrDefaultString(EnvDNSView, "External"),
+ WapiVersion: env.GetOrDefaultString(EnvWApiVersion, "2.11"),
+ Port: env.GetOrDefaultString(EnvPort, "443"),
+ SSLVerify: env.GetOrDefaultBool(EnvSSLVerify, true),
+ CACertificate: env.GetOrDefaultString(EnvCACertificate, ""),
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
@@ -83,6 +88,7 @@ type DNSProvider struct {
config *Config
transportConfig infoblox.TransportConfig
ibConfig infoblox.HostConfig
+ ibAuth infoblox.AuthConfig
recordRefs map[string]string
recordRefsMu sync.Mutex
@@ -122,13 +128,22 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("infoblox: missing credentials")
}
+ var sslVerify string
+ if config.CACertificate != "" {
+ sslVerify = config.CACertificate
+ } else {
+ sslVerify = strconv.FormatBool(config.SSLVerify)
+ }
+
return &DNSProvider{
config: config,
- transportConfig: infoblox.NewTransportConfig(strconv.FormatBool(config.SSLVerify), config.HTTPTimeout, defaultPoolConnections),
+ transportConfig: infoblox.NewTransportConfig(sslVerify, config.HTTPTimeout, defaultPoolConnections),
ibConfig: infoblox.HostConfig{
- Host: config.Host,
- Version: config.WapiVersion,
- Port: config.Port,
+ Host: config.Host,
+ Version: config.WapiVersion,
+ Port: config.Port,
+ },
+ ibAuth: infoblox.AuthConfig{
Username: config.Username,
Password: config.Password,
},
@@ -145,7 +160,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- connector, err := infoblox.NewConnector(d.ibConfig, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{})
+ connector, err := infoblox.NewConnector(d.ibConfig, d.ibAuth, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{})
if err != nil {
return fmt.Errorf("infoblox: %w", err)
}
@@ -154,7 +169,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
objectManager := infoblox.NewObjectManager(connector, useragent.Get(), "")
- record, err := objectManager.CreateTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value, uint(d.config.TTL), d.config.DNSView)
+ record, err := objectManager.CreateTXTRecord(d.config.DNSView, dns01.UnFqdn(info.EffectiveFQDN), info.Value, uint32(d.config.TTL), true, "lego", nil)
if err != nil {
return fmt.Errorf("infoblox: could not create TXT record for %s: %w", domain, err)
}
@@ -170,7 +185,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- connector, err := infoblox.NewConnector(d.ibConfig, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{})
+ connector, err := infoblox.NewConnector(d.ibConfig, d.ibAuth, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{})
if err != nil {
return fmt.Errorf("infoblox: %w", err)
}
@@ -183,6 +198,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordRefsMu.Lock()
recordRef, ok := d.recordRefs[token]
d.recordRefsMu.Unlock()
+
if !ok {
return fmt.Errorf("infoblox: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
diff --git a/providers/dns/infoblox/infoblox.toml b/providers/dns/infoblox/infoblox.toml
index ad7cb5cef..0e6972d3a 100644
--- a/providers/dns/infoblox/infoblox.toml
+++ b/providers/dns/infoblox/infoblox.toml
@@ -8,7 +8,7 @@ Example = '''
INFOBLOX_USERNAME=api-user-529 \
INFOBLOX_PASSWORD=b9841238feb177a84330febba8a83208921177bffe733 \
INFOBLOX_HOST=infoblox.example.org
-lego --email you@example.com --dns infoblox -d '*.example.com' -d example.com run
+lego --dns infoblox -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -21,14 +21,15 @@ When creating an API's user ensure it has the proper permissions for the view yo
INFOBLOX_PASSWORD = "Account Password"
INFOBLOX_HOST = "Host URI"
[Configuration.Additional]
- INFOBLOX_DNS_VIEW = "The view for the TXT records, default: External"
- INFOBLOX_WAPI_VERSION = "The version of WAPI being used, default: 2.11"
- INFOBLOX_PORT = "The port for the infoblox grid manager, default: 443"
- INFOBLOX_SSL_VERIFY = "Whether or not to verify the TLS certificate, default: true"
- INFOBLOX_POLLING_INTERVAL = "Time between DNS propagation check"
- INFOBLOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- INFOBLOX_TTL = "The TTL of the TXT record used for the DNS challenge"
- INFOBLOX_HTTP_TIMEOUT = "HTTP request timeout"
+ INFOBLOX_DNS_VIEW = "The view for the TXT records (Default: External)"
+ INFOBLOX_WAPI_VERSION = "The version of WAPI being used (Default: 2.11)"
+ INFOBLOX_PORT = "The port for the infoblox grid manager (Default: 443)"
+ INFOBLOX_SSL_VERIFY = "Whether or not to verify the TLS certificate (Default: true)"
+ INFOBLOX_CA_CERTIFICATE = "The path to the CA certificate (PEM encoded)"
+ INFOBLOX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ INFOBLOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ INFOBLOX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ INFOBLOX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
diff --git a/providers/dns/infoblox/infoblox_test.go b/providers/dns/infoblox/infoblox_test.go
index 45434e0e3..68158cb0d 100644
--- a/providers/dns/infoblox/infoblox_test.go
+++ b/providers/dns/infoblox/infoblox_test.go
@@ -68,6 +68,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -149,6 +150,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -162,6 +164,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/infomaniak/infomaniak.go b/providers/dns/infomaniak/infomaniak.go
index 84f494214..9b8b53590 100644
--- a/providers/dns/infomaniak/infomaniak.go
+++ b/providers/dns/infomaniak/infomaniak.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/infomaniak/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
// Infomaniak API reference: https://api.infomaniak.com/doc
@@ -47,9 +48,9 @@ type Config struct {
func NewDefaultConfig() *Config {
return &Config{
APIEndpoint: env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL),
- TTL: env.GetOrDefaultInt(EnvTTL, 7200),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ TTL: env.GetOrDefaultInt(EnvTTL, 300),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -96,7 +97,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("infomaniak: missing access token")
}
- client, err := internal.New(internal.OAuthStaticAccessToken(config.HTTPClient, config.AccessToken), config.APIEndpoint)
+ client, err := internal.New(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.AccessToken),
+ ),
+ config.APIEndpoint)
if err != nil {
return nil, fmt.Errorf("infomaniak: %w", err)
}
diff --git a/providers/dns/infomaniak/infomaniak.toml b/providers/dns/infomaniak/infomaniak.toml
index 2de205b8f..d924e3a26 100644
--- a/providers/dns/infomaniak/infomaniak.toml
+++ b/providers/dns/infomaniak/infomaniak.toml
@@ -6,7 +6,7 @@ Since = "v4.1.0"
Example = '''
INFOMANIAK_ACCESS_TOKEN=1234567898765432 \
-lego --email you@example.com --dns infomaniak -d '*.example.com' -d example.com run
+lego --dns infomaniak -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -21,10 +21,10 @@ You will need domain scope.
INFOMANIAK_ACCESS_TOKEN = "Access token"
[Configuration.Additional]
INFOMANIAK_ENDPOINT = "https://api.infomaniak.com"
- INFOMANIAK_POLLING_INTERVAL = "Time between DNS propagation check"
- INFOMANIAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- INFOMANIAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds"
- INFOMANIAK_HTTP_TIMEOUT = "API request timeout"
+ INFOMANIAK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ INFOMANIAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ INFOMANIAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ INFOMANIAK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.infomaniak.com/doc"
diff --git a/providers/dns/infomaniak/infomaniak_test.go b/providers/dns/infomaniak/infomaniak_test.go
index bc8fb7b58..980f3b959 100644
--- a/providers/dns/infomaniak/infomaniak_test.go
+++ b/providers/dns/infomaniak/infomaniak_test.go
@@ -39,6 +39,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -101,6 +102,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -114,6 +116,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/infomaniak/internal/client.go b/providers/dns/infomaniak/internal/client.go
index 886a8966f..40b56c707 100644
--- a/providers/dns/infomaniak/internal/client.go
+++ b/providers/dns/infomaniak/internal/client.go
@@ -50,6 +50,7 @@ func (c *Client) CreateDNSRecord(ctx context.Context, domain *DNSDomain, record
}
result := APIResponse[string]{}
+
err = c.do(req, &result)
if err != nil {
return "", err
@@ -112,6 +113,7 @@ func (c *Client) getDomainByName(ctx context.Context, name string) (*DNSDomain,
}
result := APIResponse[[]DNSDomain]{}
+
err = c.do(req, &result)
if err != nil {
return nil, err
diff --git a/providers/dns/infomaniak/internal/client_test.go b/providers/dns/infomaniak/internal/client_test.go
index 4fadaf0f5..d846f06b4 100644
--- a/providers/dns/infomaniak/internal/client_test.go
+++ b/providers/dns/infomaniak/internal/client_test.go
@@ -1,65 +1,34 @@
package internal
import (
- "bytes"
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL)
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL)
- require.NoError(t, err)
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer token"))
}
func TestClient_CreateDNSRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/1/domain/666/dns/record", 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("Authorization") != "Bearer token" {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
- defer func() { _ = req.Body.Close() }()
-
- if string(bytes.TrimSpace(raw)) != `{"source":"foo","type":"TXT","ttl":60,"target":"txtxtxttxt"}` {
- http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
- return
- }
-
- response := `{"result":"success","data": "123"}`
-
- _, err = rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /1/domain/666/dns/record",
+ servermock.RawStringResponse(`{"result":"success","data": "123"}`),
+ servermock.CheckRequestJSONBodyFromFixture("create_dns_record-request.json")).
+ Build(t)
domain := &DNSDomain{
ID: 666,
@@ -73,62 +42,22 @@ func TestClient_CreateDNSRecord(t *testing.T) {
TTL: 60,
}
- recordID, err := client.CreateDNSRecord(context.Background(), domain, record)
+ recordID, err := client.CreateDNSRecord(t.Context(), domain, record)
require.NoError(t, err)
assert.Equal(t, "123", recordID)
}
func TestClient_GetDomainByName(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /1/product",
+ servermock.ResponseFromFixture("get_domain_name.json"),
+ servermock.CheckQueryParameter().Strict().
+ WithRegexp("customer_name", `.+\.example\.com`).
+ With("service_name", "domain")).
+ Build(t)
- mux.HandleFunc("/1/product", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- if req.Header.Get("Authorization") != "Bearer token" {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- serviceName := req.URL.Query().Get("service_name")
- if serviceName != "domain" {
- http.Error(rw, fmt.Sprintf("invalid service_name: %s", serviceName), http.StatusBadRequest)
- return
- }
-
- customerName := req.URL.Query().Get("customer_name")
- if customerName == "" {
- http.Error(rw, fmt.Sprintf("invalid customer_name: %s", customerName), http.StatusBadRequest)
- return
- }
-
- response := `
- {
- "result": "success",
- "data": [
- {
- "id": 123,
- "customer_name": "two.three.example.com"
- },
- {
- "id": 456,
- "customer_name": "three.example.com"
- }
- ]
- }
- `
-
- _, err := rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- domain, err := client.GetDomainByName(context.Background(), "one.two.three.example.com.")
+ domain, err := client.GetDomainByName(t.Context(), "one.two.three.example.com.")
require.NoError(t, err)
expected := &DNSDomain{ID: 123, CustomerName: "two.three.example.com"}
@@ -136,26 +65,11 @@ func TestClient_GetDomainByName(t *testing.T) {
}
func TestClient_DeleteDNSRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /1/domain/123/dns/record/456",
+ servermock.RawStringResponse(`{"result":"success"}`)).
+ Build(t)
- mux.HandleFunc("/1/domain/123/dns/record/456", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- if req.Header.Get("Authorization") != "Bearer token" {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- _, err := rw.Write([]byte((`{"result":"success"}`)))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- err := client.DeleteDNSRecord(context.Background(), 123, "456")
+ err := client.DeleteDNSRecord(t.Context(), 123, "456")
require.NoError(t, err)
}
diff --git a/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json b/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json
new file mode 100644
index 000000000..7e00434f1
--- /dev/null
+++ b/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json
@@ -0,0 +1,6 @@
+{
+ "source": "foo",
+ "type": "TXT",
+ "ttl": 60,
+ "target": "txtxtxttxt"
+}
diff --git a/providers/dns/infomaniak/internal/fixtures/get_domain_name.json b/providers/dns/infomaniak/internal/fixtures/get_domain_name.json
new file mode 100644
index 000000000..d431cc0d7
--- /dev/null
+++ b/providers/dns/infomaniak/internal/fixtures/get_domain_name.json
@@ -0,0 +1,13 @@
+{
+ "result": "success",
+ "data": [
+ {
+ "id": 123,
+ "customer_name": "two.three.example.com"
+ },
+ {
+ "id": 456,
+ "customer_name": "three.example.com"
+ }
+ ]
+}
diff --git a/providers/dns/websupport/internal/client.go b/providers/dns/internal/active24/internal/client.go
similarity index 53%
rename from providers/dns/websupport/internal/client.go
rename to providers/dns/internal/active24/internal/client.go
index 4fef0be91..69e94b367 100644
--- a/providers/dns/websupport/internal/client.go
+++ b/providers/dns/internal/active24/internal/client.go
@@ -12,147 +12,118 @@ import (
"io"
"net/http"
"net/url"
- "strconv"
"time"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
-const defaultBaseURL = "https://rest.websupport.sk"
+const defaultBaseURL = "https://rest.%s"
-// StatusSuccess expected status text when success.
-const StatusSuccess = "success"
-
-// Client a Websupport DNS API client.
+// Client the Active24 API client.
type Client struct {
- apiKey string
- secretKey string
+ apiKey string
+ secret string
baseURL *url.URL
HTTPClient *http.Client
}
// NewClient creates a new Client.
-func NewClient(apiKey, secretKey string) (*Client, error) {
- if apiKey == "" || secretKey == "" {
+func NewClient(baseAPIDomain, apiKey, secret string) (*Client, error) {
+ if apiKey == "" || secret == "" {
return nil, errors.New("credentials missing")
}
- baseURL, _ := url.Parse(defaultBaseURL)
+ baseURL, _ := url.Parse(fmt.Sprintf(defaultBaseURL, baseAPIDomain))
return &Client{
apiKey: apiKey,
- secretKey: secretKey,
+ secret: secret,
baseURL: baseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}, nil
}
-// GetUser gets a user detail.
-// https://rest.websupport.sk/docs/v1.user#user
-func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) {
- endpoint := c.baseURL.JoinPath("v1", "user", userID)
-
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, fmt.Errorf("request payload: %w", err)
- }
-
- result := &User{}
-
- err = c.do(req, result)
- if err != nil {
- return nil, err
- }
-
- return result, nil
-}
-
-// ListRecords lists all records.
-// https://rest.websupport.sk/docs/v1.zone#records
-func (c *Client) ListRecords(ctx context.Context, domainName string) (*ListResponse, error) {
- endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record")
-
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, fmt.Errorf("request payload: %w", err)
- }
-
- result := &ListResponse{}
-
- err = c.do(req, result)
- if err != nil {
- return nil, err
- }
-
- return result, nil
-}
-
-// GetRecords gets a DNS record.
-func (c *Client) GetRecords(ctx context.Context, domainName string, recordID int) (*Record, error) {
- endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID))
+// GetServices lists of all services.
+// https://rest.active24.cz/docs/v1.service#services
+func (c *Client) GetServices(ctx context.Context) ([]Service, error) {
+ endpoint := c.baseURL.JoinPath("v1", "user", "self", "service")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
- result := &Record{}
+ var result OldAPIResponse
- err = c.do(req, result)
+ err = c.do(req, &result)
if err != nil {
return nil, err
}
- return result, nil
+ return result.Items, err
}
-// AddRecord adds a DNS record.
-// https://rest.websupport.sk/docs/v1.zone#post-record
-func (c *Client) AddRecord(ctx context.Context, domainName string, record Record) (*Response, error) {
- endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record")
+// GetRecords lists of DNS records.
+// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.record_f94908d4e0e48489468498fce87cb90b
+func (c *Client) GetRecords(ctx context.Context, service string, filter RecordFilter) ([]Record, error) {
+ endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record")
+
+ encodedFilter, err := json.Marshal(filter)
+ if err != nil {
+ return nil, fmt.Errorf("marshal records filter: %w", err)
+ }
+
+ query := endpoint.Query()
+ query.Add("filters", string(encodedFilter))
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Data, err
+}
+
+// CreateRecord creates a new DNS record.
+// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.create-record_6773d572235be9a72646bf6c54863573
+func (c *Client) CreateRecord(ctx context.Context, service string, record Record) error {
+ endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
+ return err
}
- result := &Response{}
-
- err = c.do(req, result)
- if err != nil {
- return nil, err
- }
-
- return result, nil
+ return c.do(req, nil)
}
// DeleteRecord deletes a DNS record.
-// https://rest.websupport.sk/docs/v1.zone#delete-record
-func (c *Client) DeleteRecord(ctx context.Context, domainName string, recordID int) (*Response, error) {
- endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID))
+// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.delete-record_fc6603c14848e547f8d0b967842f0a2c
+func (c *Client) DeleteRecord(ctx context.Context, service, recordID string) error {
+ endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record", recordID)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
+ return err
}
- result := &Response{}
-
- err = c.do(req, result)
- if err != nil {
- return nil, err
- }
-
- return result, nil
+ return c.do(req, nil)
}
func (c *Client) do(req *http.Request, result any) error {
req.Header.Set("Accept-Language", "en_us")
- err := c.sign(req, time.Now().UTC())
+ err := c.sign(req, time.Now())
if err != nil {
- return fmt.Errorf("signature: %w", err)
+ return fmt.Errorf("sign request: %w", err)
}
resp, err := c.HTTPClient.Do(req)
@@ -162,10 +133,14 @@ func (c *Client) do(req *http.Request, result any) error {
defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode > http.StatusBadRequest {
+ 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)
@@ -179,29 +154,6 @@ func (c *Client) do(req *http.Request, result any) error {
return nil
}
-func (c *Client) sign(req *http.Request, now time.Time) error {
- if req.URL.Path == "" {
- req.URL.Path += "/"
- }
-
- canonicalRequest := fmt.Sprintf("%s %s %d", req.Method, req.URL.Path, now.Unix())
-
- mac := hmac.New(sha1.New, []byte(c.secretKey))
- _, err := mac.Write([]byte(canonicalRequest))
- if err != nil {
- return err
- }
-
- hashed := mac.Sum(nil)
- signature := hex.EncodeToString(hashed)
-
- req.SetBasicAuth(c.apiKey, signature)
-
- req.Header.Set("Date", now.Format(time.RFC3339))
-
- return nil
-}
-
func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
@@ -230,6 +182,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
@@ -237,3 +190,29 @@ func parseError(req *http.Request, resp *http.Response) error {
return &errAPI
}
+
+// sign creates and sets request signature and date.
+// https://rest.active24.cz/v2/docs/intro
+func (c *Client) sign(req *http.Request, now time.Time) error {
+ if req.URL.Path == "" {
+ req.URL.Path += "/"
+ }
+
+ canonicalRequest := fmt.Sprintf("%s %s %d", req.Method, req.URL.Path, now.Unix())
+
+ mac := hmac.New(sha1.New, []byte(c.secret))
+
+ _, err := mac.Write([]byte(canonicalRequest))
+ if err != nil {
+ return err
+ }
+
+ hashed := mac.Sum(nil)
+ signature := hex.EncodeToString(hashed)
+
+ req.SetBasicAuth(c.apiKey, signature)
+
+ req.Header.Set("Date", now.Format(time.RFC3339))
+
+ return nil
+}
diff --git a/providers/dns/internal/active24/internal/client_test.go b/providers/dns/internal/active24/internal/client_test.go
new file mode 100644
index 000000000..f62f78785
--- /dev/null
+++ b/providers/dns/internal/active24/internal/client_test.go
@@ -0,0 +1,182 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("example.com", "user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithRegexp("Authorization", `Basic .+`).
+ WithRegexp("Date", `\d+-\d+-\d+T\d{2}:\d{2}:\d{2}.*`).
+ With("Accept-Language", "en_us"))
+}
+
+func TestClient_GetServices(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/user/self/service",
+ servermock.ResponseFromFixture("services.json")).
+ Build(t)
+
+ services, err := client.GetServices(t.Context())
+ require.NoError(t, err)
+
+ expected := []Service{
+ {
+ ID: 1111,
+ ServiceName: ".sk doména",
+ Status: "active",
+ Name: "mydomain.sk",
+ CreateTime: 1374357600,
+ ExpireTime: 1405914526,
+ Price: 12.3,
+ },
+ {
+ ID: 2222,
+ ServiceName: "The Hosting",
+ Status: "active",
+ Name: "myname_1",
+ CreateTime: 1400145443,
+ ExpireTime: 1431702371,
+ Price: 55.2,
+ },
+ }
+
+ assert.Equal(t, expected, services)
+}
+
+func TestClient_GetServices_errors(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/user/self/service",
+ servermock.ResponseFromFixture("error_v1.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.GetServices(t.Context())
+ require.EqualError(t, err, "401: No username or password.")
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v2/service/aaa/dns/record",
+ servermock.ResponseFromFixture("records.json")).
+ Build(t)
+
+ filter := RecordFilter{
+ Name: "example.com",
+ Type: []string{"TXT"},
+ Content: "txt",
+ }
+
+ records, err := client.GetRecords(t.Context(), "aaa", filter)
+ require.NoError(t, err)
+
+ expected := []Record{{
+ ID: 13,
+ Name: "string",
+ Content: "string",
+ TTL: 120,
+ Priority: 1,
+ Port: 443,
+ Weight: 50,
+ }}
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_GetRecords_errors(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v2/service/aaa/dns/record",
+ servermock.ResponseFromFixture("error_403.json").
+ WithStatusCode(http.StatusForbidden)).
+ Build(t)
+
+ filter := RecordFilter{
+ Name: "example.com",
+ Type: []string{"TXT"},
+ Content: "txt",
+ }
+
+ _, err := client.GetRecords(t.Context(), "aaa", filter)
+ require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.")
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /v2/service/aaa/dns/record",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
+
+ err := client.CreateRecord(t.Context(), "aaa", Record{})
+ require.NoError(t, err)
+}
+
+func TestClient_CreateRecord_errors(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /v2/service/aaa/dns/record",
+ servermock.ResponseFromFixture("error_403.json").
+ WithStatusCode(http.StatusForbidden)).
+ Build(t)
+
+ err := client.CreateRecord(t.Context(), "aaa", Record{})
+ require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /v2/service/aaa/dns/record/123",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "aaa", "123")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /v2/service/aaa/dns/record/123",
+ servermock.ResponseFromFixture("error_403.json").
+ WithStatusCode(http.StatusForbidden)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "aaa", "123")
+ require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.")
+}
+
+func TestClient_sign(t *testing.T) {
+ client, err := NewClient("example.com", "user", "secret")
+ require.NoError(t, err)
+
+ req, err := http.NewRequest(http.MethodGet, "/v1/user/self/service", nil)
+ require.NoError(t, err)
+
+ err = client.sign(req, time.Date(2025, 6, 28, 1, 2, 3, 4, time.UTC))
+ require.NoError(t, err)
+
+ username, password, ok := req.BasicAuth()
+ require.True(t, ok)
+
+ assert.Equal(t, "user", username)
+ assert.Equal(t, "743e2257421b260ed561f3e7af4b035414636393", password)
+}
diff --git a/providers/dns/internal/active24/internal/fixtures/error_403.json b/providers/dns/internal/active24/internal/fixtures/error_403.json
new file mode 100644
index 000000000..ee3ce196e
--- /dev/null
+++ b/providers/dns/internal/active24/internal/fixtures/error_403.json
@@ -0,0 +1,5 @@
+{
+ "type": "/errors/httpException",
+ "status": 403,
+ "title": "This action is unauthorized."
+}
diff --git a/providers/dns/internal/active24/internal/fixtures/error_422.json b/providers/dns/internal/active24/internal/fixtures/error_422.json
new file mode 100644
index 000000000..0864a1fce
--- /dev/null
+++ b/providers/dns/internal/active24/internal/fixtures/error_422.json
@@ -0,0 +1,16 @@
+{
+ "type": "/errors/validation",
+ "status": 422,
+ "title": "The given data was invalid.",
+ "violations": [
+ {
+ "propertyPath": "string",
+ "errors": [
+ {}
+ ]
+ }
+ ],
+ "data": {
+ "name": "Merlin"
+ }
+}
diff --git a/providers/dns/internal/active24/internal/fixtures/error_v1.json b/providers/dns/internal/active24/internal/fixtures/error_v1.json
new file mode 100644
index 000000000..8043412e5
--- /dev/null
+++ b/providers/dns/internal/active24/internal/fixtures/error_v1.json
@@ -0,0 +1,4 @@
+{
+ "message": "No username or password.",
+ "code": 401
+}
diff --git a/providers/dns/internal/active24/internal/fixtures/records.json b/providers/dns/internal/active24/internal/fixtures/records.json
new file mode 100644
index 000000000..bf07d9ef7
--- /dev/null
+++ b/providers/dns/internal/active24/internal/fixtures/records.json
@@ -0,0 +1,28 @@
+{
+ "currentPage": 0,
+ "rowsPerPage": 0,
+ "totalPages": 0,
+ "totalRecords": 0,
+ "actions": {
+ "additionalProp1": {
+ "additionalProp1": {}
+ },
+ "additionalProp2": {
+ "additionalProp1": {}
+ },
+ "additionalProp3": {
+ "additionalProp1": {}
+ }
+ },
+ "data": [
+ {
+ "id": 13,
+ "name": "string",
+ "content": "string",
+ "ttl": 120,
+ "priority": 1,
+ "port": 443,
+ "weight": 50
+ }
+ ]
+}
diff --git a/providers/dns/internal/active24/internal/fixtures/services.json b/providers/dns/internal/active24/internal/fixtures/services.json
new file mode 100644
index 000000000..ad9b28700
--- /dev/null
+++ b/providers/dns/internal/active24/internal/fixtures/services.json
@@ -0,0 +1,31 @@
+{
+ "items":
+ [
+ {
+ "id": 1111,
+ "serviceName": ".sk doména",
+ "status": "active",
+ "name": "mydomain.sk",
+ "createTime": 1374357600,
+ "expireTime": 1405914526,
+ "price": 12.3,
+ "autoExtend": false
+ },
+ {
+ "id": 2222,
+ "serviceName": "The Hosting",
+ "status": "active",
+ "name": "myname_1",
+ "createTime": 1400145443,
+ "expireTime": 1431702371,
+ "price": 55.2,
+ "autoExtend": false
+ }
+ ],
+ "pager":
+ {
+ "page": 1,
+ "pagesize": null,
+ "items": 2
+ }
+}
diff --git a/providers/dns/internal/active24/internal/types.go b/providers/dns/internal/active24/internal/types.go
new file mode 100644
index 000000000..ed8dfc9d3
--- /dev/null
+++ b/providers/dns/internal/active24/internal/types.go
@@ -0,0 +1,65 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+ // v2 error
+ Type string `json:"type,omitempty"`
+ Status int `json:"status,omitempty"`
+ Title string `json:"title,omitempty"`
+
+ // v1 error
+ Message string `json:"message,omitempty"`
+ Code int `json:"code,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ if a.Message != "" {
+ return fmt.Sprintf("%d: %s", a.Code, a.Message)
+ }
+
+ return fmt.Sprintf("%d: %s: %s", a.Status, a.Type, a.Title)
+}
+
+type APIResponse struct {
+ Data []Record `json:"data"`
+}
+
+type Record struct {
+ ID int `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Port int `json:"port,omitempty"`
+ Weight int `json:"weight,omitempty"`
+}
+
+type OldAPIResponse struct {
+ Items []Service `json:"items"`
+}
+
+type Service struct {
+ ID int `json:"id,omitempty"`
+ ServiceName string `json:"serviceName,omitempty"`
+ Status string `json:"status,omitempty"`
+ Name string `json:"name,omitempty"`
+ CreateTime int `json:"createTime,omitempty"`
+ ExpireTime int `json:"expireTime,omitempty"`
+ Price float64 `json:"price,omitempty"`
+ AutoExtend bool `json:"autoExtend,omitempty"`
+}
+
+type RecordFilter struct {
+ Name string `json:"name,omitempty"`
+ Type []string `json:"type,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Note string `json:"note,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Port int `json:"port,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ Flags int `json:"flags,omitempty"`
+ Tag []string `json:"tag,omitempty"`
+}
diff --git a/providers/dns/internal/active24/provider.go b/providers/dns/internal/active24/provider.go
new file mode 100644
index 000000000..ae79b8b17
--- /dev/null
+++ b/providers/dns/internal/active24/provider.go
@@ -0,0 +1,179 @@
+// Package active24 implements a DNS provider for solving the DNS-01 challenge using Active24.
+package active24
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/active24/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+ Secret string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Active24.
+func NewDNSProviderConfig(config *Config, baseAPIDomain string) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(baseAPIDomain, config.APIKey, config.Secret)
+ if err != nil {
+ return nil, err
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return err
+ }
+
+ serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("find service ID: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: subDomain,
+ Content: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ err = d.client.CreateRecord(ctx, strconv.Itoa(serviceID), record)
+ if err != nil {
+ return fmt.Errorf("create 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("could not find zone for domain %q: %w", domain, err)
+ }
+
+ serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("find service ID: %w", err)
+ }
+
+ recordID, err := d.findRecordID(ctx, strconv.Itoa(serviceID), info)
+ if err != nil {
+ return fmt.Errorf("find record ID: %w", err)
+ }
+
+ err = d.client.DeleteRecord(ctx, strconv.Itoa(serviceID), strconv.Itoa(recordID))
+ if err != nil {
+ return fmt.Errorf("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
+}
+
+func (d *DNSProvider) findServiceID(ctx context.Context, domain string) (int, error) {
+ services, err := d.client.GetServices(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("get services: %w", err)
+ }
+
+ for _, service := range services {
+ if service.ServiceName != "domain" {
+ continue
+ }
+
+ if service.Name != domain {
+ continue
+ }
+
+ return service.ID, nil
+ }
+
+ return 0, fmt.Errorf("service not found for domain: %s", domain)
+}
+
+func (d *DNSProvider) findRecordID(ctx context.Context, serviceID string, info dns01.ChallengeInfo) (int, error) {
+ // NOTE(ldez): Despite the API documentation, the filter doesn't seem to work.
+ filter := internal.RecordFilter{
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
+ Type: []string{"TXT"},
+ Content: info.Value,
+ }
+
+ records, err := d.client.GetRecords(ctx, serviceID, filter)
+ if err != nil {
+ return 0, fmt.Errorf("get records: %w", err)
+ }
+
+ for _, record := range records {
+ if record.Type != "TXT" {
+ continue
+ }
+
+ if record.Name != dns01.UnFqdn(info.EffectiveFQDN) {
+ continue
+ }
+
+ if record.Content != info.Value {
+ continue
+ }
+
+ return record.ID, nil
+ }
+
+ return 0, errors.New("no record found")
+}
diff --git a/providers/dns/internal/active24/provider_test.go b/providers/dns/internal/active24/provider_test.go
new file mode 100644
index 000000000..e2959fd6e
--- /dev/null
+++ b/providers/dns/internal/active24/provider_test.go
@@ -0,0 +1,57 @@
+package active24
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ secret string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "user",
+ secret: "secret",
+ },
+ {
+ desc: "missing API key",
+ apiKey: "",
+ secret: "secret",
+ expected: "credentials missing",
+ },
+ {
+ desc: "missing secret",
+ apiKey: "user",
+ secret: "",
+ expected: "credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ config.APIKey = test.apiKey
+ config.Secret = test.secret
+
+ p, err := NewDNSProviderConfig(config, "example.com")
+
+ 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)
+ }
+ })
+ }
+}
diff --git a/providers/dns/internal/clientdebug/.gitattributes b/providers/dns/internal/clientdebug/.gitattributes
new file mode 100644
index 000000000..0ce5804f7
--- /dev/null
+++ b/providers/dns/internal/clientdebug/.gitattributes
@@ -0,0 +1 @@
+/testdata/** text eol=lf
diff --git a/providers/dns/internal/clientdebug/client.go b/providers/dns/internal/clientdebug/client.go
new file mode 100644
index 000000000..342577b93
--- /dev/null
+++ b/providers/dns/internal/clientdebug/client.go
@@ -0,0 +1,134 @@
+package clientdebug
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httputil"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/go-acme/lego/v4/platform/config/env"
+)
+
+const replacement = "***"
+
+type Option func(*DumpTransport)
+
+func WithEnvKeys(keys ...string) Option {
+ return func(d *DumpTransport) {
+ for _, key := range keys {
+ v := strings.TrimSpace(env.GetOrFile(key))
+ if v == "" {
+ continue
+ }
+
+ d.replacements = append(d.replacements, v, replacement)
+ }
+ }
+}
+
+func WithValues(values ...string) Option {
+ return func(d *DumpTransport) {
+ for _, value := range values {
+ d.replacements = append(d.replacements, value, replacement)
+ }
+ }
+}
+
+func WithHeaders(keys ...string) Option {
+ return func(d *DumpTransport) {
+ d.regexps = append(d.regexps,
+ regexp.MustCompile(fmt.Sprintf(`(?im)^(%s):.+$`, strings.Join(keys, "|"))))
+ }
+}
+
+type DumpTransport struct {
+ rt http.RoundTripper
+
+ replacements []string
+ replacer *strings.Replacer
+
+ regexps []*regexp.Regexp
+
+ writer io.Writer
+}
+
+func NewDumpTransport(rt http.RoundTripper, opts ...Option) *DumpTransport {
+ if rt == nil {
+ rt = http.DefaultTransport
+ }
+
+ d := &DumpTransport{
+ rt: rt,
+ writer: os.Stdout,
+ }
+
+ for _, opt := range opts {
+ opt(d)
+ }
+
+ d.regexps = append(d.regexps,
+ regexp.MustCompile(`(?im)^(Authorization):.+$`),
+ regexp.MustCompile(`(?im)^(Token|X-Token):.+$`),
+ regexp.MustCompile(`(?im)^(Auth-Token|X-Auth-Token):.+$`),
+ regexp.MustCompile(`(?im)^(Api-Key|X-Api-Key|X-Api-Secret):.+$`),
+ )
+
+ if len(d.replacements) > 0 {
+ d.replacer = strings.NewReplacer(d.replacements...)
+ }
+
+ return d
+}
+
+func (d *DumpTransport) RoundTrip(h *http.Request) (*http.Response, error) {
+ data, _ := httputil.DumpRequestOut(h, true)
+
+ _, _ = fmt.Fprintln(d.writer, "[HTTP Request]")
+ _, _ = fmt.Fprintln(d.writer, d.redact(data))
+
+ resp, err := d.rt.RoundTrip(h)
+ if err != nil {
+ return nil, err
+ }
+
+ data, _ = httputil.DumpResponse(resp, true)
+
+ _, _ = fmt.Fprintln(d.writer, "[HTTP Response]")
+ _, _ = fmt.Fprintln(d.writer, d.redact(data))
+
+ return resp, err
+}
+
+func (d *DumpTransport) redact(content []byte) string {
+ data := string(content)
+
+ for _, r := range d.regexps {
+ data = r.ReplaceAllString(data, "$1: "+replacement)
+ }
+
+ if d.replacer == nil {
+ return data
+ }
+
+ return d.replacer.Replace(data)
+}
+
+// Wrap wraps an HTTP client Transport with the [DumpTransport].
+func Wrap(client *http.Client, opts ...Option) *http.Client {
+ val, found := os.LookupEnv("LEGO_DEBUG_DNS_API_HTTP_CLIENT")
+ if !found {
+ return client
+ }
+
+ if ok, _ := strconv.ParseBool(val); !ok {
+ return client
+ }
+
+ client.Transport = NewDumpTransport(client.Transport, opts...)
+
+ return client
+}
diff --git a/providers/dns/internal/clientdebug/client_test.go b/providers/dns/internal/clientdebug/client_test.go
new file mode 100644
index 000000000..3a0c4021a
--- /dev/null
+++ b/providers/dns/internal/clientdebug/client_test.go
@@ -0,0 +1,174 @@
+package clientdebug
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "text/template"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWrap_redact_env_vars(t *testing.T) {
+ t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true")
+
+ t.Setenv("MY_VAR_01", "env-aaaa-aaaa")
+ t.Setenv("MY_VAR_02", "query-aaaa-aaaa")
+ t.Setenv("MY_VAR_03", "path-aaaa-aaaa")
+ t.Setenv("MY_VAR_04", "request-body-aaaa-aaaa")
+ t.Setenv("MY_VAR_05", "request-header-aaaa-aaaa")
+ t.Setenv("MY_VAR_06", "response-body-aaaa-aaaa")
+
+ buf := bytes.NewBufferString("")
+
+ server, client, req := setupTest(t, buf,
+ WithEnvKeys("MY_VAR_01", "MY_VAR_02", "MY_VAR_03", "MY_VAR_04", "MY_VAR_05", "MY_VAR_06"),
+ )
+
+ now := time.Now()
+
+ resp, err := client.Transport.RoundTrip(req)
+ require.NoError(t, err)
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ assertDump(t, now, server, buf, "env_vars.txt")
+}
+
+func TestWrap_redact_headers(t *testing.T) {
+ t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true")
+
+ buf := bytes.NewBufferString("")
+
+ server, client, req := setupTest(t, buf,
+ WithHeaders("Secret-Request-Header", "Super-Secret-Request-Header", "Secret-Response-Header"),
+ )
+
+ now := time.Now()
+
+ resp, err := client.Transport.RoundTrip(req)
+ require.NoError(t, err)
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ assertDump(t, now, server, buf, "headers.txt")
+}
+
+func TestWrap_redact_values(t *testing.T) {
+ t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true")
+
+ buf := bytes.NewBufferString("")
+
+ server, client, req := setupTest(t, buf,
+ WithValues("query-aaaa-aaaa", "path-aaaa-aaaa", "request-body-aaaa-aaaa"),
+ )
+
+ now := time.Now()
+
+ resp, err := client.Transport.RoundTrip(req)
+ require.NoError(t, err)
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ assertDump(t, now, server, buf, "values.txt")
+}
+
+func fakeRequest(t *testing.T, baseURL string) *http.Request {
+ t.Helper()
+
+ endpoint, err := url.Parse(baseURL)
+ require.NoError(t, err)
+
+ query := endpoint.Query()
+ query.Set("foo", "query-aaaa-aaaa")
+ endpoint.RawQuery = query.Encode()
+
+ endpoint = endpoint.JoinPath("path-aaaa-aaaa")
+
+ body := `{
+ "foo": "request-body-aaaa-aaaa"
+}
+`
+
+ req := httptest.NewRequest(http.MethodGet, endpoint.String(), bytes.NewBufferString(body))
+
+ req.Header.Set("X-Authorization", "not-redacted")
+
+ req.Header.Set("Secret-Request-Header", "request-header-aaaa-aaaa")
+ req.Header.Set("Super-Secret-Request-Header", "env-aaaa-aaaa")
+
+ req.Header.Set("Authorization", "header-aaaa-0000")
+ req.Header.Set("Token", "header-aaaa-0001")
+ req.Header.Set("X-Token", "header-aaaa-0002")
+ req.Header.Set("Auth-Token", "header-aaaa-0003")
+ req.Header.Set("X-Auth-Token", "header-aaaa-0004")
+ req.Header.Set("Api-Key", "header-aaaa-0006")
+ req.Header.Set("X-Api-Key", "header-aaaa-0007")
+ req.Header.Set("X-Api-Secret", "header-aaaa-0008")
+
+ req.SetBasicAuth("user", "secret")
+
+ return req
+}
+
+func fakeResponse() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Secret-Response-Header", "response-header-aaaa-aaaa")
+ _, _ = w.Write([]byte(`{
+ "bar": "response-body-aaaa-aaaa"
+}`,
+ ))
+ }
+}
+
+func withWriter(w io.Writer) Option {
+ return func(d *DumpTransport) {
+ if w != nil {
+ d.writer = w
+ }
+ }
+}
+
+func setupTest(t *testing.T, buf io.Writer, opts ...Option) (*httptest.Server, *http.Client, *http.Request) {
+ t.Helper()
+
+ server := httptest.NewServer(fakeResponse())
+
+ opts = append(opts, withWriter(buf))
+
+ client := Wrap(server.Client(), opts...)
+
+ req := fakeRequest(t, server.URL)
+
+ return server, client, req
+}
+
+func assertDump(t *testing.T, now time.Time, server *httptest.Server, actual *bytes.Buffer, filename string) {
+ t.Helper()
+
+ tmpl, err := template.New(filename).ParseFiles(filepath.Join("testdata", filename))
+ require.NoError(t, err)
+
+ expected := bytes.NewBufferString("")
+
+ location, err := time.LoadLocation("GMT")
+ require.NoError(t, err)
+
+ baseURL, err := url.Parse(server.URL)
+ require.NoError(t, err)
+
+ err = tmpl.Execute(expected, map[string]string{
+ "Host": baseURL.Host,
+ "Date": now.In(location).Format(time.RFC1123),
+ })
+ require.NoError(t, err)
+
+ assert.Equal(t, expected.String(), strings.ReplaceAll(actual.String(), "\r", ""))
+}
diff --git a/providers/dns/internal/clientdebug/testdata/env_vars.txt b/providers/dns/internal/clientdebug/testdata/env_vars.txt
new file mode 100644
index 000000000..a2697850e
--- /dev/null
+++ b/providers/dns/internal/clientdebug/testdata/env_vars.txt
@@ -0,0 +1,32 @@
+[HTTP Request]
+GET /***?foo=*** HTTP/1.1
+Host: {{ .Host }}
+User-Agent: Go-http-client/1.1
+Content-Length: 37
+Api-Key: ***
+Auth-Token: ***
+Authorization: ***
+Secret-Request-Header: ***
+Super-Secret-Request-Header: ***
+Token: ***
+X-Api-Key: ***
+X-Api-Secret: ***
+X-Auth-Token: ***
+X-Authorization: not-redacted
+X-Token: ***
+Accept-Encoding: gzip
+
+{
+ "foo": "***"
+}
+
+[HTTP Response]
+HTTP/1.1 200 OK
+Content-Length: 37
+Content-Type: text/plain; charset=utf-8
+Date: {{ .Date }}
+Secret-Response-Header: response-header-aaaa-aaaa
+
+{
+ "bar": "***"
+}
diff --git a/providers/dns/internal/clientdebug/testdata/headers.txt b/providers/dns/internal/clientdebug/testdata/headers.txt
new file mode 100644
index 000000000..fe803fb22
--- /dev/null
+++ b/providers/dns/internal/clientdebug/testdata/headers.txt
@@ -0,0 +1,32 @@
+[HTTP Request]
+GET /path-aaaa-aaaa?foo=query-aaaa-aaaa HTTP/1.1
+Host: {{ .Host }}
+User-Agent: Go-http-client/1.1
+Content-Length: 37
+Api-Key: ***
+Auth-Token: ***
+Authorization: ***
+Secret-Request-Header: ***
+Super-Secret-Request-Header: ***
+Token: ***
+X-Api-Key: ***
+X-Api-Secret: ***
+X-Auth-Token: ***
+X-Authorization: not-redacted
+X-Token: ***
+Accept-Encoding: gzip
+
+{
+ "foo": "request-body-aaaa-aaaa"
+}
+
+[HTTP Response]
+HTTP/1.1 200 OK
+Content-Length: 37
+Content-Type: text/plain; charset=utf-8
+Date: {{ .Date }}
+Secret-Response-Header: ***
+
+{
+ "bar": "response-body-aaaa-aaaa"
+}
diff --git a/providers/dns/internal/clientdebug/testdata/values.txt b/providers/dns/internal/clientdebug/testdata/values.txt
new file mode 100644
index 000000000..b40f51f14
--- /dev/null
+++ b/providers/dns/internal/clientdebug/testdata/values.txt
@@ -0,0 +1,32 @@
+[HTTP Request]
+GET /***?foo=*** HTTP/1.1
+Host: {{ .Host }}
+User-Agent: Go-http-client/1.1
+Content-Length: 37
+Api-Key: ***
+Auth-Token: ***
+Authorization: ***
+Secret-Request-Header: request-header-aaaa-aaaa
+Super-Secret-Request-Header: env-aaaa-aaaa
+Token: ***
+X-Api-Key: ***
+X-Api-Secret: ***
+X-Auth-Token: ***
+X-Authorization: not-redacted
+X-Token: ***
+Accept-Encoding: gzip
+
+{
+ "foo": "***"
+}
+
+[HTTP Response]
+HTTP/1.1 200 OK
+Content-Length: 37
+Content-Type: text/plain; charset=utf-8
+Date: {{ .Date }}
+Secret-Response-Header: response-header-aaaa-aaaa
+
+{
+ "bar": "response-body-aaaa-aaaa"
+}
diff --git a/providers/dns/gcore/internal/client.go b/providers/dns/internal/gcore/internal/client.go
similarity index 91%
rename from providers/dns/gcore/internal/client.go
rename to providers/dns/internal/gcore/internal/client.go
index 085b4d6cb..f3ad4e461 100644
--- a/providers/dns/gcore/internal/client.go
+++ b/providers/dns/internal/gcore/internal/client.go
@@ -27,7 +27,7 @@ const txtRecordType = "TXT"
type Client struct {
token string
- baseURL *url.URL
+ BaseURL *url.URL
HTTPClient *http.Client
}
@@ -37,7 +37,7 @@ func NewClient(token string) *Client {
return &Client{
token: token,
- baseURL: baseURL,
+ BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
@@ -45,9 +45,10 @@ func NewClient(token string) *Client {
// GetZone gets zone information.
// 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)
+ endpoint := c.BaseURL.JoinPath("v2", "zones", name)
zone := Zone{}
+
err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &zone)
if err != nil {
return Zone{}, fmt.Errorf("get zone %s: %w", name, err)
@@ -59,9 +60,10 @@ func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) {
// GetRRSet gets RRSet item.
// 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)
+ endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
var result RRSet
+
err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &result)
if err != nil {
return RRSet{}, fmt.Errorf("get txt records %s -> %s: %w", zone, name, err)
@@ -73,7 +75,7 @@ func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error)
// DeleteRRSet removes RRSet record.
// 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)
+ endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
err := c.doRequest(ctx, http.MethodDelete, endpoint, nil, nil)
if err != nil {
@@ -104,19 +106,19 @@ func (c *Client) AddRRSet(ctx context.Context, zone, recordName, value string, t
// 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)
+ endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
return c.doRequest(ctx, http.MethodPost, endpoint, record, nil)
}
// 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)
+ endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
return c.doRequest(ctx, http.MethodPut, endpoint, record, nil)
}
-func (c *Client) doRequest(ctx context.Context, method string, endpoint *url.URL, bodyParams any, result any) error {
+func (c *Client) doRequest(ctx context.Context, method string, endpoint *url.URL, bodyParams, result any) error {
req, err := newJSONRequest(ctx, method, endpoint, bodyParams)
if err != nil {
return fmt.Errorf("new request: %w", err)
@@ -180,6 +182,7 @@ func parseError(resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errAPI := APIError{StatusCode: resp.StatusCode}
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
errAPI.Message = string(raw)
diff --git a/providers/dns/internal/gcore/internal/client_test.go b/providers/dns/internal/gcore/internal/client_test.go
new file mode 100644
index 000000000..7d70c9308
--- /dev/null
+++ b/providers/dns/internal/gcore/internal/client_test.go
@@ -0,0 +1,165 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ testToken = "test"
+ testRecordContent = "acme"
+ testTTL = 10
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(testToken)
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders())
+}
+
+func TestClient_GetZone(t *testing.T) {
+ expected := Zone{Name: "example.com"}
+
+ client := mockBuilder().
+ Route("GET /v2/zones/example.com",
+ servermock.JSONEncode(expected)).
+ Build(t)
+
+ zone, err := client.GetZone(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_GetZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v2/zones/example.com",
+ servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ _, err := client.GetZone(t.Context(), "example.com")
+ require.EqualError(t, err, "get zone example.com: 500: oops")
+}
+
+func TestClient_GetRRSet(t *testing.T) {
+ expected := RRSet{
+ TTL: testTTL,
+ Records: []Records{
+ {Content: []string{testRecordContent}},
+ },
+ }
+
+ client := mockBuilder().
+ Route("GET /v2/zones/example.com/foo.example.com/TXT",
+ servermock.JSONEncode(expected)).
+ Build(t)
+
+ rrSet, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com")
+ require.NoError(t, err)
+
+ assert.Equal(t, expected, rrSet)
+}
+
+func TestClient_GetRRSet_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v2/zones/example.com/foo.example.com/TXT",
+ servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ _, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com")
+ require.EqualError(t, err, "get txt records example.com -> foo.example.com: 500: oops")
+}
+
+func TestClient_DeleteRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT", nil).
+ Build(t)
+
+ err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRRSet_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.")
+ require.NoError(t, err)
+}
+
+func TestClient_AddRRSet_add(t *testing.T) {
+ client := mockBuilder().
+ // GetRRSet
+ Route("GET /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)).
+ // createRRSet
+ Route("POST /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode([]Records{{Content: []string{testRecordContent}}}),
+ servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]}]}`)).
+ Build(t)
+
+ err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL)
+ require.NoError(t, err)
+}
+
+func TestClient_AddRRSet_add_error(t *testing.T) {
+ client := mockBuilder().
+ // GetRRSet
+ Route("GET /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)).
+ // createRRSet
+ Route("POST /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL)
+ require.EqualError(t, err, "400: oops")
+}
+
+func TestClient_AddRRSet_update(t *testing.T) {
+ client := mockBuilder().
+ // GetRRSet
+ Route("GET /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode(RRSet{
+ TTL: testTTL,
+ Records: []Records{{Content: []string{"foo"}}},
+ })).
+ // updateRRSet
+ Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT", nil,
+ servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]},{"content":["foo"]}]}`)).
+ Build(t)
+
+ err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL)
+ require.NoError(t, err)
+}
+
+func TestClient_AddRRSet_update_error(t *testing.T) {
+ client := mockBuilder().
+ // GetRRSet
+ Route("GET /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode(RRSet{
+ TTL: testTTL,
+ Records: []Records{{Content: []string{"foo"}}},
+ })).
+ // updateRRSet
+ Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT",
+ servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL)
+ require.EqualError(t, err, "400: oops")
+}
diff --git a/providers/dns/gcore/internal/types.go b/providers/dns/internal/gcore/internal/types.go
similarity index 100%
rename from providers/dns/gcore/internal/types.go
rename to providers/dns/internal/gcore/internal/types.go
diff --git a/providers/dns/internal/gcore/provider.go b/providers/dns/internal/gcore/provider.go
new file mode 100644
index 000000000..b2078eba5
--- /dev/null
+++ b/providers/dns/internal/gcore/provider.go
@@ -0,0 +1,126 @@
+// Package gcore implements a DNS provider for solving the DNS-01 challenge using G-Core.
+package gcore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/gcore/internal"
+)
+
+const (
+ DefaultPropagationTimeout = 360 * time.Second
+ DefaultPollingInterval = 20 * time.Second
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config for DNSProvider.
+type Config struct {
+ APIToken string
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// DNSProvider an implementation of challenge.Provider contract.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API.
+func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ if config.APIToken == "" {
+ return nil, errors.New("incomplete credentials provided")
+ }
+
+ client := internal.NewClient(config.APIToken)
+
+ if baseURL != "" {
+ client.BaseURL, _ = url.Parse(baseURL)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record to fulfill the dns-01 challenge.
+func (d *DNSProvider) Present(domain, _, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx := context.Background()
+
+ zone, err := d.guessZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return err
+ }
+
+ err = d.client.AddRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL)
+ if err != nil {
+ return fmt.Errorf("add txt record: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx := context.Background()
+
+ zone, err := d.guessZone(ctx, info.EffectiveFQDN)
+ if err != nil {
+ return err
+ }
+
+ err = d.client.DeleteRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("remove txt 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
+}
+
+func (d *DNSProvider) guessZone(ctx context.Context, fqdn string) (string, error) {
+ var lastErr error
+
+ for zone := range dns01.UnFqdnDomainsSeq(fqdn) {
+ dnsZone, err := d.client.GetZone(ctx, zone)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+
+ return dnsZone.Name, nil
+ }
+
+ return "", fmt.Errorf("zone %q not found: %w", fqdn, lastErr)
+}
diff --git a/providers/dns/internal/gcore/provider_test.go b/providers/dns/internal/gcore/provider_test.go
new file mode 100644
index 000000000..f29dadff9
--- /dev/null
+++ b/providers/dns/internal/gcore/provider_test.go
@@ -0,0 +1,42 @@
+package gcore
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "A",
+ },
+ {
+ desc: "missing credentials",
+ expected: "incomplete credentials provided",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ config.APIToken = test.apiToken
+
+ 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)
+ }
+ })
+ }
+}
diff --git a/providers/dns/internal/hostingde/client.go b/providers/dns/internal/hostingde/internal/client.go
similarity index 73%
rename from providers/dns/internal/hostingde/client.go
rename to providers/dns/internal/hostingde/internal/client.go
index 8416f202b..133c3479c 100644
--- a/providers/dns/internal/hostingde/client.go
+++ b/providers/dns/internal/hostingde/internal/client.go
@@ -1,4 +1,4 @@
-package hostingde
+package internal
import (
"bytes"
@@ -10,14 +10,11 @@ import (
"net/url"
"time"
- "github.com/cenkalti/backoff/v4"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
-const (
- DefaultHostingdeBaseURL = "https://secure.hosting.de/api/dns/v1/json"
- DefaultHTTPNetBaseURL = "https://partner.http.net/api/dns/v1/json"
-)
+const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json"
// Client the API client for Hosting.de.
type Client struct {
@@ -29,7 +26,7 @@ type Client struct {
// NewClient creates new Client.
func NewClient(apiKey string) *Client {
- baseURL, _ := url.Parse(DefaultHostingdeBaseURL)
+ baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
apiKey: apiKey,
@@ -39,41 +36,31 @@ func NewClient(apiKey string) *Client {
}
// GetZone gets a zone.
-func (c Client) GetZone(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneConfig, error) {
- var zoneConfig *ZoneConfig
-
- operation := func() error {
+func (c *Client) GetZone(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneConfig, error) {
+ operation := func() (*ZoneConfig, error) {
response, err := c.ListZoneConfigs(ctx, req)
if err != nil {
- return backoff.Permanent(err)
+ return nil, backoff.Permanent(err)
}
if response.Data[0].Status != "active" {
- return fmt.Errorf("unexpected status: %q", response.Data[0].Status)
+ return nil, fmt.Errorf("unexpected status: %q", response.Data[0].Status)
}
- zoneConfig = &response.Data[0]
-
- return nil
+ return &response.Data[0], nil
}
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 3 * time.Second
bo.MaxInterval = 10 * bo.InitialInterval
- bo.MaxElapsedTime = 100 * bo.InitialInterval
// retry in case the zone was edited recently and is not yet active
- err := backoff.Retry(operation, bo)
- if err != nil {
- return nil, err
- }
-
- return zoneConfig, nil
+ return backoff.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithMaxElapsedTime(100*bo.InitialInterval))
}
// ListZoneConfigs lists zone configuration.
// https://www.hosting.de/api/?json#list-zoneconfigs
-func (c Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneResponse, error) {
+func (c *Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneResponse, error) {
endpoint := c.BaseURL.JoinPath("zoneConfigsFind")
req.AuthToken = c.apiKey
@@ -98,7 +85,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) {
+func (c *Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, error) {
endpoint := c.BaseURL.JoinPath("zoneUpdate")
req.AuthToken = c.apiKey
@@ -118,7 +105,7 @@ func (c Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, e
return response.Response, nil
}
-func (c Client) post(ctx context.Context, endpoint *url.URL, request, result any) ([]byte, error) {
+func (c *Client) post(ctx context.Context, endpoint *url.URL, request, result any) ([]byte, error) {
body, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON body: %w", err)
diff --git a/providers/dns/internal/hostingde/client_test.go b/providers/dns/internal/hostingde/internal/client_test.go
similarity index 55%
rename from providers/dns/internal/hostingde/client_test.go
rename to providers/dns/internal/hostingde/internal/client_test.go
index d538c8bc0..d55bbf690 100644
--- a/providers/dns/internal/hostingde/client_test.go
+++ b/providers/dns/internal/hostingde/internal/client_test.go
@@ -1,70 +1,30 @@
-package hostingde
+package internal
import (
- "bytes"
- "context"
"encoding/json"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("secret")
client.HTTPClient = server.Client()
client.BaseURL, _ = url.Parse(server.URL)
- mux.HandleFunc(pattern, handler)
-
- return client
-}
-
-func writeFixture(rw http.ResponseWriter, filename string) {
- 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)
+ return client, nil
}
func TestClient_ListZoneConfigs(t *testing.T) {
- client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- body := string(bytes.TrimSpace(raw))
- if body != `{"authToken":"secret","filter":{"field":"zoneName","value":"example.com"},"limit":1,"page":1}` {
- http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest)
- return
- }
-
- writeFixture(rw, "zoneConfigsFind.json")
- })
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /zoneConfigsFind",
+ servermock.ResponseFromFixture("zoneConfigsFind.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zoneConfigsFind-request.json")).
+ Build(t)
zonesFind := ZoneConfigsFindRequest{
Filter: Filter{Field: "zoneName", Value: "example.com"},
@@ -72,7 +32,7 @@ func TestClient_ListZoneConfigs(t *testing.T) {
Page: 1,
}
- zoneResponse, err := client.ListZoneConfigs(context.Background(), zonesFind)
+ zoneResponse, err := client.ListZoneConfigs(t.Context(), zonesFind)
require.NoError(t, err)
expected := &ZoneResponse{
@@ -109,14 +69,10 @@ func TestClient_ListZoneConfigs(t *testing.T) {
}
func TestClient_ListZoneConfigs_error(t *testing.T) {
- client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- writeFixture(rw, "zoneConfigsFind_error.json")
- })
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /zoneConfigsFind",
+ servermock.ResponseFromFixture("zoneConfigsFind_error.json")).
+ Build(t)
zonesFind := ZoneConfigsFindRequest{
Filter: Filter{Field: "zoneName", Value: "example.com"},
@@ -124,31 +80,16 @@ func TestClient_ListZoneConfigs_error(t *testing.T) {
Page: 1,
}
- _, err := client.ListZoneConfigs(context.Background(), zonesFind)
+ _, err := client.ListZoneConfigs(t.Context(), zonesFind)
require.Error(t, err)
}
func TestClient_UpdateZone(t *testing.T) {
- client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- body := string(bytes.TrimSpace(raw))
- if body != `{"authToken":"secret","zoneConfig":{"id":"123","accountId":"456","status":"s","name":"n","nameUnicode":"u","masterIp":"m","type":"t","emailAddress":"e","zoneTransferWhitelist":["a","b"],"lastChangeDate":"l","dnsServerGroupId":"g","dnsSecMode":"m","soaValues":{"refresh":1,"retry":2,"expire":3,"ttl":4,"negativeTtl":5}},"recordsToAdd":null,"recordsToDelete":[{"name":"_acme-challenge.example.com","type":"TXT","content":"\"txt\""}]}` {
- http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest)
- return
- }
-
- writeFixture(rw, "zoneUpdate.json")
- })
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /zoneUpdate",
+ servermock.ResponseFromFixture("zoneUpdate.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zoneUpdate-request.json")).
+ Build(t)
request := ZoneUpdateRequest{
ZoneConfig: ZoneConfig{
@@ -179,7 +120,7 @@ func TestClient_UpdateZone(t *testing.T) {
}},
}
- response, err := client.UpdateZone(context.Background(), request)
+ response, err := client.UpdateZone(t.Context(), request)
require.NoError(t, err)
expected := &Zone{
@@ -221,14 +162,10 @@ func TestClient_UpdateZone(t *testing.T) {
}
func TestClient_UpdateZone_error(t *testing.T) {
- client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- writeFixture(rw, "zoneUpdate_error.json")
- })
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /zoneUpdate",
+ servermock.ResponseFromFixture("zoneUpdate_error.json")).
+ Build(t)
request := ZoneUpdateRequest{
ZoneConfig: ZoneConfig{
@@ -259,6 +196,6 @@ func TestClient_UpdateZone_error(t *testing.T) {
}},
}
- _, err := client.UpdateZone(context.Background(), request)
+ _, err := client.UpdateZone(t.Context(), request)
require.Error(t, err)
}
diff --git a/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json
new file mode 100644
index 000000000..eb552d9eb
--- /dev/null
+++ b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json
@@ -0,0 +1,9 @@
+{
+ "authToken": "secret",
+ "filter": {
+ "field": "zoneName",
+ "value": "example.com"
+ },
+ "limit": 1,
+ "page": 1
+}
diff --git a/providers/dns/internal/hostingde/fixtures/zoneConfigsFind.json b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind.json
similarity index 100%
rename from providers/dns/internal/hostingde/fixtures/zoneConfigsFind.json
rename to providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind.json
diff --git a/providers/dns/internal/hostingde/fixtures/zoneConfigsFind_error.json b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind_error.json
similarity index 100%
rename from providers/dns/internal/hostingde/fixtures/zoneConfigsFind_error.json
rename to providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind_error.json
diff --git a/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json
new file mode 100644
index 000000000..38b1be50d
--- /dev/null
+++ b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json
@@ -0,0 +1,35 @@
+{
+ "authToken": "secret",
+ "zoneConfig": {
+ "id": "123",
+ "accountId": "456",
+ "status": "s",
+ "name": "n",
+ "nameUnicode": "u",
+ "masterIp": "m",
+ "type": "t",
+ "emailAddress": "e",
+ "zoneTransferWhitelist": [
+ "a",
+ "b"
+ ],
+ "lastChangeDate": "l",
+ "dnsServerGroupId": "g",
+ "dnsSecMode": "m",
+ "soaValues": {
+ "refresh": 1,
+ "retry": 2,
+ "expire": 3,
+ "ttl": 4,
+ "negativeTtl": 5
+ }
+ },
+ "recordsToAdd": null,
+ "recordsToDelete": [
+ {
+ "name": "_acme-challenge.example.com",
+ "type": "TXT",
+ "content": "\"txt\""
+ }
+ ]
+}
diff --git a/providers/dns/internal/hostingde/fixtures/zoneUpdate.json b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate.json
similarity index 100%
rename from providers/dns/internal/hostingde/fixtures/zoneUpdate.json
rename to providers/dns/internal/hostingde/internal/fixtures/zoneUpdate.json
diff --git a/providers/dns/internal/hostingde/fixtures/zoneUpdate_error.json b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate_error.json
similarity index 100%
rename from providers/dns/internal/hostingde/fixtures/zoneUpdate_error.json
rename to providers/dns/internal/hostingde/internal/fixtures/zoneUpdate_error.json
diff --git a/providers/dns/internal/hostingde/types.go b/providers/dns/internal/hostingde/internal/types.go
similarity index 98%
rename from providers/dns/internal/hostingde/types.go
rename to providers/dns/internal/hostingde/internal/types.go
index 4f3347190..330eab27d 100644
--- a/providers/dns/internal/hostingde/types.go
+++ b/providers/dns/internal/hostingde/internal/types.go
@@ -1,4 +1,4 @@
-package hostingde
+package internal
import "encoding/json"
@@ -88,7 +88,8 @@ type Zone struct {
// https://www.hosting.de/api/?json#updating-zones
type ZoneUpdateRequest struct {
BaseRequest
- ZoneConfig `json:"zoneConfig"`
+ ZoneConfig `json:"zoneConfig"`
+
RecordsToAdd []DNSRecord `json:"recordsToAdd"`
RecordsToDelete []DNSRecord `json:"recordsToDelete"`
}
@@ -97,6 +98,7 @@ type ZoneUpdateRequest struct {
// https://www.hosting.de/api/?json#list-zoneconfigs
type ZoneConfigsFindRequest struct {
BaseRequest
+
Filter Filter `json:"filter"`
Limit int `json:"limit"`
Page int `json:"page"`
diff --git a/providers/dns/internal/hostingde/provider.go b/providers/dns/internal/hostingde/provider.go
new file mode 100644
index 000000000..b5277f042
--- /dev/null
+++ b/providers/dns/internal/hostingde/provider.go
@@ -0,0 +1,196 @@
+// Package hostingde implements a DNS provider for solving the DNS-01 challenge using hosting.de.
+package hostingde
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/hostingde/internal"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// 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
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ recordIDs map[string]string
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for hosting.de.
+func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ if config.APIKey == "" {
+ return nil, errors.New("API key missing")
+ }
+
+ client := internal.NewClient(config.APIKey)
+
+ if baseURL != "" {
+ client.BaseURL, _ = url.Parse(baseURL)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ 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("could not find zone for domain %q: %w", domain, err)
+ }
+
+ ctx := context.Background()
+
+ // get the ZoneConfig for that domain
+ zonesFind := internal.ZoneConfigsFindRequest{
+ Filter: internal.Filter{Field: "zoneName", Value: zoneName},
+ Limit: 1,
+ Page: 1,
+ }
+
+ zoneConfig, err := d.client.GetZone(ctx, zonesFind)
+ if err != nil {
+ return err
+ }
+
+ zoneConfig.Name = zoneName
+
+ rec := []internal.DNSRecord{{
+ Type: "TXT",
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
+ Content: info.Value,
+ TTL: d.config.TTL,
+ }}
+
+ req := internal.ZoneUpdateRequest{
+ ZoneConfig: *zoneConfig,
+ RecordsToAdd: rec,
+ }
+
+ response, err := d.client.UpdateZone(ctx, req)
+ if err != nil {
+ return 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("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("could not find zone for domain %q: %w", domain, err)
+ }
+
+ ctx := context.Background()
+
+ // get the ZoneConfig for that domain
+ zonesFind := internal.ZoneConfigsFindRequest{
+ Filter: internal.Filter{Field: "zoneName", Value: zoneName},
+ Limit: 1,
+ Page: 1,
+ }
+
+ zoneConfig, err := d.client.GetZone(ctx, zonesFind)
+ if err != nil {
+ return err
+ }
+
+ zoneConfig.Name = zoneName
+
+ rec := []internal.DNSRecord{{
+ Type: "TXT",
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
+ Content: `"` + info.Value + `"`,
+ }}
+
+ req := internal.ZoneUpdateRequest{
+ ZoneConfig: *zoneConfig,
+ RecordsToDelete: rec,
+ }
+
+ _, err = d.client.UpdateZone(ctx, req)
+ if err != nil {
+ return err
+ }
+
+ // Delete record ID from map
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, info.EffectiveFQDN)
+ d.recordIDsMu.Unlock()
+
+ 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 for %s: %w", fqdn, err)
+ }
+
+ if zoneName == "" {
+ return "", errors.New("empty zone name")
+ }
+
+ return dns01.UnFqdn(zoneName), nil
+}
diff --git a/providers/dns/internal/hostingde/provider_test.go b/providers/dns/internal/hostingde/provider_test.go
new file mode 100644
index 000000000..3cdabf702
--- /dev/null
+++ b/providers/dns/internal/hostingde/provider_test.go
@@ -0,0 +1,50 @@
+package hostingde
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+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: "API key missing",
+ },
+ {
+ desc: "missing api key",
+ zoneName: "456",
+ expected: "API key missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ 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)
+ }
+ })
+ }
+}
diff --git a/providers/dns/ionos/internal/client.go b/providers/dns/internal/ionos/internal/client.go
similarity index 97%
rename from providers/dns/ionos/internal/client.go
rename to providers/dns/internal/ionos/internal/client.go
index 8b37d5f1c..2a556a49b 100644
--- a/providers/dns/ionos/internal/client.go
+++ b/providers/dns/internal/ionos/internal/client.go
@@ -14,9 +14,11 @@ import (
querystring "github.com/google/go-querystring/query"
)
-// defaultBaseURL represents the API endpoint to call.
const defaultBaseURL = "https://api.hosting.ionos.com/dns"
+// APIKeyHeader API key header.
+const APIKeyHeader = "X-Api-Key"
+
// Client Ionos API client.
type Client struct {
apiKey string
@@ -49,6 +51,7 @@ func (c *Client) ListZones(ctx context.Context) ([]Zone, error) {
}
var zones []Zone
+
err = c.do(req, &zones)
if err != nil {
return nil, fmt.Errorf("failed to call API: %w", err)
@@ -93,6 +96,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsF
}
var zone CustomerZone
+
err = c.do(req, &zone)
if err != nil {
return nil, fmt.Errorf("failed to call API: %w", err)
@@ -119,7 +123,7 @@ func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) erro
}
func (c *Client) do(req *http.Request, result any) error {
- req.Header.Set("X-API-Key", c.apiKey)
+ req.Header.Set(APIKeyHeader, c.apiKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
@@ -177,6 +181,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errClient := &ClientError{StatusCode: resp.StatusCode}
+
err := json.Unmarshal(raw, &errClient.errors)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/internal/ionos/internal/client_test.go b/providers/dns/internal/ionos/internal/client_test.go
new file mode 100644
index 000000000..008d153bc
--- /dev/null
+++ b/providers/dns/internal/ionos/internal/client_test.go
@@ -0,0 +1,162 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ servermock.CheckHeader().With(APIKeyHeader, "secret"))
+}
+
+func TestClient_ListZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/zones",
+ servermock.ResponseFromFixture("list_zones.json")).
+ Build(t)
+
+ zones, err := client.ListZones(t.Context())
+ require.NoError(t, err)
+
+ expected := []Zone{{
+ ID: "11af3414-ebba-11e9-8df5-66fbe8a334b4",
+ Name: "test.com",
+ Type: "NATIVE",
+ }}
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_ListZones_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/zones",
+ servermock.ResponseFromFixture("list_zones_error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ zones, err := client.ListZones(t.Context())
+ require.Error(t, err)
+
+ assert.Nil(t, zones)
+
+ var cErr *ClientError
+ assert.ErrorAs(t, err, &cErr)
+ assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode)
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/zones/azone01",
+ servermock.ResponseFromFixture("get_records.json")).
+ Build(t)
+
+ records, err := client.GetRecords(t.Context(), "azone01", nil)
+ require.NoError(t, err)
+
+ expected := []Record{{
+ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4",
+ Name: "string",
+ Content: "string",
+ Type: "A",
+ }}
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_GetRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /v1/zones/azone01",
+ servermock.ResponseFromFixture("get_records_error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ records, err := client.GetRecords(t.Context(), "azone01", nil)
+ require.Error(t, err)
+
+ assert.Nil(t, records)
+
+ var cErr *ClientError
+ assert.ErrorAs(t, err, &cErr)
+ assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode)
+}
+
+func TestClient_RemoveRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /v1/zones/azone01/records/arecord01", nil).
+ Build(t)
+
+ err := client.RemoveRecord(t.Context(), "azone01", "arecord01")
+ require.NoError(t, err)
+}
+
+func TestClient_RemoveRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /v1/zones/azone01/records/arecord01",
+ servermock.ResponseFromFixture("remove_record_error.json").
+ WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ err := client.RemoveRecord(t.Context(), "azone01", "arecord01")
+ require.Error(t, err)
+
+ var cErr *ClientError
+ assert.ErrorAs(t, err, &cErr)
+ assert.Equal(t, http.StatusInternalServerError, cErr.StatusCode)
+}
+
+func TestClient_ReplaceRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("PATCH /v1/zones/azone01", nil).
+ Build(t)
+
+ records := []Record{{
+ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4",
+ Name: "string",
+ Content: "string",
+ Type: "A",
+ }}
+
+ err := client.ReplaceRecords(t.Context(), "azone01", records)
+ require.NoError(t, err)
+}
+
+func TestClient_ReplaceRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PATCH /v1/zones/azone01",
+ servermock.ResponseFromFixture("replace_records_error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ records := []Record{{
+ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4",
+ Name: "string",
+ Content: "string",
+ Type: "A",
+ }}
+
+ err := client.ReplaceRecords(t.Context(), "azone01", records)
+ require.Error(t, err)
+
+ var cErr *ClientError
+ assert.ErrorAs(t, err, &cErr)
+ assert.Equal(t, http.StatusBadRequest, cErr.StatusCode)
+}
diff --git a/providers/dns/ionos/internal/fixtures/get_records.json b/providers/dns/internal/ionos/internal/fixtures/get_records.json
similarity index 100%
rename from providers/dns/ionos/internal/fixtures/get_records.json
rename to providers/dns/internal/ionos/internal/fixtures/get_records.json
diff --git a/providers/dns/ionos/internal/fixtures/get_records_error.json b/providers/dns/internal/ionos/internal/fixtures/get_records_error.json
similarity index 100%
rename from providers/dns/ionos/internal/fixtures/get_records_error.json
rename to providers/dns/internal/ionos/internal/fixtures/get_records_error.json
diff --git a/providers/dns/ionos/internal/fixtures/list_zones.json b/providers/dns/internal/ionos/internal/fixtures/list_zones.json
similarity index 100%
rename from providers/dns/ionos/internal/fixtures/list_zones.json
rename to providers/dns/internal/ionos/internal/fixtures/list_zones.json
diff --git a/providers/dns/ionos/internal/fixtures/list_zones_error.json b/providers/dns/internal/ionos/internal/fixtures/list_zones_error.json
similarity index 100%
rename from providers/dns/ionos/internal/fixtures/list_zones_error.json
rename to providers/dns/internal/ionos/internal/fixtures/list_zones_error.json
diff --git a/providers/dns/ionos/internal/fixtures/remove_record_error.json b/providers/dns/internal/ionos/internal/fixtures/remove_record_error.json
similarity index 100%
rename from providers/dns/ionos/internal/fixtures/remove_record_error.json
rename to providers/dns/internal/ionos/internal/fixtures/remove_record_error.json
diff --git a/providers/dns/ionos/internal/fixtures/replace_records_error.json b/providers/dns/internal/ionos/internal/fixtures/replace_records_error.json
similarity index 100%
rename from providers/dns/ionos/internal/fixtures/replace_records_error.json
rename to providers/dns/internal/ionos/internal/fixtures/replace_records_error.json
diff --git a/providers/dns/ionos/internal/types.go b/providers/dns/internal/ionos/internal/types.go
similarity index 91%
rename from providers/dns/ionos/internal/types.go
rename to providers/dns/internal/ionos/internal/types.go
index 3b7acbec2..35bfe0966 100644
--- a/providers/dns/ionos/internal/types.go
+++ b/providers/dns/internal/ionos/internal/types.go
@@ -3,6 +3,7 @@ package internal
import (
"fmt"
"strconv"
+ "strings"
)
// ClientError a detailed error.
@@ -13,21 +14,23 @@ type ClientError struct {
}
func (f ClientError) Error() string {
- msg := strconv.Itoa(f.StatusCode) + ": "
+ var msg strings.Builder
+
+ msg.WriteString(strconv.Itoa(f.StatusCode) + ": ")
if f.message != "" {
- msg += f.message + ": "
+ msg.WriteString(f.message + ": ")
}
for i, e := range f.errors {
if i != 0 {
- msg += ", "
+ msg.WriteString(", ")
}
- msg += e.Error()
+ msg.WriteString(e.Error())
}
- return msg
+ return msg.String()
}
func (f ClientError) Unwrap() error {
diff --git a/providers/dns/internal/ionos/provider.go b/providers/dns/internal/ionos/provider.go
new file mode 100644
index 000000000..a7d145840
--- /dev/null
+++ b/providers/dns/internal/ionos/provider.go
@@ -0,0 +1,173 @@
+package ionos
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ ionos "github.com/go-acme/lego/v4/providers/dns/internal/ionos/internal"
+)
+
+const MinTTL = 300
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *ionos.Client
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Ionos.
+func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ if config.APIKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ if config.TTL < MinTTL {
+ return nil, fmt.Errorf("invalid TTL, TTL (%d) must be greater than %d", config.TTL, MinTTL)
+ }
+
+ client, err := ionos.NewClient(config.APIKey)
+ if err != nil {
+ return nil, err
+ }
+
+ if baseURL != "" {
+ client.BaseURL, _ = url.Parse(baseURL)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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 using the specified parameters.
+func (d *DNSProvider) Present(domain, _, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx := context.Background()
+
+ zones, err := d.client.ListZones(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get zones: %w", err)
+ }
+
+ name := dns01.UnFqdn(info.EffectiveFQDN)
+
+ zone := findZone(zones, name)
+ if zone == nil {
+ return errors.New("no matching zone found for domain")
+ }
+
+ filter := &ionos.RecordsFilter{
+ Suffix: name,
+ RecordType: "TXT",
+ }
+
+ records, err := d.client.GetRecords(ctx, zone.ID, filter)
+ if err != nil {
+ return fmt.Errorf("failed to get records (zone=%s): %w", zone.ID, err)
+ }
+
+ records = append(records, ionos.Record{
+ Name: name,
+ Content: info.Value,
+ TTL: d.config.TTL,
+ Type: "TXT",
+ })
+
+ err = d.client.ReplaceRecords(ctx, zone.ID, records)
+ if err != nil {
+ return fmt.Errorf("failed to create/update records (zone=%s): %w", zone.ID, err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx := context.Background()
+
+ zones, err := d.client.ListZones(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get zones: %w", err)
+ }
+
+ name := dns01.UnFqdn(info.EffectiveFQDN)
+
+ zone := findZone(zones, name)
+ if zone == nil {
+ return errors.New("no matching zone found for domain")
+ }
+
+ filter := &ionos.RecordsFilter{
+ Suffix: name,
+ RecordType: "TXT",
+ }
+
+ records, err := d.client.GetRecords(ctx, zone.ID, filter)
+ if err != nil {
+ return fmt.Errorf("failed to get records (zone=%s): %w", zone.ID, err)
+ }
+
+ for _, record := range records {
+ if record.Name == name && record.Content == strconv.Quote(info.Value) {
+ err = d.client.RemoveRecord(ctx, zone.ID, record.ID)
+ if err != nil {
+ return fmt.Errorf("failed to remove record (zone=%s, record=%s): %w", zone.ID, record.ID, err)
+ }
+
+ return nil
+ }
+ }
+
+ return fmt.Errorf("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 []ionos.Zone, domain string) *ionos.Zone {
+ var result *ionos.Zone
+
+ for _, zone := range zones {
+ if zone.Name != "" && strings.HasSuffix(domain, zone.Name) {
+ if result == nil || len(zone.Name) > len(result.Name) {
+ result = &zone
+ }
+ }
+ }
+
+ return result
+}
diff --git a/providers/dns/internal/ionos/provider_test.go b/providers/dns/internal/ionos/provider_test.go
new file mode 100644
index 000000000..6b4df5cc7
--- /dev/null
+++ b/providers/dns/internal/ionos/provider_test.go
@@ -0,0 +1,52 @@
+package ionos
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ tll int
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "123",
+ tll: MinTTL,
+ },
+ {
+ desc: "missing credentials",
+ tll: MinTTL,
+ expected: "credentials missing",
+ },
+ {
+ desc: "invalid TTL",
+ apiKey: "123",
+ tll: 30,
+ expected: "invalid TTL, TTL (30) must be greater than 300",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ config.APIKey = test.apiKey
+ config.TTL = test.tll
+
+ 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)
+ }
+ })
+ }
+}
diff --git a/providers/dns/internal/ptr/types.go b/providers/dns/internal/ptr/types.go
new file mode 100644
index 000000000..b0c7974e0
--- /dev/null
+++ b/providers/dns/internal/ptr/types.go
@@ -0,0 +1,12 @@
+package ptr
+
+func Deref[T any](v *T) T {
+ if v == nil {
+ var zero T
+ return zero
+ }
+
+ return *v
+}
+
+func Pointer[T any](v T) *T { return &v }
diff --git a/providers/dns/internal/rimuhosting/client_test.go b/providers/dns/internal/rimuhosting/client_test.go
deleted file mode 100644
index ecd55b0b5..000000000
--- a/providers/dns/internal/rimuhosting/client_test.go
+++ /dev/null
@@ -1,317 +0,0 @@
-package rimuhosting
-
-import (
- "context"
- "encoding/xml"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("apikeyvaluehere")
- client.BaseURL = server.URL
- client.HTTPClient = server.Client()
-
- return client, mux
-}
-
-func TestClient_FindTXTRecords(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- query := req.URL.Query()
-
- var fixture string
- switch query.Get("name") {
- case "example.com":
- fixture = "./fixtures/find_records.xml"
- case "**.example.com":
- fixture = "./fixtures/find_records_pattern.xml"
- default:
- fixture = "./fixtures/find_records_empty.xml"
- }
-
- err := writeResponse(rw, fixture)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- testCases := []struct {
- desc string
- domain string
- expected []Record
- }{
- {
- desc: "simple",
- domain: "example.com",
- expected: []Record{
- {
- Name: "example.org",
- Type: "TXT",
- Content: "txttxtx",
- TTL: "3600 seconds",
- Priority: "0",
- },
- },
- },
- {
- desc: "pattern",
- domain: "**.example.com",
- expected: []Record{
- {
- Name: "_test.example.org",
- Type: "TXT",
- Content: "txttxtx",
- TTL: "3600 seconds",
- Priority: "0",
- },
- {
- Name: "example.org",
- Type: "TXT",
- Content: "txttxtx",
- TTL: "3600 seconds",
- Priority: "0",
- },
- },
- },
- {
- desc: "empty",
- domain: "empty.com",
- expected: nil,
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- records, err := client.FindTXTRecords(context.Background(), test.domain)
- require.NoError(t, err)
-
- assert.Equal(t, test.expected, records)
- })
- }
-}
-
-func TestClient_DoActions(t *testing.T) {
- type expected struct {
- Query string
- Resp *DNSAPIResult
- Error string
- }
-
- testCases := []struct {
- desc string
- actions []ActionParameter
- fixture string
- expected expected
- }{
- {
- desc: "SET error",
- actions: []ActionParameter{
- NewAddRecordAction("example.com", "txttxtx", 0),
- },
- fixture: "./fixtures/add_record_error.xml",
- expected: expected{
- Query: "action=SET&api_key=apikeyvaluehere&name=example.com&type=TXT&value=txttxtx",
- Error: "ERROR: No zone found for example.com",
- },
- },
- {
- desc: "SET simple",
- actions: []ActionParameter{
- NewAddRecordAction("example.org", "txttxtx", 0),
- },
- fixture: "./fixtures/add_record.xml",
- expected: expected{
- Query: "action=SET&api_key=apikeyvaluehere&name=example.org&type=TXT&value=txttxtx",
- Resp: &DNSAPIResult{
- XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
- IsOk: "OK:",
- ResultCounts: ResultCounts{Added: "1", Changed: "0", Unchanged: "0", Deleted: "0"},
- Actions: Actions{
- Action: Action{
- Action: "SET",
- Host: "example.org",
- Type: "TXT",
- Records: []Record{{
- Name: "example.org",
- Type: "TXT",
- Content: "txttxtx",
- TTL: "3600 seconds",
- Priority: "0",
- }},
- },
- },
- },
- },
- },
- {
- desc: "SET multiple values",
- actions: []ActionParameter{
- NewAddRecordAction("example.org", "txttxtx", 0),
- NewAddRecordAction("example.org", "sample", 0),
- },
- fixture: "./fixtures/add_record_same_domain.xml",
- expected: expected{
- Query: "action[0]=SET&action[1]=SET&api_key=apikeyvaluehere&name[0]=example.org&name[1]=example.org&ttl[0]=0&ttl[1]=0&type[0]=TXT&type[1]=TXT&value[0]=txttxtx&value[1]=sample",
- Resp: &DNSAPIResult{
- XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
- IsOk: "OK:",
- ResultCounts: ResultCounts{Added: "2", Changed: "0", Unchanged: "0", Deleted: "0"},
- Actions: Actions{
- Action: Action{
- Action: "SET",
- Host: "example.org",
- Type: "TXT",
- Records: []Record{
- {
- Name: "example.org",
- Type: "TXT",
- Content: "txttxtx",
- TTL: "0 seconds",
- Priority: "0",
- },
- {
- Name: "example.org",
- Type: "TXT",
- Content: "sample",
- TTL: "0 seconds",
- Priority: "0",
- },
- },
- },
- },
- },
- },
- },
- {
- desc: "DELETE error",
- actions: []ActionParameter{
- NewDeleteRecordAction("example.com", "txttxtx"),
- },
- fixture: "./fixtures/delete_record_error.xml",
- expected: expected{
- Query: "action=DELETE&api_key=apikeyvaluehere&name=example.com&type=TXT&value=txttxtx",
- Error: "ERROR: No zone found for example.com",
- },
- },
- {
- desc: "DELETE nothing",
- actions: []ActionParameter{
- NewDeleteRecordAction("example.org", "nothing"),
- },
- fixture: "./fixtures/delete_record_nothing.xml",
- expected: expected{
- Query: "action=DELETE&api_key=apikeyvaluehere&name=example.org&type=TXT&value=nothing",
- Resp: &DNSAPIResult{
- XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
- IsOk: "OK:",
- ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "0"},
- Actions: Actions{
- Action: Action{
- Action: "DELETE",
- Host: "example.org",
- Type: "TXT",
- Records: nil,
- },
- },
- },
- },
- },
- {
- desc: "DELETE simple",
- actions: []ActionParameter{
- NewDeleteRecordAction("example.org", "txttxtx"),
- },
- fixture: "./fixtures/delete_record.xml",
- expected: expected{
- Query: "action=DELETE&api_key=apikeyvaluehere&name=example.org&type=TXT&value=txttxtx",
- Resp: &DNSAPIResult{
- XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
- IsOk: "OK:",
- ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "1"},
- Actions: Actions{
- Action: Action{
- Action: "DELETE",
- Host: "example.org",
- Type: "TXT",
- Records: []Record{{
- Name: "example.org",
- Type: "TXT",
- Content: "txttxtx",
- TTL: "3600 seconds",
- Priority: "0",
- }},
- },
- },
- },
- },
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- query, err := url.QueryUnescape(req.URL.RawQuery)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if test.expected.Query != query {
- http.Error(rw, fmt.Sprintf("invalid query: %s", query), http.StatusBadRequest)
- return
- }
-
- if test.expected.Error != "" {
- rw.WriteHeader(http.StatusInternalServerError)
- }
-
- err = writeResponse(rw, test.fixture)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- resp, err := client.DoActions(context.Background(), test.actions...)
- if test.expected.Error != "" {
- require.EqualError(t, err, test.expected.Error)
- return
- }
-
- require.NoError(t, err)
-
- assert.Equal(t, test.expected.Resp, resp)
- })
- }
-}
-
-func writeResponse(rw io.Writer, filename string) error {
- file, err := os.Open(filename)
- if err != nil {
- return err
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- return err
-}
diff --git a/providers/dns/internal/rimuhosting/client.go b/providers/dns/internal/rimuhosting/internal/client.go
similarity index 86%
rename from providers/dns/internal/rimuhosting/client.go
rename to providers/dns/internal/rimuhosting/internal/client.go
index 4976f3781..5bf7393e7 100644
--- a/providers/dns/internal/rimuhosting/client.go
+++ b/providers/dns/internal/rimuhosting/internal/client.go
@@ -1,4 +1,4 @@
-package rimuhosting
+package internal
import (
"context"
@@ -15,11 +15,7 @@ import (
querystring "github.com/google/go-querystring/query"
)
-// Base URL for the RimuHosting DNS services.
-const (
- DefaultZonomiBaseURL = "https://zonomi.com/app/dns/dyndns.jsp"
- DefaultRimuHostingBaseURL = "https://rimuhosting.com/dns/dyndns.jsp"
-)
+const defaultBaseURL = "https://rimuhosting.com/dns/dyndns.jsp"
// Action names.
const (
@@ -40,7 +36,7 @@ type Client struct {
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
- BaseURL: DefaultZonomiBaseURL,
+ BaseURL: defaultBaseURL,
HTTPClient: &http.Client{Timeout: 5 * time.Second},
}
}
@@ -49,7 +45,7 @@ func NewClient(apiKey string) *Client {
// ex:
// - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=example.com&api_key=apikeyvaluehere
// - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=**.example.com&api_key=apikeyvaluehere
-func (c Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, error) {
+func (c *Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, error) {
action := ActionParameter{
Action: QueryAction,
Name: domain,
@@ -65,7 +61,7 @@ func (c Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, er
}
// DoActions performs actions.
-func (c Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNSAPIResult, error) {
+func (c *Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNSAPIResult, error) {
if len(actions) == 0 {
return nil, errors.New("no action")
}
@@ -82,18 +78,21 @@ func (c Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNS
if err != nil {
return nil, err
}
+
return resp, nil
}
multi := c.toMultiParameters(actions)
+
err := c.do(ctx, multi, resp)
if err != nil {
return nil, err
}
+
return resp, nil
}
-func (c Client) toMultiParameters(params []ActionParameter) multiActionParameter {
+func (c *Client) toMultiParameters(params []ActionParameter) multiActionParameter {
multi := multiActionParameter{
APIKey: c.apiKey,
}
@@ -109,7 +108,7 @@ func (c Client) toMultiParameters(params []ActionParameter) multiActionParameter
return multi
}
-func (c Client) do(ctx context.Context, params, result any) error {
+func (c *Client) do(ctx context.Context, params, result any) error {
baseURL, err := url.Parse(c.BaseURL)
if err != nil {
return err
@@ -160,6 +159,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errAPI := APIError{}
+
err := xml.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/internal/rimuhosting/internal/client_test.go b/providers/dns/internal/rimuhosting/internal/client_test.go
new file mode 100644
index 000000000..00126dfbe
--- /dev/null
+++ b/providers/dns/internal/rimuhosting/internal/client_test.go
@@ -0,0 +1,332 @@
+package internal
+
+import (
+ "encoding/xml"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupClient(server *httptest.Server) (*Client, error) {
+ client := NewClient("apikeyvaluehere")
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func TestClient_FindTXTRecords(t *testing.T) {
+ testCases := []struct {
+ desc string
+ domain string
+ response string
+ query url.Values
+ expected []Record
+ }{
+ {
+ desc: "simple",
+ domain: "example.com",
+ response: "find_records.xml",
+ query: url.Values{
+ "name": []string{"example.com"},
+ "type": []string{"TXT"},
+ "action": []string{"QUERY"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: []Record{
+ {
+ Name: "example.org",
+ Type: "TXT",
+ Content: "txttxtx",
+ TTL: "3600 seconds",
+ Priority: "0",
+ },
+ },
+ },
+ {
+ desc: "pattern",
+ domain: "**.example.com",
+ response: "find_records_pattern.xml",
+ query: url.Values{
+ "name": []string{"**.example.com"},
+ "type": []string{"TXT"},
+ "action": []string{"QUERY"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: []Record{
+ {
+ Name: "_test.example.org",
+ Type: "TXT",
+ Content: "txttxtx",
+ TTL: "3600 seconds",
+ Priority: "0",
+ },
+ {
+ Name: "example.org",
+ Type: "TXT",
+ Content: "txttxtx",
+ TTL: "3600 seconds",
+ Priority: "0",
+ },
+ },
+ },
+ {
+ desc: "empty",
+ domain: "empty.com",
+ response: "find_records_empty.xml",
+ query: url.Values{
+ "name": []string{"empty.com"},
+ "type": []string{"TXT"},
+ "action": []string{"QUERY"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: nil,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /",
+ servermock.ResponseFromFixture(test.response),
+ servermock.CheckQueryParameter().Strict().
+ WithValues(test.query)).
+ Build(t)
+
+ records, err := client.FindTXTRecords(t.Context(), test.domain)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expected, records)
+ })
+ }
+}
+
+func TestClient_DoActions(t *testing.T) {
+ testCases := []struct {
+ desc string
+ actions []ActionParameter
+ query url.Values
+ response string
+ expected *DNSAPIResult
+ }{
+ {
+ desc: "SET simple",
+ actions: []ActionParameter{
+ NewAddRecordAction("example.org", "txttxtx", 0),
+ },
+ response: "add_record.xml",
+ query: url.Values{
+ "action": []string{"SET"},
+ "name": []string{"example.org"},
+ "type": []string{"TXT"},
+ "value": []string{"txttxtx"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: &DNSAPIResult{
+ XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
+ IsOk: "OK:",
+ ResultCounts: ResultCounts{Added: "1", Changed: "0", Unchanged: "0", Deleted: "0"},
+ Actions: Actions{
+ Action: Action{
+ Action: "SET",
+ Host: "example.org",
+ Type: "TXT",
+ Records: []Record{{
+ Name: "example.org",
+ Type: "TXT",
+ Content: "txttxtx",
+ TTL: "3600 seconds",
+ Priority: "0",
+ }},
+ },
+ },
+ },
+ },
+ {
+ desc: "SET multiple values",
+ actions: []ActionParameter{
+ NewAddRecordAction("example.org", "txttxtx", 0),
+ NewAddRecordAction("example.org", "sample", 0),
+ },
+ response: "add_record_same_domain.xml",
+ query: url.Values{
+ "api_key": []string{"apikeyvaluehere"},
+ "action[0]": []string{"SET"},
+ "name[0]": []string{"example.org"},
+ "ttl[0]": []string{"0"},
+ "type[0]": []string{"TXT"},
+ "value[0]": []string{"txttxtx"},
+ "action[1]": []string{"SET"},
+ "name[1]": []string{"example.org"},
+ "ttl[1]": []string{"0"},
+ "type[1]": []string{"TXT"},
+ "value[1]": []string{"sample"},
+ },
+ expected: &DNSAPIResult{
+ XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
+ IsOk: "OK:",
+ ResultCounts: ResultCounts{Added: "2", Changed: "0", Unchanged: "0", Deleted: "0"},
+ Actions: Actions{
+ Action: Action{
+ Action: "SET",
+ Host: "example.org",
+ Type: "TXT",
+ Records: []Record{
+ {
+ Name: "example.org",
+ Type: "TXT",
+ Content: "txttxtx",
+ TTL: "0 seconds",
+ Priority: "0",
+ },
+ {
+ Name: "example.org",
+ Type: "TXT",
+ Content: "sample",
+ TTL: "0 seconds",
+ Priority: "0",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ desc: "DELETE nothing",
+ actions: []ActionParameter{
+ NewDeleteRecordAction("example.org", "nothing"),
+ },
+ response: "delete_record_nothing.xml",
+ query: url.Values{
+ "action": []string{"DELETE"},
+ "name": []string{"example.org"},
+ "type": []string{"TXT"},
+ "value": []string{"nothing"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: &DNSAPIResult{
+ XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
+ IsOk: "OK:",
+ ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "0"},
+ Actions: Actions{
+ Action: Action{
+ Action: "DELETE",
+ Host: "example.org",
+ Type: "TXT",
+ Records: nil,
+ },
+ },
+ },
+ },
+ {
+ desc: "DELETE simple",
+ actions: []ActionParameter{
+ NewDeleteRecordAction("example.org", "txttxtx"),
+ },
+ response: "delete_record.xml",
+ query: url.Values{
+ "action": []string{"DELETE"},
+ "name": []string{"example.org"},
+ "type": []string{"TXT"},
+ "value": []string{"txttxtx"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: &DNSAPIResult{
+ XMLName: xml.Name{Space: "", Local: "dnsapi_result"},
+ IsOk: "OK:",
+ ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "1"},
+ Actions: Actions{
+ Action: Action{
+ Action: "DELETE",
+ Host: "example.org",
+ Type: "TXT",
+ Records: []Record{{
+ Name: "example.org",
+ Type: "TXT",
+ Content: "txttxtx",
+ TTL: "3600 seconds",
+ Priority: "0",
+ }},
+ },
+ },
+ },
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /",
+ servermock.ResponseFromFixture(test.response),
+ servermock.CheckQueryParameter().Strict().
+ WithValues(test.query)).
+ Build(t)
+
+ resp, err := client.DoActions(t.Context(), test.actions...)
+ require.NoError(t, err)
+
+ assert.Equal(t, test.expected, resp)
+ })
+ }
+}
+
+func TestClient_DoActions_error(t *testing.T) {
+ testCases := []struct {
+ desc string
+ actions []ActionParameter
+ query url.Values
+ response string
+ expected string
+ }{
+ {
+ desc: "SET error",
+ actions: []ActionParameter{
+ NewAddRecordAction("example.com", "txttxtx", 0),
+ },
+ response: "add_record_error.xml",
+ query: url.Values{
+ "action": []string{"SET"},
+ "name": []string{"example.com"},
+ "type": []string{"TXT"},
+ "value": []string{"txttxtx"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: "ERROR: No zone found for example.com",
+ },
+ {
+ desc: "DELETE error",
+ actions: []ActionParameter{
+ NewDeleteRecordAction("example.com", "txttxtx"),
+ },
+ response: "delete_record_error.xml",
+ query: url.Values{
+ "action": []string{"DELETE"},
+ "name": []string{"example.com"},
+ "type": []string{"TXT"},
+ "value": []string{"txttxtx"},
+ "api_key": []string{"apikeyvaluehere"},
+ },
+ expected: "ERROR: No zone found for example.com",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /",
+ servermock.ResponseFromFixture(test.response).
+ WithStatusCode(http.StatusInternalServerError),
+ servermock.CheckQueryParameter().Strict().
+ WithValues(test.query)).
+ Build(t)
+
+ _, err := client.DoActions(t.Context(), test.actions...)
+ require.EqualError(t, err, test.expected)
+ })
+ }
+}
diff --git a/providers/dns/internal/rimuhosting/fixtures/add_record.xml b/providers/dns/internal/rimuhosting/internal/fixtures/add_record.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/add_record.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/add_record.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/add_record_error.xml b/providers/dns/internal/rimuhosting/internal/fixtures/add_record_error.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/add_record_error.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/add_record_error.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/add_record_same_domain.xml b/providers/dns/internal/rimuhosting/internal/fixtures/add_record_same_domain.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/add_record_same_domain.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/add_record_same_domain.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/delete_record.xml b/providers/dns/internal/rimuhosting/internal/fixtures/delete_record.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/delete_record.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/delete_record.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/delete_record_error.xml b/providers/dns/internal/rimuhosting/internal/fixtures/delete_record_error.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/delete_record_error.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/delete_record_error.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/delete_record_nothing.xml b/providers/dns/internal/rimuhosting/internal/fixtures/delete_record_nothing.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/delete_record_nothing.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/delete_record_nothing.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/find_records.xml b/providers/dns/internal/rimuhosting/internal/fixtures/find_records.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/find_records.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/find_records.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/find_records_empty.xml b/providers/dns/internal/rimuhosting/internal/fixtures/find_records_empty.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/find_records_empty.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/find_records_empty.xml
diff --git a/providers/dns/internal/rimuhosting/fixtures/find_records_pattern.xml b/providers/dns/internal/rimuhosting/internal/fixtures/find_records_pattern.xml
similarity index 100%
rename from providers/dns/internal/rimuhosting/fixtures/find_records_pattern.xml
rename to providers/dns/internal/rimuhosting/internal/fixtures/find_records_pattern.xml
diff --git a/providers/dns/internal/rimuhosting/types.go b/providers/dns/internal/rimuhosting/internal/types.go
similarity index 98%
rename from providers/dns/internal/rimuhosting/types.go
rename to providers/dns/internal/rimuhosting/internal/types.go
index bdb333032..c3df886a2 100644
--- a/providers/dns/internal/rimuhosting/types.go
+++ b/providers/dns/internal/rimuhosting/internal/types.go
@@ -1,4 +1,4 @@
-package rimuhosting
+package internal
import "encoding/xml"
diff --git a/providers/dns/internal/rimuhosting/provider.go b/providers/dns/internal/rimuhosting/provider.go
new file mode 100644
index 000000000..3be764cbf
--- /dev/null
+++ b/providers/dns/internal/rimuhosting/provider.go
@@ -0,0 +1,107 @@
+// Package rimuhosting implements a DNS provider for solving the DNS-01 challenge using RimuHosting DNS.
+package rimuhosting
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/rimuhosting/internal"
+)
+
+const DefaultTTL = 3600
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for RimuHosting.
+func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ if config.APIKey == "" {
+ return nil, errors.New("incomplete credentials, missing API key")
+ }
+
+ client := internal.NewClient(config.APIKey)
+
+ if baseURL != "" {
+ client.BaseURL = baseURL
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.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 using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx := context.Background()
+
+ records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("failed to find record(s) for %s: %w", domain, err)
+ }
+
+ actions := []internal.ActionParameter{
+ internal.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL),
+ }
+
+ for _, record := range records {
+ actions = append(actions, internal.NewAddRecordAction(record.Name, record.Content, d.config.TTL))
+ }
+
+ _, err = d.client.DoActions(ctx, actions...)
+ if err != nil {
+ return fmt.Errorf("failed to add record(s) for %s: %w", domain, 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)
+
+ action := internal.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+
+ _, err := d.client.DoActions(context.Background(), action)
+ if err != nil {
+ return fmt.Errorf("failed to delete record for %s: %w", domain, err)
+ }
+
+ return nil
+}
diff --git a/providers/dns/internal/rimuhosting/provider_test.go b/providers/dns/internal/rimuhosting/provider_test.go
new file mode 100644
index 000000000..d1569af31
--- /dev/null
+++ b/providers/dns/internal/rimuhosting/provider_test.go
@@ -0,0 +1,46 @@
+package rimuhosting
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ expected string
+ apiKey string
+ secretKey string
+ }{
+ {
+ desc: "success",
+ apiKey: "api_key",
+ secretKey: "api_secret",
+ },
+ {
+ desc: "missing api key",
+ apiKey: "",
+ secretKey: "api_secret",
+ expected: "incomplete credentials, missing API key",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config, "")
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
diff --git a/providers/dns/internal/selectel/client_test.go b/providers/dns/internal/selectel/client_test.go
deleted file mode 100644
index 703fd7b98..000000000
--- a/providers/dns/internal/selectel/client_test.go
+++ /dev/null
@@ -1,204 +0,0 @@
-package selectel
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("token")
- client.BaseURL, _ = url.Parse(server.URL)
- client.HTTPClient = server.Client()
-
- return client, mux
-}
-
-func TestClient_ListRecords(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- fixture := "./fixtures/list_records.json"
-
- err := writeResponse(rw, fixture)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- records, err := client.ListRecords(context.Background(), 123)
- require.NoError(t, err)
-
- expected := []Record{
- {ID: 123, Name: "example.com", Type: "TXT", TTL: 60, Email: "email@example.com", Content: "txttxttxtA"},
- {ID: 1234, Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxtB"},
- {ID: 12345, Name: "example.net", Type: "TXT", TTL: 60, Email: "email@example.net", Content: "txttxttxtC"},
- }
-
- assert.Equal(t, expected, records)
-}
-
-func TestClient_ListRecords_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- rw.WriteHeader(http.StatusUnauthorized)
- err := writeResponse(rw, "./fixtures/error.json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- records, err := client.ListRecords(context.Background(), 123)
-
- 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)
-}
-
-func TestClient_GetDomainByName(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/sub.sub.example.org", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- rw.WriteHeader(http.StatusNotFound)
- })
-
- mux.HandleFunc("/sub.example.org", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- rw.WriteHeader(http.StatusNotFound)
- })
-
- mux.HandleFunc("/example.org", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- fixture := "./fixtures/domains.json"
-
- err := writeResponse(rw, fixture)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- domain, err := client.GetDomainByName(context.Background(), "sub.sub.example.org")
- require.NoError(t, err)
-
- expected := &Domain{
- ID: 123,
- Name: "example.org",
- }
-
- assert.Equal(t, expected, domain)
-}
-
-func TestClient_AddRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- rec := Record{}
-
- err := json.NewDecoder(req.Body).Decode(&rec)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- rec.ID = 456
-
- err = json.NewEncoder(rw).Encode(rec)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- record, err := client.AddRecord(context.Background(), 123, Record{
- Name: "example.org",
- Type: "TXT",
- TTL: 60,
- Email: "email@example.org",
- Content: "txttxttxttxt",
- })
-
- require.NoError(t, err)
-
- expected := &Record{
- ID: 456,
- Name: "example.org",
- Type: "TXT",
- TTL: 60,
- Email: "email@example.org",
- Content: "txttxttxttxt",
- }
-
- assert.Equal(t, expected, record)
-}
-
-func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
- })
-
- err := client.DeleteRecord(context.Background(), 123, 456)
- require.NoError(t, err)
-}
-
-func writeResponse(rw io.Writer, filename string) error {
- file, err := os.Open(filename)
- if err != nil {
- return err
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- return err
-}
diff --git a/providers/dns/internal/selectel/client.go b/providers/dns/internal/selectel/internal/client.go
similarity index 91%
rename from providers/dns/internal/selectel/client.go
rename to providers/dns/internal/selectel/internal/client.go
index 1e1e4a215..d441c9894 100644
--- a/providers/dns/internal/selectel/client.go
+++ b/providers/dns/internal/selectel/internal/client.go
@@ -1,4 +1,4 @@
-package selectel
+package internal
import (
"bytes"
@@ -15,15 +15,11 @@ import (
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
-// Base URL for the Selectel/VScale DNS services.
-const (
- DefaultSelectelBaseURL = "https://api.selectel.ru/domains/v1"
- DefaultVScaleBaseURL = "https://api.vscale.io/v1/domains"
-)
+const defaultBaseURL = "https://api.selectel.ru/domains/v1"
const tokenHeader = "X-Token"
-// Client represents DNS client.
+// Client represents the DNS client.
type Client struct {
token string
@@ -33,7 +29,7 @@ type Client struct {
// NewClient returns a client instance.
func NewClient(token string) *Client {
- baseURL, _ := url.Parse(DefaultVScaleBaseURL)
+ baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
token: token,
@@ -52,12 +48,13 @@ func (c *Client) GetDomainByName(ctx context.Context, domainName string) (*Domai
}
domain := &Domain{}
+
statusCode, err := c.do(req, domain)
if err != nil {
if statusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1 {
// Look up for the next subdomain
- subIndex := strings.Index(domainName, ".")
- return c.GetDomainByName(ctx, domainName[subIndex+1:])
+ _, after, _ := strings.Cut(domainName, ".")
+ return c.GetDomainByName(ctx, after)
}
return nil, err
@@ -74,6 +71,7 @@ func (c *Client) AddRecord(ctx context.Context, domainID int, body Record) (*Rec
}
record := &Record{}
+
_, err = c.do(req, record)
if err != nil {
return nil, err
@@ -90,6 +88,7 @@ func (c *Client) ListRecords(ctx context.Context, domainID int) ([]Record, error
}
var records []Record
+
_, err = c.do(req, &records)
if err != nil {
return nil, err
@@ -108,6 +107,7 @@ func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error
}
_, err = c.do(req, nil)
+
return err
}
@@ -170,6 +170,7 @@ 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)
diff --git a/providers/dns/internal/selectel/internal/client_test.go b/providers/dns/internal/selectel/internal/client_test.go
new file mode 100644
index 000000000..edabe0130
--- /dev/null
+++ b/providers/dns/internal/selectel/internal/client_test.go
@@ -0,0 +1,117 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupClient(server *httptest.Server) (*Client, error) {
+ client := NewClient("token")
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func TestClient_ListRecords(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders()).
+ Route("GET /123/records/", servermock.ResponseFromFixture("list_records.json")).
+ Build(t)
+
+ records, err := client.ListRecords(t.Context(), 123)
+ require.NoError(t, err)
+
+ expected := []Record{
+ {ID: 123, Name: "example.com", Type: "TXT", TTL: 60, Email: "email@example.com", Content: "txttxttxtA"},
+ {ID: 1234, Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxtB"},
+ {ID: 12345, Name: "example.net", Type: "TXT", TTL: 60, Email: "email@example.net", Content: "txttxttxtC"},
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_ListRecords_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ With(tokenHeader, "token")).
+ Route("GET /123/records/",
+ servermock.ResponseFromFixture("error.json").WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ records, err := client.ListRecords(t.Context(), 123)
+
+ 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)
+}
+
+func TestClient_GetDomainByName(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ With(tokenHeader, "token")).
+ Route("GET /sub.sub.example.org",
+ servermock.Noop().WithStatusCode(http.StatusNotFound)).
+ Route("GET /sub.example.org",
+ servermock.Noop().WithStatusCode(http.StatusNotFound)).
+ Route("GET /example.org",
+ servermock.ResponseFromFixture("domains.json")).
+ Build(t)
+
+ domain, err := client.GetDomainByName(t.Context(), "sub.sub.example.org")
+ require.NoError(t, err)
+
+ expected := &Domain{
+ ID: 123,
+ Name: "example.org",
+ }
+
+ assert.Equal(t, expected, domain)
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ With(tokenHeader, "token")).
+ Route("POST /123/records/",
+ servermock.ResponseFromFixture("add_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")).
+ Build(t)
+
+ record, err := client.AddRecord(t.Context(), 123, Record{
+ Name: "example.org",
+ Type: "TXT",
+ TTL: 60,
+ Email: "email@example.org",
+ Content: "txttxttxttxt",
+ })
+
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: 456,
+ Name: "example.org",
+ Type: "TXT",
+ TTL: 60,
+ Email: "email@example.org",
+ Content: "txttxttxttxt",
+ }
+
+ assert.Equal(t, expected, record)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ With(tokenHeader, "token")).
+ Route("DELETE /123/records/456", nil).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), 123, 456)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/internal/selectel/internal/fixtures/add_record-request.json b/providers/dns/internal/selectel/internal/fixtures/add_record-request.json
new file mode 100644
index 000000000..c65d3d267
--- /dev/null
+++ b/providers/dns/internal/selectel/internal/fixtures/add_record-request.json
@@ -0,0 +1,7 @@
+{
+ "name": "example.org",
+ "type": "TXT",
+ "ttl": 60,
+ "email": "email@example.org",
+ "content": "txttxttxttxt"
+}
diff --git a/providers/dns/internal/selectel/internal/fixtures/add_record.json b/providers/dns/internal/selectel/internal/fixtures/add_record.json
new file mode 100644
index 000000000..18a436707
--- /dev/null
+++ b/providers/dns/internal/selectel/internal/fixtures/add_record.json
@@ -0,0 +1,8 @@
+{
+ "id": 456,
+ "name": "example.org",
+ "type": "TXT",
+ "ttl": 60,
+ "email": "email@example.org",
+ "content": "txttxttxttxt"
+}
diff --git a/providers/dns/internal/selectel/fixtures/domains.json b/providers/dns/internal/selectel/internal/fixtures/domains.json
similarity index 100%
rename from providers/dns/internal/selectel/fixtures/domains.json
rename to providers/dns/internal/selectel/internal/fixtures/domains.json
diff --git a/providers/dns/internal/selectel/fixtures/error.json b/providers/dns/internal/selectel/internal/fixtures/error.json
similarity index 100%
rename from providers/dns/internal/selectel/fixtures/error.json
rename to providers/dns/internal/selectel/internal/fixtures/error.json
diff --git a/providers/dns/internal/selectel/fixtures/list_records.json b/providers/dns/internal/selectel/internal/fixtures/list_records.json
similarity index 100%
rename from providers/dns/internal/selectel/fixtures/list_records.json
rename to providers/dns/internal/selectel/internal/fixtures/list_records.json
diff --git a/providers/dns/internal/selectel/types.go b/providers/dns/internal/selectel/internal/types.go
similarity index 98%
rename from providers/dns/internal/selectel/types.go
rename to providers/dns/internal/selectel/internal/types.go
index df7bb3fa7..e6ca792c0 100644
--- a/providers/dns/internal/selectel/types.go
+++ b/providers/dns/internal/selectel/internal/types.go
@@ -1,4 +1,4 @@
-package selectel
+package internal
import "fmt"
diff --git a/providers/dns/internal/selectel/provider.go b/providers/dns/internal/selectel/provider.go
new file mode 100644
index 000000000..495735736
--- /dev/null
+++ b/providers/dns/internal/selectel/provider.go
@@ -0,0 +1,137 @@
+// Package selectel implements a DNS provider for solving the DNS-01 challenge using Selectel Domains API.
+package selectel
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/selectel/internal"
+)
+
+const MinTTL = 60
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Token string
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+
+ // TODO(ldez): remove in v5?
+ BaseURL string
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for selectel.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ if config.Token == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ if config.TTL < MinTTL {
+ return nil, fmt.Errorf("invalid TTL, TTL (%d) must be greater than %d", config.TTL, MinTTL)
+ }
+
+ client := internal.NewClient(config.Token)
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ var err error
+
+ client.BaseURL, err = url.Parse(config.BaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("%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 DNS-01 challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ ctx := context.Background()
+
+ // TODO(ldez) replace domain by FQDN to follow CNAME.
+ domainObj, err := d.client.GetDomainByName(ctx, domain)
+ if err != nil {
+ return fmt.Errorf("get domain by name: %w", err)
+ }
+
+ txtRecord := internal.Record{
+ Type: "TXT",
+ TTL: d.config.TTL,
+ Name: info.EffectiveFQDN,
+ Content: info.Value,
+ }
+
+ _, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord)
+ if err != nil {
+ return fmt.Errorf("add record: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes a TXT record used for DNS-01 challenge.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ recordName := dns01.UnFqdn(info.EffectiveFQDN)
+
+ ctx := context.Background()
+
+ // TODO(ldez) replace domain by FQDN to follow CNAME.
+ domainObj, err := d.client.GetDomainByName(ctx, domain)
+ if err != nil {
+ return fmt.Errorf("%w", err)
+ }
+
+ records, err := d.client.ListRecords(ctx, domainObj.ID)
+ if err != nil {
+ return fmt.Errorf("list records: %w", err)
+ }
+
+ // Delete records with specific FQDN
+ var lastErr error
+
+ for _, record := range records {
+ if record.Name == recordName {
+ err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID)
+ if err != nil {
+ lastErr = fmt.Errorf("delete record: %w", err)
+ }
+ }
+ }
+
+ return lastErr
+}
diff --git a/providers/dns/internal/selectel/provider_test.go b/providers/dns/internal/selectel/provider_test.go
new file mode 100644
index 000000000..75a032bf4
--- /dev/null
+++ b/providers/dns/internal/selectel/provider_test.go
@@ -0,0 +1,55 @@
+package selectel
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ token string
+ ttl int
+ expected string
+ }{
+ {
+ desc: "success",
+ token: "123",
+ ttl: 60,
+ },
+ {
+ desc: "missing api key",
+ token: "",
+ ttl: 60,
+ expected: "credentials missing",
+ },
+ {
+ desc: "bad TTL value",
+ token: "123",
+ ttl: 59,
+ expected: fmt.Sprintf("invalid TTL, TTL (59) must be greater than %d", MinTTL),
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ config.TTL = test.ttl
+ config.Token = test.token
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ assert.NotNil(t, p.config)
+ assert.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
diff --git a/providers/dns/internal/tecnocratica/internal/client.go b/providers/dns/internal/tecnocratica/internal/client.go
new file mode 100644
index 000000000..5a529fa2f
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/internal/client.go
@@ -0,0 +1,182 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+// defaultBaseURL is the default API endpoint.
+const defaultBaseURL = "https://api.neodigit.net/v1"
+
+// Client is a Tecnocrática API client.
+type Client struct {
+ token string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(token string) (*Client, error) {
+ if token == "" {
+ return nil, errors.New("credentials missing: token")
+ }
+
+ baseURL, err := url.Parse(defaultBaseURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ token: token,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 30 * time.Second},
+ }, nil
+}
+
+// GetZones lists all DNS zones.
+func (c *Client) GetZones(ctx context.Context) ([]Zone, error) {
+ endpoint := c.BaseURL.JoinPath("dns", "zones")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var zones []Zone
+
+ err = c.do(req, &zones)
+ if err != nil {
+ return nil, err
+ }
+
+ return zones, nil
+}
+
+// GetRecords lists all records in a zone.
+func (c *Client) GetRecords(ctx context.Context, zoneID int, recordType string) ([]Record, error) {
+ endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records")
+
+ if recordType != "" {
+ query := endpoint.Query()
+ query.Set("type", recordType)
+ endpoint.RawQuery = query.Encode()
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var records []Record
+
+ err = c.do(req, &records)
+ if err != nil {
+ return nil, err
+ }
+
+ return records, nil
+}
+
+// CreateRecord creates a new DNS record.
+func (c *Client) CreateRecord(ctx context.Context, zoneID int, record Record) (*Record, error) {
+ endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records")
+
+ payload := RecordRequest{Record: record}
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ var result Record
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return &result, nil
+}
+
+// DeleteRecord deletes a DNS record.
+func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID int) error {
+ endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records", strconv.Itoa(recordID))
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set("X-TCpanel-Token", 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 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ 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
+}
diff --git a/providers/dns/internal/tecnocratica/internal/client_test.go b/providers/dns/internal/tecnocratica/internal/client_test.go
new file mode 100644
index 000000000..4e9cf3e85
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/internal/client_test.go
@@ -0,0 +1,174 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With("X-TCpanel-Token", "secret"))
+}
+
+func TestClient_GetZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/zones",
+ servermock.ResponseFromFixture("get_zones.json")).
+ Build(t)
+
+ zones, err := client.GetZones(t.Context())
+ require.NoError(t, err)
+
+ expected := []Zone{
+ {
+ ID: 6,
+ Name: "example.com",
+ HumanName: "example.com",
+ },
+ {
+ ID: 7,
+ Name: "example.org",
+ HumanName: "example.org",
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_GetZones_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/zones",
+ servermock.RawStringResponse(`{"error": "unauthorized"}`).
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ zones, err := client.GetZones(t.Context())
+ require.Error(t, err)
+
+ assert.Nil(t, zones)
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/zones/6/records",
+ servermock.ResponseFromFixture("get_records.json")).
+ Build(t)
+
+ records, err := client.GetRecords(t.Context(), 6, "")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {
+ ID: 98,
+ Name: "",
+ Type: "SOA",
+ Content: "ns1.example.org dns.example.org 2015092102 7200 7200 1209600 1800",
+ TTL: 7200,
+ },
+ {
+ ID: 99,
+ Name: "",
+ Type: "NS",
+ Content: "ns1.example.org",
+ TTL: 7200,
+ },
+ {
+ ID: 100,
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ },
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/zones/6/records",
+ servermock.ResponseFromFixture("create_record.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ }
+
+ result, err := client.CreateRecord(t.Context(), 6, record)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: 101,
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_CreateRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/zones/6/records",
+ servermock.RawStringResponse(`{"error": "bad request"}`).
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "test-value",
+ TTL: 120,
+ }
+
+ result, err := client.CreateRecord(t.Context(), 6, record)
+ require.Error(t, err)
+
+ assert.Nil(t, result)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/zones/6/records/101",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), 6, 101)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/zones/6/records/999",
+ servermock.RawStringResponse(`{"error": "not found"}`).
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), 6, 999)
+ require.Error(t, err)
+}
diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/create_record-request.json b/providers/dns/internal/tecnocratica/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..4cd339c98
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/internal/fixtures/create_record-request.json
@@ -0,0 +1,8 @@
+{
+ "record": {
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120
+ }
+}
diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/create_record.json b/providers/dns/internal/tecnocratica/internal/fixtures/create_record.json
new file mode 100644
index 000000000..6f30010ac
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/internal/fixtures/create_record.json
@@ -0,0 +1,10 @@
+{
+ "id": 101,
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "prio": null,
+ "created_at": "2015-09-21T14:40:27.127+02:00",
+ "updated_at": "2015-09-21T14:40:27.127+02:00"
+}
diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/get_records.json b/providers/dns/internal/tecnocratica/internal/fixtures/get_records.json
new file mode 100644
index 000000000..00e09c37f
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/internal/fixtures/get_records.json
@@ -0,0 +1,26 @@
+[
+ {
+ "id": 98,
+ "name": "",
+ "type": "SOA",
+ "content": "ns1.example.org dns.example.org 2015092102 7200 7200 1209600 1800",
+ "ttl": 7200,
+ "prio": null
+ },
+ {
+ "id": 99,
+ "name": "",
+ "type": "NS",
+ "content": "ns1.example.org",
+ "ttl": 7200,
+ "prio": null
+ },
+ {
+ "id": 100,
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120,
+ "prio": null
+ }
+]
diff --git a/providers/dns/internal/tecnocratica/internal/fixtures/get_zones.json b/providers/dns/internal/tecnocratica/internal/fixtures/get_zones.json
new file mode 100644
index 000000000..01a08dced
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/internal/fixtures/get_zones.json
@@ -0,0 +1,16 @@
+[
+ {
+ "id": 6,
+ "name": "example.com",
+ "created_at": "2015-09-21T12:19:04.000+02:00",
+ "updated_at": "2015-09-21T12:19:04.000+02:00",
+ "human_name": "example.com"
+ },
+ {
+ "id": 7,
+ "name": "example.org",
+ "created_at": "2015-09-22T10:00:00.000+02:00",
+ "updated_at": "2015-09-22T10:00:00.000+02:00",
+ "human_name": "example.org"
+ }
+]
diff --git a/providers/dns/internal/tecnocratica/internal/types.go b/providers/dns/internal/tecnocratica/internal/types.go
new file mode 100644
index 000000000..505bfbced
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/internal/types.go
@@ -0,0 +1,23 @@
+package internal
+
+// Zone represents a DNS zone.
+type Zone struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ HumanName string `json:"human_name"`
+}
+
+// Record represents a DNS record.
+type Record struct {
+ ID int `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Priority int `json:"prio,omitempty"`
+}
+
+// RecordRequest is the request body for creating/updating a record.
+type RecordRequest struct {
+ Record Record `json:"record"`
+}
diff --git a/providers/dns/internal/tecnocratica/provider.go b/providers/dns/internal/tecnocratica/provider.go
new file mode 100644
index 000000000..17cfb8379
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/provider.go
@@ -0,0 +1,165 @@
+// Package tecnocratica implements a DNS provider for solving the DNS-01 challenge using Tecnocrática.
+package tecnocratica
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica/internal"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Token string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ zoneIDs map[string]int
+ recordIDs map[string]int
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Tecnocrática.
+func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ if config.Token == "" {
+ return nil, errors.New("missing credentials")
+ }
+
+ client, err := internal.NewClient(config.Token)
+ if err != nil {
+ return nil, fmt.Errorf("create client: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ if baseURL != "" {
+ client.BaseURL, err = url.Parse(baseURL)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ zoneIDs: make(map[string]int),
+ recordIDs: make(map[string]int),
+ }, nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("could not find zone for domain %q: %w", domain, err)
+ }
+
+ authZone = dns01.UnFqdn(authZone)
+
+ zone, err := d.findZone(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("%w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("%w", err)
+ }
+
+ record := internal.Record{
+ Name: subDomain,
+ Type: "TXT",
+ Content: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ newRecord, err := d.client.CreateRecord(ctx, zone.ID, record)
+ if err != nil {
+ return fmt.Errorf("create record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.zoneIDs[token] = zone.ID
+ 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)
+
+ d.recordIDsMu.Lock()
+ zoneID, zoneOK := d.zoneIDs[token]
+ recordID, recordOK := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !zoneOK || !recordOK {
+ return fmt.Errorf("unknown record ID or zone ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err := d.client.DeleteRecord(context.Background(), zoneID, recordID)
+ if err != nil {
+ return fmt.Errorf("delete record: fqdn=%s, zoneID=%d, recordID=%d: %w",
+ info.EffectiveFQDN, zoneID, recordID, err)
+ }
+
+ d.recordIDsMu.Lock()
+ delete(d.zoneIDs, token)
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
+ return nil
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, zoneName string) (*internal.Zone, error) {
+ zones, err := d.client.GetZones(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get zones: %w", err)
+ }
+
+ for _, zone := range zones {
+ if zone.Name == zoneName || zone.HumanName == zoneName {
+ return &zone, nil
+ }
+ }
+
+ return nil, fmt.Errorf("zone not found: %s", zoneName)
+}
diff --git a/providers/dns/internal/tecnocratica/provider_test.go b/providers/dns/internal/tecnocratica/provider_test.go
new file mode 100644
index 000000000..33e5f7c67
--- /dev/null
+++ b/providers/dns/internal/tecnocratica/provider_test.go
@@ -0,0 +1,99 @@
+package tecnocratica
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ token string
+ expected string
+ }{
+ {
+ desc: "success",
+ token: "secret",
+ },
+ {
+ desc: "missing token",
+ expected: "missing credentials",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ 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 mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := &Config{
+ Token: "secret",
+ PropagationTimeout: 10 * time.Second,
+ PollingInterval: 1 * time.Second,
+ TTL: 120,
+ HTTPClient: server.Client(),
+ }
+
+ p, err := NewDNSProviderConfig(config, server.URL)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With("X-TCpanel-Token", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/zones",
+ servermock.ResponseFromInternal("get_zones.json")).
+ Route("POST /dns/zones/6/records",
+ servermock.ResponseFromInternal("create_record.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /dns/zones/456/records/123",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
+
+ token := "abc"
+
+ provider.recordIDs[token] = 123
+ provider.zoneIDs[token] = 456
+
+ err := provider.CleanUp("example.com", token, "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/internal/useragent/useragent.go b/providers/dns/internal/useragent/useragent.go
index faceb267a..090c9109a 100644
--- a/providers/dns/internal/useragent/useragent.go
+++ b/providers/dns/internal/useragent/useragent.go
@@ -10,12 +10,12 @@ import (
const (
// ourUserAgent is the User-Agent of this underlying library package.
- ourUserAgent = "goacme-lego/4.20.3"
+ ourUserAgent = "goacme-lego/4.32.0"
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release
// NOTE: Update this with each tagged release.
- ourUserAgentComment = "release"
+ ourUserAgentComment = "detach"
)
// Get builds and returns the User-Agent string.
diff --git a/providers/dns/internal/westcn/internal/client.go b/providers/dns/internal/westcn/internal/client.go
new file mode 100644
index 000000000..621c7865f
--- /dev/null
+++ b/providers/dns/internal/westcn/internal/client.go
@@ -0,0 +1,211 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ querystring "github.com/google/go-querystring/query"
+ "golang.org/x/text/encoding"
+ "golang.org/x/text/encoding/simplifiedchinese"
+ "golang.org/x/text/transform"
+)
+
+const defaultBaseURL = "https://api.west.cn/api/v2"
+
+// Client the West.cn API client.
+type Client struct {
+ username string
+ password string
+
+ encoder *encoding.Encoder
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(username, password string) (*Client, error) {
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ username: username,
+ password: password,
+ encoder: simplifiedchinese.GBK.NewEncoder(),
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// AddRecord adds a record.
+// https://www.west.cn/CustomerCenter/doc/domain_v2.html#37u3001u6dfbu52a0u57dfu540du89e3u67900a3ca20id3d37u3001u6dfbu52a0u57dfu540du89e3u67903e203ca3e
+func (c *Client) AddRecord(ctx context.Context, record Record) (int, error) {
+ values, err := querystring.Values(record)
+ if err != nil {
+ return 0, err
+ }
+
+ req, err := c.newRequest(ctx, "domain", "adddnsrecord", values)
+ if err != nil {
+ return 0, err
+ }
+
+ results := &APIResponse[RecordID]{}
+
+ err = c.do(req, results)
+ if err != nil {
+ return 0, err
+ }
+
+ if results.Result != http.StatusOK {
+ return 0, results
+ }
+
+ return results.Data.ID, nil
+}
+
+// DeleteRecord deleted a record.
+// https://www.west.cn/CustomerCenter/doc/domain_v2.html#39u3001u5220u9664u57dfu540du89e3u67900a3ca20id3d39u3001u5220u9664u57dfu540du89e3u67903e203ca3e
+func (c *Client) DeleteRecord(ctx context.Context, domain string, recordID int) error {
+ values := url.Values{}
+ values.Set("domain", domain)
+ values.Set("id", strconv.Itoa(recordID))
+
+ req, err := c.newRequest(ctx, "domain", "deldnsrecord", values)
+ if err != nil {
+ return err
+ }
+
+ results := &APIResponse[any]{}
+
+ err = c.do(req, results)
+ if err != nil {
+ return err
+ }
+
+ if results.Result != http.StatusOK {
+ return results
+ }
+
+ return nil
+}
+
+func (c *Client) newRequest(ctx context.Context, p, act string, form url.Values) (*http.Request, error) {
+ if form == nil {
+ form = url.Values{}
+ }
+
+ c.sign(form, time.Now())
+
+ values, err := c.convertURLValues(form)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint := c.BaseURL.JoinPath(p, "/")
+
+ query := endpoint.Query()
+ query.Set("act", act)
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(values.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ return req, nil
+}
+
+func (c *Client) sign(form url.Values, now time.Time) {
+ timestamp := strconv.FormatInt(now.UnixMilli(), 10)
+
+ sum := md5.Sum([]byte(c.username + c.password + timestamp))
+
+ form.Set("token", hex.EncodeToString(sum[:]))
+ form.Set("username", c.username)
+ form.Set("time", timestamp)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return err
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ 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 = gbkDecoder(raw).Decode(result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func (c *Client) convertURLValues(values url.Values) (url.Values, error) {
+ results := make(url.Values)
+
+ for key, vs := range values {
+ encKey, err := c.encoder.String(key)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, value := range vs {
+ encValue, err := c.encoder.String(value)
+ if err != nil {
+ return nil, err
+ }
+
+ results.Add(encKey, encValue)
+ }
+ }
+
+ return results, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ result := &APIResponse[any]{}
+
+ err := gbkDecoder(raw).Decode(result)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return result
+}
+
+func gbkDecoder(raw []byte) *json.Decoder {
+ return json.NewDecoder(transform.NewReader(bytes.NewBuffer(raw), simplifiedchinese.GBK.NewDecoder()))
+}
diff --git a/providers/dns/internal/westcn/internal/client_test.go b/providers/dns/internal/westcn/internal/client_test.go
new file mode 100644
index 000000000..53fd6ed8f
--- /dev/null
+++ b/providers/dns/internal/westcn/internal/client_test.go
@@ -0,0 +1,167 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/encoding/simplifiedchinese"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
+}
+
+func TestClientAddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domain/",
+ servermock.ResponseFromFixture("adddnsrecord.json").
+ WithHeader("Content-Type", "application/json", "Charset=gb2312"),
+ servermock.CheckQueryParameter().Strict().
+ With("act", "adddnsrecord"),
+ servermock.CheckForm().UsePostForm().Strict().
+ With("domain", "example.com").
+ With("host", "@").
+ With("ttl", "60").
+ With("type", "TXT").
+ With("value", "txtTXTtxt").
+ // With("act", "adddnsrecord").
+ With("username", "user").
+ WithRegexp("time", `\d+`).
+ WithRegexp("token", `[a-z0-9]{32}`),
+ ).
+ Build(t)
+
+ record := Record{
+ Domain: "example.com",
+ Host: "@",
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ TTL: 60,
+ }
+
+ id, err := client.AddRecord(t.Context(), record)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123456, id)
+}
+
+func TestClientAddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domain/",
+ servermock.ResponseFromFixture("error.json").
+ WithHeader("Content-Type", "application/json", "Charset=gb2312"),
+ servermock.CheckQueryParameter().Strict().
+ With("act", "adddnsrecord"),
+ ).
+ Build(t)
+
+ record := Record{
+ Domain: "example.com",
+ Host: "@",
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ TTL: 60,
+ }
+
+ _, err := client.AddRecord(t.Context(), record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "10000: username,time,token必传 (500)")
+}
+
+func TestClientDeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domain/",
+ servermock.ResponseFromFixture("deldnsrecord.json").
+ WithHeader("Content-Type", "application/json", "Charset=gb2312"),
+ servermock.CheckQueryParameter().Strict().
+ With("act", "deldnsrecord"),
+ servermock.CheckForm().UsePostForm().Strict().
+ With("id", "123").
+ With("domain", "example.com").
+ With("username", "user").
+ WithRegexp("time", `\d+`).
+ WithRegexp("token", `[a-z0-9]{32}`),
+ ).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "example.com", 123)
+ require.NoError(t, err)
+}
+
+func TestClientDeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domain/",
+ servermock.ResponseFromFixture("error.json").
+ WithHeader("Content-Type", "application/json", "Charset=gb2312"),
+ servermock.CheckQueryParameter().Strict().
+ With("act", "deldnsrecord"),
+ ).
+ Build(t)
+ err := client.DeleteRecord(t.Context(), "example.com", 123)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "10000: username,time,token必传 (500)")
+}
+
+func Test_convertURLValues(t *testing.T) {
+ client, err := NewClient("user", "secret")
+ require.NoError(t, err)
+
+ key := "你好abc"
+ value := "世界def"
+
+ form := url.Values{}
+ form.Set(key, value)
+
+ values, err := client.convertURLValues(form)
+ require.NoError(t, err)
+
+ encoder := simplifiedchinese.GBK.NewEncoder()
+
+ k, err := encoder.String(key)
+ require.NoError(t, err)
+
+ v, err := encoder.String(value)
+ require.NoError(t, err)
+
+ assert.Equal(t, v, values.Get(k))
+
+ decoder := simplifiedchinese.GBK.NewDecoder()
+
+ decValue, err := decoder.String(values.Get(k))
+ require.NoError(t, err)
+
+ assert.Equal(t, value, decValue)
+}
+
+func TestClient_sign(t *testing.T) {
+ client, err := NewClient("zhangsan", "5dh232kfg!*")
+ require.NoError(t, err)
+
+ form := url.Values{}
+
+ client.sign(form, time.UnixMilli(1554691950854))
+
+ assert.Equal(t, "zhangsan", form.Get("username"))
+ assert.Equal(t, "1554691950854", form.Get("time"))
+ assert.Equal(t, "f17581fb2535b2a7ee4468eb3f96a2a9", form.Get("token"))
+}
diff --git a/providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json b/providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json
new file mode 100644
index 000000000..f1c135206
--- /dev/null
+++ b/providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json
@@ -0,0 +1,7 @@
+{
+ "result": 200,
+ "clientid": "54880064508339547956",
+ "data": {
+ "id": 123456
+ }
+}
diff --git a/providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json b/providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json
new file mode 100644
index 000000000..e97e92f74
--- /dev/null
+++ b/providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json
@@ -0,0 +1,4 @@
+{
+ "result": 200,
+ "clientid": "54880064508339547956"
+}
diff --git a/providers/dns/internal/westcn/internal/fixtures/error.json b/providers/dns/internal/westcn/internal/fixtures/error.json
new file mode 100644
index 000000000..1c92415de
--- /dev/null
+++ b/providers/dns/internal/westcn/internal/fixtures/error.json
@@ -0,0 +1,6 @@
+{
+ "result": 500,
+ "clientid": "54880064508339547956",
+ "msg": "username,time,tokenش",
+ "errcode": 10000
+}
diff --git a/providers/dns/internal/westcn/internal/types.go b/providers/dns/internal/westcn/internal/types.go
new file mode 100644
index 000000000..d8d66be2c
--- /dev/null
+++ b/providers/dns/internal/westcn/internal/types.go
@@ -0,0 +1,28 @@
+package internal
+
+import "fmt"
+
+type APIResponse[T any] struct {
+ Result int `json:"result,omitempty"`
+ ClientID string `json:"clientid,omitempty"`
+ Message string `json:"msg,omitempty"`
+ ErrorCode int `json:"errcode,omitempty"`
+ Data T `json:"data,omitempty"`
+}
+
+func (a APIResponse[T]) Error() string {
+ return fmt.Sprintf("%d: %s (%d)", a.ErrorCode, a.Message, a.Result)
+}
+
+type Record struct {
+ Domain string `url:"domain,omitempty"`
+ Host string `url:"host,omitempty"`
+ Type string `url:"type,omitempty"`
+ Value string `url:"value,omitempty"`
+ TTL int `url:"ttl,omitempty"` // 60~86400 seconds
+ Priority int `url:"level,omitempty"`
+}
+
+type RecordID struct {
+ ID int `json:"id,omitempty"`
+}
diff --git a/providers/dns/internal/westcn/provider.go b/providers/dns/internal/westcn/provider.go
new file mode 100644
index 000000000..a9e6dad58
--- /dev/null
+++ b/providers/dns/internal/westcn/provider.go
@@ -0,0 +1,140 @@
+package westcn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/westcn/internal"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ Username string
+ Password string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ recordIDs map[string]int
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for West.cn/西部数码.
+func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("%w", err)
+ }
+
+ if baseURL != "" {
+ client.BaseURL, err = url.Parse(baseURL)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int),
+ }, 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("could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("%w", err)
+ }
+
+ record := internal.Record{
+ Domain: dns01.UnFqdn(authZone),
+ Host: subDomain,
+ Type: "TXT",
+ Value: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ recordID, err := d.client.AddRecord(context.Background(), record)
+ if err != nil {
+ return fmt.Errorf("add record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = recordID
+ 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(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("could not find zone for domain %q: %w", domain, err)
+ }
+
+ // gets the record's unique ID
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
+ if err != nil {
+ return fmt.Errorf("delete record: %w", err)
+ }
+
+ // deletes record ID from map
+ 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/dns/internal/westcn/provider_test.go b/providers/dns/internal/westcn/provider_test.go
new file mode 100644
index 000000000..2ae0f09cb
--- /dev/null
+++ b/providers/dns/internal/westcn/provider_test.go
@@ -0,0 +1,127 @@
+package westcn
+
+import (
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing username",
+ password: "secret",
+ expected: "credentials missing",
+ },
+ {
+ desc: "missing password",
+ username: "user",
+ expected: "credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := &Config{}
+ config.Username = test.username
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config, "")
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := &Config{
+ Username: "user",
+ Password: "secret",
+ PropagationTimeout: 10 * time.Second,
+ PollingInterval: 1 * time.Second,
+ TTL: 120,
+ HTTPClient: server.Client(),
+ }
+
+ p, err := NewDNSProviderConfig(config, server.URL)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /domain/",
+ servermock.ResponseFromInternal("adddnsrecord.json").
+ WithHeader("Content-Type", "application/json", "Charset=gb2312"),
+ servermock.CheckQueryParameter().Strict().
+ With("act", "adddnsrecord"),
+ servermock.CheckForm().UsePostForm().Strict().
+ With("domain", "example.com").
+ With("host", "_acme-challenge").
+ With("ttl", "120").
+ With("type", "TXT").
+ With("value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ // With("act", "adddnsrecord").
+ With("username", "user").
+ WithRegexp("time", `\d+`).
+ WithRegexp("token", `[a-z0-9]{32}`),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /domain/",
+ servermock.ResponseFromInternal("deldnsrecord.json").
+ WithHeader("Content-Type", "application/json", "Charset=gb2312"),
+ servermock.CheckQueryParameter().Strict().
+ With("act", "deldnsrecord"),
+ servermock.CheckForm().UsePostForm().Strict().
+ With("id", "123").
+ With("domain", "example.com").
+ With("username", "user").
+ WithRegexp("time", `\d+`).
+ WithRegexp("token", `[a-z0-9]{32}`),
+ ).
+ Build(t)
+
+ provider.recordIDs["abc"] = 123
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/internetbs/internal/client.go b/providers/dns/internetbs/internal/client.go
index 771408c5d..cf9e90dc5 100644
--- a/providers/dns/internetbs/internal/client.go
+++ b/providers/dns/internetbs/internal/client.go
@@ -34,7 +34,7 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(apiKey string, password string) *Client {
+func NewClient(apiKey, password string) *Client {
baseURL, _ := url.Parse(baseURL)
return &Client{
@@ -46,8 +46,9 @@ func NewClient(apiKey string, password string) *Client {
}
// AddRecord The command is intended to add a new DNS record to a specific zone (domain).
-func (c Client) AddRecord(ctx context.Context, query RecordQuery) error {
+func (c *Client) AddRecord(ctx context.Context, query RecordQuery) error {
var r APIResponse
+
err := c.doRequest(ctx, "Add", query, &r)
if err != nil {
return err
@@ -61,8 +62,9 @@ func (c Client) AddRecord(ctx context.Context, query RecordQuery) error {
}
// RemoveRecord The command is intended to remove a DNS record from a specific zone.
-func (c Client) RemoveRecord(ctx context.Context, query RecordQuery) error {
+func (c *Client) RemoveRecord(ctx context.Context, query RecordQuery) error {
var r APIResponse
+
err := c.doRequest(ctx, "Remove", query, &r)
if err != nil {
return err
@@ -76,8 +78,9 @@ func (c Client) RemoveRecord(ctx context.Context, query RecordQuery) error {
}
// ListRecords The command is intended to retrieve the list of DNS records for a specific domain.
-func (c Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Record, error) {
+func (c *Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Record, error) {
var l ListResponse
+
err := c.doRequest(ctx, "List", query, &l)
if err != nil {
return nil, err
@@ -90,7 +93,7 @@ func (c Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Recor
return l.Records, nil
}
-func (c Client) doRequest(ctx context.Context, action string, params any, result any) error {
+func (c *Client) doRequest(ctx context.Context, action string, params, result any) error {
endpoint := c.baseURL.JoinPath("Domain", "DnsRecord", action)
values, err := querystring.Values(params)
diff --git a/providers/dns/internetbs/internal/client_test.go b/providers/dns/internetbs/internal/client_test.go
index a22f1b121..4532426d5 100644
--- a/providers/dns/internetbs/internal/client_test.go
+++ b/providers/dns/internetbs/internal/client_test.go
@@ -1,16 +1,14 @@
package internal
import (
- "context"
"fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -22,8 +20,33 @@ const (
testPassword = "testpass"
)
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(testAPIKey, testPassword)
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ )
+}
+
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_SUCCESS.json")
+ client := mockBuilder().
+ Route("POST /Domain/DnsRecord/Add",
+ servermock.ResponseFromFixture("Domain_DnsRecord_Add_SUCCESS.json"),
+ servermock.CheckForm().Strict().
+ With("fullrecordname", "www.example.com").
+ With("ttl", "36000").
+ With("type", "TXT").
+ With("value", "xxx").
+ With("password", testPassword).
+ With("apiKey", testAPIKey).
+ With("ResponseFormat", "JSON")).
+ Build(t)
query := RecordQuery{
FullRecordName: "www.example.com",
@@ -32,12 +55,15 @@ func TestClient_AddRecord(t *testing.T) {
TTL: 36000,
}
- err := client.AddRecord(context.Background(), query)
+ err := client.AddRecord(t.Context(), query)
require.NoError(t, err)
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_FAILURE.json")
+ client := mockBuilder().
+ Route("POST /Domain/DnsRecord/Add",
+ servermock.ResponseFromFixture("Domain_DnsRecord_Add_FAILURE.json")).
+ Build(t)
query := RecordQuery{
FullRecordName: "www.example.com.",
@@ -46,7 +72,7 @@ func TestClient_AddRecord_error(t *testing.T) {
TTL: 36000,
}
- err := client.AddRecord(context.Background(), query)
+ err := client.AddRecord(t.Context(), query)
require.Error(t, err)
}
@@ -67,7 +93,7 @@ func TestClient_AddRecord_integration(t *testing.T) {
TTL: 36000,
}
- err := client.AddRecord(context.Background(), query)
+ err := client.AddRecord(t.Context(), query)
require.NoError(t, err)
query = RecordQuery{
@@ -77,31 +103,43 @@ func TestClient_AddRecord_integration(t *testing.T) {
TTL: 36000,
}
- err = client.AddRecord(context.Background(), query)
+ err = client.AddRecord(t.Context(), query)
require.NoError(t, err)
}
func TestClient_RemoveRecord(t *testing.T) {
- client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_SUCCESS.json")
+ client := mockBuilder().
+ Route("POST /Domain/DnsRecord/Remove",
+ servermock.ResponseFromFixture("Domain_DnsRecord_Remove_SUCCESS.json"),
+ servermock.CheckForm().Strict().
+ With("fullrecordname", "www.example.com").
+ With("type", "TXT").
+ With("password", testPassword).
+ With("apiKey", testAPIKey).
+ With("ResponseFormat", "JSON")).
+ Build(t)
query := RecordQuery{
FullRecordName: "www.example.com",
Type: "TXT",
Value: "",
}
- err := client.RemoveRecord(context.Background(), query)
+ err := client.RemoveRecord(t.Context(), query)
require.NoError(t, err)
}
func TestClient_RemoveRecord_error(t *testing.T) {
- client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_FAILURE.json")
+ client := mockBuilder().
+ Route("POST /Domain/DnsRecord/Remove",
+ servermock.ResponseFromFixture("Domain_DnsRecord_Remove_FAILURE.json")).
+ Build(t)
query := RecordQuery{
FullRecordName: "www.example.com.",
Type: "TXT",
Value: "",
}
- err := client.RemoveRecord(context.Background(), query)
+ err := client.RemoveRecord(t.Context(), query)
require.Error(t, err)
}
@@ -121,18 +159,26 @@ func TestClient_RemoveRecord_integration(t *testing.T) {
Value: "",
}
- err := client.RemoveRecord(context.Background(), query)
+ err := client.RemoveRecord(t.Context(), query)
require.NoError(t, err)
}
func TestClient_ListRecords(t *testing.T) {
- client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_SUCCESS.json")
+ client := mockBuilder().
+ Route("POST /Domain/DnsRecord/List",
+ servermock.ResponseFromFixture("Domain_DnsRecord_List_SUCCESS.json"),
+ servermock.CheckForm().Strict().
+ With("Domain", "example.com").
+ With("password", testPassword).
+ With("apiKey", testAPIKey).
+ With("ResponseFormat", "JSON")).
+ Build(t)
query := ListRecordQuery{
Domain: "example.com",
}
- records, err := client.ListRecords(context.Background(), query)
+ records, err := client.ListRecords(t.Context(), query)
require.NoError(t, err)
expected := []Record{
@@ -178,13 +224,16 @@ func TestClient_ListRecords(t *testing.T) {
}
func TestClient_ListRecords_error(t *testing.T) {
- client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_FAILURE.json")
+ client := mockBuilder().
+ Route("POST /Domain/DnsRecord/List",
+ servermock.ResponseFromFixture("Domain_DnsRecord_List_FAILURE.json")).
+ Build(t)
query := ListRecordQuery{
Domain: "www.example.com",
}
- _, err := client.ListRecords(context.Background(), query)
+ _, err := client.ListRecords(t.Context(), query)
require.Error(t, err)
}
@@ -202,58 +251,10 @@ func TestClient_ListRecords_integration(t *testing.T) {
Domain: "example.com",
}
- records, err := client.ListRecords(context.Background(), query)
+ records, err := client.ListRecords(t.Context(), query)
require.NoError(t, err)
for _, record := range records {
fmt.Println(record)
}
}
-
-func setupTest(t *testing.T, path, filename string) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc(path, testHandler(filename))
-
- client := NewClient(testAPIKey, testPassword)
- client.baseURL, _ = url.Parse(server.URL)
-
- return client
-}
-
-func testHandler(filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- if req.FormValue("apiKey") != testAPIKey {
- http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK)
- return
- }
-
- if req.FormValue("password") != testPassword {
- http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK)
- return
- }
-
- file, err := os.Open(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
- }
- }
-}
diff --git a/providers/dns/internetbs/internal/fixtures/auth_error.json b/providers/dns/internetbs/internal/fixtures/auth_error.json
new file mode 100644
index 000000000..a40a0ef5e
--- /dev/null
+++ b/providers/dns/internetbs/internal/fixtures/auth_error.json
@@ -0,0 +1,6 @@
+{
+ "transactid": "d46d812569acdb8b39c3933ec4351e79",
+ "status": "FAILURE",
+ "message": "Invalid API key and\/or Password",
+ "code": 107002
+}
diff --git a/providers/dns/internetbs/internetbs.go b/providers/dns/internetbs/internetbs.go
index 9d6c17676..e8cb868d2 100644
--- a/providers/dns/internetbs/internetbs.go
+++ b/providers/dns/internetbs/internetbs.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internetbs/internal"
)
@@ -88,6 +89,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
diff --git a/providers/dns/internetbs/internetbs.toml b/providers/dns/internetbs/internetbs.toml
index 054a1f6e9..f22850253 100644
--- a/providers/dns/internetbs/internetbs.toml
+++ b/providers/dns/internetbs/internetbs.toml
@@ -7,7 +7,7 @@ Since = "v4.5.0"
Example = '''
INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \
INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \
-lego --email you@example.com --dns internetbs -d '*.example.com' -d example.com run
+lego --dns internetbs -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns internetbs -d '*.example.com' -d example.com
INTERNET_BS_API_KEY = "API key"
INTERNET_BS_PASSWORD = "API password"
[Configuration.Additional]
- INTERNET_BS_POLLING_INTERVAL = "Time between DNS propagation check"
- INTERNET_BS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- INTERNET_BS_TTL = "The TTL of the TXT record used for the DNS challenge"
- INTERNET_BS_HTTP_TIMEOUT = "API request timeout"
+ INTERNET_BS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ INTERNET_BS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ INTERNET_BS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ INTERNET_BS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://internetbs.net/internet-bs-api.pdf"
diff --git a/providers/dns/internetbs/internetbs_test.go b/providers/dns/internetbs/internetbs_test.go
index ea328d506..be436d6e7 100644
--- a/providers/dns/internetbs/internetbs_test.go
+++ b/providers/dns/internetbs/internetbs_test.go
@@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -121,6 +122,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -134,6 +136,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/inwx/inwx.go b/providers/dns/inwx/inwx.go
index cefea832e..0e79d71e0 100644
--- a/providers/dns/inwx/inwx.go
+++ b/providers/dns/inwx/inwx.go
@@ -46,7 +46,7 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, 300),
// INWX has rather unstable propagation delays, thus using a larger default value
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 360*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
Sandbox: env.GetOrDefaultBool(EnvSandbox, false),
}
@@ -97,14 +97,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
- challengeInfo := dns01.GetChallengeInfo(domain, keyAuth)
+ info := dns01.GetChallengeInfo(domain, keyAuth)
- authZone, err := dns01.FindZoneByFqdn(challengeInfo.EffectiveFQDN)
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
- return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, challengeInfo.EffectiveFQDN, err)
+ return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
}
- info, err := d.client.Account.Login()
+ login, err := d.client.Account.Login()
if err != nil {
return fmt.Errorf("inwx: %w", err)
}
@@ -116,27 +116,24 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
}
}()
- err = d.twoFactorAuth(info)
+ err = d.twoFactorAuth(login)
if err != nil {
return fmt.Errorf("inwx: %w", err)
}
request := &goinwx.NameserverRecordRequest{
Domain: dns01.UnFqdn(authZone),
- Name: dns01.UnFqdn(challengeInfo.EffectiveFQDN),
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
Type: "TXT",
- Content: challengeInfo.Value,
+ Content: info.Value,
TTL: d.config.TTL,
}
_, err = d.client.Nameservers.CreateRecord(request)
if err != nil {
var er *goinwx.ErrorResponse
- if errors.As(err, &er) {
- if er.Message == "Object exists" {
- return nil
- }
- return fmt.Errorf("inwx: %w", err)
+ if errors.As(err, &er) && er.Message == "Object exists" {
+ return nil
}
return fmt.Errorf("inwx: %w", err)
@@ -147,14 +144,14 @@ 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 {
- challengeInfo := dns01.GetChallengeInfo(domain, keyAuth)
+ info := dns01.GetChallengeInfo(domain, keyAuth)
- authZone, err := dns01.FindZoneByFqdn(challengeInfo.EffectiveFQDN)
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
- return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, challengeInfo.EffectiveFQDN, err)
+ return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
}
- info, err := d.client.Account.Login()
+ login, err := d.client.Account.Login()
if err != nil {
return fmt.Errorf("inwx: %w", err)
}
@@ -166,29 +163,42 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
}()
- err = d.twoFactorAuth(info)
+ err = d.twoFactorAuth(login)
if err != nil {
return fmt.Errorf("inwx: %w", err)
}
response, err := d.client.Nameservers.Info(&goinwx.NameserverInfoRequest{
Domain: dns01.UnFqdn(authZone),
- Name: dns01.UnFqdn(challengeInfo.EffectiveFQDN),
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
Type: "TXT",
})
if err != nil {
return fmt.Errorf("inwx: %w", err)
}
- var lastErr error
+ var recordID string
+
for _, record := range response.Records {
- err = d.client.Nameservers.DeleteRecord(record.ID)
- if err != nil {
- lastErr = fmt.Errorf("inwx: %w", err)
+ if record.Content != info.Value {
+ continue
}
+
+ recordID = record.ID
+
+ break
}
- return lastErr
+ if recordID == "" {
+ return errors.New("inwx: TXT record not found")
+ }
+
+ err = d.client.Nameservers.DeleteRecord(recordID)
+ if err != nil {
+ return fmt.Errorf("inwx: %w", err)
+ }
+
+ return nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
diff --git a/providers/dns/inwx/inwx.toml b/providers/dns/inwx/inwx.toml
index 1186dcf20..da4c6d959 100644
--- a/providers/dns/inwx/inwx.toml
+++ b/providers/dns/inwx/inwx.toml
@@ -7,13 +7,13 @@ Since = "v2.0.0"
Example = '''
INWX_USERNAME=xxxxxxxxxx \
INWX_PASSWORD=yyyyyyyyyy \
-lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run
+lego --dns inwx -d '*.example.com' -d example.com run
# 2FA
INWX_USERNAME=xxxxxxxxxx \
INWX_PASSWORD=yyyyyyyyyy \
INWX_SHARED_SECRET=zzzzzzzzzz \
-lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run
+lego --dns inwx -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -22,9 +22,9 @@ lego --email you@example.com --dns inwx -d '*.example.com' -d example.com run
INWX_PASSWORD = "Password"
[Configuration.Additional]
INWX_SHARED_SECRET = "shared secret related to 2FA"
- INWX_POLLING_INTERVAL = "Time between DNS propagation check"
- INWX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation (default 360s)"
- INWX_TTL = "The TTL of the TXT record used for the DNS challenge"
+ INWX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ INWX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)"
+ INWX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
INWX_SANDBOX = "Activate the sandbox (boolean)"
[Links]
diff --git a/providers/dns/inwx/inwx_test.go b/providers/dns/inwx/inwx_test.go
index 39ce7d70e..47b12e228 100644
--- a/providers/dns/inwx/inwx_test.go
+++ b/providers/dns/inwx/inwx_test.go
@@ -62,6 +62,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -124,6 +125,7 @@ func TestLivePresentAndCleanup(t *testing.T) {
}
envTest.RestoreEnv()
+
envTest.Apply(map[string]string{
EnvSandbox: "true",
EnvTTL: "3600", // In sandbox mode, the minimum allowed TTL is 3600
diff --git a/providers/dns/ionos/internal/client_test.go b/providers/dns/ionos/internal/client_test.go
deleted file mode 100644
index 21a7a2675..000000000
--- a/providers/dns/ionos/internal/client_test.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package internal
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "path"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestClient_ListZones(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusOK, "list_zones.json"))
-
- zones, err := client.ListZones(context.Background())
- require.NoError(t, err)
-
- expected := []Zone{{
- ID: "11af3414-ebba-11e9-8df5-66fbe8a334b4",
- Name: "test.com",
- Type: "NATIVE",
- }}
-
- assert.Equal(t, expected, zones)
-}
-
-func TestClient_ListZones_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusUnauthorized, "list_zones_error.json"))
-
- zones, err := client.ListZones(context.Background())
- require.Error(t, err)
-
- assert.Nil(t, zones)
-
- var cErr *ClientError
- assert.ErrorAs(t, err, &cErr)
- assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode)
-}
-
-func TestClient_GetRecords(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusOK, "get_records.json"))
-
- records, err := client.GetRecords(context.Background(), "azone01", nil)
- require.NoError(t, err)
-
- expected := []Record{{
- ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4",
- Name: "string",
- Content: "string",
- Type: "A",
- }}
-
- assert.Equal(t, expected, records)
-}
-
-func TestClient_GetRecords_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusUnauthorized, "get_records_error.json"))
-
- records, err := client.GetRecords(context.Background(), "azone01", nil)
- require.Error(t, err)
-
- assert.Nil(t, records)
-
- var cErr *ClientError
- assert.ErrorAs(t, err, &cErr)
- assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode)
-}
-
-func TestClient_RemoveRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusOK, ""))
-
- err := client.RemoveRecord(context.Background(), "azone01", "arecord01")
- require.NoError(t, err)
-}
-
-func TestClient_RemoveRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusInternalServerError, "remove_record_error.json"))
-
- err := client.RemoveRecord(context.Background(), "azone01", "arecord01")
- require.Error(t, err)
-
- var cErr *ClientError
- assert.ErrorAs(t, err, &cErr)
- assert.Equal(t, http.StatusInternalServerError, cErr.StatusCode)
-}
-
-func TestClient_ReplaceRecords(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusOK, ""))
-
- records := []Record{{
- ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4",
- Name: "string",
- Content: "string",
- Type: "A",
- }}
-
- err := client.ReplaceRecords(context.Background(), "azone01", records)
- require.NoError(t, err)
-}
-
-func TestClient_ReplaceRecords_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusBadRequest, "replace_records_error.json"))
-
- records := []Record{{
- ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4",
- Name: "string",
- Content: "string",
- Type: "A",
- }}
-
- err := client.ReplaceRecords(context.Background(), "azone01", records)
- require.Error(t, err)
-
- var cErr *ClientError
- assert.ErrorAs(t, err, &cErr)
- assert.Equal(t, http.StatusBadRequest, cErr.StatusCode)
-}
-
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client, err := NewClient("secret")
- require.NoError(t, err)
-
- client.BaseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) {
- 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
- }
-
- if filename == "" {
- rw.WriteHeader(statusCode)
- return
- }
-
- file, err := os.Open(filepath.FromSlash(path.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
- }
- }
-}
diff --git a/providers/dns/ionos/ionos.go b/providers/dns/ionos/ionos.go
index d12fd7f09..892370f5d 100644
--- a/providers/dns/ionos/ionos.go
+++ b/providers/dns/ionos/ionos.go
@@ -2,18 +2,15 @@
package ionos
import (
- "context"
"errors"
"fmt"
"net/http"
- "strconv"
- "strings"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/ionos/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ionos"
)
// Environment variables names.
@@ -33,19 +30,13 @@ const minTTL = 300
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
-type Config struct {
- APIKey string
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = ionos.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ TTL: env.GetOrDefaultInt(EnvTTL, ionos.MinTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
@@ -55,8 +46,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *internal.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for Ionos.
@@ -79,126 +69,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("ionos: the configuration of the DNS provider is nil")
}
- if config.APIKey == "" {
- return nil, errors.New("ionos: credentials missing")
- }
-
- if config.TTL < minTTL {
- return nil, fmt.Errorf("ionos: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
- }
-
- client, err := internal.NewClient(config.APIKey)
+ provider, err := ionos.NewDNSProviderConfig(config, "")
if err != nil {
return nil, fmt.Errorf("ionos: %w", err)
}
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- return &DNSProvider{config: config, client: client}, nil
+ return &DNSProvider{prv: provider}, 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
+ return d.prv.Timeout()
}
// Present creates a TXT record using the specified parameters.
-func (d *DNSProvider) Present(domain, _, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- zones, err := d.client.ListZones(ctx)
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("ionos: failed to get zones: %w", err)
- }
-
- name := dns01.UnFqdn(info.EffectiveFQDN)
-
- zone := findZone(zones, name)
- if zone == nil {
- return errors.New("ionos: no matching zone found for domain")
- }
-
- filter := &internal.RecordsFilter{
- Suffix: name,
- RecordType: "TXT",
- }
-
- records, err := d.client.GetRecords(ctx, zone.ID, filter)
- if err != nil {
- return fmt.Errorf("ionos: failed to get records (zone=%s): %w", zone.ID, err)
- }
-
- records = append(records, internal.Record{
- Name: name,
- Content: info.Value,
- TTL: d.config.TTL,
- Type: "TXT",
- })
-
- err = d.client.ReplaceRecords(ctx, zone.ID, records)
- if err != nil {
- return fmt.Errorf("ionos: failed to create/update records (zone=%s): %w", zone.ID, err)
+ return fmt.Errorf("ionos: %w", err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
-func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- zones, err := d.client.ListZones(ctx)
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("ionos: failed to get zones: %w", err)
+ return fmt.Errorf("ionos: %w", err)
}
- name := dns01.UnFqdn(info.EffectiveFQDN)
-
- zone := findZone(zones, name)
- if zone == nil {
- return errors.New("ionos: no matching zone found for domain")
- }
-
- filter := &internal.RecordsFilter{
- Suffix: name,
- RecordType: "TXT",
- }
-
- records, err := d.client.GetRecords(ctx, zone.ID, filter)
- if err != nil {
- return fmt.Errorf("ionos: failed to get records (zone=%s): %w", zone.ID, err)
- }
-
- for _, record := range records {
- if record.Name == name && 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)
- }
- 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 {
- var result *internal.Zone
-
- for _, zone := range zones {
- if zone.Name != "" && strings.HasSuffix(domain, zone.Name) {
- if result == nil || len(zone.Name) > len(result.Name) {
- result = &zone
- }
- }
- }
-
- return result
+ return nil
}
diff --git a/providers/dns/ionos/ionos.toml b/providers/dns/ionos/ionos.toml
index e9bfd7319..a2c9518fb 100644
--- a/providers/dns/ionos/ionos.toml
+++ b/providers/dns/ionos/ionos.toml
@@ -6,17 +6,17 @@ Since = "v4.2.0"
Example = '''
IONOS_API_KEY=xxxxxxxx \
-lego --email you@example.com --dns ionos -d '*.example.com' -d example.com run
+lego --dns ionos -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
IONOS_API_KEY = "API key `.` https://developer.hosting.ionos.com/docs/getstarted"
[Configuration.Additional]
- IONOS_POLLING_INTERVAL = "Time between DNS propagation check"
- IONOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- IONOS_TTL = "The TTL of the TXT record used for the DNS challenge"
- IONOS_HTTP_TIMEOUT = "API request timeout"
+ IONOS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ IONOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)"
+ IONOS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ IONOS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developer.hosting.ionos.com/docs/dns"
diff --git a/providers/dns/ionos/ionos_test.go b/providers/dns/ionos/ionos_test.go
index 5aef6ad14..39dc0c511 100644
--- a/providers/dns/ionos/ionos_test.go
+++ b/providers/dns/ionos/ionos_test.go
@@ -9,9 +9,7 @@ import (
const envDomain = envNamespace + "DOMAIN"
-var envTest = tester.NewEnvTest(
- EnvAPIKey).
- WithDomain(envDomain)
+var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
@@ -37,6 +35,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -46,8 +45,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.client)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -91,8 +89,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.client)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -106,6 +103,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -119,6 +117,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/ionoscloud/internal/client.go b/providers/dns/ionoscloud/internal/client.go
new file mode 100644
index 000000000..5b7d3a0fc
--- /dev/null
+++ b/providers/dns/ionoscloud/internal/client.go
@@ -0,0 +1,172 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://dns.de-fra.ionos.com"
+
+const authorizationHeader = "Authorization"
+
+// Client the Ionos Cloud API client.
+type Client struct {
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey string) (*Client, error) {
+ if apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// RetrieveZones returns a list of the DNS zones.
+// https://api.ionos.com/docs/dns/v1/#tag/Zones/operation/zonesGet
+func (c *Client) RetrieveZones(ctx context.Context, zoneName string) ([]Zone, error) {
+ endpoint := c.BaseURL.JoinPath("zones")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ query := req.URL.Query()
+ query.Add("filter.zoneName", zoneName)
+ req.URL.RawQuery = query.Encode()
+
+ result := ZonesResponse{}
+
+ if err := c.do(req, &result); err != nil {
+ return nil, err
+ }
+
+ return result.Items, nil
+}
+
+// CreateRecord creates a new record for the DNS zone.
+// https://api.ionos.com/docs/dns/v1/#tag/Records/operation/zonesRecordsPost
+func (c *Client) CreateRecord(ctx context.Context, zoneID string, record RecordProperties) (*RecordResponse, error) {
+ endpoint := c.BaseURL.JoinPath("zones", zoneID, "records")
+
+ payload := map[string]RecordProperties{
+ "properties": record,
+ }
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &RecordResponse{}
+
+ if err := c.do(req, result); err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteRecord deletes a specified record from the DNS zone.
+// https://api.ionos.com/docs/dns/v1/#tag/Records/operation/zonesRecordsDelete
+func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
+ endpoint := c.BaseURL.JoinPath("zones", zoneID, "records", recordID)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set(authorizationHeader, "Bearer "+c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/ionoscloud/internal/client_test.go b/providers/dns/ionoscloud/internal/client_test.go
new file mode 100644
index 000000000..dc478cc64
--- /dev/null
+++ b/providers/dns/ionoscloud/internal/client_test.go
@@ -0,0 +1,134 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
+}
+
+func TestClient_RetrieveZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter.zoneName", "example.com")).
+ Build(t)
+
+ zones, err := client.RetrieveZones(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := []Zone{{
+ ID: "e74d0d15-f567-4b7b-9069-26ee1f93bae3",
+ Type: "zone",
+ Metadata: ZoneMetadata{
+ CreatedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC),
+ CreatedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ CreatedByUserID: "87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ LastModifiedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC),
+ LastModifiedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ LastModifiedByUserID: "63cef532-26fe-4a64-a4e0-de7c8a506c90",
+ ResourceURN: "ionos::::",
+ State: "PROVISIONING",
+ Nameservers: []string{"ns-ic.ui-dns.com", "ns-ic.ui-dns.de", "ns-ic.ui-dns.org", "ns-ic.ui-dns.biz"},
+ },
+ Properties: ZoneProperties{
+ ZoneName: "example.com",
+ Description: "The hosted zone is used for example.com",
+ Enabled: true,
+ },
+ }}
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_RetrieveZones_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.RetrieveZones(t.Context(), "example.com")
+ require.EqualError(t, err, "401: paas-auth-1: Unauthorized, wrong or no api key provided to process this request")
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/abc/records",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ record := RecordProperties{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 120,
+ }
+
+ result, err := client.CreateRecord(t.Context(), "abc", record)
+ require.NoError(t, err)
+
+ expected := &RecordResponse{
+ ID: "90d81ac0-3a30-44d4-95a5-12959effa6ee",
+ Type: "record",
+ Metadata: RecordMetadata{
+ CreatedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC),
+ CreatedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ CreatedByUserID: "87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ LastModifiedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC),
+ LastModifiedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ LastModifiedByUserID: "63cef532-26fe-4a64-a4e0-de7c8a506c90",
+ ResourceURN: "ionos::::",
+ State: "PROVISIONING",
+ Fqdn: "app.example.com",
+ ZoneID: "a363f30c-4c0c-4552-9a07-298d87f219bf",
+ },
+ Properties: RecordProperties{
+ Name: "app",
+ Type: "A",
+ Content: "1.2.3.4",
+ TTL: 3600,
+ Priority: 3600,
+ Enabled: true,
+ },
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /zones/abc/records/def",
+ servermock.Noop().
+ WithStatusCode(http.StatusAccepted)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "abc", "def")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/ionoscloud/internal/fixtures/create_record-request.json b/providers/dns/ionoscloud/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..d4f52bba8
--- /dev/null
+++ b/providers/dns/ionoscloud/internal/fixtures/create_record-request.json
@@ -0,0 +1,8 @@
+{
+ "properties": {
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 120
+ }
+}
diff --git a/providers/dns/ionoscloud/internal/fixtures/create_record.json b/providers/dns/ionoscloud/internal/fixtures/create_record.json
new file mode 100644
index 000000000..d3094c3b2
--- /dev/null
+++ b/providers/dns/ionoscloud/internal/fixtures/create_record.json
@@ -0,0 +1,25 @@
+{
+ "id": "90d81ac0-3a30-44d4-95a5-12959effa6ee",
+ "type": "record",
+ "href": "",
+ "metadata": {
+ "createdDate": "2022-08-21T15:52:53Z",
+ "createdBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ "createdByUserId": "87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ "lastModifiedDate": "2022-08-21T15:52:53Z",
+ "lastModifiedBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ "lastModifiedByUserId": "63cef532-26fe-4a64-a4e0-de7c8a506c90",
+ "resourceURN": "ionos::::",
+ "state": "PROVISIONING",
+ "fqdn": "app.example.com",
+ "zoneId": "a363f30c-4c0c-4552-9a07-298d87f219bf"
+ },
+ "properties": {
+ "name": "app",
+ "type": "A",
+ "content": "1.2.3.4",
+ "ttl": 3600,
+ "priority": 3600,
+ "enabled": true
+ }
+}
diff --git a/providers/dns/ionoscloud/internal/fixtures/error.json b/providers/dns/ionoscloud/internal/fixtures/error.json
new file mode 100644
index 000000000..bed0e5efb
--- /dev/null
+++ b/providers/dns/ionoscloud/internal/fixtures/error.json
@@ -0,0 +1,9 @@
+{
+ "httpStatus": 401,
+ "messages": [
+ {
+ "errorCode": "paas-auth-1",
+ "message": "Unauthorized, wrong or no api key provided to process this request"
+ }
+ ]
+}
diff --git a/providers/dns/ionoscloud/internal/fixtures/zones.json b/providers/dns/ionoscloud/internal/fixtures/zones.json
new file mode 100644
index 000000000..c9c2c62f9
--- /dev/null
+++ b/providers/dns/ionoscloud/internal/fixtures/zones.json
@@ -0,0 +1,40 @@
+{
+ "id": "e74d0d15-f567-4b7b-9069-26ee1f93bae3",
+ "type": "collection",
+ "href": "",
+ "offset": 0,
+ "limit": 1000,
+ "_links": {
+ "prev": "http://PREVIOUS-PAGE-URI",
+ "self": "http://THIS-PAGE-URI",
+ "next": "http://NEXT-PAGE-URI"
+ },
+ "items": [
+ {
+ "id": "e74d0d15-f567-4b7b-9069-26ee1f93bae3",
+ "type": "zone",
+ "href": "",
+ "metadata": {
+ "createdDate": "2022-08-21T15:52:53Z",
+ "createdBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ "createdByUserId": "87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ "lastModifiedDate": "2022-08-21T15:52:53Z",
+ "lastModifiedBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3",
+ "lastModifiedByUserId": "63cef532-26fe-4a64-a4e0-de7c8a506c90",
+ "resourceURN": "ionos::::",
+ "state": "PROVISIONING",
+ "nameservers": [
+ "ns-ic.ui-dns.com",
+ "ns-ic.ui-dns.de",
+ "ns-ic.ui-dns.org",
+ "ns-ic.ui-dns.biz"
+ ]
+ },
+ "properties": {
+ "zoneName": "example.com",
+ "description": "The hosted zone is used for example.com",
+ "enabled": true
+ }
+ }
+ ]
+}
diff --git a/providers/dns/ionoscloud/internal/types.go b/providers/dns/ionoscloud/internal/types.go
new file mode 100644
index 000000000..49348f4d1
--- /dev/null
+++ b/providers/dns/ionoscloud/internal/types.go
@@ -0,0 +1,97 @@
+package internal
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type APIError struct {
+ HTTPStatus int `json:"httpStatus"`
+ Messages []ErrorMessage `json:"messages"`
+}
+
+func (a *APIError) Error() string {
+ var msg strings.Builder
+
+ msg.WriteString(strconv.Itoa(a.HTTPStatus))
+
+ for _, m := range a.Messages {
+ msg.WriteString(": ")
+ msg.WriteString(m.String())
+ }
+
+ return msg.String()
+}
+
+type ErrorMessage struct {
+ ErrorCode string `json:"errorCode"`
+ Message string `json:"message"`
+}
+
+func (e ErrorMessage) String() string {
+ return fmt.Sprintf("%s: %s", e.ErrorCode, e.Message)
+}
+
+type ZonesResponse struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ Items []Zone `json:"items"`
+}
+
+type Zone struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Metadata ZoneMetadata `json:"metadata"`
+ Properties ZoneProperties `json:"properties"`
+}
+
+type ZoneMetadata struct {
+ CreatedDate time.Time `json:"createdDate"`
+ CreatedBy string `json:"createdBy"`
+ CreatedByUserID string `json:"createdByUserId"`
+ LastModifiedDate time.Time `json:"lastModifiedDate"`
+ LastModifiedBy string `json:"lastModifiedBy"`
+ LastModifiedByUserID string `json:"lastModifiedByUserId"`
+ ResourceURN string `json:"resourceURN"`
+ State string `json:"state"`
+ Nameservers []string `json:"nameservers"`
+}
+
+type ZoneProperties struct {
+ ZoneName string `json:"zoneName"`
+ Description string `json:"description"`
+ Enabled bool `json:"enabled"`
+}
+
+type RecordResponse struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Metadata RecordMetadata `json:"metadata"`
+ Properties RecordProperties `json:"properties"`
+}
+
+type RecordMetadata struct {
+ CreatedDate time.Time `json:"createdDate"`
+ CreatedBy string `json:"createdBy"`
+ CreatedByUserID string `json:"createdByUserId"`
+ LastModifiedDate time.Time `json:"lastModifiedDate"`
+ LastModifiedBy string `json:"lastModifiedBy"`
+ LastModifiedByUserID string `json:"lastModifiedByUserId"`
+ ResourceURN string `json:"resourceURN"`
+ State string `json:"state"`
+ Fqdn string `json:"fqdn"`
+ ZoneID string `json:"zoneId"`
+}
+
+type RecordProperties struct {
+ Name string `json:"name"`
+ Type string `json:"type,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Enabled bool `json:"enabled,omitempty"`
+}
diff --git a/providers/dns/ionoscloud/ionoscloud.go b/providers/dns/ionoscloud/ionoscloud.go
new file mode 100644
index 000000000..0c33fba9f
--- /dev/null
+++ b/providers/dns/ionoscloud/ionoscloud.go
@@ -0,0 +1,184 @@
+// Package ionoscloud implements a DNS provider for solving the DNS-01 challenge using Ionos Cloud.
+package ionoscloud
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/ionoscloud/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "IONOSCLOUD_"
+
+ 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, 120*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ zoneIDs map[string]string
+ recordIDs map[string]string
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Ionos Cloud.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIToken)
+ if err != nil {
+ return nil, fmt.Errorf("ionoscloud: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIToken = values[EnvAPIToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Ionos Cloud.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("ionoscloud: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIToken)
+ if err != nil {
+ return nil, fmt.Errorf("ionoscloud: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ zoneIDs: make(map[string]string),
+ recordIDs: make(map[string]string),
+ }, nil
+}
+
+// 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("ionoscloud: could not find zone for domain %q: %w", domain, err)
+ }
+
+ zones, err := d.client.RetrieveZones(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("ionoscloud: retrieve zones: %w", err)
+ }
+
+ if len(zones) != 1 {
+ return fmt.Errorf("ionoscloud: zone ID not found for domain %q", domain)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("ionoscloud: %w", err)
+ }
+
+ zoneID := zones[0].ID
+
+ request := internal.RecordProperties{
+ Name: subDomain,
+ Type: "TXT",
+ Content: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ record, err := d.client.CreateRecord(ctx, zoneID, request)
+ if err != nil {
+ return fmt.Errorf("ionoscloud: create record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.zoneIDs[token] = zoneID
+ d.recordIDs[token] = record.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)
+
+ d.recordIDsMu.Lock()
+ zoneID, ok := d.zoneIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("ionoscloud: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("ionoscloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err := d.client.DeleteRecord(context.Background(), zoneID, recordID)
+ if err != nil {
+ return fmt.Errorf("ionoscloud: delete record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ delete(d.zoneIDs, token)
+ 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/dns/ionoscloud/ionoscloud.toml b/providers/dns/ionoscloud/ionoscloud.toml
new file mode 100644
index 000000000..6e1d080e4
--- /dev/null
+++ b/providers/dns/ionoscloud/ionoscloud.toml
@@ -0,0 +1,22 @@
+Name = "Ionos Cloud"
+Description = ''''''
+URL = "https://cloud.ionos.de/network/cloud-dns"
+Code = "ionoscloud"
+Since = "v4.30.0"
+
+Example = '''
+IONOSCLOUD_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns ionoscloud -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ IONOSCLOUD_API_TOKEN = "API token"
+ [Configuration.Additional]
+ IONOSCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ IONOSCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ IONOSCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ IONOSCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://api.ionos.com/docs/dns/v1/"
diff --git a/providers/dns/ionoscloud/ionoscloud_test.go b/providers/dns/ionoscloud/ionoscloud_test.go
new file mode 100644
index 000000000..8282e08fc
--- /dev/null
+++ b/providers/dns/ionoscloud/ionoscloud_test.go
@@ -0,0 +1,173 @@
+package ionoscloud
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "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: "secret",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "ionoscloud: some credentials information are missing: IONOSCLOUD_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)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "ionoscloud: credentials 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)
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIToken = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromInternal("zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("filter.zoneName", "example.com")).
+ Route("POST /zones/e74d0d15-f567-4b7b-9069-26ee1f93bae3/records",
+ servermock.ResponseFromInternal("create_record.json"),
+ servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /zones/e74d0d15-f567-4b7b-9069-26ee1f93bae3/records/90d81ac0-3a30-44d4-95a5-12959effa6ee",
+ servermock.Noop().
+ WithStatusCode(http.StatusAccepted)).
+ Build(t)
+
+ token := "abc"
+
+ provider.zoneIDs[token] = "e74d0d15-f567-4b7b-9069-26ee1f93bae3"
+ provider.recordIDs[token] = "90d81ac0-3a30-44d4-95a5-12959effa6ee"
+
+ err := provider.CleanUp("example.com", token, "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/ipv64/internal/client.go b/providers/dns/ipv64/internal/client.go
index fbb871aa3..0dfd94374 100644
--- a/providers/dns/ipv64/internal/client.go
+++ b/providers/dns/ipv64/internal/client.go
@@ -34,7 +34,7 @@ func NewClient(hc *http.Client) *Client {
}
}
-func (c Client) GetDomains(ctx context.Context) (*Domains, error) {
+func (c *Client) GetDomains(ctx context.Context) (*Domains, error) {
endpoint := c.baseURL.JoinPath("api")
query := endpoint.Query()
@@ -56,7 +56,7 @@ func (c Client) GetDomains(ctx context.Context) (*Domains, error) {
return results, nil
}
-func (c Client) AddRecord(ctx context.Context, domain, prefix, recordType, content string) error {
+func (c *Client) AddRecord(ctx context.Context, domain, prefix, recordType, content string) error {
endpoint := c.baseURL.JoinPath("api")
data := make(url.Values)
@@ -73,7 +73,7 @@ func (c Client) AddRecord(ctx context.Context, domain, prefix, recordType, conte
return c.do(req, nil)
}
-func (c Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, content string) error {
+func (c *Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, content string) error {
endpoint := c.baseURL.JoinPath("api")
data := make(url.Values)
@@ -90,7 +90,7 @@ func (c Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, co
return c.do(req, nil)
}
-func (c Client) do(req *http.Request, result any) error {
+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")
}
@@ -131,6 +131,7 @@ 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)
diff --git a/providers/dns/ipv64/internal/client_test.go b/providers/dns/ipv64/internal/client_test.go
index 1966f9f68..ba5ede9fc 100644
--- a/providers/dns/ipv64/internal/client_test.go
+++ b/providers/dns/ipv64/internal/client_test.go
@@ -1,69 +1,35 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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)
-
+func setupClient(server *httptest.Server) (*Client, error) {
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
- }
- }
+ return client, nil
}
func TestClient_GetDomains(t *testing.T) {
- client := setupTest(t, testHandler(http.MethodGet, "get_domains.json", http.StatusOK))
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /api",
+ servermock.ResponseFromFixture("get_domains.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("get_domains", "")).
+ Build(t)
- domains, err := client.GetDomains(context.Background())
+ domains, err := client.GetDomains(t.Context())
require.NoError(t, err)
expected := &Domains{
@@ -112,38 +78,67 @@ func TestClient_GetDomains(t *testing.T) {
}
func TestClient_GetDomains_error(t *testing.T) {
- client := setupTest(t, testHandler(http.MethodGet, "error.json", http.StatusUnauthorized))
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /api",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- domains, err := client.GetDomains(context.Background())
+ domains, err := client.GetDomains(t.Context())
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))
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithContentTypeFromURLEncoded()).
+ Route("POST /api",
+ servermock.ResponseFromFixture("add_record.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckForm().Strict().
+ With("add_record", "lego.ipv64.net").
+ With("content", "value").
+ With("praefix", "_acme-challenge").
+ With("type", "TXT"),
+ ).
+ Build(t)
- err := client.AddRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value")
+ err := client.AddRecord(t.Context(), "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))
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /api",
+ servermock.ResponseFromFixture("add_record-error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- err := client.AddRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value")
+ err := client.AddRecord(t.Context(), "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))
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithContentTypeFromURLEncoded()).
+ Route("DELETE /api",
+ // the query parameters can be checked because the Go server ignores the body of a DELETE request.
+ servermock.ResponseFromFixture("del_record.json").
+ WithStatusCode(http.StatusAccepted)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value")
+ err := client.DeleteRecord(t.Context(), "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))
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("DELETE /api",
+ servermock.ResponseFromFixture("del_record-error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "lego.ipv64.net", "_acme-challenge", "TXT", "value")
+ err := client.DeleteRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value")
require.Error(t, err)
}
diff --git a/providers/dns/ipv64/internal/types.go b/providers/dns/ipv64/internal/types.go
index e9e357ecc..6ef31a3cc 100644
--- a/providers/dns/ipv64/internal/types.go
+++ b/providers/dns/ipv64/internal/types.go
@@ -11,6 +11,7 @@ type APIResponse struct {
type APIError struct {
APIResponse
+
AddRecordMessage string `json:"add_record"`
DelRecordMessage string `json:"del_record"`
AddDomainMessage string `json:"add_domain"`
@@ -41,6 +42,7 @@ func (a APIError) Error() string {
type Domains struct {
APIResponse
+
APICall string `json:"add_domain"`
Subdomains map[string]Subdomain `json:"subdomains"`
}
diff --git a/providers/dns/ipv64/ipv64.go b/providers/dns/ipv64/ipv64.go
index 6e8d1c5bb..078fe5ca1 100644
--- a/providers/dns/ipv64/ipv64.go
+++ b/providers/dns/ipv64/ipv64.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/ipv64/internal"
"github.com/miekg/dns"
)
@@ -85,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/ipv64/ipv64.toml b/providers/dns/ipv64/ipv64.toml
index ece506c34..aa1720c9e 100644
--- a/providers/dns/ipv64/ipv64.toml
+++ b/providers/dns/ipv64/ipv64.toml
@@ -6,17 +6,16 @@ Since = "v4.13.0"
Example = '''
IPV64_API_KEY=xxxxxx \
-lego --email you@example.com --dns ipv64 -d '*.example.com' -d example.com run
+lego --dns ipv64 -d '*.example.com' -d example.com 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"
+ IPV64_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ IPV64_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ IPV64_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://ipv64.net/dyndns_updater_api"
diff --git a/providers/dns/ipv64/ipv64_test.go b/providers/dns/ipv64/ipv64_test.go
index b3fe142e9..6dc7d1cfc 100644
--- a/providers/dns/ipv64/ipv64_test.go
+++ b/providers/dns/ipv64/ipv64_test.go
@@ -114,6 +114,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -171,6 +172,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -184,6 +186,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/ispconfig/internal/client.go b/providers/dns/ispconfig/internal/client.go
new file mode 100644
index 000000000..9280fdec1
--- /dev/null
+++ b/providers/dns/ispconfig/internal/client.go
@@ -0,0 +1,318 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+type Client struct {
+ serverURL string
+ HTTPClient *http.Client
+}
+
+func NewClient(serverURL string) (*Client, error) {
+ _, err := url.Parse(serverURL)
+ if err != nil {
+ return nil, fmt.Errorf("server URL: %w", err)
+ }
+
+ return &Client{
+ serverURL: serverURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) Login(ctx context.Context, username, password string) (string, error) {
+ payload := LoginRequest{
+ Username: username,
+ Password: password,
+ ClientLogin: false,
+ }
+
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return "", err
+ }
+
+ endpoint.RawQuery = "login"
+
+ req, err := newJSONRequest(ctx, endpoint, payload)
+ if err != nil {
+ return "", err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return "", err
+ }
+
+ return extractResponse[string](response)
+}
+
+func (c *Client) GetClientID(ctx context.Context, sessionID, sysUserID string) (int, error) {
+ payload := ClientIDRequest{
+ SessionID: sessionID,
+ SysUserID: sysUserID,
+ }
+
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return 0, err
+ }
+
+ endpoint.RawQuery = "client_get_id"
+
+ req, err := newJSONRequest(ctx, endpoint, payload)
+ if err != nil {
+ return 0, err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return 0, err
+ }
+
+ return extractResponse[int](response)
+}
+
+// GetZoneID returns the zone ID for the given name.
+func (c *Client) GetZoneID(ctx context.Context, sessionID, name string) (int, error) {
+ payload := map[string]any{
+ "session_id": sessionID,
+ "origin": name,
+ }
+
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return 0, err
+ }
+
+ endpoint.RawQuery = "dns_zone_get_id"
+
+ req, err := newJSONRequest(ctx, endpoint, payload)
+ if err != nil {
+ return 0, err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return 0, err
+ }
+
+ return extractResponse[int](response)
+}
+
+// GetZone returns the zone information for the zone ID.
+func (c *Client) GetZone(ctx context.Context, sessionID, zoneID string) (*Zone, error) {
+ payload := map[string]any{
+ "session_id": sessionID,
+ "primary_id": zoneID,
+ }
+
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint.RawQuery = "dns_zone_get"
+
+ req, err := newJSONRequest(ctx, endpoint, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractResponse[*Zone](response)
+}
+
+// GetTXT returns the TXT record for the given name.
+// `name` must be a fully qualified domain name, e.g. "example.com.".
+func (c *Client) GetTXT(ctx context.Context, sessionID, name string) (*Record, error) {
+ payload := GetTXTRequest{
+ SessionID: sessionID,
+ PrimaryID: struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ }{
+ Name: name,
+ Type: "txt",
+ },
+ }
+
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint.RawQuery = "dns_txt_get"
+
+ req, err := newJSONRequest(ctx, endpoint, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractResponse[*Record](response)
+}
+
+// AddTXT adds a TXT record.
+// It returns the ID of the newly created record.
+func (c *Client) AddTXT(ctx context.Context, sessionID, clientID string, params RecordParams) (string, error) {
+ payload := AddTXTRequest{
+ SessionID: sessionID,
+ ClientID: clientID,
+ Params: ¶ms,
+ UpdateSerial: true,
+ }
+
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return "", err
+ }
+
+ endpoint.RawQuery = "dns_txt_add"
+
+ req, err := newJSONRequest(ctx, endpoint, payload)
+ if err != nil {
+ return "", err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return "", err
+ }
+
+ return extractResponse[string](response)
+}
+
+// DeleteTXT deletes a TXT record.
+// It returns the number of deleted records.
+func (c *Client) DeleteTXT(ctx context.Context, sessionID, recordID string) (int, error) {
+ payload := DeleteTXTRequest{
+ SessionID: sessionID,
+ PrimaryID: recordID,
+ UpdateSerial: true,
+ }
+
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return 0, err
+ }
+
+ endpoint.RawQuery = "dns_txt_delete"
+
+ req, err := newJSONRequest(ctx, endpoint, payload)
+ if err != nil {
+ return 0, err
+ }
+
+ var response APIResponse
+
+ err = c.do(req, &response)
+ if err != nil {
+ return 0, err
+ }
+
+ return extractResponse[int](response)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ 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, 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, http.MethodPost, 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 extractResponse[T any](response APIResponse) (T, error) {
+ if response.Code != "ok" {
+ var zero T
+
+ return zero, &APIError{APIResponse: response}
+ }
+
+ var result T
+
+ err := json.Unmarshal(response.Response, &result)
+ if err != nil {
+ var zero T
+ return zero, fmt.Errorf("unable to unmarshal response: %s, %w", string(response.Response), err)
+ }
+
+ return result, nil
+}
diff --git a/providers/dns/ispconfig/internal/client_test.go b/providers/dns/ispconfig/internal/client_test.go
new file mode 100644
index 000000000..a4db3d5f7
--- /dev/null
+++ b/providers/dns/ispconfig/internal/client_test.go
@@ -0,0 +1,175 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL)
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ })
+}
+
+func TestClient_Login(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("login.json"),
+ servermock.CheckRequestJSONBodyFromFixture("login-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("login", ""),
+ ).
+ Build(t)
+
+ sessionID, err := client.Login(t.Context(), "user", "secret")
+ require.NoError(t, err)
+
+ assert.Equal(t, "abc", sessionID)
+}
+
+func TestClient_Login_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("error.json"),
+ ).
+ Build(t)
+
+ _, err := client.Login(t.Context(), "user", "secret")
+ require.EqualError(t, err, `code: remote_fault, message: The login failed. Username or password wrong., response: false`)
+}
+
+func TestClient_GetClientID(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("client_get_id.json"),
+ servermock.CheckRequestJSONBodyFromFixture("client_get_id-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("client_get_id", ""),
+ ).
+ Build(t)
+
+ id, err := client.GetClientID(t.Context(), "sessionA", "sysA")
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, id)
+}
+
+func TestClient_GetZoneID(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("dns_zone_get_id.json"),
+ servermock.CheckRequestJSONBodyFromFixture("dns_zone_get_id-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("dns_zone_get_id", ""),
+ ).
+ Build(t)
+
+ zoneID, err := client.GetZoneID(t.Context(), "sessionA", "example.com")
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, zoneID)
+}
+
+func TestClient_GetZone(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("dns_zone_get.json"),
+ servermock.CheckRequestJSONBodyFromFixture("dns_zone_get-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("dns_zone_get", ""),
+ ).
+ Build(t)
+
+ zone, err := client.GetZone(t.Context(), "sessionA", "example.com.")
+ require.NoError(t, err)
+
+ expected := &Zone{
+ ID: "456",
+ ServerID: "123",
+ SysUserID: "789",
+ SysGroupID: "2",
+ Origin: "example.com.",
+ Serial: "2025102902",
+ Active: "Y",
+ }
+
+ assert.Equal(t, expected, zone)
+}
+
+func TestClient_GetTXT(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("dns_txt_get.json"),
+ servermock.CheckRequestJSONBodyFromFixture("dns_txt_get-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("dns_txt_get", ""),
+ ).
+ Build(t)
+
+ record, err := client.GetTXT(t.Context(), "sessionA", "example.com.")
+ require.NoError(t, err)
+
+ expected := &Record{ID: 123}
+
+ assert.Equal(t, expected, record)
+}
+
+func TestClient_AddTXT(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("dns_txt_add.json"),
+ servermock.CheckRequestJSONBodyFromFixture("dns_txt_add-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("dns_txt_add", ""),
+ ).
+ Build(t)
+
+ now := time.Date(2025, 12, 25, 1, 1, 1, 0, time.UTC)
+
+ params := RecordParams{
+ ServerID: "serverA",
+ Zone: "example.com.",
+ Name: "foo.example.com.",
+ Type: "txt",
+ Data: "txtTXTtxt",
+ Aux: "0",
+ TTL: "3600",
+ Active: "y",
+ Stamp: now.Format("2006-01-02 15:04:05"),
+ UpdateSerial: true,
+ }
+
+ recordID, err := client.AddTXT(t.Context(), "sessionA", "clientA", params)
+ require.NoError(t, err)
+
+ assert.Equal(t, "123", recordID)
+}
+
+func TestClient_DeleteTXT(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture("dns_txt_delete.json"),
+ servermock.CheckRequestJSONBodyFromFixture("dns_txt_delete-request.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("dns_txt_delete", ""),
+ ).
+ Build(t)
+
+ count, err := client.DeleteTXT(t.Context(), "sessionA", "123")
+ require.NoError(t, err)
+
+ assert.Equal(t, 1, count)
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/client_get_id-request.json b/providers/dns/ispconfig/internal/fixtures/client_get_id-request.json
new file mode 100644
index 000000000..ba573f824
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/client_get_id-request.json
@@ -0,0 +1,4 @@
+{
+ "session_id": "sessionA",
+ "sys_userid": "sysA"
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/client_get_id.json b/providers/dns/ispconfig/internal/fixtures/client_get_id.json
new file mode 100644
index 000000000..7b9f667a0
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/client_get_id.json
@@ -0,0 +1,5 @@
+{
+ "code": "ok",
+ "message": "foo",
+ "response": 123
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_add-request.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_add-request.json
new file mode 100644
index 000000000..bf5242cd1
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_add-request.json
@@ -0,0 +1,17 @@
+{
+ "session_id": "sessionA",
+ "client_id": "clientA",
+ "params": {
+ "server_id": "serverA",
+ "zone": "example.com.",
+ "name": "foo.example.com.",
+ "type": "txt",
+ "data": "txtTXTtxt",
+ "aux": "0",
+ "ttl": "3600",
+ "active": "y",
+ "stamp": "2025-12-25 01:01:01",
+ "update_serial": true
+ },
+ "update_serial": true
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_add.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_add.json
new file mode 100644
index 000000000..7980619fe
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_add.json
@@ -0,0 +1,5 @@
+{
+ "code": "ok",
+ "message": "foo",
+ "response": "123"
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_delete-request.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete-request.json
new file mode 100644
index 000000000..240976654
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete-request.json
@@ -0,0 +1,5 @@
+{
+ "session_id": "sessionA",
+ "primary_id": "123",
+ "update_serial": true
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_delete.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete.json
new file mode 100644
index 000000000..960b650bd
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_delete.json
@@ -0,0 +1,5 @@
+{
+ "code": "ok",
+ "message": "foo",
+ "response": 1
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_get-request.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_get-request.json
new file mode 100644
index 000000000..8bda44067
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_get-request.json
@@ -0,0 +1,7 @@
+{
+ "session_id": "sessionA",
+ "primary_id": {
+ "name": "example.com.",
+ "type": "txt"
+ }
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_txt_get.json b/providers/dns/ispconfig/internal/fixtures/dns_txt_get.json
new file mode 100644
index 000000000..f707d50c3
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_txt_get.json
@@ -0,0 +1,7 @@
+{
+ "code": "ok",
+ "message": "foo",
+ "response": {
+ "id": 123
+ }
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get-request.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get-request.json
new file mode 100644
index 000000000..3d44d468f
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get-request.json
@@ -0,0 +1,4 @@
+{
+ "primary_id": "example.com.",
+ "session_id": "sessionA"
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get.json
new file mode 100644
index 000000000..37975d0e6
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get.json
@@ -0,0 +1,32 @@
+{
+ "code": "ok",
+ "message": "foo",
+ "response": {
+ "id": "456",
+ "sys_userid": "789",
+ "sys_groupid": "2",
+ "sys_perm_user": "riud",
+ "sys_perm_group": "riud",
+ "sys_perm_other": "",
+ "server_id": "123",
+ "origin": "example.com.",
+ "ns": "ns1.example.org.",
+ "mbox": "support.example.net.",
+ "serial": "2025102902",
+ "refresh": "7200",
+ "retry": "540",
+ "expire": "604800",
+ "minimum": "3600",
+ "ttl": "3600",
+ "active": "Y",
+ "xfer": "",
+ "also_notify": "",
+ "update_acl": "",
+ "dnssec_initialized": "N",
+ "dnssec_wanted": "N",
+ "dnssec_algo": "ECDSAP256SHA256",
+ "dnssec_last_signed": "0",
+ "dnssec_info": "",
+ "rendered_zone": ""
+ }
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id-request.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id-request.json
new file mode 100644
index 000000000..e3084242e
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id-request.json
@@ -0,0 +1,4 @@
+{
+ "origin": "example.com",
+ "session_id": "sessionA"
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id.json b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id.json
new file mode 100644
index 000000000..7b9f667a0
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/dns_zone_get_id.json
@@ -0,0 +1,5 @@
+{
+ "code": "ok",
+ "message": "foo",
+ "response": 123
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/error.json b/providers/dns/ispconfig/internal/fixtures/error.json
new file mode 100644
index 000000000..a9c76546c
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/error.json
@@ -0,0 +1,5 @@
+{
+ "code": "remote_fault",
+ "message": "The login failed. Username or password wrong.",
+ "response": false
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/login-request.json b/providers/dns/ispconfig/internal/fixtures/login-request.json
new file mode 100644
index 000000000..c3293a2e8
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/login-request.json
@@ -0,0 +1,5 @@
+{
+ "username": "user",
+ "password": "secret",
+ "client_login": false
+}
diff --git a/providers/dns/ispconfig/internal/fixtures/login.json b/providers/dns/ispconfig/internal/fixtures/login.json
new file mode 100644
index 000000000..e380a86ec
--- /dev/null
+++ b/providers/dns/ispconfig/internal/fixtures/login.json
@@ -0,0 +1,5 @@
+{
+ "code": "ok",
+ "message": "foo",
+ "response": "abc"
+}
diff --git a/providers/dns/ispconfig/internal/readme.md b/providers/dns/ispconfig/internal/readme.md
new file mode 100644
index 000000000..2284c338f
--- /dev/null
+++ b/providers/dns/ispconfig/internal/readme.md
@@ -0,0 +1,249 @@
+## Error Response
+
+```json
+{
+ "code": "",
+ "message": "",
+ "response": false
+}
+```
+
+## Login Endpoint
+
+* URL: `?login`
+* HTTP Method: `POST`
+
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/login.html
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/login.php
+
+### Request Body (JSON)
+
+```json
+{
+ "username": "",
+ "password": "",
+ "client_login": false
+}
+```
+
+### Response Body (JSON)
+
+```json
+{
+ "code": "ok",
+ "message": "foo",
+ "response": "abc"
+}
+```
+
+- `response`: is the `sessionID`
+
+## Get Client ID Endpoint
+
+* URL: `?client_get_id`
+* HTTP Method: `POST`
+
+- function `client_get_id`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/client.inc.php#L97
+- TABLE `sys_user`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L1852
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/client_get_id.html
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/client_get_id.php
+
+### Request Body (JSON)
+
+```json
+{
+ "session_id": "",
+ "sys_userid": ""
+}
+```
+
+### Response Body (JSON)
+
+```json
+{
+ "code": "ok",
+ "message": "foo",
+ "response": 123
+}
+```
+
+## DNS Zone Get ID Endpoint
+
+* URL: `?dns_zone_get_id`
+* HTTP Method: `POST`
+
+- function `dns_zone_get_id`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L142
+- TABLE `dns_soa`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L615
+
+### Request Body (JSON)
+
+```json
+{
+ "session_id": "",
+ "origin": ""
+}
+```
+
+### Response Body (JSON)
+
+```json
+{
+ "code": "ok",
+ "message": "foo",
+ "response": 123
+}
+```
+
+## DNS Zone Get Endpoint
+
+* URL: `?dns_zone_get`
+* HTTP Method: `POST`
+
+- function `dns_zone_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L87
+- function `getDataRecord`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remoting_lib.inc.php#L248
+- TABLE `dns_soa`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L615
+- Depending on the request, the response may be an array or an object (`primary_id` can be a string, an array or an object).
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_zone_get.html
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_zone_get.php
+
+### Request Body (JSON)
+
+```json
+{
+ "session_id": "",
+ "primary_id": ""
+}
+```
+
+### Response Body (JSON)
+
+```json
+{
+ "code": "ok",
+ "message": "foo",
+ "response": {
+ "id": 456,
+ "server_id": 123,
+ "sys_userid": 789
+ }
+}
+```
+
+## DNS TXT Get Endpoint
+
+* URL: `?dns_txt_get`
+* HTTP Method: `POST`
+
+- function `dns_txt_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L640
+- function `dns_rr_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L195
+- form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php
+- TABLE `dns_rr`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L490
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_get.html
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_get.php
+
+### Request Body (JSON)
+
+```json
+{
+ "session_id": "",
+ "primary_id": {
+ "name": ".",
+ "type": "TXT"
+ }
+}
+```
+
+### Response Body (JSON)
+
+```json
+{
+ "code": "ok",
+ "message": "foo",
+ "response": {
+ "id": 123
+ }
+}
+```
+
+## DNS TXT Add Endpoint
+
+* URL: `?dns_txt_add`
+* HTTP Method: `POST`
+
+- function `dns_txt_add`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L645
+- function `dns_rr_add` https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L212
+- form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_add.html
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_add.php
+
+### Request Body (JSON)
+
+```json
+{
+ "session_id": "",
+ "client_id": "",
+ "params": {
+ "server_id": "",
+ "zone": "",
+ "name": ".",
+ "type": "txt",
+ "data": "",
+ "aux": "0",
+ "ttl": "3600",
+ "active": "y",
+ "stamp": "",
+ "update_serial": true
+ },
+ "update_serial": true
+}
+```
+
+- `stamp`: (ex: `2025-12-17 23:35:58`)
+- `serial`: (ex: `1766010947`)
+
+### Response Body (JSON)
+
+```json
+{
+ "code": "ok",
+ "message": "foo",
+ "response": "123"
+}
+```
+
+## DNS TXT Delete Endpoint
+
+* URL: `?dns_txt_delete`
+* HTTP Method: `POST`
+
+- function `dns_txt_delete`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L655
+- function `dns_rr_delete`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L247
+- form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_delete.html
+- https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_delete.php
+
+### Request Body (JSON)
+
+```json
+{
+ "session_id": "",
+ "primary_id": "",
+ "update_serial": true
+}
+```
+
+### Response Body (JSON)
+
+```json
+{
+ "code": "ok",
+ "message": "foo",
+ "response": 1
+}
+```
+
+---
+
+https://www.ispconfig.org/
+https://git.ispconfig.org/ispconfig/ispconfig3
+https://forum.howtoforge.com/#ispconfig-3.23
diff --git a/providers/dns/ispconfig/internal/types.go b/providers/dns/ispconfig/internal/types.go
new file mode 100644
index 000000000..7db0846cc
--- /dev/null
+++ b/providers/dns/ispconfig/internal/types.go
@@ -0,0 +1,95 @@
+package internal
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+type APIError struct {
+ APIResponse
+}
+
+func (e *APIError) Error() string {
+ var msg strings.Builder
+
+ msg.WriteString("code: " + e.Code)
+
+ if e.Message != "" {
+ msg.WriteString(", message: " + e.Message)
+ }
+
+ if len(e.Response) > 0 {
+ msg.WriteString(", response: " + string(e.Response))
+ }
+
+ return msg.String()
+}
+
+type APIResponse struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Response json.RawMessage `json:"response"`
+}
+
+type LoginRequest struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ ClientLogin bool `json:"client_login"`
+}
+
+type ClientIDRequest struct {
+ SessionID string `json:"session_id"`
+ SysUserID string `json:"sys_userid"`
+}
+
+type Zone struct {
+ ID string `json:"id"`
+ ServerID string `json:"server_id"`
+ SysUserID string `json:"sys_userid"`
+ SysGroupID string `json:"sys_groupid"`
+ Origin string `json:"origin"`
+ Serial string `json:"serial"`
+ Active string `json:"active"`
+}
+
+type GetTXTRequest struct {
+ SessionID string `json:"session_id"`
+ PrimaryID struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ } `json:"primary_id"`
+}
+
+type Record struct {
+ ID int `json:"id"`
+}
+
+type AddTXTRequest struct {
+ SessionID string `json:"session_id"`
+ ClientID string `json:"client_id"`
+ Params *RecordParams `json:"params,omitempty"`
+ UpdateSerial bool `json:"update_serial"`
+}
+
+type RecordParams struct {
+ ServerID string `json:"server_id"`
+ Zone string `json:"zone"`
+ Name string `json:"name"`
+ // 'a','aaaa','alias','cname','hinfo','mx','naptr','ns','ds','ptr','rp','srv','txt'
+ Type string `json:"type"`
+ Data string `json:"data"`
+ // "0"
+ Aux string `json:"aux"`
+ TTL string `json:"ttl"`
+ // 'n','y'
+ Active string `json:"active"`
+ // `2025-12-17 23:35:58`
+ Stamp string `json:"stamp"`
+ UpdateSerial bool `json:"update_serial"`
+}
+
+type DeleteTXTRequest struct {
+ SessionID string `json:"session_id"`
+ PrimaryID string `json:"primary_id"`
+ UpdateSerial bool `json:"update_serial"`
+}
diff --git a/providers/dns/ispconfig/ispconfig.go b/providers/dns/ispconfig/ispconfig.go
new file mode 100644
index 000000000..9396430b7
--- /dev/null
+++ b/providers/dns/ispconfig/ispconfig.go
@@ -0,0 +1,220 @@
+// Package ispconfig implements a DNS provider for solving the DNS-01 challenge using ISPConfig.
+package ispconfig
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/ispconfig/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ISPCONFIG_"
+
+ EnvServerURL = envNamespace + "SERVER_URL"
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ EnvTTL = envNamespace + "TTL"
+ 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 {
+ ServerURL string
+ Username string
+ Password string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+ InsecureSkipVerify bool
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ recordIDs map[string]string
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for ISPConfig.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("ispconfig: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.ServerURL = values[EnvServerURL]
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+ config.InsecureSkipVerify = env.GetOrDefaultBool(EnvInsecureSkipVerify, false)
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ISPConfig.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("ispconfig: the configuration of the DNS provider is nil")
+ }
+
+ if config.ServerURL == "" {
+ return nil, errors.New("ispconfig: missing server URL")
+ }
+
+ if config.Username == "" || config.Password == "" {
+ return nil, errors.New("ispconfig: credentials missing")
+ }
+
+ client, err := internal.NewClient(config.ServerURL)
+ if err != nil {
+ return nil, fmt.Errorf("ispconfig: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ if config.InsecureSkipVerify {
+ client.HTTPClient.Transport = &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]string),
+ }, nil
+}
+
+// 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)
+
+ sessionID, err := d.client.Login(ctx, d.config.Username, d.config.Password)
+ if err != nil {
+ return fmt.Errorf("ispconfig: login: %w", err)
+ }
+
+ zoneID, err := d.findZone(ctx, sessionID, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("ispconfig: get zone id: %w", err)
+ }
+
+ zone, err := d.client.GetZone(ctx, sessionID, strconv.Itoa(zoneID))
+ if err != nil {
+ return fmt.Errorf("ispconfig: get zone: %w", err)
+ }
+
+ clientID, err := d.client.GetClientID(ctx, sessionID, zone.SysUserID)
+ if err != nil {
+ return fmt.Errorf("ispconfig: get client id: %w", err)
+ }
+
+ params := internal.RecordParams{
+ ServerID: "serverA",
+ Zone: zone.ID,
+ Name: info.EffectiveFQDN,
+ Type: "txt",
+ Data: info.Value,
+ Aux: "0",
+ TTL: strconv.Itoa(d.config.TTL),
+ Active: "y",
+ Stamp: time.Now().UTC().Format("2006-01-02 15:04:05"),
+ }
+
+ recordID, err := d.client.AddTXT(ctx, sessionID, strconv.Itoa(clientID), params)
+ if err != nil {
+ return fmt.Errorf("ispconfig: add txt record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = 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
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("ispconfig: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ sessionID, err := d.client.Login(ctx, d.config.Username, d.config.Password)
+ if err != nil {
+ return fmt.Errorf("ispconfig: login: %w", err)
+ }
+
+ _, err = d.client.DeleteTXT(ctx, sessionID, recordID)
+ if err != nil {
+ return fmt.Errorf("ispconfig: delete txt 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
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, sessionID, fqdn string) (int, error) {
+ for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
+ zoneID, err := d.client.GetZoneID(ctx, sessionID, domain)
+ if err == nil {
+ return zoneID, nil
+ }
+ }
+
+ return 0, fmt.Errorf("zone not found for %q", fqdn)
+}
diff --git a/providers/dns/ispconfig/ispconfig.toml b/providers/dns/ispconfig/ispconfig.toml
new file mode 100644
index 000000000..4defd5509
--- /dev/null
+++ b/providers/dns/ispconfig/ispconfig.toml
@@ -0,0 +1,27 @@
+Name = "ISPConfig 3"
+Description = ''''''
+URL = "https://www.ispconfig.org/"
+Code = "ispconfig"
+Since = "v4.31.0"
+
+Example = '''
+ISPCONFIG_SERVER_URL="https://example.com:8080/remote/json.php" \
+ISPCONFIG_USERNAME="xxx" \
+ISPCONFIG_PASSWORD="yyy" \
+lego --dns ispconfig -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ISPCONFIG_SERVER_URL = "Server URL"
+ ISPCONFIG_USERNAME = "Username"
+ ISPCONFIG_PASSWORD = "Password"
+ [Configuration.Additional]
+ ISPCONFIG_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate"
+ ISPCONFIG_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ISPCONFIG_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ISPCONFIG_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ ISPCONFIG_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/index.html"
diff --git a/providers/dns/ispconfig/ispconfig_test.go b/providers/dns/ispconfig/ispconfig_test.go
new file mode 100644
index 000000000..b03463aee
--- /dev/null
+++ b/providers/dns/ispconfig/ispconfig_test.go
@@ -0,0 +1,173 @@
+package ispconfig
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvServerURL,
+ EnvUsername,
+ EnvPassword,
+).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com:80/",
+ EnvUsername: "user",
+ EnvPassword: "secret",
+ },
+ },
+ {
+ desc: "missing server URL",
+ envVars: map[string]string{
+ EnvServerURL: "",
+ EnvUsername: "user",
+ EnvPassword: "secret",
+ },
+ expected: "ispconfig: some credentials information are missing: ISPCONFIG_SERVER_URL",
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com:80/",
+ EnvUsername: "",
+ EnvPassword: "secret",
+ },
+ expected: "ispconfig: some credentials information are missing: ISPCONFIG_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com:80/",
+ EnvUsername: "user",
+ EnvPassword: "",
+ },
+ expected: "ispconfig: some credentials information are missing: ISPCONFIG_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "ispconfig: some credentials information are missing: ISPCONFIG_SERVER_URL,ISPCONFIG_USERNAME,ISPCONFIG_PASSWORD",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ serverURL string
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ serverURL: "https://example.com:80/",
+ username: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing server URL",
+ username: "user",
+ password: "secret",
+ expected: "ispconfig: missing server URL",
+ },
+ {
+ desc: "missing username",
+ serverURL: "https://example.com:80/",
+ password: "secret",
+ expected: "ispconfig: credentials missing",
+ },
+ {
+ desc: "missing password",
+ serverURL: "https://example.com:80/",
+ username: "user",
+ expected: "ispconfig: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "ispconfig: missing server URL",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.ServerURL = test.serverURL
+ config.Username = test.username
+ config.Password = test.password
+
+ 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/dns/ispconfigddns/internal/client.go b/providers/dns/ispconfigddns/internal/client.go
new file mode 100644
index 000000000..700b58f89
--- /dev/null
+++ b/providers/dns/ispconfigddns/internal/client.go
@@ -0,0 +1,111 @@
+package internal
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ querystring "github.com/google/go-querystring/query"
+)
+
+const (
+ addAction = "add"
+ deleteAction = "delete"
+)
+
+type Client struct {
+ token string
+ serverURL string
+
+ HTTPClient *http.Client
+}
+
+func NewClient(serverURL, token string) (*Client, error) {
+ _, err := url.Parse(serverURL)
+ if err != nil {
+ return nil, fmt.Errorf("server URL: %w", err)
+ }
+
+ return &Client{
+ serverURL: serverURL,
+ token: token,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, zone, fqdn, content string) error {
+ return c.updateRecord(ctx, UpdateRecord{Action: addAction, Zone: zone, Type: "TXT", Record: fqdn, Data: content})
+}
+
+func (c *Client) DeleteTXTRecord(ctx context.Context, zone, fqdn, recordContent string) error {
+ return c.updateRecord(ctx, UpdateRecord{Action: deleteAction, Zone: zone, Type: "TXT", Record: fqdn, Data: recordContent})
+}
+
+func (c *Client) updateRecord(ctx context.Context, action UpdateRecord) error {
+ req, err := c.newRequest(ctx, action)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req)
+}
+
+func (c *Client) do(req *http.Request) error {
+ useragent.SetHeader(req.Header)
+
+ req.SetBasicAuth("anonymous", c.token)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ // The endpoint uses the `DefaultDdnsResponseWriter`,
+ // and this writer uses HTTP status code to determine if the request was successful or not.
+ // - https://github.com/mhofer117/ispconfig-ddns-module/blob/8b011a5bb138881d9f13360a5c4fec10c0084613/lib/updater/DdnsUpdater.php#L53-L57
+ // - https://github.com/mhofer117/ispconfig-ddns-module/blob/master/lib/updater/response/DefaultDdnsResponseWriter.php
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return nil
+}
+
+func (c *Client) newRequest(ctx context.Context, action UpdateRecord) (*http.Request, error) {
+ endpoint, err := url.Parse(c.serverURL)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint = endpoint.JoinPath("ddns", "update.php")
+
+ values, err := querystring.Values(action)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint.RawQuery = values.Encode()
+
+ method := http.MethodPost
+ if action.Action == deleteAction {
+ method = http.MethodDelete
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ return req, nil
+}
diff --git a/providers/dns/ispconfigddns/internal/client_test.go b/providers/dns/ispconfigddns/internal/client_test.go
new file mode 100644
index 000000000..774e5ee46
--- /dev/null
+++ b/providers/dns/ispconfigddns/internal/client_test.go
@@ -0,0 +1,83 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func setupClient(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+
+ return client, nil
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /ddns/update.php",
+ servermock.Noop(),
+ servermock.CheckHeader().
+ WithBasicAuth("anonymous", "secret"),
+ servermock.CheckQueryParameter().Strict().
+ With("action", "add").
+ With("zone", "example.com").
+ With("type", "TXT").
+ With("record", "_acme-challenge.example.com.").
+ With("data", "token"),
+ ).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token")
+ require.NoError(t, err)
+}
+
+func TestClient_AddTXTRecord_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /ddns/update.php",
+ servermock.RawStringResponse("Missing or invalid token.").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token")
+ require.EqualError(t, err, "unexpected status code: [status code: 401] body: Missing or invalid token.")
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("DELETE /ddns/update.php",
+ servermock.Noop(),
+ servermock.CheckHeader().
+ WithBasicAuth("anonymous", "secret"),
+ servermock.CheckQueryParameter().Strict().
+ With("action", "delete").
+ With("zone", "example.com").
+ With("type", "TXT").
+ With("record", "_acme-challenge.example.com.").
+ With("data", "token"),
+ ).
+ Build(t)
+
+ err := client.DeleteTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteTXTRecord_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("DELETE /ddns/update.php",
+ servermock.RawStringResponse("Missing or invalid token.").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ err := client.DeleteTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token")
+ require.EqualError(t, err, "unexpected status code: [status code: 401] body: Missing or invalid token.")
+}
diff --git a/providers/dns/ispconfigddns/internal/types.go b/providers/dns/ispconfigddns/internal/types.go
new file mode 100644
index 000000000..278738108
--- /dev/null
+++ b/providers/dns/ispconfigddns/internal/types.go
@@ -0,0 +1,9 @@
+package internal
+
+type UpdateRecord struct {
+ Action string `url:"action,omitempty"`
+ Zone string `url:"zone,omitempty"`
+ Type string `url:"type,omitempty"`
+ Record string `url:"record,omitempty"`
+ Data string `url:"data,omitempty"`
+}
diff --git a/providers/dns/ispconfigddns/ispconfigddns.go b/providers/dns/ispconfigddns/ispconfigddns.go
new file mode 100644
index 000000000..eab5d413f
--- /dev/null
+++ b/providers/dns/ispconfigddns/ispconfigddns.go
@@ -0,0 +1,145 @@
+// Package ispconfigddns implements a DNS provider for solving the DNS-01 challenge using ISPConfig 3 Dynamic DNS (DDNS) Module.
+package ispconfigddns
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/ispconfigddns/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ISPCONFIG_DDNS_"
+
+ EnvServerURL = envNamespace + "SERVER_URL"
+ EnvToken = envNamespace + "TOKEN"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ ServerURL 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, 3600),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for ISPConfig 3 Dynamic DNS (DDNS) Module.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvServerURL, EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("ispconfig (DDNS module): %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.ServerURL = values[EnvServerURL]
+ config.Token = values[EnvToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ISPConfig 3 Dynamic DNS (DDNS) Module.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("ispconfig (DDNS module): the configuration of the DNS provider is nil")
+ }
+
+ if config.ServerURL == "" {
+ return nil, errors.New("ispconfig (DDNS module): missing server URL")
+ }
+
+ if config.Token == "" {
+ return nil, errors.New("ispconfig (DDNS module): missing token")
+ }
+
+ client, err := internal.NewClient(config.ServerURL, config.Token)
+ if err != nil {
+ return nil, fmt.Errorf("ispconfig (DDNS module): %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to control checking compliance to spec.
+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)
+
+ zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("ispconfig (DDNS module): could not find zone for domain %q: %w", domain, err)
+ }
+
+ err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(zone), info.EffectiveFQDN, info.Value)
+ if err != nil {
+ return fmt.Errorf("ispconfig (DDNS module): add record: %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)
+
+ zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("ispconfig (DDNS module): could not find zone for domain %q: %w", domain, err)
+ }
+
+ err = d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(zone), info.EffectiveFQDN, info.Value)
+ if err != nil {
+ return fmt.Errorf("ispconfig (DDNS module): delete record: %w", err)
+ }
+
+ return nil
+}
diff --git a/providers/dns/ispconfigddns/ispconfigddns.toml b/providers/dns/ispconfigddns/ispconfigddns.toml
new file mode 100644
index 000000000..158ee9fbd
--- /dev/null
+++ b/providers/dns/ispconfigddns/ispconfigddns.toml
@@ -0,0 +1,32 @@
+Name = "ISPConfig 3 - Dynamic DNS (DDNS) Module"
+Description = ''''''
+URL = "https://www.ispconfig.org/"
+Code = "ispconfigddns"
+Since = "v4.31.0"
+
+Example = '''
+ISPCONFIG_DDNS_SERVER_URL="https://panel.example.com:8080" \
+ISPCONFIG_DDNS_TOKEN=xxxxxx \
+lego --dns ispconfigddns -d '*.example.com' -d example.com run
+'''
+
+Additional = '''
+ISPConfig DNS provider supports leveraging the [ISPConfig 3 Dynamic DNS (DDNS) Module](https://github.com/mhofer117/ispconfig-ddns-module).
+
+Requires the DDNS module described at https://www.ispconfig.org/ispconfig/download/
+
+See https://www.howtoforge.com/community/threads/ispconfig-3-danymic-dns-ddns-module.87967/ for additional details.
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ISPCONFIG_DDNS_SERVER_URL = "API server URL (ex: https://panel.example.com:8080)"
+ ISPCONFIG_DDNS_TOKEN = "DDNS API token"
+ [Configuration.Additional]
+ ISPCONFIG_DDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ISPCONFIG_DDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ISPCONFIG_DDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ ISPCONFIG_DDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://github.com/mhofer117/ispconfig-ddns-module/tree/master/lib/updater"
diff --git a/providers/dns/ispconfigddns/ispconfigddns_test.go b/providers/dns/ispconfigddns/ispconfigddns_test.go
new file mode 100644
index 000000000..58e7a8f54
--- /dev/null
+++ b/providers/dns/ispconfigddns/ispconfigddns_test.go
@@ -0,0 +1,193 @@
+package ispconfigddns
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvServerURL, EnvToken).
+ WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com",
+ EnvToken: "secret",
+ },
+ },
+ {
+ desc: "missing server URL",
+ envVars: map[string]string{
+ EnvServerURL: "",
+ EnvToken: "secret",
+ },
+ expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_SERVER_URL",
+ },
+ {
+ desc: "missing token",
+ envVars: map[string]string{
+ EnvServerURL: "https://example.com",
+ EnvToken: "",
+ },
+ expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_TOKEN",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_SERVER_URL,ISPCONFIG_DDNS_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
+ serverURL string
+ token string
+ expected string
+ }{
+ {
+ desc: "success",
+ serverURL: "https://example.com",
+ token: "secret",
+ },
+ {
+ desc: "missing server URL",
+ serverURL: "",
+ token: "secret",
+ expected: "ispconfig (DDNS module): missing server URL",
+ },
+ {
+ desc: "missing token",
+ serverURL: "https://example.com",
+ token: "",
+ expected: "ispconfig (DDNS module): missing token",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.ServerURL = test.serverURL
+ config.Token = test.token
+
+ 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)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.Token = "secret"
+ config.ServerURL = server.URL
+
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithBasicAuth("anonymous", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /ddns/update.php",
+ servermock.DumpRequest(),
+ servermock.CheckQueryParameter().Strict().
+ With("action", "add").
+ With("zone", "example.com").
+ With("type", "TXT").
+ With("record", "_acme-challenge.example.com.").
+ With("data", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /ddns/update.php",
+ servermock.DumpRequest(),
+ servermock.CheckQueryParameter().Strict().
+ With("action", "delete").
+ With("zone", "example.com").
+ With("type", "TXT").
+ With("record", "_acme-challenge.example.com.").
+ With("data", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/iwantmyname/internal/client.go b/providers/dns/iwantmyname/internal/client.go
deleted file mode 100644
index 7a7c50e20..000000000
--- a/providers/dns/iwantmyname/internal/client.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package internal
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "time"
-
- "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
- querystring "github.com/google/go-querystring/query"
-)
-
-const defaultBaseURL = "https://iwantmyname.com/basicauth/ddns"
-
-// Client iwantmyname client.
-type Client struct {
- username string
- password string
-
- baseURL *url.URL
- HTTPClient *http.Client
-}
-
-// NewClient creates a new Client.
-func NewClient(username string, password string) *Client {
- baseURL, _ := url.Parse(defaultBaseURL)
-
- return &Client{
- username: username,
- password: password,
- baseURL: baseURL,
- HTTPClient: &http.Client{Timeout: 10 * time.Second},
- }
-}
-
-// SendRequest send a request (create/add/delete) to the API.
-func (c Client) SendRequest(ctx context.Context, record Record) error {
- values, err := querystring.Values(record)
- if err != nil {
- return err
- }
-
- endpoint := c.baseURL
- endpoint.RawQuery = values.Encode()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody)
- if err != nil {
- return fmt.Errorf("unable to create request: %w", err)
- }
-
- req.SetBasicAuth(c.username, c.password)
-
- 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)
- }
-
- return nil
-}
diff --git a/providers/dns/iwantmyname/internal/client_test.go b/providers/dns/iwantmyname/internal/client_test.go
deleted file mode 100644
index b26f7c0f0..000000000
--- a/providers/dns/iwantmyname/internal/client_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package internal
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/http/httptest"
- "net/url"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func checkParameter(query url.Values, key, expected string) error {
- if query.Get(key) != expected {
- return fmt.Errorf("%s: want %s got %s", key, expected, query.Get(key))
- }
- return nil
-}
-
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("user", "secret")
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func TestClient_Do(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- 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
- }
-
- query := req.URL.Query()
-
- values := map[string]string{
- "hostname": "example.com",
- "type": "TXT",
- "value": "data",
- "ttl": "120",
- }
-
- for k, v := range values {
- err := checkParameter(query, k, v)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
- }
- })
-
- record := Record{
- Hostname: "example.com",
- Type: "TXT",
- Value: "data",
- TTL: 120,
- }
-
- err := client.SendRequest(context.Background(), record)
- require.NoError(t, err)
-}
diff --git a/providers/dns/iwantmyname/internal/types.go b/providers/dns/iwantmyname/internal/types.go
deleted file mode 100644
index b259235f5..000000000
--- a/providers/dns/iwantmyname/internal/types.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package internal
-
-// Record represents a record.
-type Record struct {
- Hostname string `url:"hostname,omitempty"`
- Type string `url:"type,omitempty"`
- Value string `url:"value,omitempty"`
- TTL int `url:"ttl,omitempty"`
-}
diff --git a/providers/dns/iwantmyname/iwantmyname.go b/providers/dns/iwantmyname/iwantmyname.go
index 2b53377ed..f53287e69 100644
--- a/providers/dns/iwantmyname/iwantmyname.go
+++ b/providers/dns/iwantmyname/iwantmyname.go
@@ -2,16 +2,13 @@
package iwantmyname
import (
- "context"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-acme/lego/v4/challenge"
- "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/iwantmyname/internal"
)
// Environment variables names.
@@ -41,20 +38,12 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
- return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
- HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
- },
- }
+ return &Config{}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
- client *internal.Client
}
// NewDNSProvider returns a DNSProvider instance configured for iwantmyname.
@@ -74,24 +63,7 @@ func NewDNSProvider() (*DNSProvider, error) {
// NewDNSProviderConfig return a DNSProvider instance configured for iwantmyname.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
- if config == nil {
- return nil, errors.New("iwantmyname: the configuration of the DNS provider is nil")
- }
-
- if config.Username == "" || config.Password == "" {
- return nil, errors.New("iwantmyname: credentials missing")
- }
-
- client := internal.NewClient(config.Username, config.Password)
-
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- return &DNSProvider{
- config: config,
- client: client,
- }, nil
+ return nil, errors.New("iwantmyname: the iwantmyname API has shut down https://github.com/go-acme/lego/issues/2563")
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -102,38 +74,10 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, _, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- record := internal.Record{
- Hostname: dns01.UnFqdn(info.EffectiveFQDN),
- Type: "TXT",
- Value: info.Value,
- TTL: d.config.TTL,
- }
-
- err := d.client.SendRequest(context.Background(), record)
- if err != nil {
- return fmt.Errorf("iwantmyname: %w", err)
- }
-
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- record := internal.Record{
- Hostname: dns01.UnFqdn(info.EffectiveFQDN),
- Type: "TXT",
- Value: "delete",
- TTL: d.config.TTL,
- }
-
- err := d.client.SendRequest(context.Background(), record)
- if err != nil {
- return fmt.Errorf("iwantmyname: %w", err)
- }
-
return nil
}
diff --git a/providers/dns/iwantmyname/iwantmyname.toml b/providers/dns/iwantmyname/iwantmyname.toml
index 678977029..a82c2b749 100644
--- a/providers/dns/iwantmyname/iwantmyname.toml
+++ b/providers/dns/iwantmyname/iwantmyname.toml
@@ -1,5 +1,9 @@
-Name = "iwantmyname"
-Description = ''''''
+Name = "iwantmyname (Deprecated)"
+Description = '''
+The iwantmyname API has shut down.
+
+https://github.com/go-acme/lego/issues/2563
+'''
URL = "https://iwantmyname.com"
Code = "iwantmyname"
Since = "v4.7.0"
@@ -7,7 +11,7 @@ Since = "v4.7.0"
Example = '''
IWANTMYNAME_USERNAME=xxxxxxxx \
IWANTMYNAME_PASSWORD=xxxxxxxx \
-lego --email you@example.com --dns iwantmyname -d '*.example.com' -d example.com run
+lego --dns iwantmyname -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +19,10 @@ lego --email you@example.com --dns iwantmyname -d '*.example.com' -d example.com
IWANTMYNAME_USERNAME = "API username"
IWANTMYNAME_PASSWORD = "API password"
[Configuration.Additional]
- IWANTMYNAME_POLLING_INTERVAL = "Time between DNS propagation check"
- IWANTMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- IWANTMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge"
- IWANTMYNAME_HTTP_TIMEOUT = "API request timeout"
+ IWANTMYNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ IWANTMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ IWANTMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ IWANTMYNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://iwantmyname.com/developer/domain-dns-api"
diff --git a/providers/dns/jdcloud/fixtures/create_record-request.json b/providers/dns/jdcloud/fixtures/create_record-request.json
new file mode 100644
index 000000000..581c00fea
--- /dev/null
+++ b/providers/dns/jdcloud/fixtures/create_record-request.json
@@ -0,0 +1,15 @@
+{
+ "domainId": "20",
+ "regionId": "cn-north-1",
+ "req": {
+ "hostRecord": "_acme-challenge",
+ "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "jcloudRes": null,
+ "mxPriority": null,
+ "port": null,
+ "ttl": 120,
+ "type": "TXT",
+ "viewValue": -1,
+ "weight": null
+ }
+}
diff --git a/providers/dns/jdcloud/fixtures/create_record.json b/providers/dns/jdcloud/fixtures/create_record.json
new file mode 100644
index 000000000..08bd3db26
--- /dev/null
+++ b/providers/dns/jdcloud/fixtures/create_record.json
@@ -0,0 +1,25 @@
+{
+ "requestId": "azerty",
+ "error": {
+ "code": 0,
+ "status": "",
+ "message": ""
+ },
+ "result": {
+ "dataList": {
+ "id": 123,
+ "hostRecord": "_acme-challenge",
+ "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "jcloudRes": false,
+ "mxPriority": 0,
+ "port": 0,
+ "ttl": 120,
+ "type": "TXT",
+ "weight": 0,
+ "viewValue": [
+ 1,
+ 2
+ ]
+ }
+ }
+}
diff --git a/providers/dns/jdcloud/fixtures/delete_record.json b/providers/dns/jdcloud/fixtures/delete_record.json
new file mode 100644
index 000000000..20525751c
--- /dev/null
+++ b/providers/dns/jdcloud/fixtures/delete_record.json
@@ -0,0 +1,9 @@
+{
+ "requestId": "azerty",
+ "error": {
+ "code": 0,
+ "status": "",
+ "message": ""
+ },
+ "result": {}
+}
diff --git a/providers/dns/jdcloud/fixtures/describe_domains_page1.json b/providers/dns/jdcloud/fixtures/describe_domains_page1.json
new file mode 100644
index 000000000..cde6dcd6f
--- /dev/null
+++ b/providers/dns/jdcloud/fixtures/describe_domains_page1.json
@@ -0,0 +1,55 @@
+{
+ "requestId": "azerty",
+ "error": {
+ "code": 0,
+ "status": "",
+ "message": ""
+ },
+ "result": {
+ "dataList": [
+ {
+ "id": 1,
+ "domainName": "1.example"
+ },
+ {
+ "id": 2,
+ "domainName": "2.example"
+ },
+ {
+ "id": 3,
+ "domainName": "3.example"
+ },
+ {
+ "id": 4,
+ "domainName": "4.example"
+ },
+ {
+ "id": 5,
+ "domainName": "5.example"
+ },
+ {
+ "id": 6,
+ "domainName": "6.example"
+ },
+ {
+ "id": 7,
+ "domainName": "7.example"
+ },
+ {
+ "id": 8,
+ "domainName": "8.example"
+ },
+ {
+ "id": 9,
+ "domainName": "9.example"
+ },
+ {
+ "id": 10,
+ "domainName": "10.example"
+ }
+ ],
+ "currentCount": 10,
+ "totalCount": 20,
+ "totalPage": 2
+ }
+}
diff --git a/providers/dns/jdcloud/fixtures/describe_domains_page2.json b/providers/dns/jdcloud/fixtures/describe_domains_page2.json
new file mode 100644
index 000000000..b1e1560ab
--- /dev/null
+++ b/providers/dns/jdcloud/fixtures/describe_domains_page2.json
@@ -0,0 +1,55 @@
+{
+ "requestId": "azerty",
+ "error": {
+ "code": 0,
+ "status": "",
+ "message": ""
+ },
+ "result": {
+ "dataList": [
+ {
+ "id": 11,
+ "domainName": "11.example"
+ },
+ {
+ "id": 12,
+ "domainName": "12.example"
+ },
+ {
+ "id": 13,
+ "domainName": "13.example"
+ },
+ {
+ "id": 14,
+ "domainName": "14.example"
+ },
+ {
+ "id": 15,
+ "domainName": "15.example"
+ },
+ {
+ "id": 16,
+ "domainName": "16.example"
+ },
+ {
+ "id": 17,
+ "domainName": "17.example"
+ },
+ {
+ "id": 18,
+ "domainName": "18.example"
+ },
+ {
+ "id": 19,
+ "domainName": "19.example"
+ },
+ {
+ "id": 20,
+ "domainName": "example.com"
+ }
+ ],
+ "currentCount": 10,
+ "totalCount": 20,
+ "totalPage": 2
+ }
+}
diff --git a/providers/dns/jdcloud/jdcloud.go b/providers/dns/jdcloud/jdcloud.go
new file mode 100644
index 000000000..7d9ad4e6b
--- /dev/null
+++ b/providers/dns/jdcloud/jdcloud.go
@@ -0,0 +1,217 @@
+// Package jdcloud implements a DNS provider for solving the DNS-01 challenge using JD Cloud.
+package jdcloud
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-acme/jdcloud-sdk-go/core"
+ "github.com/go-acme/jdcloud-sdk-go/services/domainservice/apis"
+ jdcclient "github.com/go-acme/jdcloud-sdk-go/services/domainservice/client"
+ domainservice "github.com/go-acme/jdcloud-sdk-go/services/domainservice/models"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "JDCLOUD_"
+
+ EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID"
+ EnvAccessKeySecret = envNamespace + "ACCESS_KEY_SECRET"
+ EnvRegionID = envNamespace + "REGION_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 {
+ AccessKeyID string
+ AccessKeySecret string
+ RegionID string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPTimeout time.Duration
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *jdcclient.DomainserviceClient
+
+ recordIDs map[string]int
+ domainIDs map[string]int
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for JD Cloud.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAccessKeyID, EnvAccessKeySecret)
+ if err != nil {
+ return nil, fmt.Errorf("jdcloud: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.AccessKeyID = values[EnvAccessKeyID]
+ config.AccessKeySecret = values[EnvAccessKeySecret]
+
+ // https://docs.jdcloud.com/en/common-declaration/api/introduction#Region%20Code
+ config.RegionID = env.GetOrDefaultString(EnvRegionID, "cn-north-1")
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for JD Cloud.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("jdcloud: the configuration of the DNS provider is nil")
+ }
+
+ if config.AccessKeyID == "" || config.AccessKeySecret == "" {
+ return nil, errors.New("jdcloud: missing credentials")
+ }
+
+ cred := core.NewCredentials(config.AccessKeyID, config.AccessKeySecret)
+
+ client := jdcclient.NewDomainserviceClient(cred)
+ client.DisableLogger()
+ client.Config.SetTimeout(config.HTTPTimeout)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int),
+ domainIDs: make(map[string]int),
+ }, 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("jdcloud: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("jdcloud: %w", err)
+ }
+
+ zone, err := d.findZone(dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("jdcloud: %w", err)
+ }
+
+ // https://docs.jdcloud.com/cn/jd-cloud-dns/api/createresourcerecord
+ crrr := apis.NewCreateResourceRecordRequestWithAllParams(
+ d.config.RegionID,
+ strconv.Itoa(zone.Id),
+ &domainservice.AddRR{
+ HostRecord: subDomain,
+ HostValue: info.Value,
+ Ttl: d.config.TTL,
+ Type: "TXT",
+ ViewValue: -1,
+ },
+ )
+
+ record, err := jdcclient.CreateResourceRecord(d.client, crrr)
+ if err != nil {
+ return fmt.Errorf("jdcloud: create resource record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.domainIDs[token] = zone.Id
+ d.recordIDs[token] = record.Result.DataList.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)
+
+ d.recordIDsMu.Lock()
+ recordID, recordOK := d.recordIDs[token]
+ domainID, domainOK := d.domainIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !recordOK {
+ return fmt.Errorf("jdcloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ if !domainOK {
+ return fmt.Errorf("jdcloud: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ // https://docs.jdcloud.com/cn/jd-cloud-dns/api/deleteresourcerecord
+ drrr := apis.NewDeleteResourceRecordRequestWithAllParams(
+ d.config.RegionID,
+ strconv.Itoa(domainID),
+ strconv.Itoa(recordID),
+ )
+
+ _, err := jdcclient.DeleteResourceRecord(d.client, drrr)
+ if err != nil {
+ return fmt.Errorf("jdcloud: delete resource 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
+}
+
+func (d *DNSProvider) findZone(zone string) (*domainservice.DomainInfo, error) {
+ // https://docs.jdcloud.com/cn/jd-cloud-dns/api/describedomains
+ ddr := apis.NewDescribeDomainsRequestWithoutParam()
+ ddr.SetRegionId(d.config.RegionID)
+ ddr.SetPageNumber(1)
+ ddr.SetPageSize(10)
+ ddr.SetDomainName(zone)
+
+ for {
+ response, err := jdcclient.DescribeDomains(d.client, ddr)
+ if err != nil {
+ return nil, fmt.Errorf("describe domains: %w", err)
+ }
+
+ for _, d := range response.Result.DataList {
+ if d.DomainName == zone {
+ return &d, nil
+ }
+ }
+
+ if len(response.Result.DataList) < ddr.PageSize || response.Result.TotalPage <= ddr.PageNumber {
+ break
+ }
+
+ ddr.SetPageNumber(ddr.PageNumber + 1)
+ }
+
+ return nil, errors.New("zone not found")
+}
diff --git a/providers/dns/jdcloud/jdcloud.toml b/providers/dns/jdcloud/jdcloud.toml
new file mode 100644
index 000000000..7ab403822
--- /dev/null
+++ b/providers/dns/jdcloud/jdcloud.toml
@@ -0,0 +1,27 @@
+Name = "JD Cloud"
+Description = ''''''
+URL = "https://www.jdcloud.com/"
+Code = "jdcloud"
+Since = "v4.31.0"
+
+Example = '''
+JDCLOUD_ACCESS_KEY_ID="xxx" \
+JDCLOUD_ACCESS_KEY_SECRET="yyy" \
+lego --dns jdcloud -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ JDCLOUD_ACCESS_KEY_ID = "Access key ID"
+ JDCLOUD_ACCESS_KEY_SECRET = "Access key secret"
+ [Configuration.Additional]
+ JDCLOUD_REGION_ID = "Region ID (Default: cn-north-1)"
+ JDCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ JDCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ JDCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ JDCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview"
+ Common = "https://docs.jdcloud.com/en/common-declaration/api/introduction"
+ GoClient = "https://github.com/jdcloud-api/jdcloud-sdk-go"
diff --git a/providers/dns/jdcloud/jdcloud_test.go b/providers/dns/jdcloud/jdcloud_test.go
new file mode 100644
index 000000000..6b3368938
--- /dev/null
+++ b/providers/dns/jdcloud/jdcloud_test.go
@@ -0,0 +1,242 @@
+package jdcloud
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvAccessKeyID,
+ EnvAccessKeySecret,
+ EnvRegionID,
+).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAccessKeyID: "abc123",
+ EnvAccessKeySecret: "secret",
+ },
+ },
+ {
+ desc: "missing access key ID",
+ envVars: map[string]string{
+ EnvAccessKeyID: "",
+ EnvAccessKeySecret: "secret",
+ },
+ expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID",
+ },
+ {
+ desc: "missing access key secret",
+ envVars: map[string]string{
+ EnvAccessKeyID: "abc123",
+ EnvAccessKeySecret: "",
+ },
+ expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_SECRET",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID,JDCLOUD_ACCESS_KEY_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
+ accessKeyID string
+ accessKeySecret string
+ expected string
+ }{
+ {
+ desc: "success",
+ accessKeyID: "abc123",
+ accessKeySecret: "secret",
+ },
+ {
+ desc: "missing access key ID",
+ accessKeySecret: "secret",
+ expected: "jdcloud: missing credentials",
+ },
+ {
+ desc: "missing access key secret",
+ accessKeyID: "abc123",
+ expected: "jdcloud: missing credentials",
+ },
+ {
+ desc: "missing credentials",
+ expected: "jdcloud: missing credentials",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.AccessKeyID = test.accessKeyID
+ config.AccessKeySecret = test.accessKeySecret
+ config.RegionID = "cn-north-1"
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.AccessKeyID = "abc123"
+ config.AccessKeySecret = "secret"
+ config.RegionID = "cn-north-1"
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, _ := url.Parse(server.URL)
+
+ p.client.Config.SetEndpoint(net.JoinHostPort(serverURL.Hostname(), serverURL.Port()))
+ p.client.Config.SetScheme(serverURL.Scheme)
+ p.client.Config.SetTimeout(server.Client().Timeout)
+
+ return p, nil
+ },
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /v2/regions/cn-north-1/domain",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ pageNumber := req.URL.Query().Get("pageNumber")
+
+ servermock.ResponseFromFixture(
+ fmt.Sprintf("describe_domains_page%s.json", pageNumber),
+ ).ServeHTTP(rw, req)
+ }),
+ servermock.CheckQueryParameter().Strict().
+ With("domainName", "example.com").
+ WithRegexp("pageNumber", `(1|2)`).
+ With("pageSize", "10"),
+ servermock.CheckHeader().
+ WithRegexp("Authorization",
+ `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`).
+ WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`).
+ WithRegexp("X-Jdcloud-Nonce", `[\w-]+`),
+ ).
+ Route("POST /v2/regions/cn-north-1/domain/20/ResourceRecord",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json"),
+ servermock.CheckHeader().
+ WithRegexp("Authorization",
+ `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`).
+ WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`).
+ WithRegexp("X-Jdcloud-Nonce", `[\w-]+`),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+
+ require.Len(t, provider.domainIDs, 1)
+ require.Len(t, provider.recordIDs, 1)
+
+ assert.Equal(t, 20, provider.domainIDs["abc"])
+ assert.Equal(t, 123, provider.recordIDs["abc"])
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /v2/regions/cn-north-1/domain/20/ResourceRecord/123",
+ servermock.ResponseFromFixture("delete_record.json"),
+ servermock.CheckHeader().
+ WithRegexp("Authorization",
+ `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`).
+ WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`).
+ WithRegexp("X-Jdcloud-Nonce", `[\w-]+`),
+ ).
+ Build(t)
+
+ provider.domainIDs["abc"] = 20
+ provider.recordIDs["abc"] = 123
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/joker/internal/dmapi/client.go b/providers/dns/joker/internal/dmapi/client.go
index 04f4350a9..576410723 100644
--- a/providers/dns/joker/internal/dmapi/client.go
+++ b/providers/dns/joker/internal/dmapi/client.go
@@ -126,7 +126,7 @@ func parseResponse(message string) *Response {
lines, body, _ := strings.Cut(message, "\n\n")
- for _, line := range strings.Split(lines, "\n") {
+ for line := range strings.Lines(lines) {
if strings.TrimSpace(line) == "" {
continue
}
@@ -176,12 +176,15 @@ func RemoveTxtEntryFromZone(zone, relative string) (string, bool) {
prefix := fmt.Sprintf("%s TXT 0 ", relative)
modified := false
+
var zoneEntries []string
- for _, line := range strings.Split(zone, "\n") {
+
+ for line := range strings.Lines(zone) {
if strings.HasPrefix(line, prefix) {
modified = true
continue
}
+
zoneEntries = append(zoneEntries, line)
}
@@ -192,7 +195,7 @@ func RemoveTxtEntryFromZone(zone, relative string) (string, bool) {
func AddTxtEntryToZone(zone, relative, value string, ttl int) string {
var zoneEntries []string
- for _, line := range strings.Split(zone, "\n") {
+ for line := range strings.Lines(zone) {
zoneEntries = append(zoneEntries, fixTxtLines(line))
}
diff --git a/providers/dns/joker/internal/dmapi/client_test.go b/providers/dns/joker/internal/dmapi/client_test.go
index dc6653bf0..5b6d68740 100644
--- a/providers/dns/joker/internal/dmapi/client_test.go
+++ b/providers/dns/joker/internal/dmapi/client_test.go
@@ -7,6 +7,7 @@ import (
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -23,14 +24,17 @@ const (
serverErrorUsername = "error"
)
-func setupTest(t *testing.T) (*http.ServeMux, string) {
- t.Helper()
+func mockBuilder(auth AuthInfo) *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(auth)
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- return mux, server.URL
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
}
func TestClient_GetZone(t *testing.T) {
@@ -70,30 +74,25 @@ func TestClient_GetZone(t *testing.T) {
},
}
- mux, serverURL := setupTest(t)
+ client := mockBuilder(AuthInfo{APIKey: "12345"}).
+ Route("POST /dns-zone-get", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ authSid := req.FormValue("auth-sid")
+ domain := req.FormValue("domain")
- mux.HandleFunc("/dns-zone-get", func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
-
- authSid := r.FormValue("auth-sid")
- domain := r.FormValue("domain")
-
- switch {
- case authSid == correctAPIKey && domain == "known":
- _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone)
- case authSid == incorrectAPIKey || (authSid == correctAPIKey && domain == "unknown"):
- _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: Authorization error")
- default:
- http.NotFound(w, r)
- }
- })
+ switch {
+ case authSid == correctAPIKey && domain == "known":
+ _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone)
+ case authSid == incorrectAPIKey || (authSid == correctAPIKey && domain == "unknown"):
+ _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: Authorization error")
+ default:
+ http.NotFound(rw, req)
+ }
+ })).
+ Build(t)
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := NewClient(AuthInfo{APIKey: "12345"})
- client.BaseURL = serverURL
-
- response, err := client.GetZone(mockContext(test.authSid), test.domain)
+ response, err := client.GetZone(mockContext(t, test.authSid), test.domain)
if test.expectedError {
require.Error(t, err)
} else {
diff --git a/providers/dns/joker/internal/dmapi/identity.go b/providers/dns/joker/internal/dmapi/identity.go
index 351d987e9..63c0b2ea1 100644
--- a/providers/dns/joker/internal/dmapi/identity.go
+++ b/providers/dns/joker/internal/dmapi/identity.go
@@ -24,6 +24,7 @@ type Token struct {
// login performs a log in to Joker's DMAPI.
func (c *Client) login(ctx context.Context) (*Response, error) {
var values url.Values
+
switch {
case c.username != "" && c.password != "":
values = url.Values{
@@ -106,5 +107,6 @@ func formatResponseError(response *Response, err error) error {
if response != nil {
return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers)
}
+
return fmt.Errorf("joker: DMAPI error: %w", err)
}
diff --git a/providers/dns/joker/internal/dmapi/identity_test.go b/providers/dns/joker/internal/dmapi/identity_test.go
index 418deaf4f..d2a80f2e6 100644
--- a/providers/dns/joker/internal/dmapi/identity_test.go
+++ b/providers/dns/joker/internal/dmapi/identity_test.go
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
- "net/http/httptest"
"sync/atomic"
"testing"
"time"
@@ -14,12 +13,14 @@ import (
"github.com/stretchr/testify/require"
)
-func mockContext(sessionID string) context.Context {
+func mockContext(t *testing.T, sessionID string) context.Context {
+ t.Helper()
+
if sessionID == "" {
sessionID = "xxx"
}
- return context.WithValue(context.Background(), sessionIDKey, sessionID)
+ return context.WithValue(t.Context(), sessionIDKey, sessionID)
}
func TestClient_login_apikey(t *testing.T) {
@@ -56,29 +57,24 @@ func TestClient_login_apikey(t *testing.T) {
},
}
- mux, serverURL := setupTest(t)
-
- mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
-
- switch r.FormValue("api-key") {
- case correctAPIKey:
- _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
- case incorrectAPIKey:
- _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
- case serverErrorAPIKey:
- http.NotFound(w, r)
- default:
- _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
- }
- })
-
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := NewClient(AuthInfo{APIKey: test.apiKey})
- client.BaseURL = serverURL
+ client := mockBuilder(AuthInfo{APIKey: test.apiKey}).
+ Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ switch req.FormValue("api-key") {
+ case correctAPIKey:
+ _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
+ case incorrectAPIKey:
+ _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error")
+ case serverErrorAPIKey:
+ http.NotFound(rw, req)
+ default:
+ _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
+ }
+ })).
+ Build(t)
- response, err := client.login(context.Background())
+ response, err := client.login(t.Context())
if test.expectedError {
require.Error(t, err)
} else {
@@ -131,29 +127,24 @@ func TestClient_login_username(t *testing.T) {
},
}
- mux, serverURL := setupTest(t)
-
- mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
-
- switch r.FormValue("username") {
- case correctUsername:
- _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
- case incorrectUsername:
- _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
- case serverErrorUsername:
- http.NotFound(w, r)
- default:
- _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
- }
- })
-
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := NewClient(AuthInfo{Username: test.username, Password: test.password})
- client.BaseURL = serverURL
+ client := mockBuilder(AuthInfo{Username: test.username, Password: test.password}).
+ Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ switch req.FormValue("username") {
+ case correctUsername:
+ _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
+ case incorrectUsername:
+ _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error")
+ case serverErrorUsername:
+ http.NotFound(rw, req)
+ default:
+ _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
+ }
+ })).
+ Build(t)
- response, err := client.login(context.Background())
+ response, err := client.login(t.Context())
if test.expectedError {
require.Error(t, err)
} else {
@@ -195,28 +186,24 @@ func TestClient_logout(t *testing.T) {
},
}
- mux, serverURL := setupTest(t)
-
- mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
-
- switch r.FormValue("auth-sid") {
- case correctAPIKey:
- _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n")
- case incorrectAPIKey:
- _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
- default:
- http.NotFound(w, r)
- }
- })
-
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := NewClient(AuthInfo{APIKey: "12345"})
- client.BaseURL = serverURL
+ client := mockBuilder(AuthInfo{APIKey: "12345"}).
+ Route("POST /logout", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ switch req.FormValue("auth-sid") {
+ case correctAPIKey:
+ _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n")
+ case incorrectAPIKey:
+ _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error")
+ default:
+ http.NotFound(rw, req)
+ }
+ })).
+ Build(t)
+
client.token = &Token{SessionID: test.authSid}
- response, err := client.Logout(mockContext(test.authSid))
+ response, err := client.Logout(mockContext(t, test.authSid))
if test.expectedError {
require.Error(t, err)
} else {
@@ -229,31 +216,23 @@ func TestClient_logout(t *testing.T) {
}
func TestClient_CreateAuthenticatedContext(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
id := atomic.Int32{}
id.Add(100)
- mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
+ client := mockBuilder(AuthInfo{Username: correctUsername, Password: "secret"}).
+ Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ switch req.FormValue("username") {
+ case correctUsername:
+ _, _ = fmt.Fprintf(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load())
+ id.Add(100)
- switch r.FormValue("username") {
- case correctUsername:
- _, _ = fmt.Fprintf(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load())
- id.Add(100)
+ default:
+ _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error")
+ }
+ })).
+ Build(t)
- default:
- _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
- }
- })
-
- client := NewClient(AuthInfo{Username: correctUsername, Password: "secret"})
- client.HTTPClient = server.Client()
- client.BaseURL = server.URL
-
- ctx, err := client.CreateAuthenticatedContext(context.Background())
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
assert.Equal(t, "100", getSessionID(ctx))
@@ -263,7 +242,7 @@ func TestClient_CreateAuthenticatedContext(t *testing.T) {
client.token.SessionID = "cache"
client.muToken.Unlock()
- ctx, err = client.CreateAuthenticatedContext(context.Background())
+ ctx, err = client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
assert.Equal(t, "cache", getSessionID(ctx))
@@ -273,7 +252,7 @@ func TestClient_CreateAuthenticatedContext(t *testing.T) {
client.token.ExpireAt = time.Now().UTC().Add(-1 * time.Hour)
client.muToken.Unlock()
- ctx, err = client.CreateAuthenticatedContext(context.Background())
+ ctx, err = client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
assert.Equal(t, "200", getSessionID(ctx))
diff --git a/providers/dns/joker/internal/svc/client_test.go b/providers/dns/joker/internal/svc/client_test.go
index 6803ae844..a6cb299e4 100644
--- a/providers/dns/joker/internal/svc/client_test.go
+++ b/providers/dns/joker/internal/svc/client_test.go
@@ -1,88 +1,66 @@
package svc
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("test", "secret")
+ client.BaseURL = server.URL
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("test", "secret")
- client.BaseURL = server.URL
- client.HTTPClient = server.Client()
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
}
func TestClient_Send(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- all, _ := io.ReadAll(req.Body)
-
- if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=123&zone=example.com" {
- http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest)
- return
- }
-
- _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted"))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /",
+ servermock.RawStringResponse("OK: 1 inserted, 0 deleted"),
+ servermock.CheckForm().Strict().
+ With("zone", "example.com").
+ With("label", "_acme-challenge").
+ With("type", "TXT").
+ With("value", "123").
+ With("username", "test").
+ With("password", "secret"),
+ ).
+ Build(t)
zone := "example.com"
label := "_acme-challenge"
value := "123"
- err := client.SendRequest(context.Background(), zone, label, value)
+ err := client.SendRequest(t.Context(), zone, label, value)
require.NoError(t, err)
}
func TestClient_Send_empty(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- all, _ := io.ReadAll(req.Body)
-
- if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=&zone=example.com" {
- http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest)
- return
- }
-
- _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted"))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /",
+ servermock.RawStringResponse("OK: 1 inserted, 0 deleted"),
+ servermock.CheckForm().Strict().
+ With("zone", "example.com").
+ With("label", "_acme-challenge").
+ With("type", "TXT").
+ With("value", "").
+ With("username", "test").
+ With("password", "secret"),
+ ).
+ Build(t)
zone := "example.com"
label := "_acme-challenge"
value := ""
- err := client.SendRequest(context.Background(), zone, label, value)
+ err := client.SendRequest(t.Context(), zone, label, value)
require.NoError(t, err)
}
diff --git a/providers/dns/joker/joker.toml b/providers/dns/joker/joker.toml
index 1f5acf17f..20e481a6d 100644
--- a/providers/dns/joker/joker.toml
+++ b/providers/dns/joker/joker.toml
@@ -9,17 +9,17 @@ Example = '''
JOKER_API_MODE=SVC \
JOKER_USERNAME= \
JOKER_PASSWORD= \
-lego --email you@example.com --dns joker -d '*.example.com' -d example.com run
+lego --dns joker -d '*.example.com' -d example.com run
# DMAPI
JOKER_API_MODE=DMAPI \
JOKER_USERNAME= \
JOKER_PASSWORD= \
-lego --email you@example.com --dns joker -d '*.example.com' -d example.com run
+lego --dns joker -d '*.example.com' -d example.com run
## or
JOKER_API_MODE=DMAPI \
JOKER_API_KEY= \
-lego --email you@example.com --dns joker -d '*.example.com' -d example.com run
+lego --dns joker -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -48,11 +48,11 @@ As per [Joker.com documentation](https://joker.com/faq/content/6/496/en/let_s-en
JOKER_PASSWORD = "Joker.com password"
JOKER_API_KEY = "API key (only with DMAPI mode)"
[Configuration.Additional]
- JOKER_POLLING_INTERVAL = "Time between DNS propagation check"
- JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- JOKER_TTL = "The TTL of the TXT record used for the DNS challenge"
- JOKER_HTTP_TIMEOUT = "API request timeout"
- JOKER_SEQUENCE_INTERVAL = "Time between sequential requests (only with 'SVC' mode)"
+ JOKER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ JOKER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ JOKER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
+ JOKER_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60), only with 'SVC' mode"
[Links]
API = "https://joker.com/faq/category/39/22-dmapi.html"
diff --git a/providers/dns/joker/joker_test.go b/providers/dns/joker/joker_test.go
index a71e4d9fe..bc21ccbbc 100644
--- a/providers/dns/joker/joker_test.go
+++ b/providers/dns/joker/joker_test.go
@@ -20,7 +20,7 @@ func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
- expected interface{}
+ expected any
}{
{
desc: "mode DMAPI (default)",
@@ -53,6 +53,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -72,7 +73,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
mode string
- expected interface{}
+ expected any
}{
{
desc: "mode DMAPI (default)",
@@ -112,6 +113,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -125,6 +127,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/joker/provider_dmapi.go b/providers/dns/joker/provider_dmapi.go
index 5c623467a..11f850136 100644
--- a/providers/dns/joker/provider_dmapi.go
+++ b/providers/dns/joker/provider_dmapi.go
@@ -10,6 +10,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/joker/internal/dmapi"
)
@@ -27,6 +28,7 @@ func newDmapiProvider() (*dmapiProvider, error) {
values, err := env.Get(EnvAPIKey)
if err != nil {
var errU error
+
values, errU = env.Get(EnvUsername, EnvPassword)
if errU != nil {
//nolint:errorlint // false-positive
@@ -66,6 +68,8 @@ func newDmapiProviderConfig(config *Config) (*dmapiProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &dmapiProvider{config: config, client: client}, nil
}
@@ -158,6 +162,7 @@ func (d *dmapiProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return formatResponseError(response, err)
}
+
return nil
}
@@ -166,5 +171,6 @@ func formatResponseError(response *dmapi.Response, err error) error {
if response != nil {
return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers)
}
+
return fmt.Errorf("joker: DMAPI error: %w", err)
}
diff --git a/providers/dns/joker/provider_dmapi_test.go b/providers/dns/joker/provider_dmapi_test.go
index 4704f2b80..06f283872 100644
--- a/providers/dns/joker/provider_dmapi_test.go
+++ b/providers/dns/joker/provider_dmapi_test.go
@@ -58,6 +58,7 @@ func Test_newDmapiProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
diff --git a/providers/dns/joker/provider_svc.go b/providers/dns/joker/provider_svc.go
index 991772fe7..f4d8fcf3f 100644
--- a/providers/dns/joker/provider_svc.go
+++ b/providers/dns/joker/provider_svc.go
@@ -9,6 +9,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/joker/internal/svc"
)
@@ -47,6 +48,8 @@ func newSvcProviderConfig(config *Config) (*svcProvider, error) {
client := svc.NewClient(config.Username, config.Password)
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &svcProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/joker/provider_svc_test.go b/providers/dns/joker/provider_svc_test.go
index ad6c74c87..dc981b6b4 100644
--- a/providers/dns/joker/provider_svc_test.go
+++ b/providers/dns/joker/provider_svc_test.go
@@ -49,6 +49,7 @@ func Test_newSvcProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
diff --git a/providers/dns/keyhelp/internal/client.go b/providers/dns/keyhelp/internal/client.go
new file mode 100644
index 000000000..a5a80db5c
--- /dev/null
+++ b/providers/dns/keyhelp/internal/client.go
@@ -0,0 +1,175 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// APIKeyHeader API key header.
+const APIKeyHeader = "X-Api-Key"
+
+// Client the KeyHelp API client.
+type Client struct {
+ apiKey string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(baseURL, apiKey string) (*Client, error) {
+ if baseURL == "" {
+ return nil, errors.New("missing base URL")
+ }
+
+ if apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ base, err := url.Parse(baseURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse base URL: %w", err)
+ }
+
+ return &Client{
+ apiKey: apiKey,
+ baseURL: base.JoinPath("api", "v2"),
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Set(APIKeyHeader, c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
+ endpoint := c.baseURL.JoinPath("domains")
+
+ query := endpoint.Query()
+ query.Set("sort", "domain_utf8")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result []Domain
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) ListDomainRecords(ctx context.Context, domainID int) (*DomainRecords, error) {
+ endpoint := c.baseURL.JoinPath("dns", strconv.Itoa(domainID))
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result DomainRecords
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return &result, nil
+}
+
+func (c *Client) UpdateDomainRecords(ctx context.Context, domainID int, records DomainRecords) (*DomainID, error) {
+ endpoint := c.baseURL.JoinPath("dns", strconv.Itoa(domainID))
+
+ req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records)
+ if err != nil {
+ return nil, err
+ }
+
+ var result DomainID
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return &result, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/keyhelp/internal/client_test.go b/providers/dns/keyhelp/internal/client_test.go
new file mode 100644
index 000000000..80b21495b
--- /dev/null
+++ b/providers/dns/keyhelp/internal/client_test.go
@@ -0,0 +1,169 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ With(APIKeyHeader, "secret").
+ WithJSONHeaders(),
+ )
+}
+
+func TestClient_ListDomains(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/v2/domains",
+ servermock.ResponseFromFixture("get_domains.json"),
+ servermock.CheckQueryParameter().
+ With("sort", "domain_utf8").
+ Strict()).
+ Build(t)
+
+ domains, err := client.ListDomains(t.Context())
+ require.NoError(t, err)
+
+ expected := []Domain{{
+ ID: 8,
+ UserID: 4,
+ ParentDomainID: 0,
+ Status: 1,
+ Domain: "example.com",
+ DomainUTF8: "example.com",
+ IsEmailDomain: true,
+ }}
+
+ assert.Equal(t, expected, domains)
+}
+
+func TestClient_ListDomains_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/v2/domains",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.ListDomains(t.Context())
+
+ require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.")
+}
+
+func TestClient_ListDomainRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/v2/dns/123",
+ servermock.ResponseFromFixture("get_domain_records.json")).
+ Build(t)
+
+ domainRecords, err := client.ListDomainRecords(t.Context(), 123)
+ require.NoError(t, err)
+
+ expected := &DomainRecords{
+ DkimRecord: `default._domainkey IN TXT ( "v=DKIM1; k=rsa; s=email; " "...DKIM KEY..." )`,
+ Records: &Records{
+ Soa: &SOARecord{
+ TTL: 86400,
+ PrimaryNs: "ns.example.com.",
+ RName: "root.example.com.",
+ Refresh: 14400,
+ Retry: 1800,
+ Expire: 604800,
+ Minimum: 3600,
+ },
+ Other: []Record{{
+ Host: "@",
+ TTL: 86400,
+ Type: "A",
+ Value: "192.168.178.1",
+ }},
+ },
+ }
+
+ assert.Equal(t, expected, domainRecords)
+}
+
+func TestClient_ListDomainRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/v2/dns/8",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.ListDomainRecords(t.Context(), 8)
+
+ require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.")
+}
+
+func TestClient_UpdateDomainRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /api/v2/dns/8",
+ servermock.ResponseFromFixture("update_domain_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("update_domain_records-request.json")).
+ Build(t)
+
+ records := DomainRecords{
+ DkimRecord: `default._domainkey IN TXT ( "v=DKIM1; k=rsa; s=email; " "...DKIM KEY..." )`,
+ Records: &Records{
+ Soa: &SOARecord{
+ TTL: 86400,
+ PrimaryNs: "ns.example.com.",
+ RName: "root.example.com.",
+ Refresh: 14400,
+ Retry: 1800,
+ Expire: 604800,
+ Minimum: 3600,
+ },
+ Other: []Record{
+ {
+ Host: "@",
+ TTL: 86400,
+ Type: "A",
+ Value: "192.168.178.1",
+ },
+ {
+ Host: "_acme-challenge",
+ TTL: 120,
+ Type: "TXT",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ },
+ },
+ },
+ }
+
+ domainID, err := client.UpdateDomainRecords(t.Context(), 8, records)
+ require.NoError(t, err)
+
+ expected := &DomainID{ID: 8}
+
+ assert.Equal(t, expected, domainID)
+}
+
+func TestClient_UpdateDomainRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /api/v2/dns/123",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ records := DomainRecords{}
+
+ _, err := client.UpdateDomainRecords(t.Context(), 123, records)
+
+ require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.")
+}
diff --git a/providers/dns/keyhelp/internal/fixtures/error.json b/providers/dns/keyhelp/internal/fixtures/error.json
new file mode 100644
index 000000000..4fdf5e8f5
--- /dev/null
+++ b/providers/dns/keyhelp/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+ "code": "401 Unauthorized",
+ "message": "API key is missing or invalid."
+}
diff --git a/providers/dns/keyhelp/internal/fixtures/get_domain_records.json b/providers/dns/keyhelp/internal/fixtures/get_domain_records.json
new file mode 100644
index 000000000..50483bb8e
--- /dev/null
+++ b/providers/dns/keyhelp/internal/fixtures/get_domain_records.json
@@ -0,0 +1,24 @@
+{
+ "is_custom_dns": false,
+ "is_dns_disabled": false,
+ "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )",
+ "records": {
+ "soa": {
+ "ttl": 86400,
+ "primary_ns": "ns.example.com.",
+ "rname": "root.example.com.",
+ "refresh": 14400,
+ "retry": 1800,
+ "expire": 604800,
+ "minimum": 3600
+ },
+ "other": [
+ {
+ "host": "@",
+ "ttl": 86400,
+ "type": "A",
+ "value": "192.168.178.1"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/keyhelp/internal/fixtures/get_domain_records2.json b/providers/dns/keyhelp/internal/fixtures/get_domain_records2.json
new file mode 100644
index 000000000..cd49fd6d0
--- /dev/null
+++ b/providers/dns/keyhelp/internal/fixtures/get_domain_records2.json
@@ -0,0 +1,30 @@
+{
+ "is_custom_dns": false,
+ "is_dns_disabled": false,
+ "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )",
+ "records": {
+ "soa": {
+ "ttl": 86400,
+ "primary_ns": "ns.example.com.",
+ "rname": "root.example.com.",
+ "refresh": 14400,
+ "retry": 1800,
+ "expire": 604800,
+ "minimum": 3600
+ },
+ "other": [
+ {
+ "host": "@",
+ "ttl": 86400,
+ "type": "A",
+ "value": "192.168.178.1"
+ },
+ {
+ "host": "_acme-challenge",
+ "ttl": 120,
+ "type": "TXT",
+ "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/keyhelp/internal/fixtures/get_domains.json b/providers/dns/keyhelp/internal/fixtures/get_domains.json
new file mode 100644
index 000000000..28ae0887d
--- /dev/null
+++ b/providers/dns/keyhelp/internal/fixtures/get_domains.json
@@ -0,0 +1,41 @@
+[
+ {
+ "id": 8,
+ "id_user": 4,
+ "id_parent_domain": 0,
+ "status": 1,
+ "domain": "example.com",
+ "domain_utf8": "example.com",
+ "created_at": "2019-08-15T11:29:13+02:00",
+ "php_version": "",
+ "traffic": 32434624,
+ "is_disabled": false,
+ "delete_on": "2025-09-02T19:31:14+0000",
+ "dkim_selector": "default",
+ "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )",
+ "is_custom_dns": false,
+ "is_dns_disabled": false,
+ "is_subdomain": false,
+ "is_system_domain": false,
+ "is_email_domain": true,
+ "is_email_sending_only": false,
+ "target": {
+ "target": "https://www.keyhelp.de",
+ "is_forwarding": true,
+ "forwarding_type": 301
+ },
+ "security": {
+ "id_certificate": 0,
+ "lets_encrypt": true,
+ "is_prefer_https": true,
+ "is_hsts": true,
+ "hsts_max_age": 10368000,
+ "hsts_include": true,
+ "hsts_preload": true
+ },
+ "apache": {
+ "http_directives": "# My custom HTTP directives",
+ "https_directives": "# My custom HTTPS directives"
+ }
+ }
+]
diff --git a/providers/dns/keyhelp/internal/fixtures/update_domain_records-request.json b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request.json
new file mode 100644
index 000000000..6f83ead11
--- /dev/null
+++ b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request.json
@@ -0,0 +1,28 @@
+{
+ "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )",
+ "records": {
+ "soa": {
+ "ttl": 86400,
+ "primary_ns": "ns.example.com.",
+ "rname": "root.example.com.",
+ "refresh": 14400,
+ "retry": 1800,
+ "expire": 604800,
+ "minimum": 3600
+ },
+ "other": [
+ {
+ "host": "@",
+ "ttl": 86400,
+ "type": "A",
+ "value": "192.168.178.1"
+ },
+ {
+ "host": "_acme-challenge",
+ "ttl": 120,
+ "type": "TXT",
+ "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/keyhelp/internal/fixtures/update_domain_records-request2.json b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request2.json
new file mode 100644
index 000000000..3ebb2ee7a
--- /dev/null
+++ b/providers/dns/keyhelp/internal/fixtures/update_domain_records-request2.json
@@ -0,0 +1,22 @@
+{
+ "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )",
+ "records": {
+ "soa": {
+ "ttl": 86400,
+ "primary_ns": "ns.example.com.",
+ "rname": "root.example.com.",
+ "refresh": 14400,
+ "retry": 1800,
+ "expire": 604800,
+ "minimum": 3600
+ },
+ "other": [
+ {
+ "host": "@",
+ "ttl": 86400,
+ "type": "A",
+ "value": "192.168.178.1"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/keyhelp/internal/fixtures/update_domain_records.json b/providers/dns/keyhelp/internal/fixtures/update_domain_records.json
new file mode 100644
index 000000000..a335b5ba5
--- /dev/null
+++ b/providers/dns/keyhelp/internal/fixtures/update_domain_records.json
@@ -0,0 +1,3 @@
+{
+ "id": 8
+}
diff --git a/providers/dns/keyhelp/internal/types.go b/providers/dns/keyhelp/internal/types.go
new file mode 100644
index 000000000..8716fa0c8
--- /dev/null
+++ b/providers/dns/keyhelp/internal/types.go
@@ -0,0 +1,63 @@
+package internal
+
+import (
+ "fmt"
+)
+
+type APIError struct {
+ Code string `json:"code,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%s: %s", a.Code, a.Message)
+}
+
+type Domain struct {
+ ID int `json:"id,omitempty"`
+ UserID int `json:"id_user,omitempty"`
+ ParentDomainID int `json:"id_parent_domain,omitempty"`
+ Status int `json:"status,omitempty"`
+ Domain string `json:"domain,omitempty"`
+ DomainUTF8 string `json:"domain_utf8,omitempty"`
+ IsDisabled bool `json:"is_disabled,omitempty"`
+ IsCustomDNS bool `json:"is_custom_dns,omitempty"`
+ IsDNSDisabled bool `json:"is_dns_disabled,omitempty"`
+ IsSubdomain bool `json:"is_subdomain,omitempty"`
+ IsSystemDomain bool `json:"is_system_domain,omitempty"`
+ IsEmailDomain bool `json:"is_email_domain,omitempty"`
+ IsEmailSendingOnly bool `json:"is_email_sending_only,omitempty"`
+}
+
+type DomainID struct {
+ ID int `json:"id,omitempty"`
+}
+
+type DomainRecords struct {
+ IsCustomDNS bool `json:"is_custom_dns,omitempty"`
+ IsDNSDisabled bool `json:"is_dns_disabled,omitempty"`
+ DkimRecord string `json:"dkim_record,omitempty"`
+ Records *Records `json:"records,omitempty"`
+}
+
+type Records struct {
+ Soa *SOARecord `json:"soa,omitempty"`
+ Other []Record `json:"other,omitempty"`
+}
+
+type SOARecord struct {
+ TTL int `json:"ttl,omitempty"`
+ PrimaryNs string `json:"primary_ns,omitempty"`
+ RName string `json:"rname,omitempty"`
+ Refresh int `json:"refresh,omitempty"`
+ Retry int `json:"retry,omitempty"`
+ Expire int `json:"expire,omitempty"`
+ Minimum int `json:"minimum,omitempty"`
+}
+
+type Record struct {
+ Host string `json:"host"`
+ TTL int `json:"ttl"`
+ Type string `json:"type"`
+ Value string `json:"value"`
+}
diff --git a/providers/dns/keyhelp/keyhelp.go b/providers/dns/keyhelp/keyhelp.go
new file mode 100644
index 000000000..67ceaaa63
--- /dev/null
+++ b/providers/dns/keyhelp/keyhelp.go
@@ -0,0 +1,225 @@
+// Package keyhelp implements a DNS provider for solving the DNS-01 challenge using KeyHelp.
+package keyhelp
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/keyhelp/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "KEYHELP_"
+
+ EnvBaseURL = envNamespace + "BASE_URL"
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ BaseURL string
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ domainIDs map[string]int
+ domainIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for KeyHelp.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvBaseURL, EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("keyhelp: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.BaseURL = values[EnvBaseURL]
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for KeyHelp.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("keyhelp: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.BaseURL, config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("keyhelp: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ domainIDs: make(map[string]int),
+ }, 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("keyhelp: could not find zone for domain %q: %w", domain, err)
+ }
+
+ ctx := context.Background()
+
+ domainInfo, err := d.findDomain(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("keyhelp: %w", err)
+ }
+
+ domainRecords, err := d.client.ListDomainRecords(ctx, domainInfo.ID)
+ if err != nil {
+ return fmt.Errorf("keyhelp: list domain records: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("keyhelp: %w", err)
+ }
+
+ records := domainRecords.Records.Other
+ records = append(records, internal.Record{
+ Host: subDomain,
+ TTL: d.config.TTL,
+ Type: "TXT",
+ Value: info.Value,
+ })
+
+ req := internal.DomainRecords{
+ DkimRecord: domainRecords.DkimRecord,
+ Records: &internal.Records{
+ Soa: domainRecords.Records.Soa,
+ Other: records,
+ },
+ }
+
+ _, err = d.client.UpdateDomainRecords(ctx, domainInfo.ID, req)
+ if err != nil {
+ return fmt.Errorf("keyhelp: update domain records (add): %w", err)
+ }
+
+ d.domainIDsMu.Lock()
+ d.domainIDs[token] = domainInfo.ID
+ d.domainIDsMu.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)
+
+ // get the domain's unique ID from when we created it
+ d.domainIDsMu.Lock()
+ domainID, ok := d.domainIDs[token]
+ d.domainIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("keyhelp: unknown record ID for '%s'", info.EffectiveFQDN)
+ }
+
+ domainRecords, err := d.client.ListDomainRecords(ctx, domainID)
+ if err != nil {
+ return fmt.Errorf("keyhelp: list domain records: %w", err)
+ }
+
+ var records []internal.Record
+
+ for _, record := range domainRecords.Records.Other {
+ if record.Type == "TXT" && record.Value == info.Value {
+ continue
+ }
+
+ records = append(records, record)
+ }
+
+ req := internal.DomainRecords{
+ DkimRecord: domainRecords.DkimRecord,
+ Records: &internal.Records{
+ Soa: domainRecords.Records.Soa,
+ Other: records,
+ },
+ }
+
+ _, err = d.client.UpdateDomainRecords(ctx, domainID, req)
+ if err != nil {
+ return fmt.Errorf("keyhelp: update domain records (delete): %w", err)
+ }
+
+ // Delete domain ID from map
+ d.domainIDsMu.Lock()
+ delete(d.domainIDs, token)
+ d.domainIDsMu.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
+}
+
+func (d *DNSProvider) findDomain(ctx context.Context, zone string) (internal.Domain, error) {
+ domains, err := d.client.ListDomains(ctx)
+ if err != nil {
+ return internal.Domain{}, fmt.Errorf("list domains: %w", err)
+ }
+
+ for _, domain := range domains {
+ if domain.DomainUTF8 == zone || domain.Domain == zone {
+ return domain, nil
+ }
+ }
+
+ return internal.Domain{}, fmt.Errorf("domain not found: %s", zone)
+}
diff --git a/providers/dns/keyhelp/keyhelp.toml b/providers/dns/keyhelp/keyhelp.toml
new file mode 100644
index 000000000..e622794ca
--- /dev/null
+++ b/providers/dns/keyhelp/keyhelp.toml
@@ -0,0 +1,24 @@
+Name = "KeyHelp"
+Description = ''''''
+URL = "https://www.keyweb.de/en/keyhelp/keyhelp/"
+Code = "keyhelp"
+Since = "v4.26.0"
+
+Example = '''
+KEYHELP_BASE_URL="https://keyhelp.example.com" \
+KEYHELP_API_KEY="xxx" \
+lego --dns keyhelp -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ KEYHELP_BASE_URL= "Server URL"
+ KEYHELP_API_KEY = "API key"
+ [Configuration.Additional]
+ KEYHELP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ KEYHELP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ KEYHELP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ KEYHELP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://app.swaggerhub.com/apis-docs/keyhelp/api/"
diff --git a/providers/dns/keyhelp/keyhelp_test.go b/providers/dns/keyhelp/keyhelp_test.go
new file mode 100644
index 000000000..8d8ac821d
--- /dev/null
+++ b/providers/dns/keyhelp/keyhelp_test.go
@@ -0,0 +1,198 @@
+package keyhelp
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/keyhelp/internal"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvBaseURL, EnvAPIKey).
+ 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://keyhelp.example.com",
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing base URL",
+ envVars: map[string]string{
+ EnvAPIKey: "secret",
+ },
+ expected: "keyhelp: some credentials information are missing: KEYHELP_BASE_URL",
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{
+ EnvBaseURL: "https://keyhelp.example.com",
+ },
+ expected: "keyhelp: some credentials information are missing: KEYHELP_API_KEY",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "keyhelp: some credentials information are missing: KEYHELP_BASE_URL,KEYHELP_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ baseURL string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ baseURL: "https://keyhelp.example.com",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing base URL",
+ apiKey: "secret",
+ expected: "keyhelp: missing base URL",
+ },
+ {
+ desc: "missing API key",
+ baseURL: "https://keyhelp.example.com",
+ expected: "keyhelp: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "keyhelp: missing base URL",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.BaseURL = test.baseURL
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.APIKey = "secret"
+ config.BaseURL = server.URL
+
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ With(internal.APIKeyHeader, "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /api/v2/domains",
+ servermock.ResponseFromInternal("get_domains.json"),
+ servermock.CheckQueryParameter().
+ With("sort", "domain_utf8").
+ Strict()).
+ Route("GET /api/v2/dns/8",
+ servermock.ResponseFromInternal("get_domain_records.json")).
+ Route("PUT /api/v2/dns/8",
+ servermock.ResponseFromInternal("update_domain_records.json"),
+ servermock.CheckRequestJSONBodyFromInternal("update_domain_records-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+
+ assert.Equal(t, 8, provider.domainIDs["abc"])
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /api/v2/dns/8",
+ servermock.ResponseFromInternal("get_domain_records2.json")).
+ Route("PUT /api/v2/dns/8",
+ servermock.ResponseFromInternal("update_domain_records.json"),
+ servermock.CheckRequestJSONBodyFromInternal("update_domain_records-request2.json")).
+ Build(t)
+
+ provider.domainIDs["abc"] = 8
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/leaseweb/internal/client.go b/providers/dns/leaseweb/internal/client.go
new file mode 100644
index 000000000..01619d49b
--- /dev/null
+++ b/providers/dns/leaseweb/internal/client.go
@@ -0,0 +1,216 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://api.leaseweb.com/hosting/v2"
+
+const AuthHeader = "X-LSW-Auth"
+
+// Client the Leaseweb API client.
+type Client struct {
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey string) (*Client, error) {
+ if apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// CreateRRSet creates a resource record set.
+// https://developer.leaseweb.com/docs/#tag/DNS/operation/createResourceRecordSet
+func (c *Client) CreateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) {
+ endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, rrset)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &RRSet{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// GetRRSet gets a resource record set.
+// https://developer.leaseweb.com/docs/#tag/DNS/operation/getResourceRecordSet
+func (c *Client) GetRRSet(ctx context.Context, domainName, name, rType string) (*RRSet, error) {
+ endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType)
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &RRSet{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// UpdateRRSet updates a resource record set.
+// https://developer.leaseweb.com/docs/#tag/DNS/operation/updateResourceRecordSet
+func (c *Client) UpdateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) {
+ endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", rrset.Name, rrset.Type)
+
+ // Reset values that are not allowed to be updated.
+ rrset.Name = ""
+ rrset.Type = ""
+ rrset.Editable = false
+
+ req, err := newJSONRequest(ctx, http.MethodPut, endpoint, rrset)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &RRSet{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteRRSet deletes a resource record set.
+// https://developer.leaseweb.com/docs/#tag/DNS/operation/deleteResourceRecordSet
+func (c *Client) DeleteRRSet(ctx context.Context, domainName, name, rType string) error {
+ endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Add(AuthHeader, c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ if resp.StatusCode == http.StatusNotFound {
+ return &NotFoundError{APIError{
+ CorrelationID: resp.Header.Get("Correlation-Id"),
+ ErrorCode: strconv.Itoa(http.StatusNotFound),
+ ErrorMessage: string(raw),
+ }}
+ }
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ if errAPI.ErrorCode == strconv.Itoa(http.StatusNotFound) {
+ return &NotFoundError{APIError: errAPI}
+ }
+
+ return &errAPI
+}
+
+// TTLRounder rounds the given TTL in seconds to the next accepted value.
+// Accepted TTL values are: 60, 300, 1800, 3600, 14400, 28800, 43200, 86400.
+func TTLRounder(ttl int) int {
+ for _, validTTL := range []int{60, 300, 1800, 3600, 14400, 28800, 43200, 86400} {
+ if ttl <= validTTL {
+ return validTTL
+ }
+ }
+
+ return 3600
+}
diff --git a/providers/dns/leaseweb/internal/client_test.go b/providers/dns/leaseweb/internal/client_test.go
new file mode 100644
index 000000000..5762aad4b
--- /dev/null
+++ b/providers/dns/leaseweb/internal/client_test.go
@@ -0,0 +1,149 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(AuthHeader, "secret"),
+ )
+}
+
+func TestClient_CreateRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/example.com/resourceRecordSets",
+ servermock.ResponseFromFixture("createResourceRecordSet.json"),
+ servermock.CheckRequestJSONBodyFromFixture("createResourceRecordSet-request.json"),
+ ).
+ Build(t)
+
+ rrset := RRSet{
+ Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
+ Name: "_acme-challenge.example.com.",
+ TTL: 300,
+ Type: "TXT",
+ }
+
+ result, err := client.CreateRRSet(t.Context(), "example.com", rrset)
+ require.NoError(t, err)
+
+ expected := &RRSet{
+ Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
+ Name: "_acme-challenge.example.com.",
+ Editable: true,
+ TTL: 300,
+ Type: "TXT",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_GetRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromFixture("getResourceRecordSet.json"),
+ ).
+ Build(t)
+
+ result, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
+ require.NoError(t, err)
+
+ expected := &RRSet{
+ Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"},
+ Name: "_acme-challenge.example.com.",
+ Editable: true,
+ TTL: 3600,
+ Type: "TXT",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_GetRRSet_error_404(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromFixture("error_404.json").
+ WithStatusCode(http.StatusNotFound),
+ ).
+ Build(t)
+
+ _, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
+ require.EqualError(t, err, "404: Resource not found (289346a1-3eaf-4da4-b707-62ef12eb08be)")
+
+ target := &NotFoundError{}
+ require.ErrorAs(t, err, &target)
+}
+
+func TestClient_UpdateRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromFixture("updateResourceRecordSet.json"),
+ servermock.CheckRequestJSONBodyFromFixture("updateResourceRecordSet-request.json"),
+ ).
+ Build(t)
+
+ rrset := RRSet{
+ Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
+ Name: "_acme-challenge.example.com.",
+ TTL: 3600,
+ Type: "TXT",
+ }
+
+ result, err := client.UpdateRRSet(t.Context(), "example.com", rrset)
+ require.NoError(t, err)
+
+ expected := &RRSet{
+ Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
+ Name: "_acme-challenge.example.com.",
+ Editable: true,
+ TTL: 3600,
+ Type: "TXT",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_DeleteRRSet(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ ).
+ Build(t)
+
+ err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRRSet_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromFixture("error_401.json").
+ WithStatusCode(http.StatusUnauthorized),
+ ).
+ Build(t)
+
+ err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT")
+ require.EqualError(t, err, "401: You are not authorized to view this resource. (289346a1-3eaf-4da4-b707-62ef12eb08be)")
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json
new file mode 100644
index 000000000..af53fcf04
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json
@@ -0,0 +1,8 @@
+{
+ "content": [
+ "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ ],
+ "name": "_acme-challenge.example.com.",
+ "ttl": 300,
+ "type": "TXT"
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json
new file mode 100644
index 000000000..8ca040d63
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json
@@ -0,0 +1,17 @@
+{
+ "_links": {
+ "self": {
+ "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
+ },
+ "collection": {
+ "href": "/domains/example.com/resourceRecordSets"
+ }
+ },
+ "content": [
+ "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ ],
+ "editable": true,
+ "name": "_acme-challenge.example.com.",
+ "ttl": 300,
+ "type": "TXT"
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/error_400.json b/providers/dns/leaseweb/internal/fixtures/error_400.json
new file mode 100644
index 000000000..1a980b6bb
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/error_400.json
@@ -0,0 +1,6 @@
+{
+ "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be",
+ "errorCode": "400",
+ "errorDetails": {},
+ "errorMessage": "The API could not interpret your request correctly."
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/error_401.json b/providers/dns/leaseweb/internal/fixtures/error_401.json
new file mode 100644
index 000000000..47d8a311d
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/error_401.json
@@ -0,0 +1,5 @@
+{
+ "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be",
+ "errorCode": "401",
+ "errorMessage": "You are not authorized to view this resource."
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/error_404.json b/providers/dns/leaseweb/internal/fixtures/error_404.json
new file mode 100644
index 000000000..1deaf5606
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/error_404.json
@@ -0,0 +1,5 @@
+{
+ "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be",
+ "errorCode": "404",
+ "errorMessage": "Resource not found"
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json
new file mode 100644
index 000000000..fd48f60c6
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json
@@ -0,0 +1,18 @@
+{
+ "_links": {
+ "self": {
+ "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
+ },
+ "collection": {
+ "href": "/domains/example.com/resourceRecordSets"
+ }
+ },
+ "content": [
+ "foo",
+ "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"
+ ],
+ "editable": true,
+ "name": "_acme-challenge.example.com.",
+ "ttl": 3600,
+ "type": "TXT"
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json
new file mode 100644
index 000000000..abf3fb4c3
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json
@@ -0,0 +1,17 @@
+{
+ "_links": {
+ "self": {
+ "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
+ },
+ "collection": {
+ "href": "/domains/example.com/resourceRecordSets"
+ }
+ },
+ "content": [
+ "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"
+ ],
+ "editable": true,
+ "name": "_acme-challenge.example.com.",
+ "ttl": 3600,
+ "type": "TXT"
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json
new file mode 100644
index 000000000..e781958c8
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json
@@ -0,0 +1,8 @@
+{
+ "content": [
+ "foo",
+ "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo",
+ "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ ],
+ "ttl": 3600
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json
new file mode 100644
index 000000000..0acc314de
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json
@@ -0,0 +1,6 @@
+{
+ "content": [
+ "foo"
+ ],
+ "ttl": 3600
+}
diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json
new file mode 100644
index 000000000..2b877982c
--- /dev/null
+++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json
@@ -0,0 +1,19 @@
+{
+ "_links": {
+ "self": {
+ "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT"
+ },
+ "collection": {
+ "href": "/domains/example.com/resourceRecordSets"
+ }
+ },
+ "content": [
+ "foo",
+ "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo",
+ "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
+ ],
+ "editable": true,
+ "name": "_acme-challenge.example.com.",
+ "ttl": 3600,
+ "type": "TXT"
+}
diff --git a/providers/dns/leaseweb/internal/types.go b/providers/dns/leaseweb/internal/types.go
new file mode 100644
index 000000000..7a4547584
--- /dev/null
+++ b/providers/dns/leaseweb/internal/types.go
@@ -0,0 +1,35 @@
+package internal
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+type NotFoundError struct {
+ APIError
+}
+
+type APIError struct {
+ CorrelationID string `json:"correlationId,omitempty"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMessage string `json:"errorMessage,omitempty"`
+ ErrorDetails json.RawMessage `json:"errorDetails,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ msg := fmt.Sprintf("%s: %s (%s)", a.ErrorCode, a.ErrorMessage, a.CorrelationID)
+
+ if len(a.ErrorDetails) > 0 {
+ msg += fmt.Sprintf(": %s", string(a.ErrorDetails))
+ }
+
+ return msg
+}
+
+type RRSet struct {
+ Content []string `json:"content,omitempty"`
+ Name string `json:"name,omitempty"`
+ Editable bool `json:"editable,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Type string `json:"type,omitempty"`
+}
diff --git a/providers/dns/leaseweb/leaseweb.go b/providers/dns/leaseweb/leaseweb.go
new file mode 100644
index 000000000..fafaf1c4d
--- /dev/null
+++ b/providers/dns/leaseweb/leaseweb.go
@@ -0,0 +1,187 @@
+// Package leaseweb implements a DNS provider for solving the DNS-01 challenge using Leaseweb.
+package leaseweb
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "LEASEWEB_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Leaseweb.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("leaseweb: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Leaseweb.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("leaseweb: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("leaseweb: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err)
+ }
+
+ existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT")
+ if err != nil {
+ notfoundErr := &internal.NotFoundError{}
+ if !errors.As(err, ¬foundErr) {
+ return fmt.Errorf("leaseweb: get RRSet: %w", err)
+ }
+
+ // Create the RRSet.
+
+ rrset := internal.RRSet{
+ Content: []string{info.Value},
+ Name: info.EffectiveFQDN,
+ TTL: internal.TTLRounder(d.config.TTL),
+ Type: "TXT",
+ }
+
+ _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), rrset)
+ if err != nil {
+ return fmt.Errorf("leaseweb: create RRSet: %w", err)
+ }
+
+ return nil
+ }
+
+ // Update the RRSet.
+
+ existingRRSet.Content = append(existingRRSet.Content, info.Value)
+
+ _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet)
+ if err != nil {
+ return fmt.Errorf("leaseweb: update RRSet: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err)
+ }
+
+ existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT")
+ if err != nil {
+ return fmt.Errorf("leaseweb: get RRSet: %w", err)
+ }
+
+ var content []string
+
+ for _, s := range existingRRSet.Content {
+ if s != info.Value {
+ content = append(content, s)
+ }
+ }
+
+ if len(content) == 0 {
+ err = d.client.DeleteRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT")
+ if err != nil {
+ return fmt.Errorf("leaseweb: delete RRSet: %w", err)
+ }
+
+ return nil
+ }
+
+ existingRRSet.Content = content
+
+ _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet)
+ if err != nil {
+ return fmt.Errorf("leaseweb: update RRSet: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
diff --git a/providers/dns/leaseweb/leaseweb.toml b/providers/dns/leaseweb/leaseweb.toml
new file mode 100644
index 000000000..2c3503291
--- /dev/null
+++ b/providers/dns/leaseweb/leaseweb.toml
@@ -0,0 +1,22 @@
+Name = "Leaseweb"
+Description = ''''''
+URL = "https://www.leaseweb.com/en/"
+Code = "leaseweb"
+Since = "v4.32.0"
+
+Example = '''
+LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns leaseweb -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ LEASEWEB_API_KEY = "API key"
+ [Configuration.Additional]
+ LEASEWEB_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ LEASEWEB_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ LEASEWEB_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ LEASEWEB_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://developer.leaseweb.com/docs/#tag/DNS"
diff --git a/providers/dns/leaseweb/leaseweb_test.go b/providers/dns/leaseweb/leaseweb_test.go
new file mode 100644
index 000000000..0450cd2c2
--- /dev/null
+++ b/providers/dns/leaseweb/leaseweb_test.go
@@ -0,0 +1,204 @@
+package leaseweb
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "leaseweb: some credentials information are missing: LEASEWEB_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "leaseweb: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With(internal.AuthHeader, "secret"),
+ )
+}
+
+func TestDNSProvider_Present_create(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromInternal("error_404.json").
+ WithStatusCode(http.StatusNotFound),
+ ).
+ Route("POST /domains/example.com/resourceRecordSets",
+ servermock.ResponseFromInternal("createResourceRecordSet.json"),
+ servermock.CheckRequestJSONBodyFromInternal("createResourceRecordSet-request.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_Present_update(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromInternal("getResourceRecordSet.json"),
+ ).
+ Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromInternal("updateResourceRecordSet.json"),
+ servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request.json"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_delete(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromInternal("getResourceRecordSet2.json"),
+ ).
+ Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "1234d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_update(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromInternal("getResourceRecordSet.json"),
+ ).
+ Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT",
+ servermock.ResponseFromInternal("updateResourceRecordSet.json"),
+ servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request2.json"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "1234d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/liara/internal/client.go b/providers/dns/liara/internal/client.go
index 89794f04d..95c39695b 100644
--- a/providers/dns/liara/internal/client.go
+++ b/providers/dns/liara/internal/client.go
@@ -20,25 +20,31 @@ const defaultBaseURL = "https://dns-service.iran.liara.ir"
type Client struct {
baseURL *url.URL
httpClient *http.Client
+
+ teamID string
}
// NewClient creates a new Client.
-func NewClient(hc *http.Client) *Client {
+func NewClient(hc *http.Client, teamID string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
- return &Client{httpClient: hc, baseURL: baseURL}
+ return &Client{
+ httpClient: hc,
+ baseURL: baseURL,
+ teamID: teamID,
+ }
}
// GetRecords gets the records of a domain.
-// https://dns-service.iran.liara.ir/swagger
-func (c Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) {
+// https://openapi.liara.ir/?urls.primaryName=DNS
+func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -60,6 +66,7 @@ func (c Client) GetRecords(ctx context.Context, domainName string) ([]Record, er
}
var response Response[[]Record]
+
err = json.Unmarshal(raw, &response)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -69,10 +76,10 @@ func (c Client) GetRecords(ctx context.Context, domainName string) ([]Record, er
}
// CreateRecord creates a record.
-func (c Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) {
+func (c *Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
- req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -94,6 +101,7 @@ func (c Client) CreateRecord(ctx context.Context, domainName string, record Reco
}
var response Response[*Record]
+
err = json.Unmarshal(raw, &response)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -103,10 +111,10 @@ func (c Client) CreateRecord(ctx context.Context, domainName string, record Reco
}
// GetRecord gets a specific record.
-func (c Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) {
+func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -128,6 +136,7 @@ func (c Client) GetRecord(ctx context.Context, domainName, recordID string) (*Re
}
var response Response[*Record]
+
err = json.Unmarshal(raw, &response)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -137,10 +146,10 @@ func (c Client) GetRecord(ctx context.Context, domainName, recordID string) (*Re
}
// DeleteRecord deletes a record.
-func (c Client) DeleteRecord(ctx context.Context, domainName, recordID string) error {
+func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) error {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
- req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
@@ -159,7 +168,14 @@ func (c Client) DeleteRecord(ctx context.Context, domainName, recordID string) e
return nil
}
-func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ if c.teamID != "" {
+ query := endpoint.Query()
+ query.Set("teamID", c.teamID)
+
+ endpoint.RawQuery = query.Encode()
+ }
+
buf := new(bytes.Buffer)
if payload != nil {
@@ -187,6 +203,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go
index ed6672ab6..b6d007046 100644
--- a/providers/dns/liara/internal/client_test.go
+++ b/providers/dns/liara/internal/client_test.go
@@ -1,28 +1,36 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const apiKey = "key"
+func mockBuilder(teamID string) *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey), teamID)
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer "+apiKey))
+}
+
func TestClient_GetRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder("").
+ Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")).
+ Build(t)
- mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordsResponse.json", http.MethodGet, http.StatusOK))
-
- records, err := client.GetRecords(context.Background(), "example.com")
+ records, err := client.GetRecords(t.Context(), "example.com")
require.NoError(t, err)
expected := []Record{
@@ -42,11 +50,11 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder("").
+ Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")).
+ Build(t)
- mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./RecordResponse.json", http.MethodGet, http.StatusOK))
-
- record, err := client.GetRecord(context.Background(), "example.com", "123")
+ record, err := client.GetRecord(t.Context(), "example.com", "123")
require.NoError(t, err)
expected := &Record{
@@ -64,9 +72,12 @@ func TestClient_GetRecord(t *testing.T) {
}
func TestClient_CreateRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordResponse.json", http.MethodPost, http.StatusCreated))
+ client := mockBuilder("").
+ Route("POST /api/v1/zones/example.com/dns-records",
+ servermock.ResponseFromFixture("RecordResponse.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`)).
+ Build(t)
data := Record{
Type: "string",
@@ -79,7 +90,46 @@ func TestClient_CreateRecord(t *testing.T) {
TTL: 3600,
}
- record, err := client.CreateRecord(context.Background(), "example.com", data)
+ record, err := client.CreateRecord(t.Context(), "example.com", data)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: "string",
+ Type: "string",
+ Name: "string",
+ Contents: []Content{
+ {
+ Text: "string",
+ },
+ },
+ TTL: 3600,
+ }
+
+ assert.Equal(t, expected, record)
+}
+
+func TestClient_CreateRecord_withTeamID(t *testing.T) {
+ client := mockBuilder("123").
+ Route("POST /api/v1/zones/example.com/dns-records",
+ servermock.ResponseFromFixture("RecordResponse.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`),
+ servermock.CheckQueryParameter().Strict().With("teamID", "123"),
+ ).
+ Build(t)
+
+ data := Record{
+ Type: "string",
+ Name: "string",
+ Contents: []Content{
+ {
+ Text: "string",
+ },
+ },
+ TTL: 3600,
+ }
+
+ record, err := client.CreateRecord(t.Context(), "example.com", data)
require.NoError(t, err)
expected := &Record{
@@ -98,76 +148,34 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder("").
+ Route("DELETE /api/v1/zones/example.com/dns-records/123",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
- mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) {
- rw.WriteHeader(http.StatusNoContent)
- })
-
- err := client.DeleteRecord(context.Background(), "example.com", "123")
+ err := client.DeleteRecord(t.Context(), "example.com", "123")
require.NoError(t, err)
}
func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder("").
+ Route("DELETE /api/v1/zones/example.com/dns-records/123",
+ servermock.Noop().
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
- mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) {
- rw.WriteHeader(http.StatusNotFound)
- })
-
- err := client.DeleteRecord(context.Background(), "example.com", "123")
+ err := client.DeleteRecord(t.Context(), "example.com", "123")
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder("").
+ Route("DELETE /api/v1/zones/example.com/dns-records/123",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./error.json", http.MethodDelete, http.StatusUnauthorized))
-
- err := client.DeleteRecord(context.Background(), "example.com", "123")
- require.Error(t, err)
-}
-
-func testHandler(filename string, method 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 "+apiKey {
- http.Error(rw, "invalid Authorization header", 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 setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey))
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ err := client.DeleteRecord(t.Context(), "example.com", "123")
+ require.EqualError(t, err, "[status code: 401] Unauthorized: Invalid token missing header")
}
diff --git a/providers/dns/liara/liara.go b/providers/dns/liara/liara.go
index a0437b0eb..c7e403eed 100644
--- a/providers/dns/liara/liara.go
+++ b/providers/dns/liara/liara.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/liara/internal"
"github.com/hashicorp/go-retryablehttp"
)
@@ -22,6 +23,7 @@ const (
envNamespace = "LIARA_"
EnvAPIKey = envNamespace + "API_KEY"
+ EnvTeamID = envNamespace + "TEAM_ID"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -38,7 +40,9 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- APIKey string
+ APIKey string
+ TeamID string
+
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
@@ -76,6 +80,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.APIKey = values[EnvAPIKey]
+ config.TeamID = env.GetOrFile(EnvTeamID)
return NewDNSProviderConfig(config)
}
@@ -99,13 +104,20 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
retryClient := retryablehttp.NewClient()
+
retryClient.RetryMax = 5
if config.HTTPClient != nil {
retryClient.HTTPClient = config.HTTPClient
}
+
retryClient.Logger = log.Logger
- client := internal.NewClient(internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey))
+ client := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey),
+ ),
+ config.TeamID,
+ )
return &DNSProvider{
config: config,
@@ -140,6 +152,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Contents: []internal.Content{{Text: info.Value}},
TTL: d.config.TTL,
}
+
newRecord, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record)
if err != nil {
return fmt.Errorf("liara: failed to create TXT record, fqdn=%s: %w", info.EffectiveFQDN, err)
@@ -165,6 +178,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("liara: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
diff --git a/providers/dns/liara/liara.toml b/providers/dns/liara/liara.toml
index aaa4061f5..f471de04e 100644
--- a/providers/dns/liara/liara.toml
+++ b/providers/dns/liara/liara.toml
@@ -6,17 +6,18 @@ Since = "v4.10.0"
Example = '''
LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns liara -d '*.example.com' -d example.com run
+lego --dns liara -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
LIARA_API_KEY = "The API key"
[Configuration.Additional]
- LIARA_POLLING_INTERVAL = "Time between DNS propagation check"
- LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- LIARA_TTL = "The TTL of the TXT record used for the DNS challenge"
- LIARA_HTTP_TIMEOUT = "API request timeout"
+ LIARA_TEAM_ID = "The team ID to access services in a team"
+ LIARA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ LIARA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ LIARA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
- API = "https://dns-service.iran.liara.ir/swagger"
+ API = "https://openapi.liara.ir/?urls.primaryName=DNS"
diff --git a/providers/dns/liara/liara_test.go b/providers/dns/liara/liara_test.go
index 4256be55e..b1f3f77c9 100644
--- a/providers/dns/liara/liara_test.go
+++ b/providers/dns/liara/liara_test.go
@@ -38,6 +38,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -113,6 +114,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -126,6 +128,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/lightsail/lightsail.go b/providers/dns/lightsail/lightsail.go
index d07b5505a..95b07c503 100644
--- a/providers/dns/lightsail/lightsail.go
+++ b/providers/dns/lightsail/lightsail.go
@@ -96,12 +96,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
// 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
- }
+ retryCount := min(attempt, 7)
delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200)
+
return time.Duration(delay) * time.Millisecond, nil
})
})
diff --git a/providers/dns/lightsail/lightsail.toml b/providers/dns/lightsail/lightsail.toml
index 4ade894d1..47b212f62 100644
--- a/providers/dns/lightsail/lightsail.toml
+++ b/providers/dns/lightsail/lightsail.toml
@@ -52,8 +52,8 @@ Alternatively, you can also set the `Resource` to `*` (wildcard), which allow to
DNS_ZONE = "Domain name of the DNS zone"
[Configuration.Additional]
AWS_SHARED_CREDENTIALS_FILE = "Managed by the AWS client. Shared credentials file."
- LIGHTSAIL_POLLING_INTERVAL = "Time between DNS propagation check"
- LIGHTSAIL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
+ LIGHTSAIL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ LIGHTSAIL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
[Links]
GoClient = "https://github.com/aws/aws-sdk-go-v2"
diff --git a/providers/dns/lightsail/lightsail_integration_test.go b/providers/dns/lightsail/lightsail_integration_test.go
index 20e45ee26..dc86bf079 100644
--- a/providers/dns/lightsail/lightsail_integration_test.go
+++ b/providers/dns/lightsail/lightsail_integration_test.go
@@ -1,12 +1,12 @@
package lightsail
import (
- "context"
"testing"
"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/go-acme/lego/v4/providers/dns/internal/ptr"
"github.com/stretchr/testify/require"
)
@@ -28,12 +28,13 @@ func TestLiveTTL(t *testing.T) {
// we need a separate Lightsail client here as the one in the DNS provider is unexported.
fqdn := "_acme-challenge." + domain
- ctx := context.Background()
+ ctx := t.Context()
cfg, err := awsconfig.LoadDefaultConfig(ctx)
require.NoError(t, err)
svc := lightsail.NewFromConfig(cfg)
+
require.NoError(t, err)
defer func() {
@@ -52,19 +53,10 @@ func TestLiveTTL(t *testing.T) {
entries := resp.Domain.DomainEntries
for _, entry := range entries {
- if deref(entry.Type) == "TXT" && deref(entry.Name) == fqdn {
+ if ptr.Deref(entry.Type) == "TXT" && ptr.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/dns/lightsail/lightsail_test.go b/providers/dns/lightsail/lightsail_test.go
index 14370ffd9..a6b46045e 100644
--- a/providers/dns/lightsail/lightsail_test.go
+++ b/providers/dns/lightsail/lightsail_test.go
@@ -1,7 +1,7 @@
package lightsail
import (
- "context"
+ "net/http/httptest"
"os"
"testing"
@@ -10,6 +10,7 @@ import (
"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/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,29 +32,16 @@ var envTest = tester.NewEnvTest(
WithDomain(EnvDNSZone).
WithLiveTestRequirements(envAwsAccessKeyID, envAwsSecretAccessKey, EnvDNSZone)
-func makeProvider(serverURL string) *DNSProvider {
- config := aws.Config{
- Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "),
- Region: "mock-region",
- BaseEndpoint: aws.String(serverURL),
- RetryMaxAttempts: 1,
- }
-
- return &DNSProvider{
- client: lightsail.NewFromConfig(config),
- config: NewDefaultConfig(),
- }
-}
-
func TestCredentialsFromEnv(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
_ = os.Setenv(envAwsAccessKeyID, "123")
_ = os.Setenv(envAwsSecretAccessKey, "123")
_ = os.Setenv(envAwsRegion, "us-east-1")
- ctx := context.Background()
+ ctx := t.Context()
cfg, err := awsconfig.LoadDefaultConfig(ctx)
require.NoError(t, err)
@@ -69,17 +57,25 @@ func TestCredentialsFromEnv(t *testing.T) {
}
func TestDNSProvider_Present(t *testing.T) {
- mockResponses := map[string]MockResponse{
- "/": {StatusCode: 200, Body: ""},
- }
-
- serverURL := newMockServer(t, mockResponses)
-
- provider := makeProvider(serverURL)
+ provider := servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ return &DNSProvider{
+ client: lightsail.NewFromConfig(aws.Config{
+ HTTPClient: server.Client(),
+ Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "),
+ Region: "mock-region",
+ BaseEndpoint: aws.String(server.URL),
+ RetryMaxAttempts: 1,
+ }),
+ config: NewDefaultConfig(),
+ }, nil
+ }).
+ Route("POST /", nil).
+ Build(t)
domain := "example.com"
keyAuth := "123456d=="
err := provider.Present(domain, "", keyAuth)
- require.NoError(t, err, "Expected Present to return no error")
+ require.NoError(t, err)
}
diff --git a/providers/dns/lightsail/mock_server_test.go b/providers/dns/lightsail/mock_server_test.go
deleted file mode 100644
index 385c80850..000000000
--- a/providers/dns/lightsail/mock_server_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package lightsail
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/stretchr/testify/require"
-)
-
-// MockResponse represents a predefined response used by a mock server.
-type MockResponse struct {
- StatusCode int
- Body string
-}
-
-func newMockServer(t *testing.T, responses map[string]MockResponse) string {
- t.Helper()
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- path := r.URL.Path
- resp, ok := responses[path]
- if !ok {
- msg := fmt.Sprintf("Requested path not found in response map: %s", path)
- require.FailNow(t, msg)
- }
-
- w.Header().Set("Content-Type", "application/xml")
- w.WriteHeader(resp.StatusCode)
- _, err := w.Write([]byte(resp.Body))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }))
-
- t.Cleanup(server.Close)
-
- time.Sleep(100 * time.Millisecond)
-
- return server.URL
-}
diff --git a/providers/dns/limacity/internal/client.go b/providers/dns/limacity/internal/client.go
index 8a8b93adb..ae6ab87eb 100644
--- a/providers/dns/limacity/internal/client.go
+++ b/providers/dns/limacity/internal/client.go
@@ -32,7 +32,7 @@ func NewClient(apiKey string) *Client {
}
}
-func (c Client) GetDomains(ctx context.Context) ([]Domain, error) {
+func (c *Client) GetDomains(ctx context.Context) ([]Domain, error) {
endpoint := c.baseURL.JoinPath("domains.json")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -41,6 +41,7 @@ func (c Client) GetDomains(ctx context.Context) ([]Domain, error) {
}
var results DomainsResponse
+
err = c.do(req, &results)
if err != nil {
return nil, err
@@ -49,7 +50,7 @@ func (c Client) GetDomains(ctx context.Context) ([]Domain, error) {
return results.Data, nil
}
-func (c Client) GetRecords(ctx context.Context, domainID int) ([]Record, error) {
+func (c *Client) GetRecords(ctx context.Context, domainID int) ([]Record, error) {
endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records.json")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -58,6 +59,7 @@ func (c Client) GetRecords(ctx context.Context, domainID int) ([]Record, error)
}
var results RecordsResponse
+
err = c.do(req, &results)
if err != nil {
return nil, err
@@ -66,7 +68,7 @@ func (c Client) GetRecords(ctx context.Context, domainID int) ([]Record, error)
return results.Data, nil
}
-func (c Client) AddRecord(ctx context.Context, domainID int, record Record) error {
+func (c *Client) AddRecord(ctx context.Context, domainID int, record Record) error {
endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records.json")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, NameserverRecordPayload{Data: record})
@@ -75,6 +77,7 @@ func (c Client) AddRecord(ctx context.Context, domainID int, record Record) erro
}
var results APIResponse
+
err = c.do(req, &results)
if err != nil {
return err
@@ -83,7 +86,7 @@ func (c Client) AddRecord(ctx context.Context, domainID int, record Record) erro
return nil
}
-func (c Client) UpdateRecord(ctx context.Context, domainID, recordID int, record Record) error {
+func (c *Client) UpdateRecord(ctx context.Context, domainID, recordID int, record Record) error {
endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records", strconv.Itoa(recordID))
req, err := newJSONRequest(ctx, http.MethodPut, endpoint, NameserverRecordPayload{Data: record})
@@ -92,6 +95,7 @@ func (c Client) UpdateRecord(ctx context.Context, domainID, recordID int, record
}
var results APIResponse
+
err = c.do(req, &results)
if err != nil {
return err
@@ -100,7 +104,7 @@ func (c Client) UpdateRecord(ctx context.Context, domainID, recordID int, record
return nil
}
-func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int) error {
+func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error {
// /domains/{domainId}/records/{recordId} DELETE
endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records", strconv.Itoa(recordID))
@@ -110,6 +114,7 @@ func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int) error
}
var results APIResponse
+
err = c.do(req, &results)
if err != nil {
return err
@@ -118,7 +123,7 @@ func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int) error
return nil
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
req.SetBasicAuth("api", c.apiKey)
resp, err := c.HTTPClient.Do(req)
@@ -177,6 +182,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIResponse
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/limacity/internal/client_test.go b/providers/dns/limacity/internal/client_test.go
index b9a13bdab..c43f12ba2 100644
--- a/providers/dns/limacity/internal/client_test.go
+++ b/providers/dns/limacity/internal/client_test.go
@@ -1,72 +1,38 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const apiKey = "secret"
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(apiKey)
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(apiKey)
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func testHandler(filename string, method 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
- }
-
- username, key, ok := req.BasicAuth()
- if username != "api" || key != apiKey || !ok {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), 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
- }
- }
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("api", apiKey),
+ )
}
func TestClient_GetDomains(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains.json", servermock.ResponseFromFixture("get-domains.json")).
+ Build(t)
- mux.HandleFunc("/domains.json", testHandler("get-domains.json", http.MethodGet, http.StatusOK))
-
- domains, err := client.GetDomains(context.Background())
+ domains, err := client.GetDomains(t.Context())
require.NoError(t, err)
expected := []Domain{{
@@ -80,20 +46,22 @@ func TestClient_GetDomains(t *testing.T) {
}
func TestClient_GetDomains_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains.json",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("/domains.json", testHandler("error.json", http.MethodGet, http.StatusBadRequest))
-
- _, err := client.GetDomains(context.Background())
+ _, err := client.GetDomains(t.Context())
require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]")
}
func TestClient_GetRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/123/records.json", servermock.ResponseFromFixture("get-records.json")).
+ Build(t)
- mux.HandleFunc("/domains/123/records.json", testHandler("get-records.json", http.MethodGet, http.StatusOK))
-
- records, err := client.GetRecords(context.Background(), 123)
+ records, err := client.GetRecords(t.Context(), 123)
require.NoError(t, err)
expected := []Record{
@@ -116,18 +84,22 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecords_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/123/records.json",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("/domains/123/records.json", testHandler("error.json", http.MethodGet, http.StatusBadRequest))
-
- _, err := client.GetRecords(context.Background(), 123)
+ _, err := client.GetRecords(t.Context(), 123)
require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]")
}
func TestClient_AddRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/domains/123/records.json", testHandler("ok.json", http.MethodPost, http.StatusOK))
+ client := mockBuilder().
+ Route("POST /domains/123/records.json",
+ servermock.ResponseFromFixture("ok.json"),
+ servermock.CheckRequestJSONBody(`{"nameserver_record":{"name":"foo","content":"bar","ttl":12,"type":"TXT"}}`)).
+ Build(t)
record := Record{
Name: "foo",
@@ -136,14 +108,16 @@ func TestClient_AddRecord(t *testing.T) {
Type: "TXT",
}
- err := client.AddRecord(context.Background(), 123, record)
+ err := client.AddRecord(t.Context(), 123, record)
require.NoError(t, err)
}
func TestClient_AddRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/domains/123/records.json", testHandler("error.json", http.MethodPost, http.StatusBadRequest))
+ client := mockBuilder().
+ Route("POST /domains/123/records.json",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
record := Record{
Name: "foo",
@@ -152,42 +126,49 @@ func TestClient_AddRecord_error(t *testing.T) {
Type: "TXT",
}
- err := client.AddRecord(context.Background(), 123, record)
+ err := client.AddRecord(t.Context(), 123, record)
require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]")
}
func TestClient_UpdateRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("PUT /domains/123/records/456",
+ servermock.ResponseFromFixture("ok.json"),
+ servermock.CheckRequestJSONBody(`{"nameserver_record":{}}`)).
+ Build(t)
- mux.HandleFunc("/domains/123/records/456", testHandler("ok.json", http.MethodPut, http.StatusOK))
-
- err := client.UpdateRecord(context.Background(), 123, 456, Record{})
+ err := client.UpdateRecord(t.Context(), 123, 456, Record{})
require.NoError(t, err)
}
func TestClient_UpdateRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("PUT /domains/123/records/456",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("/domains/123/records/456", testHandler("error.json", http.MethodPut, http.StatusBadRequest))
-
- err := client.UpdateRecord(context.Background(), 123, 456, Record{})
+ err := client.UpdateRecord(t.Context(), 123, 456, Record{})
require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]")
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /domains/123/records/456",
+ servermock.ResponseFromFixture("ok.json")).
+ Build(t)
- mux.HandleFunc("/domains/123/records/456", testHandler("ok.json", http.MethodDelete, http.StatusOK))
-
- err := client.DeleteRecord(context.Background(), 123, 456)
+ err := client.DeleteRecord(t.Context(), 123, 456)
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /domains/123/records/456",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("/domains/123/records/456", testHandler("error.json", http.MethodDelete, http.StatusBadRequest))
-
- err := client.DeleteRecord(context.Background(), 123, 456)
+ err := client.DeleteRecord(t.Context(), 123, 456)
require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]")
}
diff --git a/providers/dns/limacity/internal/types.go b/providers/dns/limacity/internal/types.go
index 5fdbacef9..7411632ea 100644
--- a/providers/dns/limacity/internal/types.go
+++ b/providers/dns/limacity/internal/types.go
@@ -10,7 +10,7 @@ type RecordsResponse struct {
}
type NameserverRecordPayload struct {
- Data Record `json:"nameserver_record,omitempty"`
+ Data Record `json:"nameserver_record"`
}
type DomainsResponse struct {
diff --git a/providers/dns/limacity/limacity.go b/providers/dns/limacity/limacity.go
index ef2c6950d..3291faf66 100644
--- a/providers/dns/limacity/limacity.go
+++ b/providers/dns/limacity/limacity.go
@@ -13,8 +13,8 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/limacity/internal"
- "github.com/miekg/dns"
)
// Environment variables names.
@@ -90,6 +90,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := internal.NewClient(config.APIKey)
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -111,9 +117,11 @@ func (d *DNSProvider) Sequential() time.Duration {
// 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)
- domains, err := d.client.GetDomains(context.Background())
+ domains, err := d.client.GetDomains(ctx)
if err != nil {
return fmt.Errorf("limacity: get domains: %w", err)
}
@@ -135,7 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Type: "TXT",
}
- err = d.client.AddRecord(context.Background(), dom.ID, record)
+ err = d.client.AddRecord(ctx, dom.ID, record)
if err != nil {
return fmt.Errorf("limacity: add record: %w", err)
}
@@ -149,22 +157,26 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// CleanUp removes the TXT record.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
info := dns01.GetChallengeInfo(domain, keyAuth)
// gets the domain's unique ID
d.domainIDsMu.Lock()
domainID, ok := d.domainIDs[token]
d.domainIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("limacity: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token)
}
- records, err := d.client.GetRecords(context.Background(), domainID)
+ records, err := d.client.GetRecords(ctx, domainID)
if err != nil {
return fmt.Errorf("limacity: get records: %w", err)
}
var recordID int
+
for _, record := range records {
if record.Type == "TXT" && record.Content == strconv.Quote(info.Value) {
recordID = record.ID
@@ -176,19 +188,20 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return errors.New("limacity: TXT record not found")
}
- err = d.client.DeleteRecord(context.Background(), domainID, recordID)
+ err = d.client.DeleteRecord(ctx, domainID, recordID)
if err != nil {
return fmt.Errorf("limacity: delete record (domain ID=%d, record ID=%d): %w", domainID, recordID, err)
}
+ d.domainIDsMu.Lock()
+ delete(d.domainIDs, info.EffectiveFQDN)
+ d.domainIDsMu.Unlock()
+
return nil
}
func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) {
- labelIndexes := dns.Split(fqdn)
-
- for _, index := range labelIndexes {
- f := fqdn[index:]
+ for f := range dns01.DomainsSeq(fqdn) {
domain := dns01.UnFqdn(f)
for _, dom := range domains {
diff --git a/providers/dns/limacity/limacity.toml b/providers/dns/limacity/limacity.toml
index c9bcaf16e..d236577d0 100644
--- a/providers/dns/limacity/limacity.toml
+++ b/providers/dns/limacity/limacity.toml
@@ -6,18 +6,18 @@ Since = "v4.18.0"
Example = '''
LIMACITY_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns limacity -d '*.example.com' -d example.com run
+lego --dns limacity -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
LIMACITY_API_KEY = "The API key"
[Configuration.Additional]
- LIMACITY_POLLING_INTERVAL = "Time between DNS propagation check"
- LIMACITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- LIMACITY_SEQUENCE_INTERVAL = "Time between sequential requests"
- LIMACITY_TTL = "The TTL of the TXT record used for the DNS challenge"
- LIMACITY_HTTP_TIMEOUT = "API request timeout"
+ LIMACITY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 80)"
+ LIMACITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 480)"
+ LIMACITY_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 90)"
+ LIMACITY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ LIMACITY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.lima-city.de/hilfe/lima-city-api"
diff --git a/providers/dns/limacity/limacity_test.go b/providers/dns/limacity/limacity_test.go
index 2834a5f1f..3301fcb2e 100644
--- a/providers/dns/limacity/limacity_test.go
+++ b/providers/dns/limacity/limacity_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/linode/linode.go b/providers/dns/linode/linode.go
index 841e24c69..b03dee4f5 100644
--- a/providers/dns/linode/linode.go
+++ b/providers/dns/linode/linode.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
"github.com/linode/linodego"
"golang.org/x/oauth2"
@@ -50,9 +51,9 @@ type Config struct {
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 0),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second),
- HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 0),
+ HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
}
}
@@ -102,7 +103,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
},
}
- client := linodego.NewClient(oauth2Client)
+ client := linodego.NewClient(clientdebug.Wrap(oauth2Client))
client.SetUserAgent(useragent.Get())
return &DNSProvider{config: config, client: &client}, nil
@@ -130,9 +131,11 @@ func (d *DNSProvider) Timeout() (time.Duration, 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)
- zone, err := d.getHostedZoneInfo(info.EffectiveFQDN)
+ zone, err := d.getHostedZoneInfo(ctx, info.EffectiveFQDN)
if err != nil {
return err
}
@@ -144,22 +147,26 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Type: linodego.RecordTypeTXT,
}
- _, err = d.client.CreateDomainRecord(context.Background(), zone.domainID, createOpts)
+ _, err = d.client.CreateDomainRecord(ctx, zone.domainID, createOpts)
+
return err
}
// 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)
- zone, err := d.getHostedZoneInfo(info.EffectiveFQDN)
+ zone, err := d.getHostedZoneInfo(ctx, info.EffectiveFQDN)
if err != nil {
return err
}
// Get all TXT records for the specified domain.
listOpts := linodego.NewListOptions(0, `{"type":"TXT"}`)
- resources, err := d.client.ListDomainRecords(context.Background(), zone.domainID, listOpts)
+
+ resources, err := d.client.ListDomainRecords(ctx, zone.domainID, listOpts)
if err != nil {
return err
}
@@ -168,7 +175,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
for _, resource := range resources {
if (resource.Name == dns01.UnFqdn(info.EffectiveFQDN) || resource.Name == zone.resourceName) &&
resource.Target == info.Value {
- if err := d.client.DeleteDomainRecord(context.Background(), zone.domainID, resource.ID); err != nil {
+ if err := d.client.DeleteDomainRecord(ctx, zone.domainID, resource.ID); err != nil {
return err
}
}
@@ -177,7 +184,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
-func (d *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) {
+func (d *DNSProvider) getHostedZoneInfo(ctx context.Context, fqdn string) (*hostedZoneInfo, error) {
// Lookup the zone that handles the specified FQDN.
authZone, err := dns01.FindZoneByFqdn(fqdn)
if err != nil {
@@ -191,7 +198,8 @@ func (d *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) {
}
listOpts := linodego.NewListOptions(0, string(filter))
- domains, err := d.client.ListDomains(context.Background(), listOpts)
+
+ domains, err := d.client.ListDomains(ctx, listOpts)
if err != nil {
return nil, err
}
diff --git a/providers/dns/linode/linode.toml b/providers/dns/linode/linode.toml
index 790a2238c..9ea30b92b 100644
--- a/providers/dns/linode/linode.toml
+++ b/providers/dns/linode/linode.toml
@@ -7,17 +7,17 @@ Since = "v1.1.0"
Example = '''
LINODE_TOKEN=xxxxx \
-lego --email you@example.com --dns linode -d '*.example.com' -d example.com run
+lego --dns linode -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
LINODE_TOKEN = "API token"
[Configuration.Additional]
- LINODE_POLLING_INTERVAL = "Time between DNS propagation check"
- LINODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- LINODE_TTL = "The TTL of the TXT record used for the DNS challenge"
- LINODE_HTTP_TIMEOUT = "API request timeout"
+ LINODE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)"
+ LINODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ LINODE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ LINODE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developers.linode.com/api/v4"
diff --git a/providers/dns/linode/linode_test.go b/providers/dns/linode/linode_test.go
index 70b33eda4..1c4903aca 100644
--- a/providers/dns/linode/linode_test.go
+++ b/providers/dns/linode/linode_test.go
@@ -1,69 +1,20 @@
package linode
import (
- "encoding/json"
- "fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
- "time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/linode/linodego"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-type MockResponseMap map[string]interface{}
-
var envTest = tester.NewEnvTest(EnvToken)
-func setupTest(t *testing.T, responses MockResponseMap) string {
- t.Helper()
-
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Ensure that we support the requested action.
- action := r.Method + ":" + r.URL.Path
- resp, ok := responses[action]
- if !ok {
- http.Error(w, fmt.Sprintf("Unsupported mock action: %q", action), http.StatusInternalServerError)
- return
- }
-
- rawResponse, err := json.Marshal(resp)
- if err != nil {
- http.Error(w, fmt.Sprintf("Failed to JSON encode response: %v", err), http.StatusInternalServerError)
- return
- }
-
- // Send the response.
- w.Header().Set("Content-Type", "application/json")
- if err, ok := resp.(linodego.APIError); ok {
- if err.Errors[0].Reason == "Not found" {
- w.WriteHeader(http.StatusNotFound)
- } else {
- w.WriteHeader(http.StatusBadRequest)
- }
- } else {
- w.WriteHeader(http.StatusOK)
- }
-
- _, err = w.Write(rawResponse)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- time.Sleep(100 * time.Millisecond)
-
- return server.URL
-}
-
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
@@ -88,6 +39,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -143,83 +95,80 @@ func TestNewDNSProviderConfig(t *testing.T) {
func TestDNSProvider_Present(t *testing.T) {
defer envTest.RestoreEnv()
- os.Setenv(EnvToken, "testing")
- p, err := NewDNSProvider()
- require.NoError(t, err)
- require.NotNil(t, p)
+ os.Setenv(EnvToken, "testing")
domain := "example.com"
keyAuth := "dGVzdGluZw=="
testCases := []struct {
desc string
- mockResponses MockResponseMap
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
desc: "Success",
- mockResponses: MockResponseMap{
- "GET:/v4/domains": linodego.DomainsPagedResponse{
- PageOptions: &linodego.PageOptions{
- Pages: 1,
- Results: 1,
- Page: 1,
- },
- Data: []linodego.Domain{{
- Domain: domain,
- ID: 1234,
- }},
- },
- "POST:/v4/domains/1234/records": linodego.DomainRecord{
+ builder: mockBuilder().
+ Route("GET /v4/domains",
+ servermock.JSONEncode(linodego.DomainsPagedResponse{
+ PageOptions: &linodego.PageOptions{
+ Pages: 1,
+ Results: 1,
+ Page: 1,
+ },
+ Data: []linodego.Domain{{
+ Domain: domain,
+ ID: 1234,
+ }},
+ })).
+ Route("POST /v4/domains/1234/records", servermock.JSONEncode(linodego.DomainRecord{
ID: 1234,
- },
- },
+ })),
},
{
desc: "NoDomain",
- mockResponses: MockResponseMap{
- "GET:/v4/domains": linodego.APIError{
- Errors: []linodego.APIErrorReason{{
- Reason: "Not found",
- }},
- },
- },
+ builder: mockBuilder().
+ Route("GET /v4/domains",
+ servermock.JSONEncode(linodego.APIError{
+ Errors: []linodego.APIErrorReason{{
+ Reason: "Not found",
+ }},
+ }).
+ WithStatusCode(http.StatusNotFound)),
expectedError: "[404] Not found",
},
{
desc: "CreateFailed",
- mockResponses: MockResponseMap{
- "GET:/v4/domains": &linodego.DomainsPagedResponse{
- PageOptions: &linodego.PageOptions{
- Pages: 1,
- Results: 1,
- Page: 1,
- },
- Data: []linodego.Domain{{
- Domain: "example.com",
- ID: 1234,
- }},
- },
- "POST:/v4/domains/1234/records": linodego.APIError{
- Errors: []linodego.APIErrorReason{{
- Reason: "Failed to create domain resource",
- Field: "somefield",
- }},
- },
- },
+ builder: mockBuilder().
+ Route("GET /v4/domains",
+ servermock.JSONEncode(&linodego.DomainsPagedResponse{
+ PageOptions: &linodego.PageOptions{
+ Pages: 1,
+ Results: 1,
+ Page: 1,
+ },
+ Data: []linodego.Domain{{
+ Domain: "example.com",
+ ID: 1234,
+ }},
+ })).
+ Route("POST /v4/domains/1234/records",
+ servermock.JSONEncode(linodego.APIError{
+ Errors: []linodego.APIErrorReason{{
+ Reason: "Failed to create domain resource",
+ Field: "somefield",
+ }},
+ }).
+ WithStatusCode(http.StatusBadRequest)),
expectedError: "[400] [somefield] Failed to create domain resource",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- serverURL := setupTest(t, test.mockResponses)
+ provider := test.builder.Build(t)
- assert.NotNil(t, p.client)
- p.client.SetBaseURL(serverURL)
-
- err = p.Present(domain, "", keyAuth)
+ err := provider.Present(domain, "", keyAuth)
if test.expectedError == "" {
assert.NoError(t, err)
} else {
@@ -231,109 +180,114 @@ func TestDNSProvider_Present(t *testing.T) {
func TestDNSProvider_CleanUp(t *testing.T) {
defer envTest.RestoreEnv()
- os.Setenv(EnvToken, "testing")
- p, err := NewDNSProvider()
- require.NoError(t, err)
+ os.Setenv(EnvToken, "testing")
domain := "example.com"
keyAuth := "dGVzdGluZw=="
testCases := []struct {
desc string
- mockResponses MockResponseMap
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
desc: "Success",
- mockResponses: MockResponseMap{
- "GET:/v4/domains": &linodego.DomainsPagedResponse{
- PageOptions: &linodego.PageOptions{
- Pages: 1,
- Results: 1,
- Page: 1,
- },
- Data: []linodego.Domain{{
- Domain: "foobar.com",
- ID: 1234,
- }},
- },
- "GET:/v4/domains/1234/records": &linodego.DomainRecordsPagedResponse{
- PageOptions: &linodego.PageOptions{
- Pages: 1,
- Results: 1,
- Page: 1,
- },
- Data: []linodego.DomainRecord{{
- ID: 1234,
- Name: "_acme-challenge",
- Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM",
- Type: "TXT",
- }},
- },
- "DELETE:/v4/domains/1234/records/1234": struct{}{},
- },
+ builder: mockBuilder().
+ Route("GET /v4/domains",
+ servermock.JSONEncode(&linodego.DomainsPagedResponse{
+ PageOptions: &linodego.PageOptions{
+ Pages: 1,
+ Results: 1,
+ Page: 1,
+ },
+ Data: []linodego.Domain{{
+ Domain: "foobar.com",
+ ID: 1234,
+ }},
+ })).
+ Route("GET /v4/domains/1234/records",
+ servermock.JSONEncode(&linodego.DomainRecordsPagedResponse{
+ PageOptions: &linodego.PageOptions{
+ Pages: 1,
+ Results: 1,
+ Page: 1,
+ },
+ Data: []linodego.DomainRecord{{
+ ID: 1234,
+ Name: "_acme-challenge",
+ Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM",
+ Type: "TXT",
+ }},
+ })).
+ Route("DELETE /v4/domains/1234/records/1234",
+ servermock.RawStringResponse("{}").WithHeader("Content-Type", "application/json")),
},
{
desc: "NoDomain",
- mockResponses: MockResponseMap{
- "GET:/v4/domains": linodego.APIError{
- Errors: []linodego.APIErrorReason{{
- Reason: "Not found",
- }},
- },
- "GET:/v4/domains/1234/records": linodego.APIError{
- Errors: []linodego.APIErrorReason{{
- Reason: "Not found",
- }},
- },
- },
+ builder: mockBuilder().
+ Route("GET /v4/domains",
+ servermock.JSONEncode(linodego.APIError{
+ Errors: []linodego.APIErrorReason{{
+ Reason: "Not found",
+ }},
+ }).
+ WithStatusCode(http.StatusNotFound)).
+ Route("GET /v4/domains/1234/records",
+ servermock.JSONEncode(linodego.APIError{
+ Errors: []linodego.APIErrorReason{{
+ Reason: "Not found",
+ }},
+ },
+ ).
+ WithStatusCode(http.StatusNotFound)),
expectedError: "[404] Not found",
},
{
desc: "DeleteFailed",
- mockResponses: MockResponseMap{
- "GET:/v4/domains": linodego.DomainsPagedResponse{
- PageOptions: &linodego.PageOptions{
- Pages: 1,
- Results: 1,
- Page: 1,
- },
- Data: []linodego.Domain{{
- ID: 1234,
- Domain: "example.com",
- }},
- },
- "GET:/v4/domains/1234/records": linodego.DomainRecordsPagedResponse{
- PageOptions: &linodego.PageOptions{
- Pages: 1,
- Results: 1,
- Page: 1,
- },
- Data: []linodego.DomainRecord{{
- ID: 1234,
- Name: "_acme-challenge",
- Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM",
- Type: "TXT",
- }},
- },
- "DELETE:/v4/domains/1234/records/1234": linodego.APIError{
- Errors: []linodego.APIErrorReason{{
- Reason: "Failed to delete domain resource",
- }},
- },
- },
+ builder: mockBuilder().
+ Route("GET /v4/domains",
+ servermock.JSONEncode(linodego.DomainsPagedResponse{
+ PageOptions: &linodego.PageOptions{
+ Pages: 1,
+ Results: 1,
+ Page: 1,
+ },
+ Data: []linodego.Domain{{
+ ID: 1234,
+ Domain: "example.com",
+ }},
+ })).
+ Route("GET /v4/domains/1234/records",
+ servermock.JSONEncode(linodego.DomainRecordsPagedResponse{
+ PageOptions: &linodego.PageOptions{
+ Pages: 1,
+ Results: 1,
+ Page: 1,
+ },
+ Data: []linodego.DomainRecord{{
+ ID: 1234,
+ Name: "_acme-challenge",
+ Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM",
+ Type: "TXT",
+ }},
+ })).
+ Route("DELETE /v4/domains/1234/records/1234",
+ servermock.JSONEncode(linodego.APIError{
+ Errors: []linodego.APIErrorReason{{
+ Reason: "Failed to delete domain resource",
+ }},
+ }).
+ WithStatusCode(http.StatusBadRequest)),
expectedError: "[400] Failed to delete domain resource",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- serverURL := setupTest(t, test.mockResponses)
+ provider := test.builder.Build(t)
- p.client.SetBaseURL(serverURL)
-
- err = p.CleanUp(domain, "", keyAuth)
+ err := provider.CleanUp(domain, "", keyAuth)
if test.expectedError == "" {
assert.NoError(t, err)
} else {
@@ -356,3 +310,16 @@ func TestLiveCleanUp(t *testing.T) {
}
// TODO implement this test
}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ p, err := NewDNSProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.SetBaseURL(server.URL)
+
+ return p, nil
+ })
+}
diff --git a/providers/dns/liquidweb/liquidweb.go b/providers/dns/liquidweb/liquidweb.go
index 76f965123..6e93e2a12 100644
--- a/providers/dns/liquidweb/liquidweb.go
+++ b/providers/dns/liquidweb/liquidweb.go
@@ -55,15 +55,16 @@ func NewDefaultConfig() *Config {
BaseURL: defaultBaseURL,
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)),
+ PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)),
HTTPTimeout: env.GetOneWithFallback(EnvHTTPTimeout, 1*time.Minute, env.ParseSecond, altEnvName(EnvHTTPTimeout)),
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *lw.API
+ config *Config
+ client *lw.API
+
recordIDs map[string]int
recordIDsMu sync.Mutex
}
@@ -159,6 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
params := &network.DNSRecordParams{ID: recordID}
+
_, err := d.client.NetworkDNS.Delete(params)
if err != nil {
return fmt.Errorf("liquidweb: could not remove TXT record: %w", err)
@@ -179,6 +181,7 @@ func (d *DNSProvider) findZone(domain string) (string, error) {
// 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)
diff --git a/providers/dns/liquidweb/liquidweb.toml b/providers/dns/liquidweb/liquidweb.toml
index 987b8027d..386b99cab 100644
--- a/providers/dns/liquidweb/liquidweb.toml
+++ b/providers/dns/liquidweb/liquidweb.toml
@@ -7,7 +7,7 @@ Since = "v3.1.0"
Example = '''
LWAPI_USERNAME=someuser \
LWAPI_PASSWORD=somepass \
-lego --email you@example.com --dns liquidweb -d '*.example.com' -d example.com run
+lego --dns liquidweb -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,10 @@ lego --email you@example.com --dns liquidweb -d '*.example.com' -d example.com r
[Configuration.Additional]
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)"
+ LWAPI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ LWAPI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ LWAPI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ LWAPI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
[Links]
API = "https://api.liquidweb.com/docs/"
diff --git a/providers/dns/liquidweb/liquidweb_test.go b/providers/dns/liquidweb/liquidweb_test.go
index a26b18e1b..a34d19037 100644
--- a/providers/dns/liquidweb/liquidweb_test.go
+++ b/providers/dns/liquidweb/liquidweb_test.go
@@ -18,22 +18,6 @@ var envTest = tester.NewEnvTest(
EnvZone).
WithDomain(envDomain)
-func setupTest(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider {
- t.Helper()
-
- serverURL := mockAPIServer(t, initRecs)
-
- config := NewDefaultConfig()
- config.Username = "blars"
- config.Password = "tacoman"
- config.BaseURL = serverURL
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- return provider
-}
-
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
@@ -43,16 +27,16 @@ func TestNewDNSProvider(t *testing.T) {
{
desc: "minimum-success",
envVars: map[string]string{
- EnvUsername: "blars",
- EnvPassword: "tacoman",
+ EnvUsername: "user",
+ EnvPassword: "secret",
},
},
{
desc: "set-everything",
envVars: map[string]string{
- EnvURL: "https://storm.com",
- EnvUsername: "blars",
- EnvPassword: "tacoman",
+ EnvURL: "https://storm.example",
+ EnvUsername: "user",
+ EnvPassword: "secret",
EnvZone: "blars.com",
},
},
@@ -64,16 +48,16 @@ func TestNewDNSProvider(t *testing.T) {
{
desc: "missing username",
envVars: map[string]string{
- EnvPassword: "tacoman",
- EnvZone: "blars.com",
+ EnvPassword: "secret",
+ EnvZone: "blars.example",
},
expected: "liquidweb: some credentials information are missing: LIQUID_WEB_USERNAME",
},
{
desc: "missing password",
envVars: map[string]string{
- EnvUsername: "blars",
- EnvZone: "blars.com",
+ EnvUsername: "user",
+ EnvZone: "blars.example",
},
expected: "liquidweb: some credentials information are missing: LIQUID_WEB_PASSWORD",
},
@@ -82,6 +66,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -161,15 +146,15 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
func TestDNSProvider_Present(t *testing.T) {
- provider := setupTest(t)
+ provider := mockProvider(t)
- err := provider.Present("tacoman.com", "", "")
+ err := provider.Present("tacoman.example", "", "")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
- provider := setupTest(t, network.DNSRecord{
- Name: "_acme-challenge.tacoman.com",
+ provider := mockProvider(t, network.DNSRecord{
+ Name: "_acme-challenge.tacoman.example",
RData: "123d==",
Type: "TXT",
TTL: 300,
@@ -179,7 +164,7 @@ func TestDNSProvider_CleanUp(t *testing.T) {
provider.recordIDs["123d=="] = 1234567
- err := provider.CleanUp("tacoman.com.", "123d==", "")
+ err := provider.CleanUp("tacoman.example.", "123d==", "")
require.NoError(t, err)
}
@@ -196,7 +181,7 @@ func TestDNSProvider(t *testing.T) {
}{
{
desc: "expected successful",
- domain: "tacoman.com",
+ domain: "tacoman.example",
token: "123",
keyAuth: "456",
present: true,
@@ -204,7 +189,7 @@ func TestDNSProvider(t *testing.T) {
},
{
desc: "other successful",
- domain: "banana.com",
+ domain: "banana.example",
token: "123",
keyAuth: "456",
present: true,
@@ -212,16 +197,16 @@ func TestDNSProvider(t *testing.T) {
},
{
desc: "zone not on account",
- domain: "huckleberry.com",
+ domain: "huckleberry.example",
token: "123",
keyAuth: "456",
present: true,
- expPresentErr: "no valid zone in account for certificate '_acme-challenge.huckleberry.com'",
+ expPresentErr: "no valid zone in account for certificate '_acme-challenge.huckleberry.example'",
cleanup: false,
},
{
desc: "ssl for domain",
- domain: "sundae.cherry.com",
+ domain: "sundae.cherry.example",
token: "5847953",
keyAuth: "34872934",
present: true,
@@ -229,7 +214,7 @@ func TestDNSProvider(t *testing.T) {
},
{
desc: "complicated domain",
- domain: "always.money.stand.banana.com",
+ domain: "always.money.stand.banana.example",
token: "5847953",
keyAuth: "there is always money in the banana stand",
present: true,
@@ -239,7 +224,7 @@ func TestDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- provider := setupTest(t, test.initRecs...)
+ provider := mockProvider(t, test.initRecs...)
if test.present {
err := provider.Present(test.domain, test.token, test.keyAuth)
@@ -264,6 +249,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/liquidweb/servermock_test.go b/providers/dns/liquidweb/servermock_test.go
index 8c22595af..4886e17f1 100644
--- a/providers/dns/liquidweb/servermock_test.go
+++ b/providers/dns/liquidweb/servermock_test.go
@@ -1,7 +1,6 @@
package liquidweb
import (
- "bytes"
"encoding/json"
"fmt"
"io"
@@ -10,11 +9,12 @@ import (
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/liquidweb/liquidweb-go/network"
"github.com/liquidweb/liquidweb-go/types"
)
-func mockAPIServer(t *testing.T, initRecs []network.DNSRecord) string {
+func mockProvider(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider {
t.Helper()
recs := make(map[int]network.DNSRecord)
@@ -23,157 +23,142 @@ func mockAPIServer(t *testing.T, initRecs []network.DNSRecord) string {
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())
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Username = "user"
+ config.Password = "secret"
+ config.BaseURL = server.URL
- 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)
- }
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().
+ WithBasicAuth("user", "secret"),
+ ).
+ Route("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)).
+ Route("/v1/Network/DNS/Record/create", mockAPICreate(recs)).
+ Route("/v1/Network/DNS/Zone/list", mockAPIListZones()).
+ Route("/bleed/Network/DNS/Record/delete", mockAPIDelete(recs)).
+ Route("/bleed/Network/DNS/Record/create", mockAPICreate(recs)).
+ Route("/bleed/Network/DNS/Zone/list", mockAPIListZones()).
+ Build(t)
}
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)
+ return func(rw http.ResponseWriter, req *http.Request) {
+ body, err := io.ReadAll(req.Body)
if err != nil {
- http.Error(w, "invalid request", http.StatusInternalServerError)
+ http.Error(rw, "invalid request", http.StatusInternalServerError)
return
}
- req := struct {
+ payload := struct {
Params network.DNSRecord `json:"params"`
}{}
- if err = json.Unmarshal(body, &req); err != nil {
- http.Error(w, makeEncodingError(body), http.StatusBadRequest)
+ if err = json.Unmarshal(body, &payload); err != nil {
+ http.Error(rw, 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)
+ payload.Params.ID = types.FlexInt(rand.Intn(10000000))
+ payload.Params.ZoneID = types.FlexInt(mockAPIServerZones[payload.Params.Name])
+
+ if _, exists := recs[int(payload.Params.ID)]; exists {
+ http.Error(rw, "dns record already exists", http.StatusTeapot)
return
}
- recs[int(req.Params.ID)] = req.Params
- resp, err := json.Marshal(req.Params)
+ recs[int(payload.Params.ID)] = payload.Params
+
+ resp, err := json.Marshal(payload.Params)
if err != nil {
- http.Error(w, "", http.StatusInternalServerError)
+ http.Error(rw, "", http.StatusInternalServerError)
return
}
- http.Error(w, string(resp), http.StatusOK)
+
+ http.Error(rw, 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)
+ return func(rw http.ResponseWriter, req *http.Request) {
+ body, err := io.ReadAll(req.Body)
if err != nil {
- http.Error(w, "invalid request", http.StatusInternalServerError)
+ http.Error(rw, "invalid request", http.StatusInternalServerError)
return
}
- req := struct {
+ payload := 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)
+ if err := json.Unmarshal(body, &payload); err != nil {
+ http.Error(rw, 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)
+ if payload.Params.ID == 0 {
+ http.Error(rw, `{"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)
+ if _, ok := recs[payload.Params.ID]; !ok {
+ http.Error(rw, 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}`, payload.Params.ID, payload.Params.ID), http.StatusOK)
return
}
- delete(recs, req.Params.ID)
- http.Error(w, fmt.Sprintf("{\"deleted\":%d}", req.Params.ID), http.StatusOK)
+
+ delete(recs, payload.Params.ID)
+ http.Error(rw, fmt.Sprintf("{\"deleted\":%d}", payload.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)
+ return func(rw http.ResponseWriter, req *http.Request) {
+ body, err := io.ReadAll(req.Body)
if err != nil {
- http.Error(w, "invalid request", http.StatusInternalServerError)
+ http.Error(rw, "invalid request", http.StatusInternalServerError)
return
}
- req := struct {
+ payload := 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)
+ if err = json.Unmarshal(body, &payload); err != nil {
+ http.Error(rw, 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)
+ case payload.Params.PageNum < 1:
+ payload.Params.PageNum = 1
+ case payload.Params.PageNum > len(mockZones):
+ payload.Params.PageNum = len(mockZones)
}
- resp := mockZones[req.Params.PageNum]
+
+ resp := mockZones[payload.Params.PageNum]
resp.ItemTotal = types.FlexInt(len(mockAPIServerZones))
- resp.PageNum = types.FlexInt(req.Params.PageNum)
+ resp.PageNum = types.FlexInt(payload.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)
+ http.Error(rw, string(respBody), http.StatusOK)
return
}
- http.Error(w, "", http.StatusInternalServerError)
+ http.Error(rw, "", http.StatusInternalServerError)
}
}
@@ -187,38 +172,38 @@ func makeMockZones() (map[int]network.DNSZoneList, map[string]int) {
Items: []network.DNSZone{
{
ID: 1,
- Name: "blars.com",
+ Name: "blars.example",
Active: 1,
DelegationStatus: "CORRECT",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 2,
- Name: "tacoman.com",
+ Name: "tacoman.example",
Active: 1,
DelegationStatus: "CORRECT",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 3,
- Name: "storm.com",
+ Name: "storm.example",
Active: 1,
DelegationStatus: "CORRECT",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 4,
- Name: "not-apple.com",
+ Name: "not-apple.example",
Active: 1,
DelegationStatus: "BAD_NAMESERVERS",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 5,
Name: "example.com",
Active: 1,
DelegationStatus: "BAD_NAMESERVERS",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
},
},
@@ -226,38 +211,38 @@ func makeMockZones() (map[int]network.DNSZoneList, map[string]int) {
Items: []network.DNSZone{
{
ID: 6,
- Name: "banana.com",
+ Name: "banana.example",
Active: 1,
DelegationStatus: "NXDOMAIN",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 7,
- Name: "cherry.com",
+ Name: "cherry.example",
Active: 1,
DelegationStatus: "SERVFAIL",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 8,
- Name: "dates.com",
+ Name: "dates.example",
Active: 1,
DelegationStatus: "SERVFAIL",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 9,
- Name: "eggplant.com",
+ Name: "eggplant.example",
Active: 1,
DelegationStatus: "SERVFAIL",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 10,
- Name: "fig.com",
+ Name: "fig.example",
Active: 1,
DelegationStatus: "UNKNOWN",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
},
},
@@ -265,41 +250,43 @@ func makeMockZones() (map[int]network.DNSZoneList, map[string]int) {
Items: []network.DNSZone{
{
ID: 11,
- Name: "grapes.com",
+ Name: "grapes.example",
Active: 1,
DelegationStatus: "UNKNOWN",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 12,
- Name: "money.banana.com",
+ Name: "money.banana.example",
Active: 1,
DelegationStatus: "UNKNOWN",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 13,
- Name: "money.stand.banana.com",
+ Name: "money.stand.banana.example",
Active: 1,
DelegationStatus: "UNKNOWN",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
{
ID: 14,
- Name: "stand.banana.com",
+ Name: "stand.banana.example",
Active: 1,
DelegationStatus: "UNKNOWN",
- PrimaryNameserver: "ns.liquidweb.com",
+ PrimaryNameserver: "ns.example.org",
},
},
},
}
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/dns/loopia/internal/client.go b/providers/dns/loopia/internal/client.go
index d521ffeec..0e9513024 100644
--- a/providers/dns/loopia/internal/client.go
+++ b/providers/dns/loopia/internal/client.go
@@ -37,7 +37,7 @@ func NewClient(apiUser, apiPassword string) *Client {
}
// AddTXTRecord adds a TXT record.
-func (c *Client) AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error {
+func (c *Client) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error {
call := &methodCall{
MethodName: "addZoneRecord",
Params: []param{
@@ -67,7 +67,7 @@ func (c *Client) AddTXTRecord(ctx context.Context, domain string, subdomain stri
}
// RemoveTXTRecord removes a TXT record.
-func (c *Client) RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error {
+func (c *Client) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error {
call := &methodCall{
MethodName: "removeZoneRecord",
Params: []param{
@@ -89,7 +89,7 @@ func (c *Client) RemoveTXTRecord(ctx context.Context, domain string, subdomain s
}
// GetTXTRecords gets TXT records.
-func (c *Client) GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]RecordObj, error) {
+func (c *Client) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]RecordObj, error) {
call := &methodCall{
MethodName: "getZoneRecords",
Params: []param{
diff --git a/providers/dns/loopia/internal/client_test.go b/providers/dns/loopia/internal/client_test.go
index 4fe2e1fd0..fed7d94f1 100644
--- a/providers/dns/loopia/internal/client_test.go
+++ b/providers/dns/loopia/internal/client_test.go
@@ -1,65 +1,80 @@
package internal
import (
- "context"
"encoding/xml"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+func mockBuilder(password string) *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("apiuser", password)
+ client.HTTPClient = server.Client()
+ client.BaseURL = server.URL + "/"
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithContentType("text/xml"),
+ )
+}
+
func TestClient_AddZoneRecord(t *testing.T) {
- serverResponses := map[string]string{
- addZoneRecordGoodAuth: responseOk,
- addZoneRecordBadAuth: responseAuthError,
- addZoneRecordNonValidDomain: responseUnknownError,
- addZoneRecordEmptyResponse: "",
- }
-
- serverURL := createFakeServer(t, serverResponses)
-
testCases := []struct {
desc string
password string
domain string
+ request string
+ response string
err string
}{
{
desc: "auth ok",
password: "goodpassword",
domain: exampleDomain,
+ request: addZoneRecordGoodAuth,
+ response: responseOk,
},
{
desc: "auth error",
password: "badpassword",
domain: exampleDomain,
+ request: addZoneRecordBadAuth,
+ response: responseAuthError,
err: "authentication error",
},
{
desc: "unknown error",
password: "goodpassword",
domain: "badexample.com",
+ request: addZoneRecordNonValidDomain,
+ response: responseUnknownError,
err: `unknown error: "UNKNOWN_ERROR"`,
},
{
desc: "empty response",
password: "goodpassword",
domain: "empty.com",
+ request: addZoneRecordEmptyResponse,
+ response: "",
err: "unmarshal error: EOF",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := NewClient("apiuser", test.password)
- client.BaseURL = serverURL + "/"
+ client := mockBuilder(test.password).
+ Route("POST /",
+ servermock.RawStringResponse(test.response),
+ servermock.CheckRequestBody(test.request)).
+ Build(t)
- err := client.AddTXTRecord(context.Background(), test.domain, exampleSubDomain, 123, "TXTrecord")
+ err := client.AddTXTRecord(t.Context(), test.domain, exampleSubDomain, 123, "TXTrecord")
if test.err == "" {
require.NoError(t, err)
} else {
@@ -71,52 +86,56 @@ func TestClient_AddZoneRecord(t *testing.T) {
}
func TestClient_RemoveSubdomain(t *testing.T) {
- serverResponses := map[string]string{
- removeSubdomainGoodAuth: responseOk,
- removeSubdomainBadAuth: responseAuthError,
- removeSubdomainNonValidDomain: responseUnknownError,
- removeSubdomainEmptyResponse: "",
- }
-
- serverURL := createFakeServer(t, serverResponses)
-
testCases := []struct {
desc string
password string
domain string
+ request string
+ response string
err string
}{
{
desc: "auth ok",
password: "goodpassword",
domain: exampleDomain,
+ request: removeSubdomainGoodAuth,
+ response: responseOk,
},
{
desc: "auth error",
password: "badpassword",
domain: exampleDomain,
+ request: removeSubdomainBadAuth,
+ response: responseAuthError,
err: "authentication error",
},
{
desc: "unknown error",
password: "goodpassword",
domain: "badexample.com",
+ request: removeSubdomainNonValidDomain,
+ response: responseUnknownError,
err: `unknown error: "UNKNOWN_ERROR"`,
},
{
desc: "empty response",
password: "goodpassword",
domain: "empty.com",
+ request: removeSubdomainEmptyResponse,
+ response: "",
err: "unmarshal error: EOF",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := NewClient("apiuser", test.password)
- client.BaseURL = serverURL + "/"
+ client := mockBuilder(test.password).
+ Route("POST /",
+ servermock.RawStringResponse(test.response),
+ servermock.CheckRequestBody(test.request)).
+ Build(t)
- err := client.RemoveSubdomain(context.Background(), test.domain, exampleSubDomain)
+ err := client.RemoveSubdomain(t.Context(), test.domain, exampleSubDomain)
if test.err == "" {
require.NoError(t, err)
} else {
@@ -128,52 +147,56 @@ func TestClient_RemoveSubdomain(t *testing.T) {
}
func TestClient_RemoveZoneRecord(t *testing.T) {
- serverResponses := map[string]string{
- removeRecordGoodAuth: responseOk,
- removeRecordBadAuth: responseAuthError,
- removeRecordNonValidDomain: responseUnknownError,
- removeRecordEmptyResponse: "",
- }
-
- serverURL := createFakeServer(t, serverResponses)
-
testCases := []struct {
desc string
password string
domain string
+ request string
+ response string
err string
}{
{
desc: "auth ok",
password: "goodpassword",
domain: exampleDomain,
+ request: removeRecordGoodAuth,
+ response: responseOk,
},
{
desc: "auth error",
password: "badpassword",
domain: exampleDomain,
+ request: removeRecordBadAuth,
+ response: responseAuthError,
err: "authentication error",
},
{
desc: "uknown error",
password: "goodpassword",
domain: "badexample.com",
+ request: removeRecordNonValidDomain,
+ response: responseUnknownError,
err: `unknown error: "UNKNOWN_ERROR"`,
},
{
desc: "empty response",
password: "goodpassword",
domain: "empty.com",
+ request: removeRecordEmptyResponse,
+ response: "",
err: "unmarshal error: EOF",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := NewClient("apiuser", test.password)
- client.BaseURL = serverURL + "/"
+ client := mockBuilder(test.password).
+ Route("POST /",
+ servermock.RawStringResponse(test.response),
+ servermock.CheckRequestBody(test.request)).
+ Build(t)
- err := client.RemoveTXTRecord(context.Background(), test.domain, exampleSubDomain, 12345678)
+ err := client.RemoveTXTRecord(t.Context(), test.domain, exampleSubDomain, 12345678)
if test.err == "" {
require.NoError(t, err)
} else {
@@ -185,16 +208,13 @@ func TestClient_RemoveZoneRecord(t *testing.T) {
}
func TestClient_GetZoneRecord(t *testing.T) {
- serverResponses := map[string]string{
- getZoneRecords: getZoneRecordsResponse,
- }
+ client := mockBuilder("goodpassword").
+ Route("POST /",
+ servermock.RawStringResponse(getZoneRecordsResponse),
+ servermock.CheckRequestBody(getZoneRecords)).
+ Build(t)
- serverURL := createFakeServer(t, serverResponses)
-
- client := NewClient("apiuser", "goodpassword")
- client.BaseURL = serverURL + "/"
-
- recordObjs, err := client.GetTXTRecords(context.Background(), exampleDomain, exampleSubDomain)
+ recordObjs, err := client.GetTXTRecords(t.Context(), exampleDomain, exampleSubDomain)
require.NoError(t, err)
expected := []RecordObj{
@@ -206,27 +226,15 @@ func TestClient_GetZoneRecord(t *testing.T) {
RecordID: 12345678,
},
}
- assert.EqualValues(t, expected, recordObjs)
+ assert.Equal(t, expected, recordObjs)
}
func TestClient_rpcCall_404(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, err := io.ReadAll(r.Body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- w.WriteHeader(http.StatusNotFound)
-
- _, err = fmt.Fprint(w, "")
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }))
-
- t.Cleanup(server.Close)
+ client := mockBuilder("apipassword").
+ Route("POST /",
+ servermock.RawStringResponse("").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
call := &methodCall{
MethodName: "dummyMethod",
@@ -235,29 +243,15 @@ func TestClient_rpcCall_404(t *testing.T) {
},
}
- client := NewClient("apiuser", "apipassword")
- client.BaseURL = server.URL + "/"
-
- err := client.rpcCall(context.Background(), call, &responseString{})
+ err := client.rpcCall(t.Context(), call, &responseString{})
require.EqualError(t, err, "unexpected status code: [status code: 404] body: ")
}
func TestClient_rpcCall_RPCError(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, err := io.ReadAll(r.Body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- _, err = fmt.Fprint(w, responseRPCError)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }))
-
- t.Cleanup(server.Close)
+ client := mockBuilder("apipassword").
+ Route("POST /",
+ servermock.RawStringResponse(responseRPCError)).
+ Build(t)
call := &methodCall{
MethodName: "getDomains",
@@ -266,10 +260,7 @@ func TestClient_rpcCall_RPCError(t *testing.T) {
},
}
- client := NewClient("apiuser", "apipassword")
- client.BaseURL = server.URL + "/"
-
- err := client.rpcCall(context.Background(), call, &responseString{})
+ err := client.rpcCall(t.Context(), call, &responseString{})
require.EqualError(t, err, "RPC Error: (201) Method signature error: 42")
}
@@ -301,37 +292,3 @@ func TestUnmarshallFaultyRecordObject(t *testing.T) {
})
}
}
-
-func createFakeServer(t *testing.T, serverResponses map[string]string) string {
- t.Helper()
-
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Header.Get("Content-Type") != "text/xml" {
- http.Error(w, fmt.Sprintf("invalid content type: %s", r.Header.Get("Content-Type")), http.StatusBadRequest)
- return
- }
-
- req, err := io.ReadAll(r.Body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- resp, ok := serverResponses[string(req)]
- if !ok {
- http.Error(w, "no response for request", http.StatusBadRequest)
- return
- }
-
- _, err = fmt.Fprint(w, resp)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- return server.URL
-}
diff --git a/providers/dns/loopia/internal/types.go b/providers/dns/loopia/internal/types.go
index c286c01fd..c3425c8b1 100644
--- a/providers/dns/loopia/internal/types.go
+++ b/providers/dns/loopia/internal/types.go
@@ -66,6 +66,7 @@ type response interface {
type responseString struct {
responseFault
+
Value string `xml:"params>param>value>string"`
}
@@ -88,6 +89,7 @@ func (e RPCError) Error() string {
type recordObjectsResponse struct {
responseFault
+
XMLName xml.Name `xml:"methodResponse"`
Params []RecordObj `xml:"params>param>value>array>data>value>struct"`
}
@@ -102,6 +104,7 @@ type RecordObj struct {
func (r *RecordObj) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var name string
+
for {
t, err := d.Token()
if err != nil {
@@ -144,6 +147,7 @@ func (r *RecordObj) decodeValueString(name string, d *xml.Decoder, start xml.Sta
}
s = strings.TrimSpace(s)
+
switch name {
case "type":
r.Type = s
diff --git a/providers/dns/loopia/loopia.go b/providers/dns/loopia/loopia.go
index 34d4374fb..be3416ddf 100644
--- a/providers/dns/loopia/loopia.go
+++ b/providers/dns/loopia/loopia.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/loopia/internal"
)
@@ -34,9 +35,9 @@ const minTTL = 300
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type dnsClient interface {
- AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error
- RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error
- GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]internal.RecordObj, error)
+ AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error
+ RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error
+ GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error)
RemoveSubdomain(ctx context.Context, domain, subdomain string) error
}
@@ -56,9 +57,9 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 40*time.Minute),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 60*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPropagationTimeout),
HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
},
}
}
@@ -113,6 +114,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
if config.BaseURL != "" {
client.BaseURL = config.BaseURL
}
diff --git a/providers/dns/loopia/loopia.toml b/providers/dns/loopia/loopia.toml
index f1065b35e..a201852c9 100644
--- a/providers/dns/loopia/loopia.toml
+++ b/providers/dns/loopia/loopia.toml
@@ -7,7 +7,7 @@ Since = "v4.2.0"
Example = '''
LOOPIA_API_USER=xxxxxxxx \
LOOPIA_API_PASSWORD=yyyyyyyy \
-lego --email you@example.com --dns loopia -d '*.example.com' -d example.com run
+lego --dns loopia -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -29,10 +29,10 @@ It needs to have the following permissions:
LOOPIA_API_PASSWORD = "API password"
[Configuration.Additional]
LOOPIA_API_URL = "API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV"
- LOOPIA_POLLING_INTERVAL = "Time between DNS propagation check"
- LOOPIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- LOOPIA_TTL = "The TTL of the TXT record used for the DNS challenge"
- LOOPIA_HTTP_TIMEOUT = "API request timeout"
+ LOOPIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2400)"
+ LOOPIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ LOOPIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ LOOPIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
[Links]
API = "https://www.loopia.com/api"
diff --git a/providers/dns/loopia/loopia_mock_test.go b/providers/dns/loopia/loopia_mock_test.go
index 93f26af06..fb0bcaa2b 100644
--- a/providers/dns/loopia/loopia_mock_test.go
+++ b/providers/dns/loopia/loopia_mock_test.go
@@ -215,17 +215,17 @@ type mockedClient struct {
mock.Mock
}
-func (c *mockedClient) RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error {
+func (c *mockedClient) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error {
args := c.Called(domain, subdomain, recordID)
return args.Error(0)
}
-func (c *mockedClient) AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error {
+func (c *mockedClient) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error {
args := c.Called(domain, subdomain, ttl, value)
return args.Error(0)
}
-func (c *mockedClient) GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]internal.RecordObj, error) {
+func (c *mockedClient) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error) {
args := c.Called(domain, subdomain)
return args.Get(0).([]internal.RecordObj), args.Error(1)
}
diff --git a/providers/dns/loopia/loopia_test.go b/providers/dns/loopia/loopia_test.go
index e397c9639..b3163fc77 100644
--- a/providers/dns/loopia/loopia_test.go
+++ b/providers/dns/loopia/loopia_test.go
@@ -103,6 +103,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -192,6 +193,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -205,6 +207,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/luadns/internal/client.go b/providers/dns/luadns/internal/client.go
index 8e46418f2..5ce9cca86 100644
--- a/providers/dns/luadns/internal/client.go
+++ b/providers/dns/luadns/internal/client.go
@@ -49,6 +49,7 @@ func (c *Client) ListZones(ctx context.Context) ([]DNSZone, error) {
}
var zones []DNSZone
+
err = c.do(req, &zones)
if err != nil {
return nil, fmt.Errorf("could not list zones: %w", err)
@@ -68,6 +69,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone DNSZone, newRecord DNSRe
}
var record *DNSRecord
+
err = c.do(req, &record)
if err != nil {
return nil, fmt.Errorf("could not create record %#v: %w", record, err)
@@ -153,6 +155,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errResp errorResponse
+
err := json.Unmarshal(raw, &errResp)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/luadns/internal/client_test.go b/providers/dns/luadns/internal/client_test.go
index 1fd3efd74..0a3a79e6c 100644
--- a/providers/dns/luadns/internal/client_test.go
+++ b/providers/dns/luadns/internal/client_test.go
@@ -1,63 +1,34 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, apiToken string) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder(apiToken string) *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("me", apiToken)
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("me", apiToken)
- client.baseURL, _ = url.Parse(server.URL)
- client.HTTPClient = server.Client()
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("me", apiToken))
}
func TestClient_ListZones(t *testing.T) {
- client, mux := setupTest(t, "secretA")
+ client := mockBuilder("secretA").
+ Route("GET /v1/zones", servermock.ResponseFromFixture("list_zones.json")).
+ Build(t)
- mux.HandleFunc("/v1/zones", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Basic bWU6c2VjcmV0QQ==" {
- http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized)
- return
- }
-
- file, err := os.Open("./fixtures/list_zones.json")
- 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
- }
- })
-
- zones, err := client.ListZones(context.Background())
+ zones, err := client.ListZones(t.Context())
require.NoError(t, err)
expected := []DNSZone{
@@ -89,33 +60,11 @@ func TestClient_ListZones(t *testing.T) {
}
func TestClient_CreateRecord(t *testing.T) {
- client, mux := setupTest(t, "secretB")
-
- mux.HandleFunc("/v1/zones/1/records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Basic bWU6c2VjcmV0Qg==" {
- http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized)
- return
- }
-
- file, err := os.Open("./fixtures/create_record.json")
- 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
- }
- })
+ client := mockBuilder("secretB").
+ Route("POST /v1/zones/1/records",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckRequestJSONBody(`{"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300}`)).
+ Build(t)
zone := DNSZone{ID: 1}
@@ -126,7 +75,7 @@ func TestClient_CreateRecord(t *testing.T) {
TTL: 300,
}
- newRecord, err := client.CreateRecord(context.Background(), zone, record)
+ newRecord, err := client.CreateRecord(t.Context(), zone, record)
require.NoError(t, err)
expected := &DNSRecord{
@@ -142,33 +91,11 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t, "secretC")
-
- mux.HandleFunc("/v1/zones/1/records/2", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Basic bWU6c2VjcmV0Qw==" {
- http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized)
- return
- }
-
- file, err := os.Open("./fixtures/delete_record.json")
- 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
- }
- })
+ client := mockBuilder("secretC").
+ Route("DELETE /v1/zones/1/records/2",
+ servermock.ResponseFromFixture("delete_record.json"),
+ servermock.CheckRequestJSONBody(`{"id":2,"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300,"zone_id":1}`)).
+ Build(t)
record := &DNSRecord{
ID: 2,
@@ -179,6 +106,6 @@ func TestClient_DeleteRecord(t *testing.T) {
ZoneID: 1,
}
- err := client.DeleteRecord(context.Background(), record)
+ err := client.DeleteRecord(t.Context(), record)
require.NoError(t, err)
}
diff --git a/providers/dns/luadns/luadns.go b/providers/dns/luadns/luadns.go
index ef0a9b7d6..68b9c66b8 100644
--- a/providers/dns/luadns/luadns.go
+++ b/providers/dns/luadns/luadns.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/luadns/internal"
)
@@ -48,7 +49,7 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -100,11 +101,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
- config: config,
- client: client,
- recordsMu: sync.Mutex{},
- records: make(map[string]*internal.DNSRecord),
+ config: config,
+ client: client,
+ records: make(map[string]*internal.DNSRecord),
}, nil
}
diff --git a/providers/dns/luadns/luadns.toml b/providers/dns/luadns/luadns.toml
index b55751f55..e56fac0b6 100644
--- a/providers/dns/luadns/luadns.toml
+++ b/providers/dns/luadns/luadns.toml
@@ -7,7 +7,7 @@ Since = "v3.7.0"
Example = '''
LUADNS_API_USERNAME=youremail \
LUADNS_API_TOKEN=xxxxxxxx \
-lego --email you@example.com --dns luadns -d '*.example.com' -d example.com run
+lego --dns luadns -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns luadns -d '*.example.com' -d example.com run
LUADNS_API_USERNAME = "Username (your email)"
LUADNS_API_TOKEN = "API token"
[Configuration.Additional]
- LUADNS_POLLING_INTERVAL = "Time between DNS propagation check"
- LUADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- LUADNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- LUADNS_HTTP_TIMEOUT = "API request timeout"
+ LUADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ LUADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ LUADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ LUADNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://luadns.com/api.html"
diff --git a/providers/dns/luadns/luadns_test.go b/providers/dns/luadns/luadns_test.go
index ea4d06ae1..a1aa36872 100644
--- a/providers/dns/luadns/luadns_test.go
+++ b/providers/dns/luadns/luadns_test.go
@@ -58,6 +58,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -199,6 +200,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -212,6 +214,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/mailinabox/mailinabox.go b/providers/dns/mailinabox/mailinabox.go
index 3ea8a9f29..cf6202a92 100644
--- a/providers/dns/mailinabox/mailinabox.go
+++ b/providers/dns/mailinabox/mailinabox.go
@@ -5,11 +5,13 @@ import (
"context"
"errors"
"fmt"
+ "net/http"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/nrdcg/mailinabox"
)
@@ -23,6 +25,7 @@ const (
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
@@ -34,6 +37,7 @@ type Config struct {
BaseURL string
PropagationTimeout time.Duration
PollingInterval time.Duration
+ HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -41,6 +45,9 @@ func NewDefaultConfig() *Config {
return &Config{
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
}
}
@@ -81,7 +88,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("mailinabox: missing base URL")
}
- client, err := mailinabox.New(config.BaseURL, config.Email, config.Password)
+ if config.HTTPClient == nil {
+ config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
+ }
+
+ config.HTTPClient = clientdebug.Wrap(config.HTTPClient)
+
+ client, err := mailinabox.New(config.BaseURL, config.Email, config.Password, mailinabox.WithHTTPClient(config.HTTPClient))
if err != nil {
return nil, fmt.Errorf("mailinabox: %w", err)
}
diff --git a/providers/dns/mailinabox/mailinabox.toml b/providers/dns/mailinabox/mailinabox.toml
index 8ee282396..74d8aabbc 100644
--- a/providers/dns/mailinabox/mailinabox.toml
+++ b/providers/dns/mailinabox/mailinabox.toml
@@ -8,7 +8,7 @@ Example = '''
MAILINABOX_EMAIL=user@example.com \
MAILINABOX_PASSWORD=yyyy \
MAILINABOX_BASE_URL=https://box.example.com \
-lego --email you@example.com --dns mailinabox -d '*.example.com' -d example.com run
+lego --dns mailinabox -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,8 +17,9 @@ lego --email you@example.com --dns mailinabox -d '*.example.com' -d example.com
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"
+ MAILINABOX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)"
+ MAILINABOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ MAILINABOX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://mailinabox.email/api-docs.html"
diff --git a/providers/dns/mailinabox/mailinabox_test.go b/providers/dns/mailinabox/mailinabox_test.go
index 1b95c220d..11143a11f 100644
--- a/providers/dns/mailinabox/mailinabox_test.go
+++ b/providers/dns/mailinabox/mailinabox_test.go
@@ -59,6 +59,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -136,6 +137,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -149,6 +151,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/manageengine/internal/client.go b/providers/dns/manageengine/internal/client.go
new file mode 100644
index 000000000..b5a7dbae7
--- /dev/null
+++ b/providers/dns/manageengine/internal/client.go
@@ -0,0 +1,199 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://clouddns.manageengine.com/v1"
+
+// Client the ManageEngine CloudDNS API client.
+type Client struct {
+ baseURL *url.URL
+ httpClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(hc *http.Client) *Client {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ baseURL: baseURL,
+ httpClient: hc,
+ }
+}
+
+// GetAllZones gets all zones.
+// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All
+func (c *Client) GetAllZones(ctx context.Context) ([]Zone, error) {
+ endpoint := c.baseURL.JoinPath("dns", "domain")
+
+ req, err := newRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var results []Zone
+
+ err = c.do(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ return results, nil
+}
+
+// GetAllZoneRecords gets all "zone records" for a zone.
+// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All_9
+func (c *Client) GetAllZoneRecords(ctx context.Context, zoneID int) ([]ZoneRecord, error) {
+ endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT")
+
+ req, err := newRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var results []ZoneRecord
+
+ err = c.do(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ return results, nil
+}
+
+// DeleteZoneRecord deletes a "zone record".
+// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#DEL_Delete_10
+func (c *Client) DeleteZoneRecord(ctx context.Context, zoneID, domainID int) error {
+ endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", strconv.Itoa(domainID))
+
+ req, err := newRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ var results APIResponse
+
+ return c.do(req, &results)
+}
+
+// CreateZoneRecord creates a "zone record".
+// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#POST_Create_10
+func (c *Client) CreateZoneRecord(ctx context.Context, zoneID int, record ZoneRecord) error {
+ endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", "/")
+
+ req, err := newRequest(ctx, http.MethodPost, endpoint, []ZoneRecord{record})
+ if err != nil {
+ return err
+ }
+
+ var results APIResponse
+
+ return c.do(req, &results)
+}
+
+// UpdateZoneRecord update an existing "zone record".
+// https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#PUT_Update_10
+func (c *Client) UpdateZoneRecord(ctx context.Context, record ZoneRecord) error {
+ if record.SpfTxtDomainID == 0 {
+ return errors.New("SpfTxtDomainID is empty")
+ }
+
+ if record.ZoneID == 0 {
+ return errors.New("ZoneID is empty")
+ }
+
+ endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(record.ZoneID), "records", "SPF_TXT", strconv.Itoa(record.SpfTxtDomainID), "/")
+
+ req, err := newRequest(ctx, http.MethodPut, endpoint, []ZoneRecord{record})
+ if err != nil {
+ return err
+ }
+
+ var results APIResponse
+
+ return c.do(req, &results)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ 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 newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ var body io.Reader = http.NoBody
+
+ if payload != nil {
+ buf := new(bytes.Buffer)
+
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+
+ values := url.Values{}
+ values.Set("config", buf.String())
+ body = strings.NewReader(values.Encode())
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+ 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/x-www-form-urlencoded")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI)
+}
diff --git a/providers/dns/manageengine/internal/client_test.go b/providers/dns/manageengine/internal/client_test.go
new file mode 100644
index 000000000..25d1730f6
--- /dev/null
+++ b/providers/dns/manageengine/internal/client_test.go
@@ -0,0 +1,302 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(server.Client())
+
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithAccept("application/json"))
+}
+
+func TestClient_GetAllZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/domain", servermock.ResponseFromFixture("zone_domains_all.json")).
+ Build(t)
+
+ groups, err := client.GetAllZones(t.Context())
+ require.NoError(t, err)
+
+ expected := []Zone{
+ {
+ ZoneID: 1,
+ ZoneName: "test.com.",
+ ZoneTTL: 500,
+ ZoneTargeting: true,
+ Refresh: 43200,
+ Retry: 3600,
+ Expiry: 1209600,
+ Minimum: 180,
+ Org: 2,
+ NsID: 1,
+ Serial: 2022042206,
+ Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."},
+ },
+ {
+ ZoneID: 2,
+ ZoneName: "yourdomain.com.",
+ ZoneTTL: 1000,
+ Refresh: 43200,
+ Retry: 3600,
+ Expiry: 1209600,
+ Minimum: 180,
+ Org: 2,
+ Vanity: true,
+ NsID: 1,
+ Serial: 2022040608,
+ Nss: []string{"ns11.yourdomain.com.", "ns21.yourdomain.net.", "ns31.yourdomain.com.", "ns41.yourdomain.net."},
+ },
+ {
+ ZoneID: 20,
+ ZoneName: "hello45.com.",
+ ZoneTTL: 3000,
+ Refresh: 43200,
+ Retry: 3600,
+ Expiry: 1209600,
+ Minimum: 180,
+ Org: 2,
+ NsID: 1,
+ Serial: 2022040711,
+ Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."},
+ },
+ {
+ ZoneID: 22,
+ ZoneName: "zohoaccl.com.",
+ ZoneTTL: 300,
+ ZoneTargeting: true,
+ Refresh: 43200,
+ Retry: 3600,
+ Expiry: 1209600,
+ Minimum: 180,
+ Org: 2,
+ NsID: 1,
+ Serial: 2022042206,
+ Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."},
+ },
+ {
+ ZoneID: 23,
+ ZoneName: "zohocal.com.",
+ ZoneTTL: 300,
+ ZoneTargeting: true,
+ Refresh: 43200,
+ Retry: 3600,
+ Expiry: 1209600,
+ Minimum: 180,
+ Org: 2,
+ NsID: 1,
+ Serial: 2022041310,
+ Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."},
+ },
+ }
+
+ assert.Equal(t, expected, groups)
+}
+
+func TestClient_GetAllZones_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/domain",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.GetAllZones(t.Context())
+ require.Error(t, err)
+
+ require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.")
+}
+
+func TestClient_GetAllZoneRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/domain/4/records/SPF_TXT", servermock.ResponseFromFixture("zone_records_all.json")).
+ Build(t)
+
+ groups, err := client.GetAllZoneRecords(t.Context(), 4)
+ require.NoError(t, err)
+
+ expected := []ZoneRecord{
+ {
+ ZoneID: 4,
+ SpfTxtDomainID: 6,
+ DomainName: "spftest.example.com.",
+ DomainTTL: 300,
+ DomainLocationID: 1,
+ RecordType: "SPF",
+ Records: []Record{{
+ ID: 1,
+ Values: []string{"necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0"},
+ DomainID: 6,
+ }},
+ },
+ {
+ ZoneID: 4,
+ SpfTxtDomainID: 13,
+ DomainName: "txt.example.com.",
+ DomainTTL: 300,
+ DomainLocationID: 1,
+ RecordType: "TXT",
+ Records: []Record{{
+ ID: 1,
+ Values: []string{"v=spf1include:transmail.netinclude:example.com~all", "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8"},
+ DomainID: 13,
+ }},
+ },
+ }
+
+ assert.Equal(t, expected, groups)
+}
+
+func TestClient_GetAllZoneRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/domain/4/records/SPF_TXT",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ _, err := client.GetAllZoneRecords(t.Context(), 4)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.")
+}
+
+func TestClient_DeleteZoneRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/domain/4/records/SPF_TXT/6", servermock.ResponseFromFixture("zone_record_delete.json")).
+ Build(t)
+
+ err := client.DeleteZoneRecord(t.Context(), 4, 6)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteZoneRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/domain/4/records/SPF_TXT/6",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ err := client.DeleteZoneRecord(t.Context(), 4, 6)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.")
+}
+
+func TestClient_CreateZoneRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/domain/4/records/SPF_TXT/",
+ servermock.ResponseFromFixture("zone_record_create.json"),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ servermock.CheckForm().Strict().
+ With("config", `[{"zone_id":1,"spf_txt_domain_id":2,"domain_name":"example.com","domain_ttl":120,"domain_location_id":3,"record_type":"TXT","records":[{"record_id":123,"value":["value1"],"domain_id":1}]}]
+`)).
+ Build(t)
+
+ record := ZoneRecord{
+ ZoneID: 1,
+ SpfTxtDomainID: 2,
+ DomainName: "example.com",
+ DomainTTL: 120,
+ DomainLocationID: 3,
+ RecordType: "TXT",
+ Records: []Record{
+ {
+ ID: 123,
+ Values: []string{"value1"},
+ Disabled: false,
+ DomainID: 1,
+ },
+ },
+ }
+
+ err := client.CreateZoneRecord(t.Context(), 4, record)
+ require.NoError(t, err)
+}
+
+func TestClient_CreateZoneRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/domain/4/records/SPF_TXT/",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded()).
+ Build(t)
+
+ record := ZoneRecord{}
+
+ err := client.CreateZoneRecord(t.Context(), 4, record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.")
+}
+
+func TestClient_CreateZoneRecord_error_bad_request(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/domain/4/records/SPF_TXT/",
+ servermock.ResponseFromFixture("error_bad_request.json").
+ WithStatusCode(http.StatusBadRequest),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded()).
+ Build(t)
+
+ record := ZoneRecord{}
+
+ err := client.CreateZoneRecord(t.Context(), 4, record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "[status code: 400] Invalid record format, Record should be in list.")
+}
+
+func TestClient_UpdateZoneRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /dns/domain/4/records/SPF_TXT/6/",
+ servermock.ResponseFromFixture("zone_record_update.json"),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ servermock.CheckForm().Strict().
+ With("config", `[{"zone_id":4,"spf_txt_domain_id":6,"records":null}]
+`)).
+ Build(t)
+
+ record := ZoneRecord{
+ SpfTxtDomainID: 6,
+ ZoneID: 4,
+ }
+
+ err := client.UpdateZoneRecord(t.Context(), record)
+ require.NoError(t, err)
+}
+
+func TestClient_UpdateZoneRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /dns/domain/4/records/SPF_TXT/6/",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded()).
+ Build(t)
+
+ record := ZoneRecord{
+ SpfTxtDomainID: 6,
+ ZoneID: 4,
+ }
+
+ err := client.UpdateZoneRecord(t.Context(), record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.")
+}
diff --git a/providers/dns/manageengine/internal/fixtures/error.json b/providers/dns/manageengine/internal/fixtures/error.json
new file mode 100644
index 000000000..5cd198670
--- /dev/null
+++ b/providers/dns/manageengine/internal/fixtures/error.json
@@ -0,0 +1,3 @@
+{
+ "detail": "Authentication credentials were not provided."
+}
diff --git a/providers/dns/manageengine/internal/fixtures/error_bad_request.json b/providers/dns/manageengine/internal/fixtures/error_bad_request.json
new file mode 100644
index 000000000..944cef6c0
--- /dev/null
+++ b/providers/dns/manageengine/internal/fixtures/error_bad_request.json
@@ -0,0 +1,3 @@
+{
+ "error": "Invalid record format, Record should be in list."
+}
diff --git a/providers/dns/manageengine/internal/fixtures/zone_domains_all.json b/providers/dns/manageengine/internal/fixtures/zone_domains_all.json
new file mode 100644
index 000000000..3e37f52a7
--- /dev/null
+++ b/providers/dns/manageengine/internal/fixtures/zone_domains_all.json
@@ -0,0 +1,146 @@
+[
+ {
+ "zone_id": 1,
+ "zone_name": "test.com.",
+ "zone_ttl": 500,
+ "zone_type": 0,
+ "zone_targeting": true,
+ "zone_logging": "{}",
+ "zone_contact": "mathes.zoho.com",
+ "refresh": 43200,
+ "retry": 3600,
+ "expiry": 1209600,
+ "minimum": 180,
+ "org": 2,
+ "any_query": false,
+ "dnssec": true,
+ "vanity": false,
+ "ns_id": 1,
+ "serial": 2022042206,
+ "ns": [
+ "ns11.zns-53.com.",
+ "ns21.zns-53.net.",
+ "ns31.zns-53.com.",
+ "ns41.zns-53.net."
+ ],
+ "contact_group": [
+ "test_contact1",
+ "test_contact2"
+ ],
+ "ds": [
+ {
+ "record_id": 59,
+ "keyTag": 36938,
+ "algorithm": 13,
+ "digestType": 1,
+ "digest": "e9f03d176455d5d16f826b69f9ecb11f59be35e7",
+ "domain_id": 30
+ },
+ {
+ "record_id": 60,
+ "keyTag": 36938,
+ "algorithm": 13,
+ "digestType": 2,
+ "digest": "7ea640a8668eafd9d89a9b2e9994f5fcfb1dee0668d1e93ba556aa57ac047f96",
+ "domain_id": 30
+ }
+ ]
+ },
+ {
+ "zone_id": 2,
+ "zone_name": "yourdomain.com.",
+ "zone_ttl": 1000,
+ "zone_type": 0,
+ "zone_targeting": false,
+ "zone_logging": "{}",
+ "zone_contact": "contact.yourdomain.com",
+ "refresh": 43200,
+ "retry": 3600,
+ "expiry": 1209600,
+ "minimum": 180,
+ "org": 2,
+ "any_query": false,
+ "dnssec": false,
+ "vanity": true,
+ "vanity_grp": "yourdomain",
+ "ns_id": 1,
+ "serial": 2022040608,
+ "ns": [
+ "ns11.yourdomain.com.",
+ "ns21.yourdomain.net.",
+ "ns31.yourdomain.com.",
+ "ns41.yourdomain.net."
+ ]
+ },
+ {
+ "zone_id": 20,
+ "zone_name": "hello45.com.",
+ "zone_ttl": 3000,
+ "zone_targeting": false,
+ "zone_logging": "{}",
+ "zone_contact": "mathes.zoho.com",
+ "refresh": 43200,
+ "retry": 3600,
+ "expiry": 1209600,
+ "minimum": 180,
+ "org": 2,
+ "any_query": false,
+ "dnssec": false,
+ "ns_id": 1,
+ "serial": 2022040711,
+ "ns": [
+ "ns11.zns-53.com.",
+ "ns21.zns-53.net.",
+ "ns31.zns-53.com.",
+ "ns41.zns-53.net."
+ ]
+ },
+ {
+ "zone_id": 22,
+ "zone_name": "zohoaccl.com.",
+ "zone_ttl": 300,
+ "zone_type": 0,
+ "zone_targeting": true,
+ "zone_logging": "{}",
+ "zone_contact": "networkone.zohocorp.com",
+ "refresh": 43200,
+ "retry": 3600,
+ "expiry": 1209600,
+ "minimum": 180,
+ "org": 2,
+ "any_query": false,
+ "dnssec": false,
+ "ns_id": 1,
+ "serial": 2022042206,
+ "ns": [
+ "ns11.zns-53.com.",
+ "ns21.zns-53.net.",
+ "ns31.zns-53.com.",
+ "ns41.zns-53.net."
+ ]
+ },
+ {
+ "zone_id": 23,
+ "zone_name": "zohocal.com.",
+ "zone_ttl": 300,
+ "zone_type": 0,
+ "zone_targeting": true,
+ "zone_logging": "{}",
+ "zone_contact": "mathes.zoho.com",
+ "refresh": 43200,
+ "retry": 3600,
+ "expiry": 1209600,
+ "minimum": 180,
+ "org": 2,
+ "any_query": false,
+ "dnssec": false,
+ "ns_id": 1,
+ "serial": 2022041310,
+ "ns": [
+ "ns11.zns-53.com.",
+ "ns21.zns-53.net.",
+ "ns31.zns-53.com.",
+ "ns41.zns-53.net."
+ ]
+ }
+]
diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_create.json b/providers/dns/manageengine/internal/fixtures/zone_record_create.json
new file mode 100644
index 000000000..3fd216f2d
--- /dev/null
+++ b/providers/dns/manageengine/internal/fixtures/zone_record_create.json
@@ -0,0 +1,3 @@
+{
+ "message": "Record created successfully"
+}
diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_delete.json b/providers/dns/manageengine/internal/fixtures/zone_record_delete.json
new file mode 100644
index 000000000..c657d84ea
--- /dev/null
+++ b/providers/dns/manageengine/internal/fixtures/zone_record_delete.json
@@ -0,0 +1,3 @@
+{
+ "message": "Record deleted successfully"
+}
diff --git a/providers/dns/manageengine/internal/fixtures/zone_record_update.json b/providers/dns/manageengine/internal/fixtures/zone_record_update.json
new file mode 100644
index 000000000..178c1fb0f
--- /dev/null
+++ b/providers/dns/manageengine/internal/fixtures/zone_record_update.json
@@ -0,0 +1,3 @@
+{
+ "message": "Record updated successfully"
+}
diff --git a/providers/dns/manageengine/internal/fixtures/zone_records_all.json b/providers/dns/manageengine/internal/fixtures/zone_records_all.json
new file mode 100644
index 000000000..ae08a4c7e
--- /dev/null
+++ b/providers/dns/manageengine/internal/fixtures/zone_records_all.json
@@ -0,0 +1,40 @@
+[
+ {
+ "spf_txt_domain_id": 6,
+ "zone_id": 4,
+ "domain_name": "spftest.example.com.",
+ "domain_ttl": 300,
+ "domain_location_id": 1,
+ "record_type": "SPF",
+ "records": [
+ {
+ "record_id": 1,
+ "value": [
+ "necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0"
+ ],
+ "disabled": false,
+ "domain_id": 6
+ }
+ ]
+ },
+ {
+ "spf_txt_domain_id": 13,
+ "zone_id": 4,
+ "domain_name": "txt.example.com.",
+ "domain_ttl": 300,
+ "domain_maxhost": 1,
+ "domain_location_id": 1,
+ "record_type": "TXT",
+ "records": [
+ {
+ "record_id": 1,
+ "value": [
+ "v=spf1include:transmail.netinclude:example.com~all",
+ "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8"
+ ],
+ "disabled": false,
+ "domain_id": 13
+ }
+ ]
+ }
+]
diff --git a/providers/dns/manageengine/internal/identity.go b/providers/dns/manageengine/internal/identity.go
new file mode 100644
index 000000000..ec28121e4
--- /dev/null
+++ b/providers/dns/manageengine/internal/identity.go
@@ -0,0 +1,20 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+
+ "golang.org/x/oauth2/clientcredentials"
+)
+
+const defaultAuthURL = "https://clouddns.manageengine.com/oauth2/token/"
+
+func CreateOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client {
+ config := &clientcredentials.Config{
+ TokenURL: defaultAuthURL,
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ }
+
+ return config.Client(ctx)
+}
diff --git a/providers/dns/manageengine/internal/types.go b/providers/dns/manageengine/internal/types.go
new file mode 100644
index 000000000..7a039f67f
--- /dev/null
+++ b/providers/dns/manageengine/internal/types.go
@@ -0,0 +1,63 @@
+package internal
+
+import (
+ "strings"
+)
+
+type APIError struct {
+ Message string `json:"error"`
+ Detail string `json:"detail"`
+}
+
+func (a *APIError) Error() string {
+ var msg []string
+
+ if a.Message != "" {
+ msg = append(msg, a.Message)
+ }
+
+ if a.Detail != "" {
+ msg = append(msg, a.Detail)
+ }
+
+ return strings.Join(msg, " ")
+}
+
+type APIResponse struct {
+ Message string `json:"message,omitempty"`
+}
+
+type ZoneRecord struct {
+ ZoneID int `json:"zone_id,omitempty"`
+ SpfTxtDomainID int `json:"spf_txt_domain_id,omitempty"`
+ DomainName string `json:"domain_name,omitempty"`
+ DomainTTL int `json:"domain_ttl,omitempty"`
+ DomainLocationID int `json:"domain_location_id,omitempty"`
+ RecordType string `json:"record_type,omitempty"`
+ Records []Record `json:"records"`
+}
+
+type Record struct {
+ ID int `json:"record_id,omitempty"`
+ Values []string `json:"value,omitempty"`
+ Disabled bool `json:"disabled,omitempty"`
+ DomainID int `json:"domain_id,omitempty"`
+}
+
+type Zone struct {
+ ZoneID int `json:"zone_id"`
+ ZoneName string `json:"zone_name"`
+ ZoneTTL int `json:"zone_ttl"`
+ ZoneType int `json:"zone_type,omitempty"`
+ ZoneTargeting bool `json:"zone_targeting"`
+ Refresh int `json:"refresh"`
+ Retry int `json:"retry"`
+ Expiry int `json:"expiry"`
+ Minimum int `json:"minimum"`
+ Org int `json:"org"`
+ AnyQuery bool `json:"any_query"`
+ Vanity bool `json:"vanity,omitempty"`
+ NsID int `json:"ns_id"`
+ Serial int `json:"serial"`
+ Nss []string `json:"ns"`
+}
diff --git a/providers/dns/manageengine/manageengine.go b/providers/dns/manageengine/manageengine.go
new file mode 100644
index 000000000..76b6644c0
--- /dev/null
+++ b/providers/dns/manageengine/manageengine.go
@@ -0,0 +1,266 @@
+// Package manageengine implements a DNS provider for solving the DNS-01 challenge using ManageEngine CloudDNS.
+package manageengine
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/manageengine/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "MANAGEENGINE_"
+
+ EnvClientID = envNamespace + "CLIENT_ID"
+ EnvClientSecret = envNamespace + "CLIENT_SECRET"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ ClientID string
+ ClientSecret string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ 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, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for ManageEngine CloudDNS.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvClientID, EnvClientSecret)
+ if err != nil {
+ return nil, fmt.Errorf("manageengine: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.ClientID = values[EnvClientID]
+ config.ClientSecret = values[EnvClientSecret]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ManageEngine CloudDNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("manageengine: the configuration of the DNS provider is nil")
+ }
+
+ if config.ClientID == "" || config.ClientSecret == "" {
+ return nil, errors.New("manageengine: credentials missing")
+ }
+
+ return &DNSProvider{
+ config: config,
+ client: internal.NewClient(
+ clientdebug.Wrap(
+ internal.CreateOAuthClient(context.Background(), config.ClientID, config.ClientSecret),
+ ),
+ ),
+ }, nil
+}
+
+// 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err)
+ }
+
+ zoneID, err := d.findZoneID(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("manageengine: find zone ID: %w", err)
+ }
+
+ zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("manageengine: find zone record: %w", err)
+ }
+
+ // Update the existing zone record.
+ if zoneRecord != nil {
+ for _, record := range zoneRecord.Records {
+ if slices.Contains(record.Values, info.Value) {
+ continue
+ }
+
+ zr := internal.ZoneRecord{
+ ZoneID: zoneID,
+ SpfTxtDomainID: zoneRecord.SpfTxtDomainID,
+ DomainName: info.EffectiveFQDN,
+ DomainTTL: d.config.TTL,
+ RecordType: "TXT",
+ Records: []internal.Record{{
+ Values: append(record.Values, info.Value),
+ DomainID: zoneRecord.SpfTxtDomainID,
+ }},
+ }
+
+ // Update the zone record.
+ err = d.client.UpdateZoneRecord(ctx, zr)
+ if err != nil {
+ return fmt.Errorf("manageengine: update zone record: %w", err)
+ }
+
+ return nil
+ }
+
+ return errors.New("manageengine: zone already contains the TXT record value")
+ }
+
+ // Create a new zone record.
+ record := internal.ZoneRecord{
+ ZoneID: zoneID,
+ DomainName: info.EffectiveFQDN,
+ DomainTTL: d.config.TTL,
+ RecordType: "TXT",
+ Records: []internal.Record{{
+ Values: []string{info.Value},
+ }},
+ }
+
+ err = d.client.CreateZoneRecord(ctx, zoneID, record)
+ if err != nil {
+ return fmt.Errorf("manageengine: create zone 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err)
+ }
+
+ zoneID, err := d.findZoneID(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("manageengine: find zone ID: %w", err)
+ }
+
+ zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("manageengine: find zone record: %w", err)
+ }
+
+ for _, record := range zoneRecord.Records {
+ if !slices.Contains(record.Values, info.Value) {
+ continue
+ }
+
+ // Delete the zone record.
+ if len(record.Values) <= 1 {
+ err = d.client.DeleteZoneRecord(ctx, zoneID, zoneRecord.SpfTxtDomainID)
+ if err != nil {
+ return fmt.Errorf("manageengine: delete zone record: %w", err)
+ }
+
+ return nil
+ }
+
+ // Update the zone record.
+ var values []string
+
+ for _, value := range record.Values {
+ if value != info.Value {
+ values = append(values, value)
+ }
+ }
+
+ zr := internal.ZoneRecord{
+ ZoneID: zoneID,
+ SpfTxtDomainID: zoneRecord.SpfTxtDomainID,
+ DomainName: info.EffectiveFQDN,
+ DomainTTL: d.config.TTL,
+ RecordType: "TXT",
+ Records: []internal.Record{{
+ Values: values,
+ DomainID: zoneRecord.SpfTxtDomainID,
+ }},
+ }
+
+ err = d.client.UpdateZoneRecord(ctx, zr)
+ if err != nil {
+ return fmt.Errorf("manageengine: create zone record: %w", err)
+ }
+
+ return nil
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findZoneID(ctx context.Context, authZone string) (int, error) {
+ zones, err := d.client.GetAllZones(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("get all zone groups: %w", err)
+ }
+
+ for _, zone := range zones {
+ if strings.EqualFold(zone.ZoneName, authZone) {
+ return zone.ZoneID, nil
+ }
+ }
+
+ return 0, fmt.Errorf("zone not found %s", authZone)
+}
+
+func (d *DNSProvider) findZoneRecord(ctx context.Context, zoneID int, fqdn string) (*internal.ZoneRecord, error) {
+ zoneRecords, err := d.client.GetAllZoneRecords(ctx, zoneID)
+ if err != nil {
+ return nil, fmt.Errorf("get all zone records: %w", err)
+ }
+
+ for _, zoneRecord := range zoneRecords {
+ if !strings.EqualFold(zoneRecord.DomainName, fqdn) {
+ continue
+ }
+
+ if strings.EqualFold(zoneRecord.RecordType, "TXT") {
+ return &zoneRecord, nil
+ }
+ }
+
+ return nil, nil
+}
diff --git a/providers/dns/manageengine/manageengine.toml b/providers/dns/manageengine/manageengine.toml
new file mode 100644
index 000000000..43a782841
--- /dev/null
+++ b/providers/dns/manageengine/manageengine.toml
@@ -0,0 +1,23 @@
+Name = "ManageEngine CloudDNS"
+Description = ''''''
+URL = "https://clouddns.manageengine.com"
+Code = "manageengine"
+Since = "v4.21.0"
+
+Example = '''
+MANAGEENGINE_CLIENT_ID="xxx" \
+MANAGEENGINE_CLIENT_SECRET="yyy" \
+lego --dns manageengine -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ MANAGEENGINE_CLIENT_ID = "Client ID"
+ MANAGEENGINE_CLIENT_SECRET = "Client Secret"
+ [Configuration.Additional]
+ MANAGEENGINE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ MANAGEENGINE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ MANAGEENGINE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+
+[Links]
+ API = "https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation"
diff --git a/providers/dns/manageengine/manageengine_test.go b/providers/dns/manageengine/manageengine_test.go
new file mode 100644
index 000000000..215de68dd
--- /dev/null
+++ b/providers/dns/manageengine/manageengine_test.go
@@ -0,0 +1,146 @@
+package manageengine
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvClientID, EnvClientSecret).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvClientID: "abc",
+ EnvClientSecret: "secret",
+ },
+ },
+ {
+ desc: "missing client ID",
+ envVars: map[string]string{
+ EnvClientID: "",
+ EnvClientSecret: "secret",
+ },
+ expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID",
+ },
+ {
+ desc: "missing client secret",
+ envVars: map[string]string{
+ EnvClientID: "abc",
+ EnvClientSecret: "",
+ },
+ expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_SECRET",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID,MANAGEENGINE_CLIENT_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
+ clientID string
+ clientSecret string
+ expected string
+ }{
+ {
+ desc: "success",
+ clientID: "abc",
+ clientSecret: "secret",
+ },
+ {
+ desc: "missing client ID",
+ clientSecret: "secret",
+ expected: "manageengine: credentials missing",
+ },
+ {
+ desc: "missing client secret",
+ clientID: "abc",
+ expected: "manageengine: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "manageengine: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.ClientID = test.clientID
+ config.ClientSecret = test.clientSecret
+
+ 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/dns/manual/manual.go b/providers/dns/manual/manual.go
new file mode 100644
index 000000000..2985bc595
--- /dev/null
+++ b/providers/dns/manual/manual.go
@@ -0,0 +1,13 @@
+package manual
+
+import (
+ "github.com/go-acme/lego/v4/challenge/dns01"
+)
+
+// DNSProvider is an implementation of the ChallengeProvider interface.
+type DNSProvider = dns01.DNSProviderManual
+
+// NewDNSProvider returns a DNSProvider instance.
+func NewDNSProvider() (*DNSProvider, error) {
+ return &DNSProvider{}, nil
+}
diff --git a/docs/content/dns/manual.md b/providers/dns/manual/manual.toml
similarity index 76%
rename from docs/content/dns/manual.md
rename to providers/dns/manual/manual.toml
index 3f9cf0a8e..fc47a8fae 100644
--- a/docs/content/dns/manual.md
+++ b/providers/dns/manual/manual.toml
@@ -1,24 +1,19 @@
----
-title: "Manual"
-date: 2019-03-03T16:39:46+01:00
-draft: false
-slug: manual
-dnsprovider:
- since: v0.3.0
- code: manual
- url:
----
+Name = "Manual"
+Description = '''Solving the DNS-01 challenge using CLI prompt.'''
+Code = "manual"
+Since = "v0.3.0"
-Solving the DNS-01 challenge using CLI prompt.
-
-
+Example = '''
+lego --dns manual -d '*.example.com' -d example.com run
+'''
+Additional = '''
## Example
To start using the CLI prompt "provider", start lego with `--dns manual`:
```console
-$ lego --email "you@example.com" --domains="example.com" --dns "manual" run
+$ lego --dns manual -d example.com run
```
What follows are a few log print-outs, interspersed with some prompts, asking for you to do perform some actions:
@@ -36,13 +31,13 @@ If you accept the linked Terms of Service, hit `Enter`.
[INFO] acme: Registering account for you@example.com
!!!! HEADS UP !!!!
- Your account credentials have been saved in your Let's Encrypt
- configuration directory at "./.lego/accounts".
+Your account credentials have been saved in your
+configuration directory at "./.lego/accounts".
- You should make a secure backup of this folder now. This
- configuration directory will also contain certificates and
- private keys obtained from Let's Encrypt so making regular
- backups of this folder is ideal.
+You should make a secure backup of this folder now. This
+configuration directory will also contain private keys
+generated by lego and certificates obtained from the ACME
+server. Making regular backups of this folder is ideal.
[INFO] [example.com] acme: Obtaining bundled SAN certificate
[INFO] [example.com] AuthURL: https://acme-v02.api.letsencrypt.org/acme/authz-v3/2345678901
[INFO] [example.com] acme: Could not find solver for: tls-alpn-01
@@ -70,3 +65,5 @@ _acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5
```
As mentioned, you can now remove the TXT record again.
+
+'''
diff --git a/challenge/dns01/dns_challenge_manual_test.go b/providers/dns/manual/manual_test.go
similarity index 85%
rename from challenge/dns01/dns_challenge_manual_test.go
rename to providers/dns/manual/manual_test.go
index cfc728aca..7badd4b8b 100644
--- a/challenge/dns01/dns_challenge_manual_test.go
+++ b/providers/dns/manual/manual_test.go
@@ -1,4 +1,4 @@
-package dns01
+package manual
import (
"io"
@@ -10,6 +10,7 @@ import (
func TestDNSProviderManual(t *testing.T) {
backupStdin := os.Stdin
+
defer func() { os.Stdin = backupStdin }()
testCases := []struct {
@@ -30,9 +31,10 @@ func TestDNSProviderManual(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- file, err := os.CreateTemp("", "lego_test")
+ file, err := os.CreateTemp(t.TempDir(), "lego_test")
require.NoError(t, err)
- defer func() { _ = os.Remove(file.Name()) }()
+
+ t.Cleanup(func() { _ = file.Close() })
_, err = file.WriteString(test.input)
require.NoError(t, err)
@@ -42,7 +44,7 @@ func TestDNSProviderManual(t *testing.T) {
os.Stdin = file
- manualProvider, err := NewDNSProviderManual()
+ manualProvider, err := NewDNSProvider()
require.NoError(t, err)
err = manualProvider.Present("example.com", "", "")
diff --git a/providers/dns/metaname/metaname.go b/providers/dns/metaname/metaname.go
index 9b8c41def..d6e962024 100644
--- a/providers/dns/metaname/metaname.go
+++ b/providers/dns/metaname/metaname.go
@@ -79,6 +79,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config.AccountReference == "" {
return nil, errors.New("metaname: missing account reference")
}
+
if config.APIKey == "" {
return nil, errors.New("metaname: missing api key")
}
@@ -152,6 +153,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("metaname: delete record: %w", err)
}
+ d.recordsMu.Lock()
+ delete(d.records, token)
+ d.recordsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/metaname/metaname.toml b/providers/dns/metaname/metaname.toml
index 142f06639..654dcaed0 100644
--- a/providers/dns/metaname/metaname.toml
+++ b/providers/dns/metaname/metaname.toml
@@ -7,7 +7,7 @@ Since = "v4.13.0"
Example = '''
METANAME_ACCOUNT_REFERENCE=xxxx \
METANAME_API_KEY=yyyyyyy \
-lego --email you@example.com --dns metaname -d '*.example.com' -d example.com run
+lego --dns metaname -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,9 +15,9 @@ lego --email you@example.com --dns metaname -d '*.example.com' -d example.com ru
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"
+ METANAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ METANAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ METANAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
[Links]
API = "https://metaname.net/api/1.1/doc"
diff --git a/providers/dns/metaname/metaname_test.go b/providers/dns/metaname/metaname_test.go
index 174af4014..855fc493d 100644
--- a/providers/dns/metaname/metaname_test.go
+++ b/providers/dns/metaname/metaname_test.go
@@ -51,6 +51,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -122,6 +123,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -135,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/metaregistrar/internal/client.go b/providers/dns/metaregistrar/internal/client.go
new file mode 100644
index 000000000..df99d81ba
--- /dev/null
+++ b/providers/dns/metaregistrar/internal/client.go
@@ -0,0 +1,131 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://api.metaregistrar.com"
+
+const tokenHeader = "token"
+
+// Client is a client to interact with the Metaregistrar API.
+type Client struct {
+ token string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(token string) (*Client, error) {
+ if token == "" {
+ return nil, errors.New("token missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ token: token,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// UpdateDNSZone updates the DNS zone for a domain.
+// To add or remove a TXT record we make a PATCH request.
+// https://metaregistrar.dev/docu/metaapi/requests/patch_Update_dns_zone.html
+func (c *Client) UpdateDNSZone(ctx context.Context, domain string, updateRequest DNSZoneUpdateRequest) (*DNSZoneUpdateResponse, error) {
+ endpoint := c.baseURL.JoinPath("dnszone", domain)
+
+ req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, updateRequest)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &DNSZoneUpdateResponse{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Add(tokenHeader, c.token)
+
+ 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 parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/metaregistrar/internal/client_test.go b/providers/dns/metaregistrar/internal/client_test.go
new file mode 100644
index 000000000..33e92cd7b
--- /dev/null
+++ b/providers/dns/metaregistrar/internal/client_test.go
@@ -0,0 +1,98 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With(tokenHeader, "secret"))
+}
+
+func TestClient_UpdateDNSZone(t *testing.T) {
+ client := mockBuilder().
+ Route("PATCH /dnszone/example.com",
+ servermock.ResponseFromFixture("update-dns-zone.json"),
+ servermock.CheckRequestJSONBody(`{"add":[{"name":"@","type":"TXT","ttl":60,"content":"value"}]}`)).
+ Build(t)
+
+ updateRequest := DNSZoneUpdateRequest{
+ Add: []Record{{
+ Name: "@",
+ Type: "TXT",
+ TTL: 60,
+ Content: "value",
+ }},
+ }
+
+ response, err := client.UpdateDNSZone(t.Context(), "example.com", updateRequest)
+ require.NoError(t, err)
+
+ expected := &DNSZoneUpdateResponse{
+ ResponseID: "mapi1_cb46ad8790b62b76535bd3102bd282aec83b894c",
+ Status: "ok",
+ Message: "Command completed successfully",
+ }
+
+ assert.Equal(t, expected, response)
+}
+
+func TestClient_UpdateDNSZone_error(t *testing.T) {
+ testCases := []struct {
+ desc string
+ filename string
+ expected string
+ }{
+ {
+ desc: "authentication error",
+ filename: "error.json",
+ expected: "invalid_token: the supplied token is invalid",
+ },
+ {
+ desc: "API error",
+ filename: "error-response.json",
+ expected: "error: does_not_exist: This server does not exist",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ client := mockBuilder().
+ Route("PATCH /dnszone/example.com",
+ servermock.ResponseFromFixture(test.filename).
+ WithStatusCode(http.StatusUnprocessableEntity)).
+ Build(t)
+
+ updateRequest := DNSZoneUpdateRequest{
+ Add: []Record{{
+ Name: "@",
+ Type: "TXT",
+ TTL: 60,
+ Content: "value",
+ }},
+ }
+
+ _, err := client.UpdateDNSZone(t.Context(), "example.com", updateRequest)
+ require.EqualError(t, err, test.expected)
+ })
+ }
+}
diff --git a/providers/dns/metaregistrar/internal/fixtures/error-response.json b/providers/dns/metaregistrar/internal/fixtures/error-response.json
new file mode 100644
index 000000000..8fa5a5ff3
--- /dev/null
+++ b/providers/dns/metaregistrar/internal/fixtures/error-response.json
@@ -0,0 +1,6 @@
+{
+ "responseId": "1_0a407cb0634a56374ba80f863fda53ae37fd0042",
+ "status": "error",
+ "errorCode": "does_not_exist",
+ "errorMessage": "This server does not exist"
+}
diff --git a/providers/dns/metaregistrar/internal/fixtures/error.json b/providers/dns/metaregistrar/internal/fixtures/error.json
new file mode 100644
index 000000000..c76a32fc8
--- /dev/null
+++ b/providers/dns/metaregistrar/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+ "error": "invalid_token",
+ "message": "the supplied token is invalid"
+}
\ No newline at end of file
diff --git a/providers/dns/metaregistrar/internal/fixtures/update-dns-zone.json b/providers/dns/metaregistrar/internal/fixtures/update-dns-zone.json
new file mode 100644
index 000000000..b4977272a
--- /dev/null
+++ b/providers/dns/metaregistrar/internal/fixtures/update-dns-zone.json
@@ -0,0 +1,5 @@
+{
+ "responseId": "mapi1_cb46ad8790b62b76535bd3102bd282aec83b894c",
+ "status": "ok",
+ "message": "Command completed successfully"
+}
diff --git a/providers/dns/metaregistrar/internal/types.go b/providers/dns/metaregistrar/internal/types.go
new file mode 100644
index 000000000..d8b6b3f87
--- /dev/null
+++ b/providers/dns/metaregistrar/internal/types.go
@@ -0,0 +1,67 @@
+package internal
+
+import (
+ "strings"
+)
+
+// APIError It's a mix of documented and undocumented fields.
+// Note: the documentation is inconsistent: the names of property are not the same as the JSON sample.
+// https://metaregistrar.dev/docu/metaapi/requests/response_ErrorResponse.html
+type APIError struct {
+ ResponseID string `json:"responseId,omitempty"`
+ Status string `json:"status,omitempty"`
+ Message string `json:"message,omitempty"`
+ Err string `json:"error,omitempty"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMessage string `json:"errorMessage,omitempty"`
+}
+
+func (e *APIError) Error() string {
+ var msg []string
+
+ if e.Status != "" {
+ msg = append(msg, e.Status)
+ }
+
+ if e.Err != "" {
+ msg = append(msg, e.Err)
+ }
+
+ if e.ErrorCode != "" {
+ msg = append(msg, e.ErrorCode)
+ }
+
+ if e.Message != "" {
+ msg = append(msg, e.Message)
+ }
+
+ if e.ErrorMessage != "" {
+ msg = append(msg, e.ErrorMessage)
+ }
+
+ return strings.Join(msg, ": ")
+}
+
+type Record struct {
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Content string `json:"content,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Disabled bool `json:"disabled,omitempty"`
+}
+
+// DNSZoneUpdateRequest is the representation of DnszoneUpdateRequest object.
+// https://metaregistrar.dev/docu/metaapi/requests/request_DnszoneUpdateRequest.html
+type DNSZoneUpdateRequest struct {
+ Add []Record `json:"add,omitempty"`
+ Remove []Record `json:"rem,omitempty"`
+}
+
+// DNSZoneUpdateResponse is the representation of DnszoneUpdateResponse object.
+// https://metaregistrar.dev/docu/metaapi/requests/response_DnszoneUpdateResponse.html
+type DNSZoneUpdateResponse struct {
+ ResponseID string `json:"responseId,omitempty"`
+ Status string `json:"status,omitempty"`
+ Message string `json:"message,omitempty"`
+}
diff --git a/providers/dns/metaregistrar/metaregistrar.go b/providers/dns/metaregistrar/metaregistrar.go
new file mode 100644
index 000000000..7a601ef21
--- /dev/null
+++ b/providers/dns/metaregistrar/metaregistrar.go
@@ -0,0 +1,150 @@
+// Package metaregistrar implements a DNS provider for solving the DNS-01 challenge using Metaregistrar.
+package metaregistrar
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/metaregistrar/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "METAREGISTRAR_"
+
+ EnvToken = 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, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Metaregistrar.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("metaregistrar: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIToken = values[EnvToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Metaregistrar.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("metaregistrar: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIToken)
+ if err != nil {
+ return nil, fmt.Errorf("metaregistrar: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("metaregistrar: could not find zone for domain %q: %w", domain, err)
+ }
+
+ updateRequest := internal.DNSZoneUpdateRequest{
+ Add: []internal.Record{{
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
+ Type: "TXT",
+ TTL: d.config.TTL,
+ Content: info.Value,
+ }},
+ }
+
+ _, err = d.client.UpdateDNSZone(context.Background(), dns01.UnFqdn(authZone), updateRequest)
+ if err != nil {
+ return fmt.Errorf("metaregistrar: %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("metaregistrar: could not find zone for domain %q: %w", domain, err)
+ }
+
+ updateRequest := internal.DNSZoneUpdateRequest{
+ Remove: []internal.Record{{
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
+ Type: "TXT",
+ TTL: d.config.TTL,
+ Content: strconv.Quote(info.Value),
+ }},
+ }
+
+ _, err = d.client.UpdateDNSZone(context.Background(), dns01.UnFqdn(authZone), updateRequest)
+ if err != nil {
+ return fmt.Errorf("metaregistrar: %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/dns/metaregistrar/metaregistrar.toml b/providers/dns/metaregistrar/metaregistrar.toml
new file mode 100644
index 000000000..e505e0ce2
--- /dev/null
+++ b/providers/dns/metaregistrar/metaregistrar.toml
@@ -0,0 +1,22 @@
+Name = "Metaregistrar"
+Description = ''''''
+URL = "https://metaregistrar.com/"
+Code = "metaregistrar"
+Since = "v4.23.0"
+
+Example = '''
+METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns metaregistrar -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ METAREGISTRAR_API_TOKEN = "The API token"
+ [Configuration.Additional]
+ METAREGISTRAR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ METAREGISTRAR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ METAREGISTRAR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ METAREGISTRAR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://metaregistrar.dev/docu/metaapi/"
diff --git a/providers/dns/metaregistrar/metaregistrar_test.go b/providers/dns/metaregistrar/metaregistrar_test.go
new file mode 100644
index 000000000..aa9bbbb58
--- /dev/null
+++ b/providers/dns/metaregistrar/metaregistrar_test.go
@@ -0,0 +1,116 @@
+package metaregistrar
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvToken: "token",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "metaregistrar: some credentials information are missing: METAREGISTRAR_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)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiToken: "token",
+ },
+ {
+ desc: "missing credentials",
+ expected: "metaregistrar: 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)
+ 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/dns/mijnhost/internal/client.go b/providers/dns/mijnhost/internal/client.go
index 82bdcfeb9..a51233211 100644
--- a/providers/dns/mijnhost/internal/client.go
+++ b/providers/dns/mijnhost/internal/client.go
@@ -38,7 +38,7 @@ func NewClient(apiKey string) *Client {
// ListDomains Retrieve all domains from an account.
// https://mijn.host/api/doc/api-3563872
-func (c Client) ListDomains(ctx context.Context) ([]Domain, error) {
+func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
endpoint := c.baseURL.JoinPath("domains")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -47,6 +47,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) {
}
var results Response[DomainData]
+
err = c.do(req, &results)
if err != nil {
return nil, err
@@ -57,7 +58,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) {
// GetRecords Retrieve DNS records of specific domain.
// https://mijn.host/api/doc/api-3563906
-func (c Client) GetRecords(ctx context.Context, domain string) ([]Record, error) {
+func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) {
endpoint := c.baseURL.JoinPath("domains", domain, "dns")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -66,6 +67,7 @@ func (c Client) GetRecords(ctx context.Context, domain string) ([]Record, error)
}
var results Response[RecordData]
+
err = c.do(req, &results)
if err != nil {
return nil, err
@@ -76,7 +78,7 @@ func (c Client) GetRecords(ctx context.Context, domain string) ([]Record, error)
// UpdateRecords Update DNS records of specific domain.
// https://mijn.host/api/doc/api-3563907
-func (c Client) UpdateRecords(ctx context.Context, domain string, records []Record) error {
+func (c *Client) UpdateRecords(ctx context.Context, domain string, records []Record) error {
endpoint := c.baseURL.JoinPath("domains", domain, "dns")
req, err := newJSONRequest(ctx, http.MethodPut, endpoint, RecordData{Records: records})
@@ -92,7 +94,7 @@ func (c Client) UpdateRecords(ctx context.Context, domain string, records []Reco
return nil
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
req.Header.Set(authorizationHeader, c.apiKey)
resp, err := c.HTTPClient.Do(req)
@@ -151,6 +153,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/mijnhost/internal/client_test.go b/providers/dns/mijnhost/internal/client_test.go
index 876ca5e1c..208616541 100644
--- a/providers/dns/mijnhost/internal/client_test.go
+++ b/providers/dns/mijnhost/internal/client_test.go
@@ -1,72 +1,37 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const apiKey = "secret"
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(apiKey)
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(apiKey)
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func testHandler(filename string, method 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(authorizationHeader)
- if auth != apiKey {
- http.Error(rw, "invalid Authorization header", 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
- }
- }
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With(authorizationHeader, apiKey),
+ )
}
func TestClient_ListDomains(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains", servermock.ResponseFromFixture("list-domains.json")).
+ Build(t)
- mux.HandleFunc("/domains", testHandler("./list-domains.json", http.MethodGet, http.StatusOK))
-
- domains, err := client.ListDomains(context.Background())
+ domains, err := client.ListDomains(t.Context())
require.NoError(t, err)
expected := []Domain{{
@@ -82,11 +47,11 @@ func TestClient_ListDomains(t *testing.T) {
}
func TestClient_GetRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /domains/example.com/dns", servermock.ResponseFromFixture("get-dns-records.json")).
+ Build(t)
- mux.HandleFunc("/domains/example.com/dns", testHandler("./get-dns-records.json", http.MethodGet, http.StatusOK))
-
- records, err := client.GetRecords(context.Background(), "example.com")
+ records, err := client.GetRecords(t.Context(), "example.com")
require.NoError(t, err)
expected := []Record{
@@ -120,10 +85,19 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_UpdateRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("PUT /domains/example.com/dns",
+ servermock.ResponseFromFixture("update-dns-records.json"),
+ servermock.CheckRequestJSONBody(`{"records":[{"type":"TXT","name":"foo","value":"value1","ttl":120}]}`)).
+ Build(t)
- mux.HandleFunc("/domains/example.com/dns", testHandler("./update-dns-records.json", http.MethodPut, http.StatusOK))
+ records := []Record{{
+ Type: "TXT",
+ Name: "foo",
+ Value: "value1",
+ TTL: 120,
+ }}
- err := client.UpdateRecords(context.Background(), "example.com", nil)
+ err := client.UpdateRecords(t.Context(), "example.com", records)
require.NoError(t, err)
}
diff --git a/providers/dns/mijnhost/mijnhost.go b/providers/dns/mijnhost/mijnhost.go
index 32aadfb2d..adb3e9ce3 100644
--- a/providers/dns/mijnhost/mijnhost.go
+++ b/providers/dns/mijnhost/mijnhost.go
@@ -11,8 +11,8 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/mijnhost/internal"
- "github.com/miekg/dns"
)
// Environment variables names.
@@ -28,6 +28,8 @@ const (
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
+const txtType = "TXT"
+
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
@@ -85,6 +87,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := internal.NewClient(config.APIKey)
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -105,9 +113,11 @@ func (d *DNSProvider) Sequential() time.Duration {
// 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)
- domains, err := d.client.ListDomains(context.Background())
+ domains, err := d.client.ListDomains(ctx)
if err != nil {
return fmt.Errorf("mijnhost: list domains: %w", err)
}
@@ -117,7 +127,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("mijnhost: find domain: %w", err)
}
- records, err := d.client.GetRecords(context.Background(), dom.Domain)
+ records, err := d.client.GetRecords(ctx, dom.Domain)
if err != nil {
return fmt.Errorf("mijnhost: get records: %w", err)
}
@@ -128,7 +138,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
}
record := internal.Record{
- Type: "TXT",
+ Type: txtType,
Name: subDomain,
Value: info.Value,
TTL: d.config.TTL,
@@ -137,12 +147,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// mijn.host doesn't support multiple values for a domain,
// so we removed existing record for the subdomain.
cleanedRecords := filterRecords(records, func(record internal.Record) bool {
- return record.Name == subDomain || record.Name == dns01.UnFqdn(info.EffectiveFQDN)
+ return record.Type == txtType && (record.Name == subDomain || record.Name == dns01.UnFqdn(info.EffectiveFQDN))
})
cleanedRecords = append(cleanedRecords, record)
- err = d.client.UpdateRecords(context.Background(), dom.Domain, cleanedRecords)
+ err = d.client.UpdateRecords(ctx, dom.Domain, cleanedRecords)
if err != nil {
return fmt.Errorf("mijnhost: update records: %w", err)
}
@@ -152,9 +162,11 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// CleanUp removes the TXT record.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
info := dns01.GetChallengeInfo(domain, keyAuth)
- domains, err := d.client.ListDomains(context.Background())
+ domains, err := d.client.ListDomains(ctx)
if err != nil {
return fmt.Errorf("mijnhost: list domains: %w", err)
}
@@ -164,16 +176,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("mijnhost: find domain: %w", err)
}
- records, err := d.client.GetRecords(context.Background(), dom.Domain)
+ records, err := d.client.GetRecords(ctx, dom.Domain)
if err != nil {
return fmt.Errorf("mijnhost: get records: %w", err)
}
cleanedRecords := filterRecords(records, func(record internal.Record) bool {
- return record.Value == info.Value
+ return record.Type == txtType && record.Value == info.Value
})
- err = d.client.UpdateRecords(context.Background(), dom.Domain, cleanedRecords)
+ err = d.client.UpdateRecords(ctx, dom.Domain, cleanedRecords)
if err != nil {
return fmt.Errorf("mijnhost: update records: %w", err)
}
@@ -182,11 +194,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) {
- labelIndexes := dns.Split(fqdn)
-
- for _, index := range labelIndexes {
- domain := dns01.UnFqdn(fqdn[index:])
-
+ for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
for _, dom := range domains {
if dom.Domain == domain {
return dom, nil
diff --git a/providers/dns/mijnhost/mijnhost.toml b/providers/dns/mijnhost/mijnhost.toml
index 7cea55a18..416fdde53 100644
--- a/providers/dns/mijnhost/mijnhost.toml
+++ b/providers/dns/mijnhost/mijnhost.toml
@@ -6,18 +6,18 @@ Since = "v4.18.0"
Example = '''
MIJNHOST_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns mijnhost -d '*.example.com' -d example.com run
+lego --dns mijnhost -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
MIJNHOST_API_KEY = "The API key"
[Configuration.Additional]
- MIJNHOST_POLLING_INTERVAL = "Time between DNS propagation check"
- MIJNHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- MIJNHOST_SEQUENCE_INTERVAL = "Time between sequential requests"
- MIJNHOST_TTL = "The TTL of the TXT record used for the DNS challenge"
- MIJNHOST_HTTP_TIMEOUT = "API request timeout"
+ MIJNHOST_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ MIJNHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ MIJNHOST_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ MIJNHOST_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ MIJNHOST_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://mijn.host/api/doc/"
diff --git a/providers/dns/mijnhost/mijnhost_test.go b/providers/dns/mijnhost/mijnhost_test.go
index a48f84ca8..c87ae0a40 100644
--- a/providers/dns/mijnhost/mijnhost_test.go
+++ b/providers/dns/mijnhost/mijnhost_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -94,6 +95,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -107,6 +109,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/mittwald/internal/client.go b/providers/dns/mittwald/internal/client.go
index 712caf8df..2b1564dc1 100644
--- a/providers/dns/mittwald/internal/client.go
+++ b/providers/dns/mittwald/internal/client.go
@@ -38,7 +38,7 @@ func NewClient(token string) *Client {
// ListDomains List Domains.
// https://api.mittwald.de/v2/docs/#/Domain/domain-list-domains
-func (c Client) ListDomains(ctx context.Context) ([]Domain, error) {
+func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
endpoint := c.baseURL.JoinPath("domains")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -47,6 +47,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) {
}
var result []Domain
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -57,7 +58,7 @@ func (c Client) ListDomains(ctx context.Context) ([]Domain, error) {
// GetDNSZone Get a DNSZone.
// https://api.mittwald.de/v2/docs/#/Domain/dns-get-dns-zone
-func (c Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error) {
+func (c *Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error) {
endpoint := c.baseURL.JoinPath("dns-zones", zoneID)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -66,6 +67,7 @@ func (c Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error)
}
result := &DNSZone{}
+
err = c.do(req, result)
if err != nil {
return nil, err
@@ -76,7 +78,7 @@ func (c Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error)
// ListDNSZones List DNSZones belonging to a Project.
// https://api.mittwald.de/v2/docs/#/Domain/dns-list-dns-zones
-func (c Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone, error) {
+func (c *Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone, error) {
endpoint := c.baseURL.JoinPath("projects", projectID, "dns-zones")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -85,6 +87,7 @@ func (c Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone,
}
var result []DNSZone
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -95,7 +98,7 @@ func (c Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone,
// CreateDNSZone Create a DNSZone.
// https://api.mittwald.de/v2/docs/#/Domain/dns-create-dns-zone
-func (c Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (*DNSZone, error) {
+func (c *Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (*DNSZone, error) {
endpoint := c.baseURL.JoinPath("dns-zones")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zone)
@@ -104,6 +107,7 @@ func (c Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (*
}
result := &DNSZone{}
+
err = c.do(req, result)
if err != nil {
return nil, err
@@ -114,7 +118,7 @@ func (c Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (*
// UpdateTXTRecord Update a record set on a DNSZone.
// https://api.mittwald.de/v2/docs/#/Domain/dns-update-record-set
-func (c Client) UpdateTXTRecord(ctx context.Context, zoneID string, record TXTRecord) error {
+func (c *Client) UpdateTXTRecord(ctx context.Context, zoneID string, record TXTRecord) error {
endpoint := c.baseURL.JoinPath("dns-zones", zoneID, "record-sets", "txt")
req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record)
@@ -127,7 +131,7 @@ func (c Client) UpdateTXTRecord(ctx context.Context, zoneID string, record TXTRe
// DeleteDNSZone Delete a DNSZone.
// https://api.mittwald.de/v2/docs/#/Domain/dns-delete-dns-zone
-func (c Client) DeleteDNSZone(ctx context.Context, zoneID string) error {
+func (c *Client) DeleteDNSZone(ctx context.Context, zoneID string) error {
endpoint := c.baseURL.JoinPath("dns-zones", zoneID)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -138,7 +142,7 @@ func (c Client) DeleteDNSZone(ctx context.Context, zoneID string) error {
return c.do(req, nil)
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
req.Header.Set(authorizationHeader, "Bearer "+c.token)
resp, err := c.HTTPClient.Do(req)
@@ -197,6 +201,7 @@ 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)
diff --git a/providers/dns/mittwald/internal/client_test.go b/providers/dns/mittwald/internal/client_test.go
index 63fc52004..e57c80f7a 100644
--- a/providers/dns/mittwald/internal/client_test.go
+++ b/providers/dns/mittwald/internal/client_test.go
@@ -1,75 +1,36 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc(pattern, handler)
-
- client := NewClient("secret")
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client
-}
-
-func testHandler(method string, statusCode int, filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get(authorizationHeader)
- if auth != "Bearer secret" {
- http.Error(rw, fmt.Sprintf("invalid API Token: %s", auth), http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(statusCode)
-
- if statusCode == http.StatusNoContent {
- return
- }
-
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
- }
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
}
func TestClient_ListDomains(t *testing.T) {
- client := setupTest(t, "/domains", testHandler(http.MethodGet, http.StatusOK, "domain-list-domains.json"))
+ client := mockBuilder().
+ Route("GET /domains", servermock.ResponseFromFixture("domain-list-domains.json")).
+ Build(t)
- domains, err := client.ListDomains(context.Background())
+ domains, err := client.ListDomains(t.Context())
require.NoError(t, err)
require.Len(t, domains, 1)
@@ -84,16 +45,22 @@ func TestClient_ListDomains(t *testing.T) {
}
func TestClient_ListDomains_error(t *testing.T) {
- client := setupTest(t, "/domains", testHandler(http.MethodGet, http.StatusBadRequest, "error-client.json"))
+ client := mockBuilder().
+ Route("GET /domains",
+ servermock.ResponseFromFixture("error-client.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- _, err := client.ListDomains(context.Background())
+ _, err := client.ListDomains(t.Context())
require.EqualError(t, err, "[status code 400] ValidationError: Validation failed [format: should be string (.address.street, email)]")
}
func TestClient_ListDNSZones(t *testing.T) {
- client := setupTest(t, "/projects/my-project-id/dns-zones", testHandler(http.MethodGet, http.StatusOK, "dns-list-dns-zones.json"))
+ client := mockBuilder().
+ Route("GET /projects/my-project-id/dns-zones", servermock.ResponseFromFixture("dns-list-dns-zones.json")).
+ Build(t)
- zones, err := client.ListDNSZones(context.Background(), "my-project-id")
+ zones, err := client.ListDNSZones(t.Context(), "my-project-id")
require.NoError(t, err)
require.Len(t, zones, 1)
@@ -110,9 +77,11 @@ func TestClient_ListDNSZones(t *testing.T) {
}
func TestClient_GetDNSZone(t *testing.T) {
- client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodGet, http.StatusOK, "dns-get-dns-zone.json"))
+ client := mockBuilder().
+ Route("GET /dns-zones/my-zone-id", servermock.ResponseFromFixture("dns-get-dns-zone.json")).
+ Build(t)
- zone, err := client.GetDNSZone(context.Background(), "my-zone-id")
+ zone, err := client.GetDNSZone(t.Context(), "my-zone-id")
require.NoError(t, err)
expected := &DNSZone{
@@ -127,14 +96,18 @@ func TestClient_GetDNSZone(t *testing.T) {
}
func TestClient_CreateDNSZone(t *testing.T) {
- client := setupTest(t, "/dns-zones", testHandler(http.MethodPost, http.StatusCreated, "dns-create-dns-zone.json"))
+ client := mockBuilder().
+ Route("POST /dns-zones",
+ servermock.ResponseFromFixture("dns-create-dns-zone.json"),
+ servermock.CheckRequestJSONBody(`{"name":"test","parentZoneId":"my-parent-zone-id"}`)).
+ Build(t)
request := CreateDNSZoneRequest{
Name: "test",
ParentZoneID: "my-parent-zone-id",
}
- zone, err := client.CreateDNSZone(context.Background(), request)
+ zone, err := client.CreateDNSZone(t.Context(), request)
require.NoError(t, err)
expected := &DNSZone{
@@ -145,7 +118,12 @@ func TestClient_CreateDNSZone(t *testing.T) {
}
func TestClient_UpdateTXTRecord(t *testing.T) {
- client := setupTest(t, "/dns-zones/my-zone-id/record-sets/txt", testHandler(http.MethodPut, http.StatusNoContent, ""))
+ client := mockBuilder().
+ Route("PUT /dns-zones/my-zone-id/record-sets/txt",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent),
+ servermock.CheckRequestJSONBody(`{"settings":{"ttl":{"auto":true}},"entries":["txt"]}`)).
+ Build(t)
record := TXTRecord{
Settings: Settings{
@@ -154,20 +132,27 @@ func TestClient_UpdateTXTRecord(t *testing.T) {
Entries: []string{"txt"},
}
- err := client.UpdateTXTRecord(context.Background(), "my-zone-id", record)
+ err := client.UpdateTXTRecord(t.Context(), "my-zone-id", record)
require.NoError(t, err)
}
func TestClient_DeleteDNSZone(t *testing.T) {
- client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodDelete, http.StatusOK, ""))
+ client := mockBuilder().
+ Route("DELETE /dns-zones/my-zone-id",
+ servermock.Noop()).
+ Build(t)
- err := client.DeleteDNSZone(context.Background(), "my-zone-id")
+ err := client.DeleteDNSZone(t.Context(), "my-zone-id")
require.NoError(t, err)
}
func TestClient_DeleteDNSZone_error(t *testing.T) {
- client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodDelete, http.StatusInternalServerError, "error.json"))
+ client := mockBuilder().
+ Route("DELETE /dns-zones/my-zone-id",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
- err := client.DeleteDNSZone(context.Background(), "my-zone-id")
+ err := client.DeleteDNSZone(t.Context(), "my-zone-id")
assert.EqualError(t, err, "[status code 500] InternalServerError: Something went wrong")
}
diff --git a/providers/dns/mittwald/internal/types.go b/providers/dns/mittwald/internal/types.go
index df10ab293..86cdf065c 100644
--- a/providers/dns/mittwald/internal/types.go
+++ b/providers/dns/mittwald/internal/types.go
@@ -1,6 +1,9 @@
package internal
-import "fmt"
+import (
+ "fmt"
+ "strings"
+)
// https://api.mittwald.de/v2/docs/#/Domain/domain-list-domains
@@ -36,7 +39,7 @@ type NewDNSZone struct {
// https://api.mittwald.de/v2/docs/#/Domain/dns-update-record-set
type TXTRecord struct {
- Settings Settings `json:"settings,omitempty"`
+ Settings Settings `json:"settings"`
Entries []string `json:"entries,omitempty"`
}
@@ -58,23 +61,25 @@ type APIError struct {
}
func (a APIError) Error() string {
- msg := fmt.Sprintf("%s: %s", a.Type, a.Message)
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "%s: %s", a.Type, a.Message)
if len(a.ValidationErrors) > 0 {
for _, validationError := range a.ValidationErrors {
- msg += fmt.Sprintf(" [%s: %s (%s, %s)]",
+ _, _ = fmt.Fprintf(msg, " [%s: %s (%s, %s)]",
validationError.Type, validationError.Message, validationError.Path, validationError.Context.Format)
}
}
- return msg
+ return msg.String()
}
type ValidationError struct {
Message string `json:"message,omitempty"`
Path string `json:"path,omitempty"`
Type string `json:"type,omitempty"`
- Context ValidationErrorContext `json:"context,omitempty"`
+ Context ValidationErrorContext `json:"context"`
}
type ValidationErrorContext struct {
diff --git a/providers/dns/mittwald/mittwald.go b/providers/dns/mittwald/mittwald.go
index 47c62be52..dcd882482 100644
--- a/providers/dns/mittwald/mittwald.go
+++ b/providers/dns/mittwald/mittwald.go
@@ -12,8 +12,8 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/mittwald/internal"
- "github.com/miekg/dns"
)
// Environment variables names.
@@ -93,9 +93,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("mittwald: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
}
+ client := internal.NewClient(config.Token)
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
- client: internal.NewClient(config.Token),
+ client: client,
zoneIDs: map[string]string{},
}, nil
}
@@ -150,6 +158,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.zoneIDsMu.Lock()
zoneID, ok := d.zoneIDs[token]
d.zoneIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("mittwald: unknown zone ID for '%s'", info.EffectiveFQDN)
}
@@ -161,6 +170,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("mittwald: update/delete TXT record: %w", err)
}
+ d.zoneIDsMu.Lock()
+ delete(d.zoneIDs, token)
+ d.zoneIDsMu.Unlock()
+
return nil
}
@@ -212,11 +225,7 @@ func (d *DNSProvider) getOrCreateZone(ctx context.Context, fqdn string) (*intern
}
func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) {
- labelIndexes := dns.Split(fqdn)
-
- for _, index := range labelIndexes {
- domain := dns01.UnFqdn(fqdn[index:])
-
+ for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
for _, dom := range domains {
if dom.Domain == domain {
return dom, nil
@@ -228,11 +237,7 @@ func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error)
}
func findZone(zones []internal.DNSZone, fqdn string) (internal.DNSZone, error) {
- labelIndexes := dns.Split(fqdn)
-
- for _, index := range labelIndexes {
- domain := dns01.UnFqdn(fqdn[index:])
-
+ for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
for _, zon := range zones {
if zon.Domain == domain {
return zon, nil
diff --git a/providers/dns/mittwald/mittwald.toml b/providers/dns/mittwald/mittwald.toml
index 7df9797b6..36a9f6c16 100644
--- a/providers/dns/mittwald/mittwald.toml
+++ b/providers/dns/mittwald/mittwald.toml
@@ -6,18 +6,18 @@ Since = "v1.48.0"
Example = '''
MITTWALD_TOKEN=my-token \
-lego --email you@example.com --dns mittwald -d '*.example.com' -d example.com run
+lego --dns mittwald -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
MITTWALD_TOKEN = "API token"
[Configuration.Additional]
- MITTWALD_POLLING_INTERVAL = "Time between DNS propagation check"
- MITTWALD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- MITTWALD_TTL = "The TTL of the TXT record used for the DNS challenge"
- MITTWALD_HTTP_TIMEOUT = "API request timeout"
- MITTWALD_SEQUENCE_INTERVAL = "Time between sequential requests"
+ MITTWALD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ MITTWALD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ MITTWALD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ MITTWALD_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)"
+ MITTWALD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.mittwald.de/v2/docs/"
diff --git a/providers/dns/mittwald/mittwald_test.go b/providers/dns/mittwald/mittwald_test.go
index d8cbdb263..6a6599536 100644
--- a/providers/dns/mittwald/mittwald_test.go
+++ b/providers/dns/mittwald/mittwald_test.go
@@ -38,6 +38,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -104,6 +105,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -117,6 +119,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/myaddr/internal/client.go b/providers/dns/myaddr/internal/client.go
new file mode 100644
index 000000000..40f919c7d
--- /dev/null
+++ b/providers/dns/myaddr/internal/client.go
@@ -0,0 +1,115 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://myaddr.tools"
+
+// Client the myaddr.{tools,dev,io} API client.
+type Client struct {
+ baseURL *url.URL
+ HTTPClient *http.Client
+
+ credentials map[string]string
+ credMu sync.Mutex
+}
+
+// NewClient creates a new Client.
+func NewClient(credentials map[string]string) (*Client, error) {
+ if len(credentials) == 0 {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ credentials: credentials,
+ }, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, subdomain, value string) error {
+ c.credMu.Lock()
+ privateKey, ok := c.credentials[subdomain]
+ c.credMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("subdomain %s not found in credentials, check your credentials map", subdomain)
+ }
+
+ payload := ACMEChallenge{Key: privateKey, Data: value}
+
+ req, err := newJSONRequest(ctx, http.MethodPost, c.baseURL.JoinPath("update"), payload)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ 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
+}
diff --git a/providers/dns/myaddr/internal/client_test.go b/providers/dns/myaddr/internal/client_test.go
new file mode 100644
index 000000000..36506d94a
--- /dev/null
+++ b/providers/dns/myaddr/internal/client_test.go
@@ -0,0 +1,62 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ credentials := map[string]string{
+ "example": "secret",
+ }
+
+ client, err := NewClient(credentials)
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /update", nil,
+ servermock.CheckRequestJSONBody(`{"key":"secret","acme_challenge":"txt"}`)).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example", "txt")
+ require.NoError(t, err)
+}
+
+func TestClient_AddTXTRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /update",
+ servermock.ResponseFromFixture("error.txt").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "example", "txt")
+ require.EqualError(t, err, `unexpected status code: [status code: 400] body: invalid value for "key"`)
+}
+
+func TestClient_AddTXTRecord_error_credentials(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /update", nil).
+ Build(t)
+
+ err := client.AddTXTRecord(t.Context(), "nx", "txt")
+ require.EqualError(t, err, "subdomain nx not found in credentials, check your credentials map")
+}
diff --git a/providers/dns/myaddr/internal/fixtures/error.txt b/providers/dns/myaddr/internal/fixtures/error.txt
new file mode 100644
index 000000000..64a417673
--- /dev/null
+++ b/providers/dns/myaddr/internal/fixtures/error.txt
@@ -0,0 +1 @@
+invalid value for "key"
diff --git a/providers/dns/myaddr/internal/types.go b/providers/dns/myaddr/internal/types.go
new file mode 100644
index 000000000..36f057497
--- /dev/null
+++ b/providers/dns/myaddr/internal/types.go
@@ -0,0 +1,6 @@
+package internal
+
+type ACMEChallenge struct {
+ Key string `json:"key"`
+ Data string `json:"acme_challenge"`
+}
diff --git a/providers/dns/myaddr/myaddr.go b/providers/dns/myaddr/myaddr.go
new file mode 100644
index 000000000..fb7ea66a0
--- /dev/null
+++ b/providers/dns/myaddr/myaddr.go
@@ -0,0 +1,147 @@
+// Package myaddr implements a DNS provider for solving the DNS-01 challenge using myaddr.{tools,dev,io}.
+package myaddr
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/myaddr/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "MYADDR_"
+
+ EnvPrivateKeysMapping = envNamespace + "PRIVATE_KEYS_MAPPING"
+
+ 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 {
+ Credentials map[string]string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ SequenceInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for myaddr.{tools,dev,io}.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvPrivateKeysMapping)
+ if err != nil {
+ return nil, fmt.Errorf("myaddr: %w", err)
+ }
+
+ config := NewDefaultConfig()
+
+ credentials, err := env.ParsePairs(values[EnvPrivateKeysMapping])
+ if err != nil {
+ return nil, fmt.Errorf("myaddr: %w", err)
+ }
+
+ config.Credentials = credentials
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for myaddr.{tools,dev,io}.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("myaddr: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Credentials)
+ if err != nil {
+ return nil, fmt.Errorf("myaddr: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("myaddr: could not find zone for domain %q: %w", domain, err)
+ }
+
+ fullSubdomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("myaddr: %w", err)
+ }
+
+ _, after, found := strings.Cut(fullSubdomain, ".")
+ if !found {
+ return fmt.Errorf("myaddr: subdomain not found in: %q (%s)", fullSubdomain, info.EffectiveFQDN)
+ }
+
+ err = d.client.AddTXTRecord(context.Background(), after, info.Value)
+ if err != nil {
+ return fmt.Errorf("myaddr: add TXT record: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ // There is no API endpoint to delete a TXT record:
+ // TXT records are automatically removed after a few minutes.
+ 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
+}
+
+// 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/dns/myaddr/myaddr.toml b/providers/dns/myaddr/myaddr.toml
new file mode 100644
index 000000000..2f5fe6c1f
--- /dev/null
+++ b/providers/dns/myaddr/myaddr.toml
@@ -0,0 +1,23 @@
+Name = "myaddr.{tools,dev,io}"
+Description = ''''''
+URL = "https://myaddr.tools/"
+Code = "myaddr"
+Since = "v4.22.0"
+
+Example = '''
+MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \
+lego --dns myaddr -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ MYADDR_PRIVATE_KEYS_MAPPING = "Mapping between subdomains and private keys. The format is: `:,:,:`"
+ [Configuration.Additional]
+ MYADDR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ MYADDR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ MYADDR_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 2)"
+ MYADDR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ MYADDR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://myaddr.tools/"
diff --git a/providers/dns/googledomains/googledomains_test.go b/providers/dns/myaddr/myaddr_test.go
similarity index 76%
rename from providers/dns/googledomains/googledomains_test.go
rename to providers/dns/myaddr/myaddr_test.go
index 038fb5346..8e555ecfd 100644
--- a/providers/dns/googledomains/googledomains_test.go
+++ b/providers/dns/myaddr/myaddr_test.go
@@ -1,4 +1,4 @@
-package googledomains
+package myaddr
import (
"testing"
@@ -9,9 +9,7 @@ import (
const envDomain = envNamespace + "DOMAIN"
-var envTest = tester.NewEnvTest(EnvAccessToken).
- WithDomain(envDomain).
- WithLiveTestRequirements(EnvAccessToken, envDomain)
+var envTest = tester.NewEnvTest(EnvPrivateKeysMapping).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
@@ -22,29 +20,33 @@ func TestNewDNSProvider(t *testing.T) {
{
desc: "success",
envVars: map[string]string{
- EnvAccessToken: "abc",
+ EnvPrivateKeysMapping: "example:123",
},
- expected: "",
},
{
desc: "missing credentials",
envVars: map[string]string{},
- expected: "googledomains: some credentials information are missing: GOOGLE_DOMAINS_ACCESS_TOKEN",
+ expected: "myaddr: some credentials information are missing: MYADDR_PRIVATE_KEYS_MAPPING",
},
}
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.Error(t, err)
- require.Contains(t, err.Error(), test.expected)
+ require.EqualError(t, err, test.expected)
}
})
}
@@ -53,23 +55,23 @@ func TestNewDNSProvider(t *testing.T) {
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
- accessToken string
+ credentials map[string]string
expected string
}{
{
desc: "success",
- accessToken: "abc",
+ credentials: map[string]string{"example": "123"},
},
{
desc: "missing credentials",
- expected: "googledomains: access token is missing",
+ expected: "myaddr: credentials missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
- config.AccessToken = test.accessToken
+ config.Credentials = test.credentials
p, err := NewDNSProviderConfig(config)
@@ -77,6 +79,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
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)
}
@@ -90,6 +93,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -103,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/mydnsjp/internal/client.go b/providers/dns/mydnsjp/internal/client.go
index 9859ed685..20469d657 100644
--- a/providers/dns/mydnsjp/internal/client.go
+++ b/providers/dns/mydnsjp/internal/client.go
@@ -23,7 +23,7 @@ type Client struct {
}
// NewClient Creates a new Client.
-func NewClient(masterID string, password string) *Client {
+func NewClient(masterID, password string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
@@ -34,15 +34,15 @@ func NewClient(masterID string, password string) *Client {
}
}
-func (c Client) AddTXTRecord(ctx context.Context, domain, value string) error {
+func (c *Client) AddTXTRecord(ctx context.Context, domain, value string) error {
return c.doRequest(ctx, domain, value, "REGIST")
}
-func (c Client) DeleteTXTRecord(ctx context.Context, domain, value string) error {
+func (c *Client) DeleteTXTRecord(ctx context.Context, domain, value string) error {
return c.doRequest(ctx, domain, value, "DELETE")
}
-func (c Client) buildRequest(ctx context.Context, domain, value, cmd string) (*http.Request, error) {
+func (c *Client) buildRequest(ctx context.Context, domain, value, cmd string) (*http.Request, error) {
params := url.Values{}
params.Set("CERTBOT_DOMAIN", domain)
params.Set("CERTBOT_VALIDATION", value)
@@ -58,7 +58,7 @@ func (c Client) buildRequest(ctx context.Context, domain, value, cmd string) (*h
return req, nil
}
-func (c Client) doRequest(ctx context.Context, domain, value, cmd string) error {
+func (c *Client) doRequest(ctx context.Context, domain, value, cmd string) error {
req, err := c.buildRequest(ctx, domain, value, cmd)
if err != nil {
return err
diff --git a/providers/dns/mydnsjp/internal/client_test.go b/providers/dns/mydnsjp/internal/client_test.go
index a68f6888b..41ccbba87 100644
--- a/providers/dns/mydnsjp/internal/client_test.go
+++ b/providers/dns/mydnsjp/internal/client_test.go
@@ -1,92 +1,50 @@
package internal
import (
- "context"
- "fmt"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, cmdName string) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("xxx", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- username, password, ok := req.BasicAuth()
- if !ok {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- if username != "xxx" {
- http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "xxx"), http.StatusUnauthorized)
- return
- }
-
- if password != "secret" {
- http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized)
- return
- }
-
- if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
- http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest)
- return
- }
-
- err := req.ParseForm()
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- domain := req.Form.Get("CERTBOT_DOMAIN")
- if domain != "example.com" {
- http.Error(rw, fmt.Sprintf("unexpected CERTBOT_DOMAIN: %s", domain), http.StatusBadRequest)
- return
- }
-
- validation := req.Form.Get("CERTBOT_VALIDATION")
- if validation != "txt" {
- http.Error(rw, fmt.Sprintf("unexpected CERTBOT_VALIDATION: %s", validation), http.StatusBadRequest)
- return
- }
-
- cmd := req.Form.Get("EDIT_CMD")
- if cmd != cmdName {
- http.Error(rw, fmt.Sprintf("unexpected EDIT_CMD: %s", cmd), http.StatusBadRequest)
- return
- }
- })
-
- client := NewClient("xxx", "secret")
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded().
+ WithBasicAuth("xxx", "secret"))
}
func TestClient_AddTXTRecord(t *testing.T) {
- client := setupTest(t, "REGIST")
+ client := mockBuilder().
+ Route("POST /", nil,
+ servermock.CheckForm().Strict().
+ With("CERTBOT_DOMAIN", "example.com").
+ With("CERTBOT_VALIDATION", "txt").
+ With("EDIT_CMD", "REGIST")).
+ Build(t)
- err := client.AddTXTRecord(context.Background(), "example.com", "txt")
+ err := client.AddTXTRecord(t.Context(), "example.com", "txt")
require.NoError(t, err)
}
func TestClient_DeleteTXTRecord(t *testing.T) {
- client := setupTest(t, "DELETE")
+ client := mockBuilder().
+ Route("POST /", nil,
+ servermock.CheckForm().Strict().
+ With("CERTBOT_DOMAIN", "example.com").
+ With("CERTBOT_VALIDATION", "txt").
+ With("EDIT_CMD", "DELETE")).
+ Build(t)
- err := client.DeleteTXTRecord(context.Background(), "example.com", "txt")
+ err := client.DeleteTXTRecord(t.Context(), "example.com", "txt")
require.NoError(t, err)
}
diff --git a/providers/dns/mydnsjp/mydnsjp.go b/providers/dns/mydnsjp/mydnsjp.go
index ec1aca357..8a790c88e 100644
--- a/providers/dns/mydnsjp/mydnsjp.go
+++ b/providers/dns/mydnsjp/mydnsjp.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/mydnsjp/internal"
)
@@ -41,7 +42,7 @@ type Config struct {
func NewDefaultConfig() *Config {
return &Config{
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -79,9 +80,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("mydnsjp: some credentials information are missing")
}
+ client := internal.NewClient(config.MasterID, config.Password)
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
- client: internal.NewClient(config.MasterID, config.Password),
+ client: client,
}, nil
}
@@ -100,6 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("mydnsjp: %w", err)
}
+
return nil
}
@@ -112,5 +122,6 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("mydnsjp: %w", err)
}
+
return nil
}
diff --git a/providers/dns/mydnsjp/mydnsjp.toml b/providers/dns/mydnsjp/mydnsjp.toml
index d462e9537..eb9e73acc 100644
--- a/providers/dns/mydnsjp/mydnsjp.toml
+++ b/providers/dns/mydnsjp/mydnsjp.toml
@@ -7,7 +7,7 @@ Since = "v1.2.0"
Example = '''
MYDNSJP_MASTER_ID=xxxxx \
MYDNSJP_PASSWORD=xxxxx \
-lego --email you@example.com --dns mydnsjp -d '*.example.com' -d example.com run
+lego --dns mydnsjp -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,9 @@ lego --email you@example.com --dns mydnsjp -d '*.example.com' -d example.com run
MYDNSJP_MASTER_ID = "Master ID"
MYDNSJP_PASSWORD = "Password"
[Configuration.Additional]
- MYDNSJP_POLLING_INTERVAL = "Time between DNS propagation check"
- MYDNSJP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- MYDNSJP_TTL = "The TTL of the TXT record used for the DNS challenge"
- MYDNSJP_HTTP_TIMEOUT = "API request timeout"
+ MYDNSJP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ MYDNSJP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ MYDNSJP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.mydns.jp/?MENU=030"
diff --git a/providers/dns/mydnsjp/mydnsjp_test.go b/providers/dns/mydnsjp/mydnsjp_test.go
index 96eb95865..c82bd2264 100644
--- a/providers/dns/mydnsjp/mydnsjp_test.go
+++ b/providers/dns/mydnsjp/mydnsjp_test.go
@@ -56,6 +56,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -124,6 +125,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -137,6 +139,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/mythicbeasts/internal/client.go b/providers/dns/mythicbeasts/internal/client.go
index 91fbbaf54..82c51dbf3 100644
--- a/providers/dns/mythicbeasts/internal/client.go
+++ b/providers/dns/mythicbeasts/internal/client.go
@@ -35,7 +35,7 @@ type Client struct {
}
// NewClient Creates a new Client.
-func NewClient(username string, password string) *Client {
+func NewClient(username, password string) *Client {
apiEndpoint, _ := url.Parse(APIBaseURL)
authEndpoint, _ := url.Parse(AuthBaseURL)
@@ -99,6 +99,7 @@ func (c *Client) createTXTRecord(ctx context.Context, zone, leaf, recordType, va
}
resp := &createTXTResponse{}
+
err = c.do(req, resp)
if err != nil {
return nil, err
diff --git a/providers/dns/mythicbeasts/internal/client_test.go b/providers/dns/mythicbeasts/internal/client_test.go
index 7e3857986..acbf85268 100644
--- a/providers/dns/mythicbeasts/internal/client_test.go
+++ b/providers/dns/mythicbeasts/internal/client_test.go
@@ -1,69 +1,54 @@
package internal
import (
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
"time"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.APIEndpoint, _ = url.Parse(server.URL)
+ client.token = &Token{
+ Token: "secret",
+ Lifetime: 60,
+ TokenType: "bearer",
+ Deadline: time.Now().Add(1 * time.Minute),
+ }
- 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{
- Token: "secret",
- Lifetime: 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)
- }
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer "+fakeToken),
+ )
}
func TestClient_CreateTXTRecord(t *testing.T) {
- client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodPost, "post-zoneszonerecords.json"))
+ client := mockBuilder().
+ Route("POST /zones/example.com/records/foo/TXT",
+ servermock.ResponseFromFixture("post-zoneszonerecords.json"),
+ servermock.CheckRequestJSONBody(`{"records":[{"host":"foo","ttl":120,"type":"TXT","data":"txt"}]}`)).
+ Build(t)
- err := client.CreateTXTRecord(mockContext(), "example.com", "foo", "txt", 120)
+ err := client.CreateTXTRecord(mockContext(t), "example.com", "foo", "txt", 120)
require.NoError(t, err)
}
func TestClient_RemoveTXTRecord(t *testing.T) {
- client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodDelete, "delete-zoneszonerecords.json"))
+ client := mockBuilder().
+ Route("DELETE /zones/example.com/records/foo/TXT",
+ servermock.ResponseFromFixture("delete-zoneszonerecords.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("data", "txt")).
+ Build(t)
- err := client.RemoveTXTRecord(mockContext(), "example.com", "foo", "txt")
+ err := client.RemoveTXTRecord(mockContext(t), "example.com", "foo", "txt")
require.NoError(t, err)
}
diff --git a/providers/dns/mythicbeasts/internal/fixtures/token.json b/providers/dns/mythicbeasts/internal/fixtures/token.json
new file mode 100644
index 000000000..f23fe58ea
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/fixtures/token.json
@@ -0,0 +1,5 @@
+{
+ "access_token": "xxx",
+ "expires_in": 666,
+ "token_type": "bearer"
+}
diff --git a/providers/dns/mythicbeasts/internal/identity.go b/providers/dns/mythicbeasts/internal/identity.go
index 417f1c759..15e35ba69 100644
--- a/providers/dns/mythicbeasts/internal/identity.go
+++ b/providers/dns/mythicbeasts/internal/identity.go
@@ -44,6 +44,7 @@ func (c *Client) obtainToken(ctx context.Context) (*Token, error) {
}
tok := Token{}
+
err = json.Unmarshal(raw, &tok)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -83,6 +84,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errResp := &authResponseError{}
+
err := json.Unmarshal(raw, errResp)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/mythicbeasts/internal/identity_test.go b/providers/dns/mythicbeasts/internal/identity_test.go
index 9d8daf827..3e1e8ba4f 100644
--- a/providers/dns/mythicbeasts/internal/identity_test.go
+++ b/providers/dns/mythicbeasts/internal/identity_test.go
@@ -2,80 +2,72 @@ package internal
import (
"context"
- "encoding/json"
- "fmt"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func mockContext() context.Context {
- return context.WithValue(context.Background(), tokenKey, &Token{Token: "xxx"})
+const fakeToken = "xxx"
+
+func mockContext(t *testing.T) context.Context {
+ t.Helper()
+
+ return context.WithValue(t.Context(), tokenKey, &Token{Token: fakeToken})
}
-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
- }
+func mockBuilderIdentity() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.AuthEndpoint, _ = url.Parse(server.URL)
- username, password, ok := req.BasicAuth()
- if !ok || username != "user" || password != "secret" {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- _ = json.NewEncoder(rw).Encode(Token{
- Token: "xxx",
- Lifetime: 666,
- TokenType: "bearer",
- })
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithBasicAuth("user", "secret"),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
}
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)
+ client := mockBuilderIdentity().
+ Route("POST /",
+ servermock.ResponseFromFixture("token.json"),
+ servermock.CheckForm().Strict().
+ With("grant_type", "client_credentials")).
+ Build(t)
assert.Nil(t, client.token)
- tok, err := client.obtainToken(context.Background())
+ tok, err := client.obtainToken(t.Context())
require.NoError(t, err)
assert.NotNil(t, tok)
assert.NotZero(t, tok.Deadline)
- assert.Equal(t, "xxx", tok.Token)
+ assert.Equal(t, fakeToken, tok.Token)
}
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)
+ client := mockBuilderIdentity().
+ Route("POST /",
+ servermock.ResponseFromFixture("token.json"),
+ servermock.CheckForm().Strict().
+ With("grant_type", "client_credentials")).
+ Build(t)
assert.Nil(t, client.token)
- ctx, err := client.CreateAuthenticatedContext(context.Background())
+ ctx, err := client.CreateAuthenticatedContext(t.Context())
require.NoError(t, err)
tok := getToken(ctx)
assert.NotNil(t, tok)
assert.NotZero(t, tok.Deadline)
- assert.Equal(t, "xxx", tok.Token)
+ assert.Equal(t, fakeToken, tok.Token)
}
diff --git a/providers/dns/mythicbeasts/mythicbeasts.go b/providers/dns/mythicbeasts/mythicbeasts.go
index ae8f72d33..e8f5081f7 100644
--- a/providers/dns/mythicbeasts/mythicbeasts.go
+++ b/providers/dns/mythicbeasts/mythicbeasts.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/mythicbeasts/internal"
)
@@ -87,6 +88,7 @@ func NewDNSProvider() (*DNSProvider, error) {
if err != nil {
return nil, fmt.Errorf("mythicbeasts: %w", err)
}
+
config.UserName = values[EnvUserName]
config.Password = values[EnvPassword]
@@ -117,6 +119,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/mythicbeasts/mythicbeasts.toml b/providers/dns/mythicbeasts/mythicbeasts.toml
index 86d69d017..cada3041d 100644
--- a/providers/dns/mythicbeasts/mythicbeasts.toml
+++ b/providers/dns/mythicbeasts/mythicbeasts.toml
@@ -7,7 +7,7 @@ Since = "v0.3.7"
Example = '''
MYTHICBEASTS_USERNAME=myuser \
MYTHICBEASTS_PASSWORD=mypass \
-lego --email you@example.com --dns mythicbeasts -d '*.example.com' -d example.com run
+lego --dns mythicbeasts -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -23,10 +23,10 @@ Your API key name is not needed to operate lego.
[Configuration.Additional]
MYTHICBEASTS_API_ENDPOINT = "The endpoint for the API (must implement v2)"
MYTHICBEASTS_AUTH_API_ENDPOINT = "The endpoint for Mythic Beasts' Authentication"
- MYTHICBEASTS_POLLING_INTERVAL = "Time between DNS propagation check"
- MYTHICBEASTS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- MYTHICBEASTS_TTL = "The TTL of the TXT record used for the DNS challenge"
- MYTHICBEASTS_HTTP_TIMEOUT = "API request timeout"
+ MYTHICBEASTS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ MYTHICBEASTS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ MYTHICBEASTS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ MYTHICBEASTS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://www.mythic-beasts.com/support/api/dnsv2"
diff --git a/providers/dns/mythicbeasts/mythicbeasts_test.go b/providers/dns/mythicbeasts/mythicbeasts_test.go
index 5a8a9d4bb..c684725b7 100644
--- a/providers/dns/mythicbeasts/mythicbeasts_test.go
+++ b/providers/dns/mythicbeasts/mythicbeasts_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -108,6 +109,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
config, err := NewDefaultConfig()
require.NoError(t, err)
+
config.UserName = test.username
config.Password = test.password
@@ -130,6 +132,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -143,6 +146,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/namecheap/internal/client.go b/providers/dns/namecheap/internal/client.go
index f7ca8f66f..6fb737b95 100644
--- a/providers/dns/namecheap/internal/client.go
+++ b/providers/dns/namecheap/internal/client.go
@@ -32,7 +32,7 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(apiUser string, apiKey string, clientIP string) *Client {
+func NewClient(apiUser, apiKey, clientIP string) *Client {
return &Client{
apiUser: apiUser,
apiKey: apiKey,
@@ -54,6 +54,7 @@ func (c *Client) GetHosts(ctx context.Context, sld, tld string) ([]Record, error
}
var ghr getHostsResponse
+
err = c.do(request, &ghr)
if err != nil {
return nil, err
@@ -88,6 +89,7 @@ func (c *Client) SetHosts(ctx context.Context, sld, tld string, hosts []Record)
}
var shr setHostsResponse
+
err = c.do(req, &shr)
if err != nil {
return err
@@ -96,6 +98,7 @@ func (c *Client) SetHosts(ctx context.Context, sld, tld string, hosts []Record)
if len(shr.Errors) > 0 {
return shr.Errors[0]
}
+
if shr.Result.IsSuccess != "true" {
return errors.New("setHosts failed")
}
diff --git a/providers/dns/namecheap/internal/client_test.go b/providers/dns/namecheap/internal/client_test.go
index 9d78ee213..d7bea7b6e 100644
--- a/providers/dns/namecheap/internal/client_test.go
+++ b/providers/dns/namecheap/internal/client_test.go
@@ -1,75 +1,38 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, handler http.HandlerFunc) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/", handler)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("user", "secret", "127.0.0.1")
client.HTTPClient = server.Client()
client.BaseURL = server.URL
- return client
-}
-
-func writeFixture(rw http.ResponseWriter, filename string) {
- 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)
+ return client, nil
}
func TestClient_GetHosts(t *testing.T) {
- client := setupTest(t, 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
- }
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /",
+ servermock.ResponseFromFixture("getHosts.xml"),
+ servermock.CheckQueryParameter().Strict().
+ With("ApiKey", "secret").
+ With("ApiUser", "user").
+ With("ClientIp", "127.0.0.1").
+ With("Command", "namecheap.domains.dns.getHosts").
+ With("SLD", "foo").
+ With("TLD", "example.com").
+ With("UserName", "user"),
+ ).
+ Build(t)
- expectedParams := map[string]string{
- "ApiKey": "secret",
- "ApiUser": "user",
- "ClientIp": "127.0.0.1",
- "Command": "namecheap.domains.dns.getHosts",
- "SLD": "foo",
- "TLD": "example.com",
- "UserName": "user",
- }
-
- query := req.URL.Query()
- for k, v := range expectedParams {
- if query.Get(k) != v {
- http.Error(rw, fmt.Sprintf("invalid query parameter %s value: %s", k, query.Get(k)), http.StatusBadRequest)
- return
- }
- }
-
- writeFixture(rw, "getHosts.xml")
- })
-
- hosts, err := client.GetHosts(context.Background(), "foo", "example.com")
+ hosts, err := client.GetHosts(t.Context(), "foo", "example.com")
require.NoError(t, err)
expected := []Record{
@@ -81,93 +44,62 @@ func TestClient_GetHosts(t *testing.T) {
}
func TestClient_GetHosts_error(t *testing.T) {
- client := setupTest(t, 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
- }
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /",
+ servermock.ResponseFromFixture("getHosts_errorBadAPIKey1.xml")).
+ Build(t)
- writeFixture(rw, "getHosts_errorBadAPIKey1.xml")
- })
-
- _, err := client.GetHosts(context.Background(), "foo", "example.com")
+ _, err := client.GetHosts(t.Context(), "foo", "example.com")
require.ErrorAs(t, err, &apiError{})
}
func TestClient_SetHosts(t *testing.T) {
- client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
- http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest)
- return
- }
-
- err := req.ParseForm()
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- expectedParams := map[string]string{
- "HostName1": "_acme-challenge.test.example.com",
- "RecordType1": "TXT",
- "Address1": "txtTXTtxt",
- "MXPref1": "10",
- "TTL1": "120",
-
- "HostName2": "_acme-challenge.test.example.org",
- "RecordType2": "TXT",
- "Address2": "txtTXTtxt",
- "MXPref2": "10",
- "TTL2": "120",
-
- "ApiKey": "secret",
- "ApiUser": "user",
- "ClientIp": "127.0.0.1",
- "Command": "namecheap.domains.dns.setHosts",
- "SLD": "foo",
- "TLD": "example.com",
- "UserName": "user",
- }
-
- for k, v := range expectedParams {
- if req.Form.Get(k) != v {
- http.Error(rw, fmt.Sprintf("invalid form data %s value: %q", k, req.Form.Get(k)), http.StatusBadRequest)
- return
- }
- }
-
- writeFixture(rw, "setHosts.xml")
- })
+ client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()).
+ Route("POST /",
+ servermock.ResponseFromFixture("setHosts.xml"),
+ servermock.CheckForm().Strict().
+ With("ApiKey", "secret").
+ With("ApiUser", "user").
+ With("ClientIp", "127.0.0.1").
+ With("Command", "namecheap.domains.dns.setHosts").
+ With("SLD", "foo").
+ With("TLD", "example.com").
+ With("UserName", "user").
+ // entry 1
+ With("HostName1", "_acme-challenge.test.example.com").
+ With("RecordType1", "TXT").
+ With("Address1", "txtTXTtxt").
+ With("MXPref1", "10").
+ With("TTL1", "120").
+ // entry 2
+ With("HostName2", "_acme-challenge.test.example.org").
+ With("RecordType2", "TXT").
+ With("Address2", "txtTXTtxt").
+ With("MXPref2", "10").
+ With("TTL2", "120"),
+ ).
+ Build(t)
records := []Record{
{Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
{Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
}
- err := client.SetHosts(context.Background(), "foo", "example.com", records)
+ err := client.SetHosts(t.Context(), "foo", "example.com", records)
require.NoError(t, err)
}
func TestClient_SetHosts_error(t *testing.T) {
- client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- writeFixture(rw, "setHosts_errorBadAPIKey1.xml")
- })
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /",
+ servermock.ResponseFromFixture("setHosts_errorBadAPIKey1.xml")).
+ Build(t)
records := []Record{
{Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
{Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
}
- err := client.SetHosts(context.Background(), "foo", "example.com", records)
+ err := client.SetHosts(t.Context(), "foo", "example.com", records)
require.ErrorAs(t, err, &apiError{})
}
diff --git a/providers/dns/namecheap/namecheap.go b/providers/dns/namecheap/namecheap.go
index f410fa5a3..54640f8e0 100644
--- a/providers/dns/namecheap/namecheap.go
+++ b/providers/dns/namecheap/namecheap.go
@@ -14,6 +14,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/namecheap/internal"
"golang.org/x/net/publicsuffix"
)
@@ -72,10 +73,11 @@ func NewDefaultConfig() *Config {
BaseURL: baseURL,
Debug: env.GetOrDefaultBool(EnvDebug, false),
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Minute),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Hour),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second),
HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
+ Transport: defaultTransport(envNamespace),
},
}
}
@@ -117,6 +119,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if err != nil {
return nil, fmt.Errorf("namecheap: %w", err)
}
+
config.ClientIP = clientIP
}
@@ -127,6 +130,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -171,6 +176,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("namecheap: %w", err)
}
+
return nil
}
@@ -190,8 +196,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
// Find the challenge TXT record and remove it if found.
- var found bool
- var newRecords []internal.Record
+ var (
+ found bool
+ newRecords []internal.Record
+ )
+
for _, h := range records {
if h.Name == pr.key && h.Type == "TXT" {
found = true
@@ -208,6 +217,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("namecheap: %w", err)
}
+
return nil
}
diff --git a/providers/dns/namecheap/namecheap.toml b/providers/dns/namecheap/namecheap.toml
index ef2ef53c4..b0f92a1bd 100644
--- a/providers/dns/namecheap/namecheap.toml
+++ b/providers/dns/namecheap/namecheap.toml
@@ -14,7 +14,7 @@ More information in the section [Enabling API Access](https://www.namecheap.com/
Example = '''
NAMECHEAP_API_USER=user \
NAMECHEAP_API_KEY=key \
-lego --email you@example.com --dns namecheap -d '*.example.com' -d example.com run
+lego --dns namecheap -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -22,10 +22,10 @@ lego --email you@example.com --dns namecheap -d '*.example.com' -d example.com r
NAMECHEAP_API_USER = "API user"
NAMECHEAP_API_KEY = "API key"
[Configuration.Additional]
- NAMECHEAP_POLLING_INTERVAL = "Time between DNS propagation check"
- NAMECHEAP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NAMECHEAP_TTL = "The TTL of the TXT record used for the DNS challenge"
- NAMECHEAP_HTTP_TIMEOUT = "API request timeout"
+ NAMECHEAP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)"
+ NAMECHEAP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 3600)"
+ NAMECHEAP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ NAMECHEAP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
NAMECHEAP_SANDBOX = "Activate the sandbox (boolean)"
[Links]
diff --git a/providers/dns/namecheap/namecheap_test.go b/providers/dns/namecheap/namecheap_test.go
index 01f87aaf0..e55a4a6bc 100644
--- a/providers/dns/namecheap/namecheap_test.go
+++ b/providers/dns/namecheap/namecheap_test.go
@@ -1,16 +1,10 @@
package namecheap
import (
- "io"
- "net/http"
"net/http/httptest"
- "net/url"
- "os"
- "path/filepath"
"testing"
- "time"
- "github.com/go-acme/lego/v4/providers/dns/namecheap/internal"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -24,7 +18,6 @@ const (
type testCase struct {
name string
domain string
- hosts []internal.Record
errString string
getHostsResponse string
setHostsResponse string
@@ -32,26 +25,14 @@ type testCase struct {
var testCases = []testCase{
{
- name: "Test:Success:1",
- domain: "test.example.com",
- hosts: []internal.Record{
- {Type: "A", Name: "home", Address: "10.0.0.1", MXPref: "10", TTL: "1799"},
- {Type: "A", Name: "www", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
- {Type: "AAAA", Name: "a", Address: "::0", MXPref: "10", TTL: "1799"},
- {Type: "CNAME", Name: "*", Address: "example.com.", MXPref: "10", TTL: "1799"},
- {Type: "MXE", Name: "example.com", Address: "10.0.0.5", MXPref: "10", TTL: "1800"},
- {Type: "URL", Name: "xyz", Address: "https://google.com", MXPref: "10", TTL: "1799"},
- },
+ name: "Test:Success:1",
+ domain: "test.example.com",
getHostsResponse: "getHosts_success1.xml",
setHostsResponse: "setHosts_success1.xml",
},
{
- name: "Test:Success:2",
- domain: "example.com",
- hosts: []internal.Record{
- {Type: "A", Name: "@", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
- {Type: "A", Name: "www", Address: "10.0.0.3", MXPref: "10", TTL: "60"},
- },
+ name: "Test:Success:2",
+ domain: "example.com",
getHostsResponse: "getHosts_success2.xml",
setHostsResponse: "setHosts_success2.xml",
},
@@ -63,96 +44,37 @@ var testCases = []testCase{
},
}
-func setupTest(t *testing.T, tc *testCase) *DNSProvider {
- t.Helper()
-
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- values := r.URL.Query()
- cmd := values.Get("Command")
- switch cmd {
- case "namecheap.domains.dns.getHosts":
- assertHdr(t, tc, &values)
- w.WriteHeader(http.StatusOK)
- writeFixture(w, tc.getHostsResponse)
- default:
- t.Errorf("Unexpected GET command: %s", cmd)
- }
-
- case http.MethodPost:
- err := r.ParseForm()
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- values := r.Form
- cmd := values.Get("Command")
- switch cmd {
- case "namecheap.domains.dns.setHosts":
- assertHdr(t, tc, &values)
- w.WriteHeader(http.StatusOK)
- writeFixture(w, tc.setHostsResponse)
- default:
- t.Errorf("Unexpected POST command: %s", cmd)
- }
-
- default:
- t.Errorf("Unexpected http method: %s", r.Method)
- }
- })
-
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- return mockDNSProvider(t, server.URL)
-}
-
-func mockDNSProvider(t *testing.T, baseURL string) *DNSProvider {
- t.Helper()
-
- config := NewDefaultConfig()
- config.BaseURL = baseURL
- config.APIUser = envTestUser
- config.APIKey = envTestKey
- config.ClientIP = envTestClientIP
- config.HTTPClient = &http.Client{Timeout: 60 * time.Second}
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- return provider
-}
-
-func assertHdr(t *testing.T, tc *testCase, values *url.Values) {
- t.Helper()
-
- ch, _ := newPseudoRecord(tc.domain, "")
- assert.Equal(t, envTestUser, values.Get("ApiUser"), "ApiUser")
- assert.Equal(t, envTestKey, values.Get("ApiKey"), "ApiKey")
- assert.Equal(t, envTestUser, values.Get("UserName"), "UserName")
- assert.Equal(t, envTestClientIP, values.Get("ClientIp"), "ClientIp")
- assert.Equal(t, ch.sld, values.Get("SLD"), "SLD")
- assert.Equal(t, ch.tld, values.Get("TLD"), "TLD")
-}
-
-func writeFixture(rw http.ResponseWriter, filename string) {
- file, err := os.Open(filepath.Join("internal", "fixtures", filename))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- defer func() { _ = file.Close() }()
-
- _, _ = io.Copy(rw, file)
-}
-
func TestDNSProvider_Present(t *testing.T) {
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
- p := setupTest(t, &test)
+ ch, _ := newPseudoRecord(test.domain, "")
- err := p.Present(test.domain, "", "dummyKey")
+ provider := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromInternal(test.getHostsResponse),
+ servermock.CheckForm().Strict().
+ With("ClientIp", "10.0.0.1").
+ With("Command", "namecheap.domains.dns.getHosts").
+ With("SLD", ch.sld).
+ With("TLD", ch.tld).
+ With("UserName", "foo").
+ With("ApiKey", "bar").
+ With("ApiUser", "foo"),
+ ).
+ Route("POST /",
+ servermock.ResponseFromInternal(test.setHostsResponse),
+ servermock.CheckForm().
+ With("ClientIp", "10.0.0.1").
+ With("Command", "namecheap.domains.dns.setHosts").
+ With("SLD", ch.sld).
+ With("TLD", ch.tld).
+ With("UserName", "foo").
+ With("ApiKey", "bar").
+ With("ApiUser", "foo"),
+ ).
+ Build(t)
+
+ err := provider.Present(test.domain, "", "dummyKey")
if test.errString != "" {
assert.EqualError(t, err, "namecheap: "+test.errString)
} else {
@@ -165,9 +87,34 @@ func TestDNSProvider_Present(t *testing.T) {
func TestDNSProvider_CleanUp(t *testing.T) {
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
- p := setupTest(t, &test)
+ ch, _ := newPseudoRecord(test.domain, "")
- err := p.CleanUp(test.domain, "", "dummyKey")
+ provider := mockBuilder().
+ Route("GET /",
+ servermock.ResponseFromInternal(test.getHostsResponse),
+ servermock.CheckForm().Strict().
+ With("ClientIp", "10.0.0.1").
+ With("Command", "namecheap.domains.dns.getHosts").
+ With("SLD", ch.sld).
+ With("TLD", ch.tld).
+ With("UserName", "foo").
+ With("ApiKey", "bar").
+ With("ApiUser", "foo"),
+ ).
+ Route("POST /",
+ servermock.ResponseFromInternal(test.setHostsResponse),
+ servermock.CheckForm().
+ With("ClientIp", "10.0.0.1").
+ With("Command", "namecheap.domains.dns.setHosts").
+ With("SLD", ch.sld).
+ With("TLD", ch.tld).
+ With("UserName", "foo").
+ With("ApiKey", "bar").
+ With("ApiUser", "foo"),
+ ).
+ Build(t)
+
+ err := provider.CleanUp(test.domain, "", "dummyKey")
if test.errString != "" {
assert.EqualError(t, err, "namecheap: "+test.errString)
} else {
@@ -205,6 +152,7 @@ func Test_newPseudoRecord_domainSplit(t *testing.T) {
for _, test := range tests {
t.Run(test.domain, func(t *testing.T) {
valid := true
+
ch, err := newPseudoRecord(test.domain, "")
if err != nil {
valid = false
@@ -226,3 +174,16 @@ func Test_newPseudoRecord_domainSplit(t *testing.T) {
})
}
}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.BaseURL = server.URL
+ config.APIUser = envTestUser
+ config.APIKey = envTestKey
+ config.ClientIP = envTestClientIP
+
+ return NewDNSProviderConfig(config)
+ })
+}
diff --git a/providers/dns/namecheap/transport.go b/providers/dns/namecheap/transport.go
new file mode 100644
index 000000000..584dc6e50
--- /dev/null
+++ b/providers/dns/namecheap/transport.go
@@ -0,0 +1,71 @@
+package namecheap
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "golang.org/x/net/http/httpproxy"
+)
+
+const (
+ envHTTPProxy = "HTTP_PROXY"
+ envHTTPProxyLower = "http_proxy"
+ envHTTPSProxy = "HTTPS_PROXY"
+ envHTTPSProxyLower = "https_proxy"
+ envNoProxy = "NO_PROXY"
+ envNoProxyLower = "no_proxy"
+ envRequestMethod = "REQUEST_METHOD"
+)
+
+// Allows lazy loading of the proxy.
+var (
+ envProxyOnce sync.Once
+ envProxyFuncValue func(*url.URL) (*url.URL, error)
+)
+
+func defaultTransport(namespace string) http.RoundTripper {
+ tr, ok := http.DefaultTransport.(*http.Transport)
+ if !ok {
+ return nil
+ }
+
+ clone := tr.Clone()
+ clone.Proxy = proxyFromEnvironment(namespace)
+
+ return clone
+}
+
+// Inspired by:
+// - https://pkg.go.dev/net/http#ProxyFromEnvironment
+// - https://pkg.go.dev/golang.org/x/net/http/httpproxy#FromEnvironment
+func envProxyFunc(namespace string) func(*url.URL) (*url.URL, error) {
+ envProxyOnce.Do(func() {
+ cfg := &httpproxy.Config{
+ HTTPProxy: getEnv(namespace, envHTTPProxy, envHTTPProxyLower),
+ HTTPSProxy: getEnv(namespace, envHTTPSProxy, envHTTPSProxyLower),
+ NoProxy: getEnv(namespace, envNoProxy, envNoProxyLower),
+ CGI: env.GetOneWithFallback(namespace+envRequestMethod, "", env.ParseString, envRequestMethod) != "",
+ }
+
+ envProxyFuncValue = cfg.ProxyFunc()
+ })
+
+ return envProxyFuncValue
+}
+
+// Inspired by:
+// - https://pkg.go.dev/net/http#ProxyFromEnvironment
+// - https://pkg.go.dev/golang.org/x/net/http/httpproxy#FromEnvironment
+func proxyFromEnvironment(namespace string) func(req *http.Request) (*url.URL, error) {
+ return func(req *http.Request) (*url.URL, error) {
+ return envProxyFunc(namespace)(req.URL)
+ }
+}
+
+func getEnv(namespace, baseEnvName, baseEnvNameLower string) string {
+ return env.GetOneWithFallback(namespace+baseEnvName, "", env.ParseString,
+ strings.ToLower(namespace)+baseEnvNameLower, baseEnvName, baseEnvNameLower)
+}
diff --git a/providers/dns/namecheap/transport_test.go b/providers/dns/namecheap/transport_test.go
new file mode 100644
index 000000000..cd3e9ff17
--- /dev/null
+++ b/providers/dns/namecheap/transport_test.go
@@ -0,0 +1,39 @@
+package namecheap
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_defaultTransport(t *testing.T) {
+ client := servermock.NewBuilder(
+ func(server *httptest.Server) (*http.Client, error) {
+ cl := server.Client()
+
+ t.Setenv("NAMECHEAP_HTTP_PROXY", server.URL)
+
+ cl.Transport = defaultTransport(envNamespace)
+
+ return cl, nil
+ }).
+ Route("/",
+ servermock.Noop().WithStatusCode(http.StatusTeapot)).
+ Build(t)
+
+ req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
+ require.NoError(t, err)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ _ = resp.Body.Close()
+ })
+
+ assert.Equal(t, http.StatusTeapot, resp.StatusCode)
+}
diff --git a/providers/dns/namedotcom/namedotcom.go b/providers/dns/namedotcom/namedotcom.go
index 5b2bbaf21..04c8b5967 100644
--- a/providers/dns/namedotcom/namedotcom.go
+++ b/providers/dns/namedotcom/namedotcom.go
@@ -10,7 +10,8 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/namedotcom/go/namecom"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/namedotcom/go/v4/namecom"
)
// Environment variables names.
@@ -97,7 +98,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
client := namecom.New(config.Username, config.APIToken)
- client.Client = config.HTTPClient
+
+ if config.HTTPClient != nil {
+ client.Client = config.HTTPClient
+ }
+
+ client.Client = clientdebug.Wrap(client.Client)
if config.Server != "" {
client.Server = config.Server
@@ -110,7 +116,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- // TODO(ldez) replace domain by FQDN to follow CNAME.
+ if info.EffectiveFQDN != info.FQDN {
+ domain = dns01.UnFqdn(info.EffectiveFQDN)
+ }
+
domainDetails, err := d.client.GetDomain(&namecom.GetDomainRequest{DomainName: domain})
if err != nil {
return fmt.Errorf("namedotcom: API call failed: %w", err)
@@ -121,7 +130,6 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("namedotcom: %w", err)
}
- // TODO(ldez) replace domain by FQDN to follow CNAME.
request := &namecom.Record{
DomainName: domain,
Host: subDomain,
@@ -142,7 +150,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- // TODO(ldez) replace domain by FQDN to follow CNAME.
+ if info.EffectiveFQDN != info.FQDN {
+ domain = dns01.UnFqdn(info.EffectiveFQDN)
+ }
+
records, err := d.getRecords(domain)
if err != nil {
return fmt.Errorf("namedotcom: %w", err)
@@ -150,11 +161,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
for _, rec := range records {
if rec.Fqdn == info.EffectiveFQDN && rec.Type == "TXT" {
- // TODO(ldez) replace domain by FQDN to follow CNAME.
request := &namecom.DeleteRecordRequest{
DomainName: domain,
ID: rec.ID,
}
+
_, err := d.client.DeleteRecord(request)
if err != nil {
return fmt.Errorf("namedotcom: %w", err)
@@ -178,6 +189,7 @@ func (d *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) {
}
var records []*namecom.Record
+
for request.Page > 0 {
response, err := d.client.ListRecords(request)
if err != nil {
diff --git a/providers/dns/namedotcom/namedotcom.toml b/providers/dns/namedotcom/namedotcom.toml
index 768164cf8..3651c424b 100644
--- a/providers/dns/namedotcom/namedotcom.toml
+++ b/providers/dns/namedotcom/namedotcom.toml
@@ -7,7 +7,7 @@ Since = "v0.5.0"
Example = '''
NAMECOM_USERNAME=foo.bar \
NAMECOM_API_TOKEN=a379a6f6eeafb9a55e378c118034e2751e682fab \
-lego --email you@example.com --dns namedotcom -d '*.example.com' -d example.com run
+lego --dns namedotcom -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns namedotcom -d '*.example.com' -d example.com
NAMECOM_USERNAME = "Username"
NAMECOM_API_TOKEN = "API token"
[Configuration.Additional]
- NAMECOM_POLLING_INTERVAL = "Time between DNS propagation check"
- NAMECOM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NAMECOM_TTL = "The TTL of the TXT record used for the DNS challenge"
- NAMECOM_HTTP_TIMEOUT = "API request timeout"
+ NAMECOM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)"
+ NAMECOM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)"
+ NAMECOM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ NAMECOM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://www.name.com/api-docs/DNS"
diff --git a/providers/dns/namedotcom/namedotcom_test.go b/providers/dns/namedotcom/namedotcom_test.go
index c7d4deaa1..da9878bdc 100644
--- a/providers/dns/namedotcom/namedotcom_test.go
+++ b/providers/dns/namedotcom/namedotcom_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -131,6 +132,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -144,6 +146,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/namesilo/namesilo.go b/providers/dns/namesilo/namesilo.go
index f76c8549e..0297b4e1c 100644
--- a/providers/dns/namesilo/namesilo.go
+++ b/providers/dns/namesilo/namesilo.go
@@ -2,6 +2,7 @@
package namesilo
import (
+ "context"
"errors"
"fmt"
"time"
@@ -9,6 +10,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/nrdcg/namesilo"
)
@@ -79,12 +81,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("namesilo: TTL should be in [%d, %d]", defaultTTL, maxTTL)
}
- transport, err := namesilo.NewTokenTransport(config.APIKey)
- if err != nil {
- return nil, fmt.Errorf("namesilo: %w", err)
+ if config.APIKey == "" {
+ return nil, errors.New("namesilo: credentials missing")
}
- return &DNSProvider{client: namesilo.NewClient(transport.Client()), config: config}, nil
+ client := namesilo.NewClient(config.APIKey)
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{client: client, config: config}, nil
}
// Present creates a TXT record to fulfill the dns-01 challenge.
@@ -103,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("namesilo: %w", err)
}
- _, err = d.client.DnsAddRecord(&namesilo.DnsAddRecordParams{
+ _, err = d.client.DnsAddRecord(context.Background(), &namesilo.DnsAddRecordParams{
Domain: zoneName,
Type: "TXT",
Host: subdomain,
@@ -113,11 +118,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("namesilo: failed to add record %w", 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)
zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -127,7 +135,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
zoneName := dns01.UnFqdn(zone)
- resp, err := d.client.DnsListRecords(&namesilo.DnsListRecordsParams{Domain: zoneName})
+ resp, err := d.client.DnsListRecords(ctx, &namesilo.DnsListRecordsParams{Domain: zoneName})
if err != nil {
return fmt.Errorf("namesilo: %w", err)
}
@@ -139,7 +147,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
for _, r := range resp.Reply.ResourceRecord {
if r.Type == "TXT" && r.Value == info.Value && (r.Host == subdomain || r.Host == dns01.UnFqdn(info.EffectiveFQDN)) {
- _, err := d.client.DnsDeleteRecord(&namesilo.DnsDeleteRecordParams{Domain: zoneName, ID: r.RecordID})
+ _, err := d.client.DnsDeleteRecord(ctx, &namesilo.DnsDeleteRecordParams{Domain: zoneName, ID: r.RecordID})
if err != nil {
return fmt.Errorf("namesilo: %w", err)
}
diff --git a/providers/dns/namesilo/namesilo.toml b/providers/dns/namesilo/namesilo.toml
index 991e78fcc..113ddb5c5 100644
--- a/providers/dns/namesilo/namesilo.toml
+++ b/providers/dns/namesilo/namesilo.toml
@@ -6,16 +6,16 @@ Since = "v2.7.0"
Example = '''
NAMESILO_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \
-lego --email you@example.com --dns namesilo -d '*.example.com' -d example.com run
+lego --dns namesilo -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
NAMESILO_API_KEY = "Client ID"
[Configuration.Additional]
- NAMESILO_POLLING_INTERVAL = "Time between DNS propagation check"
- NAMESILO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation, it is better to set larger than 15m"
- NAMESILO_TTL = "The TTL of the TXT record used for the DNS challenge, should be in [3600, 2592000]"
+ NAMESILO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NAMESILO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes"
+ NAMESILO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000]"
[Links]
API = "https://www.namesilo.com/api_reference.php"
diff --git a/providers/dns/namesilo/namesilo_test.go b/providers/dns/namesilo/namesilo_test.go
index 4b01d7388..09eacd035 100644
--- a/providers/dns/namesilo/namesilo_test.go
+++ b/providers/dns/namesilo/namesilo_test.go
@@ -45,6 +45,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -77,7 +78,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
{
desc: "missing API key",
ttl: defaultTTL,
- expected: "namesilo: credentials missing: API key",
+ expected: "namesilo: credentials missing",
},
{
desc: "unavailable TTL",
@@ -112,6 +113,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/namesurfer/internal/client.go b/providers/dns/namesurfer/internal/client.go
new file mode 100644
index 000000000..e40a7988c
--- /dev/null
+++ b/providers/dns/namesurfer/internal/client.go
@@ -0,0 +1,226 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+type Client struct {
+ apiKey string
+ apiSecret string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+func NewClient(baseURL, apiKey, apiSecret string) (*Client, error) {
+ if apiKey == "" || apiSecret == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ if baseURL == "" {
+ return nil, errors.New("base URL missing")
+ }
+
+ apiEndpoint, err := url.Parse(baseURL)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{
+ apiKey: apiKey,
+ apiSecret: apiSecret,
+ BaseURL: apiEndpoint.JoinPath("jsonrpc10"),
+ HTTPClient: &http.Client{
+ Timeout: 5 * time.Second,
+ },
+ }, nil
+}
+
+// AddDNSRecord adds a DNS record.
+// http://95.128.3.201:8053/API/NSService_10#addDNSRecord
+func (d *Client) AddDNSRecord(ctx context.Context, zoneName, viewName string, record DNSNode) error {
+ digest := d.computeDigest(
+ zoneName,
+ viewName,
+ record.Name,
+ record.Type,
+ strconv.Itoa(record.TTL),
+ record.Data,
+ )
+
+ // JSON-RPC 1.0 requires positional parameters array
+ params := []any{
+ digest,
+ zoneName,
+ viewName,
+ record,
+ }
+
+ var ok bool
+
+ err := d.doRequest(ctx, "addDNSRecord", params, &ok)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ return errors.New("addDNSRecord failed")
+ }
+
+ return nil
+}
+
+// UpdateDNSHost updates a DNS host record.
+// Passing an empty newNode removes the oldNode.
+// http://95.128.3.201:8053/API/NSService_10#updateDNSHost
+func (d *Client) UpdateDNSHost(ctx context.Context, zoneName, viewName string, oldNode, newNode DNSNode) error {
+ digest := d.computeDigest(zoneName, viewName)
+
+ // JSON-RPC 1.0 requires positional parameters array
+ params := []any{
+ digest,
+ zoneName,
+ viewName,
+ oldNode,
+ newNode,
+ }
+
+ var ok bool
+
+ err := d.doRequest(ctx, "updateDNSHost", params, &ok)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ return errors.New("updateDNSHost failed")
+ }
+
+ return nil
+}
+
+// SearchDNSHosts searches for DNS host records.
+// http://95.128.3.201:8053/API/NSService_10#searchDNSHosts
+func (d *Client) SearchDNSHosts(ctx context.Context, pattern string) ([]DNSNode, error) {
+ digest := d.computeDigest(pattern)
+
+ // JSON-RPC 1.0 requires positional parameters array
+ params := []any{
+ digest,
+ pattern,
+ }
+
+ var nodes []DNSNode
+
+ err := d.doRequest(ctx, "searchDNSHosts", params, &nodes)
+ if err != nil {
+ return nil, err
+ }
+
+ return nodes, nil
+}
+
+// ListZones lists DNS zones.
+// http://95.128.3.201:8053/API/NSService_10#listZones
+func (d *Client) ListZones(ctx context.Context, mode string) ([]DNSZone, error) {
+ digest := d.computeDigest()
+
+ // JSON-RPC 1.0 requires positional parameters array
+ params := []any{
+ digest,
+ mode,
+ }
+
+ var zones []DNSZone
+
+ err := d.doRequest(ctx, "listZones", params, &zones)
+ if err != nil {
+ return nil, err
+ }
+
+ return zones, nil
+}
+
+func (d *Client) doRequest(ctx context.Context, method string, params []any, result any) error {
+ payload := APIRequest{
+ ID: 1,
+ Method: method,
+ Params: slices.Concat([]any{d.apiKey}, params),
+ }
+
+ buf := new(bytes.Buffer)
+
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.BaseURL.String(), buf)
+ if err != nil {
+ return fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := d.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ if resp.StatusCode/100 != 2 {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ var rpcResp APIResponse
+
+ err = json.Unmarshal(raw, &rpcResp)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ if rpcResp.Error != nil {
+ return rpcResp.Error
+ }
+
+ err = json.Unmarshal(rpcResp.Result, result)
+ if err != nil {
+ return fmt.Errorf("unable to unmarshal response: %w: %s", err, rpcResp.Result)
+ }
+
+ return nil
+}
+
+func (d *Client) computeDigest(parts ...string) string {
+ params := []string{d.apiKey}
+ params = append(params, parts...)
+ params = append(params, d.apiSecret)
+
+ mac := hmac.New(sha256.New, []byte(d.apiSecret))
+ mac.Write([]byte(strings.Join(params, "&")))
+
+ return hex.EncodeToString(mac.Sum(nil))
+}
diff --git a/providers/dns/namesurfer/internal/client_test.go b/providers/dns/namesurfer/internal/client_test.go
new file mode 100644
index 000000000..9e8f917bc
--- /dev/null
+++ b/providers/dns/namesurfer/internal/client_test.go
@@ -0,0 +1,158 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestClient_AddDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /jsonrpc10",
+ servermock.ResponseFromFixture("addDNSRecord.json"),
+ servermock.CheckRequestJSONBodyFromFixture("addDNSRecord-request.json"),
+ ).
+ Build(t)
+
+ record := DNSNode{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 300,
+ }
+
+ err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record)
+ require.NoError(t, err)
+}
+
+func TestClient_AddDNSRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /jsonrpc10",
+ servermock.ResponseFromFixture("error.json"),
+ ).
+ Build(t)
+
+ record := DNSNode{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 300,
+ }
+
+ err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record)
+ require.EqualError(t, err, "code: Server.Keyfailure, "+
+ "filename: service, line: 13, "+
+ "message: Unknown keyname user, "+
+ `detail: Traceback (most recent call last): File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 159, in dispatch_request result = self.call_method(method,req_dict,tc,export_dict,log_line) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 96, in call_method result = getattr(service_class_instance,req_dict['methodname'])(*args) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py", line 77, in injector res = f(*args,**kw) File "/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py", line 502, in addDNSRecord key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data]) File "/usr/local/namesurfer/webui2/webui/service/base/implementation.py", line 63, in validate_key raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname) ApiFault: service(13): Unknown keyname user `)
+}
+
+func TestClient_UpdateDNSHost(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /jsonrpc10",
+ servermock.ResponseFromFixture("updateDNSHost.json"),
+ servermock.CheckRequestJSONBodyFromFixture("updateDNSHost-request.json"),
+ ).
+ Build(t)
+
+ record := DNSNode{
+ Name: "_acme-challenge",
+ Type: "TXT",
+ Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: 300,
+ }
+
+ err := client.UpdateDNSHost(t.Context(), "example.com", "viewA", record, DNSNode{})
+ require.NoError(t, err)
+}
+
+func TestClient_SearchDNSHosts(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /jsonrpc10",
+ servermock.ResponseFromFixture("searchDNSHosts.json"),
+ servermock.CheckRequestJSONBodyFromFixture("searchDNSHosts-request.json"),
+ ).
+ Build(t)
+
+ records, err := client.SearchDNSHosts(t.Context(), "value")
+ require.NoError(t, err)
+
+ expected := []DNSNode{
+ {Name: "foo", Type: "TXT", Data: "xxx", TTL: 300},
+ {Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300},
+ {Name: "bar", Type: "A", Data: "yyy", TTL: 300},
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_ListZones(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /jsonrpc10",
+ servermock.ResponseFromFixture("listZones.json"),
+ servermock.CheckRequestJSONBodyFromFixture("listZones-request.json"),
+ ).
+ Build(t)
+
+ zones, err := client.ListZones(t.Context(), "value")
+ require.NoError(t, err)
+
+ expected := []DNSZone{
+ {Name: "example.com", View: "viewA"},
+ {Name: "example.org", View: "viewB"},
+ {Name: "example.net", View: "viewC"},
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_computeDigest(t *testing.T) {
+ client, err := NewClient("https://test.example.com", "testkey", "testsecret")
+ require.NoError(t, err)
+
+ testCases := []struct {
+ desc string
+ parts []string
+ expected string
+ }{
+ {
+ desc: "no parts",
+ parts: []string{},
+ expected: "99b5dcdc19bfc0ce2af3fe848f4bcb6f7beb352e9599e8ba50544d86de567282",
+ },
+ {
+ desc: "parts",
+ parts: []string{"zone.example.com", "default"},
+ expected: "94efef76383889b1ae620582a25d1c3aa9bd9ba9ac4bdccdf4aefbc3ae6e8329",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ t.Parallel()
+
+ digest := client.computeDigest(test.parts...)
+
+ assert.Equal(t, test.expected, digest)
+ })
+ }
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json
new file mode 100644
index 000000000..660109aae
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json
@@ -0,0 +1,16 @@
+{
+ "id": 1,
+ "method": "addDNSRecord",
+ "params": [
+ "user",
+ "4fcc5fa29531709b0381c8debea127a6a26e71cb9491727876819cf5805c4990",
+ "example.com",
+ "viewA",
+ {
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 300
+ }
+ ]
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json
new file mode 100644
index 000000000..f41779e30
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json
@@ -0,0 +1,4 @@
+{
+ "id": 1,
+ "result": true
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/error.json b/providers/dns/namesurfer/internal/fixtures/error.json
new file mode 100644
index 000000000..8ddf8df25
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/error.json
@@ -0,0 +1,24 @@
+{
+ "result": null,
+ "error": {
+ "filename": "service",
+ "lineno": 13,
+ "code": "Server.Keyfailure",
+ "string": "Unknown keyname user",
+ "detail": [
+ "Traceback (most recent call last):",
+ " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 159, in dispatch_request",
+ " result = self.call_method(method,req_dict,tc,export_dict,log_line)",
+ " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 96, in call_method",
+ " result = getattr(service_class_instance,req_dict['methodname'])(*args)",
+ " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py\", line 77, in injector",
+ " res = f(*args,**kw)",
+ " File \"/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py\", line 502, in addDNSRecord",
+ " key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data])",
+ " File \"/usr/local/namesurfer/webui2/webui/service/base/implementation.py\", line 63, in validate_key",
+ " raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname)",
+ "ApiFault: service(13): Unknown keyname user",
+ ""
+ ]
+ }
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/listZones-request.json b/providers/dns/namesurfer/internal/fixtures/listZones-request.json
new file mode 100644
index 000000000..06689de7a
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/listZones-request.json
@@ -0,0 +1,9 @@
+{
+ "id": 1,
+ "method": "listZones",
+ "params": [
+ "user",
+ "2739461ea1a3dc51302993f724f40228409c53b78025d8d7b1d7bba3c1bf2d66",
+ "value"
+ ]
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/listZones.json b/providers/dns/namesurfer/internal/fixtures/listZones.json
new file mode 100644
index 000000000..37fa2053b
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/listZones.json
@@ -0,0 +1,17 @@
+{
+ "id": 1,
+ "result": [
+ {
+ "name": "example.com",
+ "view": "viewA"
+ },
+ {
+ "name": "example.org",
+ "view": "viewB"
+ },
+ {
+ "name": "example.net",
+ "view": "viewC"
+ }
+ ]
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json
new file mode 100644
index 000000000..4a88340e2
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json
@@ -0,0 +1,9 @@
+{
+ "id": 1,
+ "method": "searchDNSHosts",
+ "params": [
+ "user",
+ "02cf1a2f6e124507d16738d595f583932185313fc96afc2d8404960acaec29b4",
+ "value"
+ ]
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json
new file mode 100644
index 000000000..822459148
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json
@@ -0,0 +1,23 @@
+{
+ "id": 1,
+ "result": [
+ {
+ "name": "foo",
+ "type": "TXT",
+ "data": "xxx",
+ "ttl": 300
+ },
+ {
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 300
+ },
+ {
+ "name": "bar",
+ "type": "A",
+ "data": "yyy",
+ "ttl": 300
+ }
+ ]
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json
new file mode 100644
index 000000000..494de20c6
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json
@@ -0,0 +1,22 @@
+{
+ "id": 1,
+ "method": "updateDNSHost",
+ "params": [
+ "user",
+ "510e63288ac874c1d5ba313a9411591daa346e5621fb0153263adc278794e378",
+ "example.com",
+ "viewA",
+ {
+ "name": "_acme-challenge",
+ "type": "TXT",
+ "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "ttl": 300
+ },
+ {
+ "name": "",
+ "type": "",
+ "data": "",
+ "ttl": 0
+ }
+ ]
+}
diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json
new file mode 100644
index 000000000..f41779e30
--- /dev/null
+++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json
@@ -0,0 +1,4 @@
+{
+ "id": 1,
+ "result": true
+}
diff --git a/providers/dns/namesurfer/internal/types.go b/providers/dns/namesurfer/internal/types.go
new file mode 100644
index 000000000..d364c1876
--- /dev/null
+++ b/providers/dns/namesurfer/internal/types.go
@@ -0,0 +1,72 @@
+package internal
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// DNSNode represents a DNS record.
+// http://95.128.3.201:8053/API/NSService_10#DNSNode
+type DNSNode struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Data string `json:"data"`
+ TTL int `json:"ttl"`
+}
+
+// DNSZone represents a DNS zone.
+// http://95.128.3.201:8053/API/NSService_10#DNSZone
+type DNSZone struct {
+ Name string `json:"name,omitempty"`
+ View string `json:"view,omitempty"`
+}
+
+// APIRequest represents a JSON-RPC request.
+// https://www.jsonrpc.org/specification_v1#a1.1Requestmethodinvocation
+type APIRequest struct {
+ ID any `json:"id"` // Can be int or string depending on API
+ Method string `json:"method"`
+ Params []any `json:"params"`
+}
+
+// APIResponse represents a JSON-RPC response.
+// https://www.jsonrpc.org/specification_v1#a1.2Response
+type APIResponse struct {
+ ID any `json:"id"` // Can be int or string depending on API
+ Result json.RawMessage `json:"result"`
+ Error *APIError `json:"error"`
+}
+
+// APIError represents an error.
+type APIError struct {
+ Code any `json:"code"` // Can be int or string depending on API
+ Filename string `json:"filename"`
+ LineNumber int `json:"lineno"`
+ Message string `json:"string"`
+ Detail []string `json:"detail"`
+}
+
+func (e *APIError) Error() string {
+ msg := new(strings.Builder)
+
+ _, _ = fmt.Fprintf(msg, "code: %v", e.Code)
+
+ if e.Filename != "" {
+ _, _ = fmt.Fprintf(msg, ", filename: %s", e.Filename)
+ }
+
+ if e.LineNumber > 0 {
+ _, _ = fmt.Fprintf(msg, ", line: %d", e.LineNumber)
+ }
+
+ if e.Message != "" {
+ _, _ = fmt.Fprintf(msg, ", message: %s", e.Message)
+ }
+
+ if len(e.Detail) > 0 {
+ _, _ = fmt.Fprintf(msg, ", detail: %v", strings.Join(e.Detail, " "))
+ }
+
+ return msg.String()
+}
diff --git a/providers/dns/namesurfer/namesurfer.go b/providers/dns/namesurfer/namesurfer.go
new file mode 100644
index 000000000..6b7f48402
--- /dev/null
+++ b/providers/dns/namesurfer/namesurfer.go
@@ -0,0 +1,214 @@
+// Package namesurfer implements a DNS provider for solving the DNS-01 challenge using FusionLayer NameSurfer API.
+package namesurfer
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/namesurfer/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "NAMESURFER_"
+
+ EnvBaseURL = envNamespace + "BASE_URL"
+ EnvAPIKey = envNamespace + "API_KEY"
+ EnvAPISecret = envNamespace + "API_SECRET"
+ EnvView = envNamespace + "VIEW"
+
+ EnvTTL = envNamespace + "TTL"
+ 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 {
+ BaseURL string
+ APIKey string
+ APISecret string
+ View 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, 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 *internal.Client
+
+ zones map[string]string
+ zonesMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for FusionLayer NameSurfer.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvBaseURL, EnvAPIKey, EnvAPISecret)
+ if err != nil {
+ return nil, fmt.Errorf("namesurfer: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.BaseURL = values[EnvBaseURL]
+ config.APIKey = values[EnvAPIKey]
+ config.APISecret = values[EnvAPISecret]
+ config.View = env.GetOrDefaultString(EnvView, "")
+
+ if env.GetOrDefaultBool(EnvInsecureSkipVerify, false) {
+ config.HTTPClient.Transport = &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+ }
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for FusionLayer NameSurfer.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("namesurfer: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.BaseURL, config.APIKey, config.APISecret)
+ if err != nil {
+ return nil, fmt.Errorf("namesurfer: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ zones: make(map[string]string),
+ }, nil
+}
+
+// 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, info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("namesurfer: %w", err)
+ }
+
+ d.zonesMu.Lock()
+ d.zones[token] = zone
+ d.zonesMu.Unlock()
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
+ if err != nil {
+ return fmt.Errorf("namesurfer: %w", err)
+ }
+
+ record := internal.DNSNode{
+ Name: subDomain,
+ Type: "TXT",
+ TTL: d.config.TTL,
+ Data: info.Value,
+ }
+
+ err = d.client.AddDNSRecord(ctx, zone, d.config.View, record)
+ if err != nil {
+ return fmt.Errorf("namesurfer: add DNS 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)
+
+ d.zonesMu.Lock()
+ zone, ok := d.zones[token]
+ d.zonesMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("namesurfer: unknown zone for '%s'", info.EffectiveFQDN)
+ }
+
+ d.zonesMu.Lock()
+ delete(d.zones, token)
+ d.zonesMu.Unlock()
+
+ existing, err := d.client.SearchDNSHosts(ctx, dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("namesurfer: search DNS hosts: %w", err)
+ }
+
+ for _, node := range existing {
+ if node.Type != "TXT" || node.Data != info.Value {
+ continue
+ }
+
+ err = d.client.UpdateDNSHost(ctx, zone, d.config.View, node, internal.DNSNode{})
+ if err != nil {
+ return fmt.Errorf("namesurfer: update DNS host: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) {
+ zones, err := d.client.ListZones(ctx, "forward")
+ if err != nil {
+ return "", fmt.Errorf("list zones: %w", err)
+ }
+
+ domain := dns01.UnFqdn(fqdn)
+
+ var zoneName string
+
+ for _, zone := range zones {
+ if strings.HasSuffix(domain, zone.Name) && len(zone.Name) > len(zoneName) {
+ zoneName = zone.Name
+ }
+ }
+
+ if zoneName == "" {
+ return "", fmt.Errorf("no zone found for %s", fqdn)
+ }
+
+ return zoneName, nil
+}
diff --git a/providers/dns/namesurfer/namesurfer.toml b/providers/dns/namesurfer/namesurfer.toml
new file mode 100644
index 000000000..fd914ec0c
--- /dev/null
+++ b/providers/dns/namesurfer/namesurfer.toml
@@ -0,0 +1,28 @@
+Name = "FusionLayer NameSurfer"
+Description = ''''''
+URL = "https://www.fusionlayer.com/"
+Code = "namesurfer"
+Since = "v4.32.0"
+
+Example = '''
+NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \
+NAMESURFER_API_KEY=xxx \
+NAMESURFER_API_SECRET=yyy \
+lego --dns namesurfer -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ NAMESURFER_BASE_URL = "The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)"
+ NAMESURFER_API_KEY = "API key name"
+ NAMESURFER_API_SECRET = "API secret"
+ [Configuration.Additional]
+ NAMESURFER_VIEW = "DNS view name (optional, default: empty string)"
+ NAMESURFER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NAMESURFER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ NAMESURFER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ NAMESURFER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+ NAMESURFER_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate"
+
+[Links]
+ API = "https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10"
diff --git a/providers/dns/namesurfer/namesurfer_test.go b/providers/dns/namesurfer/namesurfer_test.go
new file mode 100644
index 000000000..ce3aa37af
--- /dev/null
+++ b/providers/dns/namesurfer/namesurfer_test.go
@@ -0,0 +1,174 @@
+package namesurfer
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(
+ EnvBaseURL,
+ EnvAPIKey,
+ EnvAPISecret,
+ EnvView,
+).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",
+ EnvAPIKey: "user",
+ EnvAPISecret: "secret",
+ },
+ },
+ {
+ desc: "missing base URL",
+ envVars: map[string]string{
+ EnvBaseURL: "",
+ EnvAPIKey: "user",
+ EnvAPISecret: "secret",
+ },
+ expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL",
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{
+ EnvBaseURL: "https://example.com",
+ EnvAPIKey: "",
+ EnvAPISecret: "secret",
+ },
+ expected: "namesurfer: some credentials information are missing: NAMESURFER_API_KEY",
+ },
+ {
+ desc: "missing API secret",
+ envVars: map[string]string{
+ EnvBaseURL: "https://example.com",
+ EnvAPIKey: "user",
+ EnvAPISecret: "",
+ },
+ expected: "namesurfer: some credentials information are missing: NAMESURFER_API_SECRET",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL,NAMESURFER_API_KEY,NAMESURFER_API_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
+ baseURL string
+ apiKey string
+ apiSecret string
+ expected string
+ }{
+ {
+ desc: "success",
+ baseURL: "https://example.com",
+ apiKey: "user",
+ apiSecret: "secret",
+ },
+ {
+ desc: "missing base URL",
+ apiKey: "user",
+ apiSecret: "secret",
+ expected: "namesurfer: base URL missing",
+ },
+ {
+ desc: "missing API key",
+ baseURL: "https://example.com",
+ apiSecret: "secret",
+ expected: "namesurfer: credentials missing",
+ },
+ {
+ desc: "missing API secret",
+ baseURL: "https://example.com",
+ apiKey: "user",
+ expected: "namesurfer: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "namesurfer: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.BaseURL = test.baseURL
+ config.APIKey = test.apiKey
+ config.APISecret = test.apiSecret
+
+ 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/dns/nearlyfreespeech/internal/client.go b/providers/dns/nearlyfreespeech/internal/client.go
index 08d8d511f..5d7e79fbe 100644
--- a/providers/dns/nearlyfreespeech/internal/client.go
+++ b/providers/dns/nearlyfreespeech/internal/client.go
@@ -34,7 +34,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(login string, apiKey string) *Client {
+func NewClient(login, apiKey string) *Client {
baseURL, _ := url.Parse(apiURL)
return &Client{
@@ -46,7 +46,7 @@ func NewClient(login string, apiKey string) *Client {
}
}
-func (c Client) AddRecord(ctx context.Context, domain string, record Record) error {
+func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error {
endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "addRR")
params, err := querystring.Values(record)
@@ -57,7 +57,7 @@ func (c Client) AddRecord(ctx context.Context, domain string, record Record) err
return c.doRequest(ctx, endpoint, params)
}
-func (c Client) RemoveRecord(ctx context.Context, domain string, record Record) error {
+func (c *Client) RemoveRecord(ctx context.Context, domain string, record Record) error {
endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "removeRR")
params, err := querystring.Values(record)
@@ -68,7 +68,7 @@ func (c Client) RemoveRecord(ctx context.Context, domain string, record Record)
return c.doRequest(ctx, endpoint, params)
}
-func (c Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Values) error {
+func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Values) error {
payload := params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(payload))
@@ -97,6 +97,7 @@ 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)
@@ -114,11 +115,10 @@ func NewSigner() *Signer {
return &Signer{saltShaker: getRandomSalt, clock: time.Now}
}
-func (c Signer) Sign(uri string, body, login, apiKey string) string {
+func (c Signer) Sign(uri, 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(c.clock().Unix(), 10)
diff --git a/providers/dns/nearlyfreespeech/internal/client_test.go b/providers/dns/nearlyfreespeech/internal/client_test.go
index 935ee4fff..26e4552be 100644
--- a/providers/dns/nearlyfreespeech/internal/client_test.go
+++ b/providers/dns/nearlyfreespeech/internal/client_test.go
@@ -1,27 +1,18 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
"testing"
"time"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("user", "secret")
client.HTTPClient = server.Client()
client.baseURL, _ = url.Parse(server.URL)
@@ -29,66 +20,22 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) {
client.signer.saltShaker = func() []byte { return []byte("0123456789ABCDEF") }
client.signer.clock = func() time.Time { return time.Unix(1692475113, 0) }
- return client, mux
-}
-
-func testHandler(params map[string]string) http.HandlerFunc {
- return 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(authenticationHeader) == "" {
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- err := req.ParseForm()
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- for k, v := range params {
- if req.PostForm.Get(k) != v {
- http.Error(rw, fmt.Sprintf("data: got %s want %s", k, v), http.StatusBadRequest)
- return
- }
- }
- }
-}
-
-func testErrorHandler() http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- file, err := os.Open("./fixtures/error.json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- rw.WriteHeader(http.StatusUnauthorized)
-
- _, _ = io.Copy(rw, file)
- }
+ return client, nil
}
func TestClient_AddRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- params := map[string]string{
- "data": "txtTXTtxt",
- "name": "sub",
- "type": "TXT",
- "ttl": "30",
- }
-
- mux.Handle("/dns/example.com/addRR", testHandler(params))
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded().
+ With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"),
+ ).
+ Route("POST /dns/example.com/addRR", nil, servermock.CheckForm().Strict().
+ With("data", "txtTXTtxt").
+ With("name", "sub").
+ With("type", "TXT").
+ With("ttl", "30"),
+ ).
+ Build(t)
record := Record{
Name: "sub",
@@ -97,14 +44,20 @@ func TestClient_AddRecord(t *testing.T) {
TTL: 30,
}
- err := client.AddRecord(context.Background(), "example.com", record)
+ err := client.AddRecord(t.Context(), "example.com", record)
require.NoError(t, err)
}
func TestClient_AddRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.Handle("/dns/example.com/addRR", testErrorHandler())
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded().
+ With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"),
+ ).
+ Route("POST /dns/example.com/addRR",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
record := Record{
Name: "sub",
@@ -113,20 +66,23 @@ func TestClient_AddRecord_error(t *testing.T) {
TTL: 30,
}
- err := client.AddRecord(context.Background(), "example.com", record)
+ err := client.AddRecord(t.Context(), "example.com", record)
require.Error(t, err)
}
func TestClient_RemoveRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- params := map[string]string{
- "data": "txtTXTtxt",
- "name": "sub",
- "type": "TXT",
- }
-
- mux.Handle("/dns/example.com/removeRR", testHandler(params))
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded().
+ With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"),
+ ).
+ Route("POST /dns/example.com/removeRR", nil,
+ servermock.CheckForm().Strict().
+ With("data", "txtTXTtxt").
+ With("name", "sub").
+ With("type", "TXT"),
+ ).
+ Build(t)
record := Record{
Name: "sub",
@@ -134,14 +90,20 @@ func TestClient_RemoveRecord(t *testing.T) {
Data: "txtTXTtxt",
}
- err := client.RemoveRecord(context.Background(), "example.com", record)
+ err := client.RemoveRecord(t.Context(), "example.com", record)
require.NoError(t, err)
}
func TestClient_RemoveRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.Handle("/dns/example.com/removeRR", testErrorHandler())
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded().
+ With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"),
+ ).
+ Route("POST /dns/example.com/removeRR",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
record := Record{
Name: "sub",
@@ -149,7 +111,7 @@ func TestClient_RemoveRecord_error(t *testing.T) {
Data: "txtTXTtxt",
}
- err := client.RemoveRecord(context.Background(), "example.com", record)
+ err := client.RemoveRecord(t.Context(), "example.com", record)
require.Error(t, err)
}
@@ -201,6 +163,7 @@ func TestSigner_Sign(t *testing.T) {
for _, test := range testCases {
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) }
diff --git a/providers/dns/nearlyfreespeech/nearlyfreespeech.go b/providers/dns/nearlyfreespeech/nearlyfreespeech.go
index 464ac35d0..af5e5363c 100644
--- a/providers/dns/nearlyfreespeech/nearlyfreespeech.go
+++ b/providers/dns/nearlyfreespeech/nearlyfreespeech.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech/internal"
)
@@ -92,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
diff --git a/providers/dns/nearlyfreespeech/nearlyfreespeech.toml b/providers/dns/nearlyfreespeech/nearlyfreespeech.toml
index 985df6cba..3a1e25942 100644
--- a/providers/dns/nearlyfreespeech/nearlyfreespeech.toml
+++ b/providers/dns/nearlyfreespeech/nearlyfreespeech.toml
@@ -7,7 +7,7 @@ Since = "v4.8.0"
Example = '''
NEARLYFREESPEECH_API_KEY=xxxxxx \
NEARLYFREESPEECH_LOGIN=xxxx \
-lego --email you@example.com --dns nearlyfreespeech -d '*.example.com' -d example.com run
+lego --dns nearlyfreespeech -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,11 +15,11 @@ lego --email you@example.com --dns nearlyfreespeech -d '*.example.com' -d exampl
NEARLYFREESPEECH_API_KEY = "API Key for API requests"
NEARLYFREESPEECH_LOGIN = "Username for API requests"
[Configuration.Additional]
- NEARLYFREESPEECH_POLLING_INTERVAL = "Time between DNS propagation check"
- NEARLYFREESPEECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NEARLYFREESPEECH_TTL = "The TTL of the TXT record used for the DNS challenge"
- NEARLYFREESPEECH_HTTP_TIMEOUT = "API request timeout"
- NEARLYFREESPEECH_SEQUENCE_INTERVAL = "Time between sequential requests"
+ NEARLYFREESPEECH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NEARLYFREESPEECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ NEARLYFREESPEECH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ NEARLYFREESPEECH_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ NEARLYFREESPEECH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://members.nearlyfreespeech.net/wiki/API/Reference"
diff --git a/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go b/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go
index adc7efe1e..b67b350e9 100644
--- a/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go
+++ b/providers/dns/nearlyfreespeech/nearlyfreespeech_test.go
@@ -54,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -126,6 +127,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -139,6 +141,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/neodigit/neodigit.go b/providers/dns/neodigit/neodigit.go
new file mode 100644
index 000000000..d41846307
--- /dev/null
+++ b/providers/dns/neodigit/neodigit.go
@@ -0,0 +1,103 @@
+// Package neodigit implements a DNS provider for solving the DNS-01 challenge using Neodigit DNS.
+package neodigit
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "NEODIGIT_"
+
+ EnvToken = envNamespace + "TOKEN"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultBaseURL = "https://api.neodigit.net/v1"
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config = tecnocratica.Config
+
+// 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, 10*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ prv challenge.ProviderTimeout
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Neodigit.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("neodigit: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Token = values[EnvToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Neodigit.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("neodigit: the configuration of the DNS provider is nil")
+ }
+
+ provider, err := tecnocratica.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("neodigit: %w", err)
+ }
+
+ return &DNSProvider{prv: provider}, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("neodigit: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("neodigit: %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.prv.Timeout()
+}
diff --git a/providers/dns/neodigit/neodigit.toml b/providers/dns/neodigit/neodigit.toml
new file mode 100644
index 000000000..91b3cfb07
--- /dev/null
+++ b/providers/dns/neodigit/neodigit.toml
@@ -0,0 +1,22 @@
+Name = "Neodigit"
+Description = ''''''
+URL = "https://www.neodigit.net"
+Code = "neodigit"
+Since = "v4.30.0"
+
+Example = '''
+NEODIGIT_TOKEN=xxxxxx \
+lego --dns neodigit -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ NEODIGIT_TOKEN = "API token"
+ [Configuration.Additional]
+ NEODIGIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ NEODIGIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ NEODIGIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ NEODIGIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://developers.neodigit.net/#dns"
diff --git a/providers/dns/neodigit/neodigit_test.go b/providers/dns/neodigit/neodigit_test.go
new file mode 100644
index 000000000..39f67c59c
--- /dev/null
+++ b/providers/dns/neodigit/neodigit_test.go
@@ -0,0 +1,116 @@
+package neodigit
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ },
+ },
+ {
+ desc: "missing credentials: token",
+ envVars: map[string]string{
+ EnvToken: "",
+ },
+ expected: "neodigit: some credentials information are missing: NEODIGIT_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.prv)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ token string
+ expected string
+ }{
+ {
+ desc: "success",
+ token: "secret",
+ },
+ {
+ desc: "missing token",
+ expected: "neodigit: missing credentials",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Token = test.token
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.prv)
+ } 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/dns/netcup/internal/client.go b/providers/dns/netcup/internal/client.go
index 9573c09c8..1287a8d7a 100644
--- a/providers/dns/netcup/internal/client.go
+++ b/providers/dns/netcup/internal/client.go
@@ -80,6 +80,7 @@ func (c *Client) GetDNSRecords(ctx context.Context, hostname string) ([]DNSRecor
}
var responseData InfoDNSRecordsResponse
+
err := c.doRequest(ctx, payload, &responseData)
if err != nil {
return nil, fmt.Errorf("error when sending the request: %w", err)
@@ -139,10 +140,11 @@ func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) {
return index, nil
}
}
+
return -1, errors.New("no DNS Record found")
}
-func newJSONRequest(ctx context.Context, method string, endpoint string, payload any) (*http.Request, error) {
+func newJSONRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
@@ -173,6 +175,7 @@ func unmarshalResponseMsg(req *http.Request, resp *http.Response) (*ResponseMsg,
}
var respMsg ResponseMsg
+
err = json.Unmarshal(raw, &respMsg)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/netcup/internal/client_live_test.go b/providers/dns/netcup/internal/client_live_test.go
new file mode 100644
index 000000000..68621882e
--- /dev/null
+++ b/providers/dns/netcup/internal/client_live_test.go
@@ -0,0 +1,137 @@
+package internal
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var envTest = tester.NewEnvTest(
+ "NETCUP_CUSTOMER_NUMBER",
+ "NETCUP_API_KEY",
+ "NETCUP_API_PASSWORD").
+ WithDomain("NETCUP_DOMAIN")
+
+func TestClient_GetDNSRecords_Live(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ // Setup
+ envTest.RestoreEnv()
+
+ client, err := NewClient(
+ envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
+ envTest.GetValue("NETCUP_API_KEY"),
+ envTest.GetValue("NETCUP_API_PASSWORD"))
+ require.NoError(t, err)
+
+ ctx, err := client.CreateSessionContext(t.Context())
+ require.NoError(t, err)
+
+ info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==")
+
+ zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ require.NoError(t, err)
+
+ zone = dns01.UnFqdn(zone)
+
+ // TestMethod
+ _, err = client.GetDNSRecords(ctx, zone)
+ require.NoError(t, err)
+
+ // Tear down
+ err = client.Logout(ctx)
+ require.NoError(t, err)
+}
+
+func TestClient_UpdateDNSRecord_Live(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ // Setup
+ envTest.RestoreEnv()
+
+ client, err := NewClient(
+ envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
+ envTest.GetValue("NETCUP_API_KEY"),
+ envTest.GetValue("NETCUP_API_PASSWORD"))
+ require.NoError(t, err)
+
+ ctx, err := client.CreateSessionContext(t.Context())
+ require.NoError(t, err)
+
+ info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==")
+
+ zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err))
+
+ hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1)
+
+ record := DNSRecord{
+ Hostname: hostname,
+ RecordType: "TXT",
+ Destination: "asdf5678",
+ DeleteRecord: false,
+ }
+
+ // test
+ zone = dns01.UnFqdn(zone)
+
+ err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record})
+ require.NoError(t, err)
+
+ records, err := client.GetDNSRecords(ctx, zone)
+ require.NoError(t, err)
+
+ recordIdx, err := GetDNSRecordIdx(records, record)
+ require.NoError(t, err)
+
+ assert.Equal(t, record.Hostname, records[recordIdx].Hostname)
+ assert.Equal(t, record.RecordType, records[recordIdx].RecordType)
+ assert.Equal(t, record.Destination, records[recordIdx].Destination)
+ assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord)
+
+ records[recordIdx].DeleteRecord = true
+
+ // Tear down
+ err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]})
+ require.NoError(t, err)
+
+ err = client.Logout(ctx)
+ require.NoError(t, err)
+}
+
+func TestLiveClientAuth(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ // Setup
+ envTest.RestoreEnv()
+
+ client, err := NewClient(
+ envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
+ envTest.GetValue("NETCUP_API_KEY"),
+ envTest.GetValue("NETCUP_API_PASSWORD"))
+ require.NoError(t, err)
+
+ for i := range 4 {
+ t.Run("Test_"+strconv.Itoa(i+1), func(t *testing.T) {
+ t.Parallel()
+
+ ctx, err := client.CreateSessionContext(t.Context())
+ require.NoError(t, err)
+
+ err = client.Logout(ctx)
+ require.NoError(t, err)
+ })
+ }
+}
diff --git a/providers/dns/netcup/internal/client_test.go b/providers/dns/netcup/internal/client_test.go
index 0e028e881..83c59460e 100644
--- a/providers/dns/netcup/internal/client_test.go
+++ b/providers/dns/netcup/internal/client_test.go
@@ -1,41 +1,30 @@
package internal
import (
- "bytes"
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
- "strings"
"testing"
- "github.com/go-acme/lego/v4/challenge/dns01"
- "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-var envTest = tester.NewEnvTest(
- "NETCUP_CUSTOMER_NUMBER",
- "NETCUP_API_KEY",
- "NETCUP_API_PASSWORD").
- WithDomain("NETCUP_DOMAIN")
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("a", "b", "c")
+ if err != nil {
+ return nil, err
+ }
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+ client.baseURL = server.URL
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client, err := NewClient("a", "b", "c")
- require.NoError(t, err)
-
- client.baseURL = server.URL
- client.HTTPClient = server.Client()
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestGetDNSRecordIdx(t *testing.T) {
@@ -140,59 +129,10 @@ func TestGetDNSRecordIdx(t *testing.T) {
}
func TestClient_GetDNSRecords(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if string(bytes.TrimSpace(raw)) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":""}}` {
- http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
- return
- }
-
- response := `
- {
- "serverrequestid":"srv-request-id",
- "clientrequestid":"",
- "action":"infoDnsRecords",
- "status":"success",
- "statuscode":2000,
- "shortmessage":"Login successful",
- "longmessage":"Session has been created successful.",
- "responsedata":{
- "apisessionid":"api-session-id",
- "dnsrecords":[
- {
- "id":"1",
- "hostname":"example.com",
- "type":"TXT",
- "priority":"1",
- "destination":"bGVnbzE=",
- "state":"yes",
- "ttl":300
- },
- {
- "id":"2",
- "hostname":"example2.com",
- "type":"TXT",
- "priority":"1",
- "destination":"bGVnbw==",
- "state":"yes",
- "ttl":300
- }
- ]
- }
- }`
- _, err = rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("get_dns_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("get_dns_records-request.json")).
+ Build(t)
expected := []DNSRecord{{
ID: 1,
@@ -202,7 +142,6 @@ func TestClient_GetDNSRecords(t *testing.T) {
Destination: "bGVnbzE=",
DeleteRecord: false,
State: "yes",
- TTL: 300,
}, {
ID: 2,
Hostname: "example2.com",
@@ -211,10 +150,9 @@ func TestClient_GetDNSRecords(t *testing.T) {
Destination: "bGVnbw==",
DeleteRecord: false,
State: "yes",
- TTL: 300,
}}
- records, err := client.GetDNSRecords(context.Background(), "example.com")
+ records, err := client.GetDNSRecords(t.Context(), "example.com")
require.NoError(t, err)
assert.Equal(t, expected, records)
@@ -222,67 +160,24 @@ func TestClient_GetDNSRecords(t *testing.T) {
func TestClient_GetDNSRecords_errors(t *testing.T) {
testCases := []struct {
- desc string
- handler func(rw http.ResponseWriter, req *http.Request)
+ desc string
+ handler http.Handler
+ expected string
}{
{
- desc: "HTTP error",
- handler: func(rw http.ResponseWriter, _ *http.Request) {
- http.Error(rw, "error message", http.StatusInternalServerError)
- },
+ desc: "HTTP error",
+ handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError),
+ expected: `error when sending the request: unexpected status code: [status code: 500] body: `,
},
{
- desc: "API error",
- handler: func(rw http.ResponseWriter, _ *http.Request) {
- response := `
- {
- "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
- "clientrequestid":"",
- "action":"infoDnsRecords",
- "status":"error",
- "statuscode":4013,
- "shortmessage":"Validation Error.",
- "longmessage":"Message is empty.",
- "responsedata":""
- }`
- _, err := rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- },
+ desc: "API error",
+ handler: servermock.ResponseFromFixture("get_dns_records_error.json"),
+ expected: `error when sending the request: an error occurred during the action infoDnsRecords: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`,
},
{
- desc: "responsedata marshaling error",
- handler: func(rw http.ResponseWriter, req *http.Request) {
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if string(raw) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":"api-session-id"}}` {
- http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
- return
- }
-
- response := `
- {
- "serverrequestid":"srv-request-id",
- "clientrequestid":"",
- "action":"infoDnsRecords",
- "status":"success",
- "statuscode":2000,
- "shortmessage":"Login successful",
- "longmessage":"Session has been created successful.",
- "responsedata":""
- }`
- _, err = rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- },
+ desc: "responsedata marshaling error",
+ handler: servermock.ResponseFromFixture("get_dns_records_error_unmarshal.json"),
+ expected: `error when sending the request: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.InfoDNSRecordsResponse`,
},
}
@@ -290,105 +185,13 @@ func TestClient_GetDNSRecords_errors(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /", test.handler).
+ Build(t)
- mux.HandleFunc("/", test.handler)
-
- records, err := client.GetDNSRecords(context.Background(), "example.com")
- require.Error(t, err)
+ records, err := client.GetDNSRecords(t.Context(), "example.com")
+ require.EqualError(t, err, test.expected)
assert.Empty(t, records)
})
}
}
-
-func TestClient_GetDNSRecords_Live(t *testing.T) {
- if !envTest.IsLiveTest() {
- t.Skip("skipping live test")
- }
-
- // Setup
- envTest.RestoreEnv()
-
- client, err := NewClient(
- envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
- envTest.GetValue("NETCUP_API_KEY"),
- envTest.GetValue("NETCUP_API_PASSWORD"))
- require.NoError(t, err)
-
- ctx, err := client.CreateSessionContext(context.Background())
- require.NoError(t, err)
-
- info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==")
-
- zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
- require.NoError(t, err, "error finding DNSZone")
-
- zone = dns01.UnFqdn(zone)
-
- // TestMethod
- _, err = client.GetDNSRecords(ctx, zone)
- require.NoError(t, err)
-
- // Tear down
- err = client.Logout(ctx)
- require.NoError(t, err)
-}
-
-func TestClient_UpdateDNSRecord_Live(t *testing.T) {
- if !envTest.IsLiveTest() {
- t.Skip("skipping live test")
- }
-
- // Setup
- envTest.RestoreEnv()
-
- client, err := NewClient(
- envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
- envTest.GetValue("NETCUP_API_KEY"),
- envTest.GetValue("NETCUP_API_PASSWORD"))
- require.NoError(t, err)
-
- ctx, err := client.CreateSessionContext(context.Background())
- require.NoError(t, err)
-
- info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==")
-
- zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
- require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err))
-
- hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1)
-
- record := DNSRecord{
- Hostname: hostname,
- RecordType: "TXT",
- Destination: "asdf5678",
- DeleteRecord: false,
- TTL: 120,
- }
-
- // test
- zone = dns01.UnFqdn(zone)
-
- err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record})
- require.NoError(t, err)
-
- records, err := client.GetDNSRecords(ctx, zone)
- require.NoError(t, err)
-
- recordIdx, err := GetDNSRecordIdx(records, record)
- require.NoError(t, err)
-
- assert.Equal(t, record.Hostname, records[recordIdx].Hostname)
- assert.Equal(t, record.RecordType, records[recordIdx].RecordType)
- assert.Equal(t, record.Destination, records[recordIdx].Destination)
- assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord)
-
- records[recordIdx].DeleteRecord = true
-
- // Tear down
- err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]})
- require.NoError(t, err, "Did not remove record! Please do so yourself.")
-
- err = client.Logout(ctx)
- require.NoError(t, err)
-}
diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records-request.json b/providers/dns/netcup/internal/fixtures/get_dns_records-request.json
new file mode 100644
index 000000000..bcf8e5310
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/get_dns_records-request.json
@@ -0,0 +1,9 @@
+{
+ "action": "infoDnsRecords",
+ "param": {
+ "domainname": "example.com",
+ "customernumber": "a",
+ "apikey": "b",
+ "apisessionid": ""
+ }
+}
diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records.json b/providers/dns/netcup/internal/fixtures/get_dns_records.json
new file mode 100644
index 000000000..e521a8e24
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/get_dns_records.json
@@ -0,0 +1,32 @@
+{
+ "serverrequestid": "srv-request-id",
+ "clientrequestid": "",
+ "action": "infoDnsRecords",
+ "status": "success",
+ "statuscode": 2000,
+ "shortmessage": "Login successful",
+ "longmessage": "Session has been created successful.",
+ "responsedata": {
+ "apisessionid": "api-session-id",
+ "dnsrecords": [
+ {
+ "id": "1",
+ "hostname": "example.com",
+ "type": "TXT",
+ "priority": "1",
+ "destination": "bGVnbzE=",
+ "state": "yes",
+ "ttl": 300
+ },
+ {
+ "id": "2",
+ "hostname": "example2.com",
+ "type": "TXT",
+ "priority": "1",
+ "destination": "bGVnbw==",
+ "state": "yes",
+ "ttl": 300
+ }
+ ]
+ }
+}
diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records_error.json b/providers/dns/netcup/internal/fixtures/get_dns_records_error.json
new file mode 100644
index 000000000..3ba472366
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/get_dns_records_error.json
@@ -0,0 +1,10 @@
+{
+ "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
+ "clientrequestid":"",
+ "action":"infoDnsRecords",
+ "status":"error",
+ "statuscode":4013,
+ "shortmessage":"Validation Error.",
+ "longmessage":"Message is empty.",
+ "responsedata":""
+}
diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json b/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json
new file mode 100644
index 000000000..f8f91329f
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json
@@ -0,0 +1,10 @@
+{
+ "serverrequestid":"srv-request-id",
+ "clientrequestid":"",
+ "action":"infoDnsRecords",
+ "status":"success",
+ "statuscode":2000,
+ "shortmessage":"Login successful",
+ "longmessage":"Session has been created successful.",
+ "responsedata":""
+}
diff --git a/providers/dns/netcup/internal/fixtures/login-request.json b/providers/dns/netcup/internal/fixtures/login-request.json
new file mode 100644
index 000000000..1e287dfe0
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/login-request.json
@@ -0,0 +1,8 @@
+{
+ "action": "login",
+ "param": {
+ "customernumber": "a",
+ "apikey": "b",
+ "apipassword": "c"
+ }
+}
diff --git a/providers/dns/netcup/internal/fixtures/login.json b/providers/dns/netcup/internal/fixtures/login.json
new file mode 100644
index 000000000..a66979544
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/login.json
@@ -0,0 +1,12 @@
+{
+ "serverrequestid": "srv-request-id",
+ "clientrequestid": "",
+ "action": "login",
+ "status": "success",
+ "statuscode": 2000,
+ "shortmessage": "Login successful",
+ "longmessage": "Session has been created successful.",
+ "responsedata": {
+ "apisessionid": "api-session-id"
+ }
+}
diff --git a/providers/dns/netcup/internal/fixtures/login_error.json b/providers/dns/netcup/internal/fixtures/login_error.json
new file mode 100644
index 000000000..a32568f78
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/login_error.json
@@ -0,0 +1,10 @@
+{
+ "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
+ "clientrequestid":"",
+ "action":"login",
+ "status":"error",
+ "statuscode":4013,
+ "shortmessage":"Validation Error.",
+ "longmessage":"Message is empty.",
+ "responsedata":""
+}
diff --git a/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json b/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json
new file mode 100644
index 000000000..96e7cbd0c
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json
@@ -0,0 +1,10 @@
+{
+ "serverrequestid": "srv-request-id",
+ "clientrequestid": "",
+ "action": "login",
+ "status": "success",
+ "statuscode": 2000,
+ "shortmessage": "Login successful",
+ "longmessage": "Session has been created successful.",
+ "responsedata": ""
+}
diff --git a/providers/dns/netcup/internal/fixtures/logout-request.json b/providers/dns/netcup/internal/fixtures/logout-request.json
new file mode 100644
index 000000000..add759c3a
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/logout-request.json
@@ -0,0 +1,8 @@
+{
+ "action": "logout",
+ "param": {
+ "customernumber": "a",
+ "apikey": "b",
+ "apisessionid": "session-id"
+ }
+}
diff --git a/providers/dns/netcup/internal/fixtures/logout.json b/providers/dns/netcup/internal/fixtures/logout.json
new file mode 100644
index 000000000..50881fff3
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/logout.json
@@ -0,0 +1,10 @@
+{
+ "serverrequestid": "request-id",
+ "clientrequestid": "",
+ "action": "logout",
+ "status": "success",
+ "statuscode": 2000,
+ "shortmessage": "Logout successful",
+ "longmessage": "Session has been terminated successful.",
+ "responsedata": ""
+}
diff --git a/providers/dns/netcup/internal/fixtures/logout_error.json b/providers/dns/netcup/internal/fixtures/logout_error.json
new file mode 100644
index 000000000..a2de32da1
--- /dev/null
+++ b/providers/dns/netcup/internal/fixtures/logout_error.json
@@ -0,0 +1,10 @@
+{
+ "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
+ "clientrequestid":"",
+ "action":"logout",
+ "status":"error",
+ "statuscode":4013,
+ "shortmessage":"Validation Error.",
+ "longmessage":"Message is empty.",
+ "responsedata":""
+}
diff --git a/providers/dns/netcup/internal/session.go b/providers/dns/netcup/internal/session.go
index 6627d74e1..b53751edf 100644
--- a/providers/dns/netcup/internal/session.go
+++ b/providers/dns/netcup/internal/session.go
@@ -24,6 +24,7 @@ func (c *Client) login(ctx context.Context) (string, error) {
}
var responseData LoginResponse
+
err := c.doRequest(ctx, payload, &responseData)
if err != nil {
return "", fmt.Errorf("loging error: %w", err)
diff --git a/providers/dns/netcup/internal/session_test.go b/providers/dns/netcup/internal/session_test.go
index 2b69265d2..7704c2604 100644
--- a/providers/dns/netcup/internal/session_test.go
+++ b/providers/dns/netcup/internal/session_test.go
@@ -1,59 +1,28 @@
package internal
import (
- "bytes"
"context"
- "fmt"
- "io"
"net/http"
- "strconv"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func mockContext() context.Context {
- return context.WithValue(context.Background(), sessionIDKey, "session-id")
+func mockContext(t *testing.T) context.Context {
+ t.Helper()
+
+ return context.WithValue(t.Context(), sessionIDKey, "session-id")
}
func TestClient_Login(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("login.json"),
+ servermock.CheckRequestJSONBodyFromFixture("login-request.json")).
+ Build(t)
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if string(bytes.TrimSpace(raw)) != `{"action":"login","param":{"customernumber":"a","apikey":"b","apipassword":"c"}}` {
- http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
- return
- }
-
- response := `
- {
- "serverrequestid": "srv-request-id",
- "clientrequestid": "",
- "action": "login",
- "status": "success",
- "statuscode": 2000,
- "shortmessage": "Login successful",
- "longmessage": "Session has been created successful.",
- "responsedata": {
- "apisessionid": "api-session-id"
- }
- }
- `
- _, err = rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- sessionID, err := client.login(context.Background())
+ sessionID, err := client.login(t.Context())
require.NoError(t, err)
assert.Equal(t, "api-session-id", sessionID)
@@ -61,56 +30,24 @@ func TestClient_Login(t *testing.T) {
func TestClient_Login_errors(t *testing.T) {
testCases := []struct {
- desc string
- handler func(rw http.ResponseWriter, req *http.Request)
+ desc string
+ handler http.Handler
+ expected string
}{
{
- desc: "HTTP error",
- handler: func(rw http.ResponseWriter, _ *http.Request) {
- http.Error(rw, "error message", http.StatusInternalServerError)
- },
+ desc: "HTTP error",
+ handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError),
+ expected: `loging error: unexpected status code: [status code: 500] body: `,
},
{
- desc: "API error",
- handler: func(rw http.ResponseWriter, _ *http.Request) {
- response := `
- {
- "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
- "clientrequestid":"",
- "action":"login",
- "status":"error",
- "statuscode":4013,
- "shortmessage":"Validation Error.",
- "longmessage":"Message is empty.",
- "responsedata":""
- }`
- _, err := rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- },
+ desc: "API error",
+ handler: servermock.ResponseFromFixture("login_error.json"),
+ expected: `loging error: an error occurred during the action login: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`,
},
{
- desc: "responsedata marshaling error",
- handler: func(rw http.ResponseWriter, _ *http.Request) {
- response := `
- {
- "serverrequestid": "srv-request-id",
- "clientrequestid": "",
- "action": "login",
- "status": "success",
- "statuscode": 2000,
- "shortmessage": "Login successful",
- "longmessage": "Session has been created successful.",
- "responsedata": ""
- }`
- _, err := rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- },
+ desc: "responsedata marshaling error",
+ handler: servermock.ResponseFromFixture("login_error_unmarshal.json"),
+ expected: `loging error: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.LoginResponse`,
},
}
@@ -118,85 +55,40 @@ func TestClient_Login_errors(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /", test.handler).
+ Build(t)
- mux.HandleFunc("/", test.handler)
-
- sessionID, err := client.login(context.Background())
- assert.Error(t, err)
- assert.Equal(t, "", sessionID)
+ sessionID, err := client.login(t.Context())
+ assert.EqualError(t, err, test.expected)
+ assert.Empty(t, sessionID)
})
}
}
func TestClient_Logout(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /", servermock.ResponseFromFixture("logout.json"),
+ servermock.CheckRequestJSONBodyFromFixture("logout-request.json")).
+ Build(t)
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if string(bytes.TrimSpace(raw)) != `{"action":"logout","param":{"customernumber":"a","apikey":"b","apisessionid":"session-id"}}` {
- http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
- return
- }
-
- response := `
- {
- "serverrequestid": "request-id",
- "clientrequestid": "",
- "action": "logout",
- "status": "success",
- "statuscode": 2000,
- "shortmessage": "Logout successful",
- "longmessage": "Session has been terminated successful.",
- "responsedata": ""
- }`
- _, err = rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- err := client.Logout(mockContext())
+ err := client.Logout(mockContext(t))
require.NoError(t, err)
}
func TestClient_Logout_errors(t *testing.T) {
testCases := []struct {
- desc string
- handler func(rw http.ResponseWriter, req *http.Request)
+ desc string
+ handler http.Handler
+ expected string
}{
{
- desc: "HTTP error",
- handler: func(rw http.ResponseWriter, _ *http.Request) {
- http.Error(rw, "error message", http.StatusInternalServerError)
- },
+ desc: "HTTP error",
+ handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError),
},
{
- desc: "API error",
- handler: func(rw http.ResponseWriter, _ *http.Request) {
- response := `
- {
- "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
- "clientrequestid":"",
- "action":"logout",
- "status":"error",
- "statuscode":4013,
- "shortmessage":"Validation Error.",
- "longmessage":"Message is empty.",
- "responsedata":""
- }`
- _, err := rw.Write([]byte(response))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- },
+ desc: "API error",
+ handler: servermock.ResponseFromFixture("login_error.json"),
},
}
@@ -204,39 +96,12 @@ func TestClient_Logout_errors(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /", test.handler).
+ Build(t)
- mux.HandleFunc("/", test.handler)
-
- err := client.Logout(context.Background())
+ err := client.Logout(t.Context())
require.Error(t, err)
})
}
}
-
-func TestLiveClientAuth(t *testing.T) {
- if !envTest.IsLiveTest() {
- t.Skip("skipping live test")
- }
-
- // Setup
- envTest.RestoreEnv()
-
- client, err := NewClient(
- envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
- envTest.GetValue("NETCUP_API_KEY"),
- envTest.GetValue("NETCUP_API_PASSWORD"))
- require.NoError(t, err)
-
- for i := range 4 {
- t.Run("Test_"+strconv.Itoa(i+1), func(t *testing.T) {
- t.Parallel()
-
- ctx, err := client.CreateSessionContext(context.Background())
- require.NoError(t, err)
-
- err = client.Logout(ctx)
- require.NoError(t, err)
- })
- }
-}
diff --git a/providers/dns/netcup/internal/types.go b/providers/dns/netcup/internal/types.go
index 55212f909..e4cc5ec14 100644
--- a/providers/dns/netcup/internal/types.go
+++ b/providers/dns/netcup/internal/types.go
@@ -72,7 +72,6 @@ type DNSRecord struct {
Destination string `json:"destination"`
DeleteRecord bool `json:"deleterecord,omitempty"`
State string `json:"state,omitempty"`
- TTL int `json:"ttl,omitempty"`
}
// ResponseMsg as specified in netcup WSDL.
diff --git a/providers/dns/netcup/netcup.go b/providers/dns/netcup/netcup.go
index 014e09a15..13b329e07 100644
--- a/providers/dns/netcup/netcup.go
+++ b/providers/dns/netcup/netcup.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/netcup/internal"
)
@@ -24,7 +25,9 @@ const (
EnvAPIKey = envNamespace + "API_KEY"
EnvAPIPassword = envNamespace + "API_PASSWORD"
- EnvTTL = envNamespace + "TTL"
+ // Deprecated: the TTL is not configurable on record.
+ EnvTTL = envNamespace + "TTL"
+
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
@@ -37,18 +40,19 @@ type Config struct {
Key string
Password string
Customer string
- TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
HTTPClient *http.Client
+
+ // Deprecated: the TTL is not configurable on record.
+ 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, 120*time.Second),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
},
@@ -89,7 +93,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("netcup: %w", err)
}
- client.HTTPClient = config.HTTPClient
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
return &DNSProvider{client: client, config: config}, nil
}
@@ -111,7 +119,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
defer func() {
err = d.client.Logout(ctx)
if err != nil {
- log.Print("netcup: %v", err)
+ log.Printf("netcup: %v", err)
}
}()
@@ -120,7 +128,6 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Hostname: hostname,
RecordType: "TXT",
Destination: info.Value,
- TTL: d.config.TTL,
}
zone = dns01.UnFqdn(zone)
@@ -158,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
defer func() {
err = d.client.Logout(ctx)
if err != nil {
- log.Print("netcup: %v", err)
+ log.Printf("netcup: %v", err)
}
}()
diff --git a/providers/dns/netcup/netcup.toml b/providers/dns/netcup/netcup.toml
index 0954d07d6..4ef8688c6 100644
--- a/providers/dns/netcup/netcup.toml
+++ b/providers/dns/netcup/netcup.toml
@@ -8,7 +8,7 @@ Example = '''
NETCUP_CUSTOMER_NUMBER=xxxx \
NETCUP_API_KEY=yyyy \
NETCUP_API_PASSWORD=zzzz \
-lego --email you@example.com --dns netcup -d '*.example.com' -d example.com run
+lego --dns netcup -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,9 @@ lego --email you@example.com --dns netcup -d '*.example.com' -d example.com run
NETCUP_API_KEY = "API key"
NETCUP_API_PASSWORD = "API password"
[Configuration.Additional]
- NETCUP_POLLING_INTERVAL = "Time between DNS propagation check"
- NETCUP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NETCUP_TTL = "The TTL of the TXT record used for the DNS challenge"
- NETCUP_HTTP_TIMEOUT = "API request timeout"
+ NETCUP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)"
+ NETCUP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)"
+ NETCUP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://www.netcup-wiki.de/wiki/DNS_API"
diff --git a/providers/dns/netcup/netcup_test.go b/providers/dns/netcup/netcup_test.go
index f9cc43ab9..fedc56ba9 100644
--- a/providers/dns/netcup/netcup_test.go
+++ b/providers/dns/netcup/netcup_test.go
@@ -72,6 +72,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -158,13 +159,14 @@ func TestLivePresentAndCleanup(t *testing.T) {
}
envTest.RestoreEnv()
+
p, err := NewDNSProvider()
require.NoError(t, err)
info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==")
zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
- require.NoError(t, err, "error finding DNSZone")
+ require.NoError(t, err)
zone = dns01.UnFqdn(zone)
@@ -181,7 +183,7 @@ func TestLivePresentAndCleanup(t *testing.T) {
require.NoError(t, err)
err = p.CleanUp(test, "987d", "123d==")
- require.NoError(t, err, "Did not clean up! Please remove record yourself.")
+ require.NoError(t, err)
})
}
}
diff --git a/providers/dns/netlify/internal/client.go b/providers/dns/netlify/internal/client.go
index 06651bdec..3b6b681fe 100644
--- a/providers/dns/netlify/internal/client.go
+++ b/providers/dns/netlify/internal/client.go
@@ -59,6 +59,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]DNSRecord, er
}
var records []DNSRecord
+
err = json.Unmarshal(raw, &records)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -93,6 +94,7 @@ func (c *Client) CreateRecord(ctx context.Context, zoneID string, record DNSReco
}
var recordResp DNSRecord
+
err = json.Unmarshal(raw, &recordResp)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -124,7 +126,7 @@ func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) erro
return nil
}
-func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload interface{}) (*http.Request, error) {
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
diff --git a/providers/dns/netlify/internal/client_test.go b/providers/dns/netlify/internal/client_test.go
index e06a579b7..b19a8f071 100644
--- a/providers/dns/netlify/internal/client_test.go
+++ b/providers/dns/netlify/internal/client_test.go
@@ -1,64 +1,35 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, token string) (*Client, *http.ServeMux) {
- t.Helper()
+func setupClient(token string) func(server *httptest.Server) (*Client, error) {
+ return func(server *httptest.Server) (*Client, error) {
+ client := NewClient(OAuthStaticAccessToken(server.Client(), token))
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(OAuthStaticAccessToken(server.Client(), token))
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ return client, nil
+ }
}
func TestClient_GetRecords(t *testing.T) {
- client, mux := setupTest(t, "tokenA")
+ client := servermock.NewBuilder[*Client](setupClient("tokenA"),
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer tokenA"),
+ ).
+ Route("GET /dns_zones/zoneID/dns_records",
+ servermock.ResponseFromFixture("get_records.json")).
+ Build(t)
- mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "unsupported method", http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Bearer tokenA" {
- http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized)
- return
- }
-
- rw.Header().Set("Content-Type", "application/json; charset=utf-8")
-
- file, err := os.Open("./fixtures/get_records.json")
- 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
- }
- })
-
- records, err := client.GetRecords(context.Background(), "zoneID")
+ records, err := client.GetRecords(t.Context(), "zoneID")
require.NoError(t, err)
expected := []DNSRecord{
@@ -70,36 +41,16 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_CreateRecord(t *testing.T) {
- client, mux := setupTest(t, "tokenB")
-
- mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "unsupported method", http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Bearer tokenB" {
- http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized)
- return
- }
-
- rw.Header().Set("Content-Type", "application/json; charset=utf-8")
- rw.WriteHeader(http.StatusCreated)
-
- file, err := os.Open("./fixtures/create_record.json")
- 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
- }
- })
+ client := servermock.NewBuilder[*Client](setupClient("tokenB"),
+ servermock.CheckHeader().
+ WithAccept("application/json").
+ WithContentType("application/json; charset=utf-8").
+ WithAuthorization("Bearer tokenB"),
+ ).
+ Route("POST /dns_zones/zoneID/dns_records",
+ servermock.ResponseFromFixture("create_record.json").
+ WithStatusCode(http.StatusCreated)).
+ Build(t)
record := DNSRecord{
Hostname: "_acme-challenge.example.com",
@@ -108,7 +59,7 @@ func TestClient_CreateRecord(t *testing.T) {
Value: "txtxtxtxtxtxt",
}
- result, err := client.CreateRecord(context.Background(), "zoneID", record)
+ result, err := client.CreateRecord(t.Context(), "zoneID", record)
require.NoError(t, err)
expected := &DNSRecord{
@@ -123,23 +74,15 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_RemoveRecord(t *testing.T) {
- client, mux := setupTest(t, "tokenC")
+ client := servermock.NewBuilder[*Client](setupClient("tokenC"),
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer tokenC"),
+ ).
+ Route("DELETE /dns_zones/zoneID/dns_records/recordID",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
- mux.HandleFunc("/dns_zones/zoneID/dns_records/recordID", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, "unsupported method", http.StatusMethodNotAllowed)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Bearer tokenC" {
- http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(http.StatusNoContent)
- })
-
- err := client.RemoveRecord(context.Background(), "zoneID", "recordID")
+ err := client.RemoveRecord(t.Context(), "zoneID", "recordID")
require.NoError(t, err)
}
diff --git a/providers/dns/netlify/netlify.go b/providers/dns/netlify/netlify.go
index 1d4c78f4f..5b2980d24 100644
--- a/providers/dns/netlify/netlify.go
+++ b/providers/dns/netlify/netlify.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/netlify/internal"
)
@@ -84,7 +85,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("netlify: incomplete credentials, missing token")
}
- client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token))
+ client := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.Token),
+ ),
+ )
return &DNSProvider{
config: config,
@@ -144,6 +149,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("netlify: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
diff --git a/providers/dns/netlify/netlify.toml b/providers/dns/netlify/netlify.toml
index 1191c6beb..9d3c0f6b5 100644
--- a/providers/dns/netlify/netlify.toml
+++ b/providers/dns/netlify/netlify.toml
@@ -6,17 +6,17 @@ Since = "v3.7.0"
Example = '''
NETLIFY_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns netlify -d '*.example.com' -d example.com run
+lego --dns netlify -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
NETLIFY_TOKEN = "Token"
[Configuration.Additional]
- NETLIFY_POLLING_INTERVAL = "Time between DNS propagation check"
- NETLIFY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NETLIFY_TTL = "The TTL of the TXT record used for the DNS challenge"
- NETLIFY_HTTP_TIMEOUT = "API request timeout"
+ NETLIFY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NETLIFY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ NETLIFY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ NETLIFY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://open-api.netlify.com/"
diff --git a/providers/dns/netlify/netlify_test.go b/providers/dns/netlify/netlify_test.go
index f351802da..1e84517be 100644
--- a/providers/dns/netlify/netlify_test.go
+++ b/providers/dns/netlify/netlify_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/nicmanager/internal/client.go b/providers/dns/nicmanager/internal/client.go
index 3134fc4fd..16bfe497b 100644
--- a/providers/dns/nicmanager/internal/client.go
+++ b/providers/dns/nicmanager/internal/client.go
@@ -23,7 +23,7 @@ const (
// Modes.
const (
ModeAnycast = "anycast"
- ModeZone = "zone"
+ ModeZone = "zones"
)
// Options the Client options.
@@ -74,7 +74,7 @@ func NewClient(opts Options) *Client {
return c
}
-func (c Client) GetZone(ctx context.Context, name string) (*Zone, error) {
+func (c *Client) GetZone(ctx context.Context, name string) (*Zone, error) {
endpoint := c.baseURL.JoinPath(c.mode, name)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -83,6 +83,7 @@ func (c Client) GetZone(ctx context.Context, name string) (*Zone, error) {
}
var zone Zone
+
err = c.do(req, http.StatusOK, &zone)
if err != nil {
return nil, err
@@ -91,7 +92,7 @@ func (c Client) GetZone(ctx context.Context, name string) (*Zone, error) {
return &zone, nil
}
-func (c Client) AddRecord(ctx context.Context, zone string, payload RecordCreateUpdate) error {
+func (c *Client) AddRecord(ctx context.Context, zone string, payload RecordCreateUpdate) error {
endpoint := c.baseURL.JoinPath(c.mode, zone, "records")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
@@ -107,7 +108,7 @@ func (c Client) AddRecord(ctx context.Context, zone string, payload RecordCreate
return nil
}
-func (c Client) DeleteRecord(ctx context.Context, zone string, record int) error {
+func (c *Client) DeleteRecord(ctx context.Context, zone string, record int) error {
endpoint := c.baseURL.JoinPath(c.mode, zone, "records", strconv.Itoa(record))
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -123,7 +124,7 @@ func (c Client) DeleteRecord(ctx context.Context, zone string, record int) error
return nil
}
-func (c Client) do(req *http.Request, expectedStatusCode int, result any) error {
+func (c *Client) do(req *http.Request, expectedStatusCode int, result any) error {
req.SetBasicAuth(c.username, c.password)
if c.otp != "" {
diff --git a/providers/dns/nicmanager/internal/client_test.go b/providers/dns/nicmanager/internal/client_test.go
index 822ec0db2..1eb7d5a36 100644
--- a/providers/dns/nicmanager/internal/client_test.go
+++ b/providers/dns/nicmanager/internal/client_test.go
@@ -1,24 +1,44 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestClient_GetZone(t *testing.T) {
- client := setupTest(t, "/anycast/nicmanager-anycastdns4.net", testHandler(http.MethodGet, http.StatusOK, "zone.json"))
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ opts := Options{
+ Login: "l",
+ Username: "u",
+ Password: "p",
+ OTP: "2hsn",
+ }
- zone, err := client.GetZone(context.Background(), "nicmanager-anycastdns4.net")
+ client := NewClient(opts)
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("l.u", "p").
+ WithRegexp(headerTOTPToken, `\d{6}`))
+}
+
+func TestClient_GetZone(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /anycast/nicmanager-anycastdns4.net",
+ servermock.ResponseFromFixture("zone.json")).
+ Build(t)
+
+ zone, err := client.GetZone(t.Context(), "nicmanager-anycastdns4.net")
require.NoError(t, err)
expected := &Zone{
@@ -39,14 +59,22 @@ func TestClient_GetZone(t *testing.T) {
}
func TestClient_GetZone_error(t *testing.T) {
- client := setupTest(t, "/anycast/foo", testHandler(http.MethodGet, http.StatusNotFound, "error.json"))
+ client := mockBuilder().
+ Route("GET /anycast/foo",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
- _, err := client.GetZone(context.Background(), "foo")
- require.Error(t, err)
+ _, err := client.GetZone(t.Context(), "foo")
+ require.EqualError(t, err, "404: Not Found")
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "/anycast/zonedomain.tld/records", testHandler(http.MethodPost, http.StatusAccepted, "error.json"))
+ client := mockBuilder().
+ Route("POST /anycast/zonedomain.tld/records",
+ servermock.Noop().
+ WithStatusCode(http.StatusAccepted)).
+ Build(t)
record := RecordCreateUpdate{
Type: "TXT",
@@ -55,12 +83,16 @@ func TestClient_AddRecord(t *testing.T) {
TTL: 3600,
}
- err := client.AddRecord(context.Background(), "zonedomain.tld", record)
+ err := client.AddRecord(t.Context(), "zonedomain.tld", record)
require.NoError(t, err)
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "/anycast/zonedomain.tld", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json"))
+ client := mockBuilder().
+ Route("POST /anycast/zonedomain.tld/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
record := RecordCreateUpdate{
Type: "TXT",
@@ -69,78 +101,28 @@ func TestClient_AddRecord_error(t *testing.T) {
TTL: 3600,
}
- err := client.AddRecord(context.Background(), "zonedomain.tld", record)
- require.Error(t, err)
+ err := client.AddRecord(t.Context(), "zonedomain.tld", record)
+ require.EqualError(t, err, "401: Not Found")
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusAccepted, "error.json"))
+ client := mockBuilder().
+ Route("DELETE /anycast/zonedomain.tld/records/6",
+ servermock.Noop().
+ WithStatusCode(http.StatusAccepted)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "zonedomain.tld", 6)
+ err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6)
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusNoContent, ""))
+ client := mockBuilder().
+ Route("DELETE /anycast/zonedomain.tld/records/6",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "zonedomain.tld", 7)
- require.Error(t, err)
-}
-
-func setupTest(t *testing.T, path string, handler http.Handler) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.Handle(path, handler)
-
- opts := Options{
- Login: "foo",
- Username: "bar",
- Password: "foo",
- OTP: "2hsn",
- }
-
- client := NewClient(opts)
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client
-}
-
-func testHandler(method string, statusCode int, filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- username, password, ok := req.BasicAuth()
- if !ok || username != "foo.bar" || password != "foo" {
- http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(statusCode)
-
- if statusCode == http.StatusNoContent {
- return
- }
-
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
-
- defer func() { _ = file.Close() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
- return
- }
- }
+ err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6)
+ require.EqualError(t, err, "404: Not Found")
}
diff --git a/providers/dns/nicmanager/nicmanager.go b/providers/dns/nicmanager/nicmanager.go
index f9307d8c1..9b27df64e 100644
--- a/providers/dns/nicmanager/nicmanager.go
+++ b/providers/dns/nicmanager/nicmanager.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/nicmanager/internal"
)
@@ -24,7 +25,7 @@ const (
EnvEmail = envNamespace + "API_EMAIL"
EnvPassword = envNamespace + "API_PASSWORD"
EnvOTP = envNamespace + "API_OTP"
- EnvMode = envNamespace + "MODE"
+ EnvMode = envNamespace + "API_MODE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -85,7 +86,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.Password = values[EnvPassword]
- config.Mode = env.GetOrDefaultString(EnvMode, internal.ModeAnycast)
+ config.Mode = env.GetOneWithFallback(EnvMode, internal.ModeAnycast, env.ParseString, envNamespace+"MODE")
config.Username = env.GetOrFile(EnvUsername)
config.Login = env.GetOrFile(EnvLogin)
config.Email = env.GetOrFile(EnvEmail)
@@ -128,6 +129,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{client: client, config: config}, nil
}
@@ -188,8 +191,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
name := dns01.UnFqdn(info.EffectiveFQDN)
- var existingRecord internal.Record
- var existingRecordFound bool
+ var (
+ existingRecord internal.Record
+ existingRecordFound bool
+ )
+
for _, record := range zone.Records {
if strings.EqualFold(record.Type, "TXT") && strings.EqualFold(record.Name, name) && record.Content == info.Value {
existingRecord = record
diff --git a/providers/dns/nicmanager/nicmanager.toml b/providers/dns/nicmanager/nicmanager.toml
index 7be44deb8..d5921de5a 100644
--- a/providers/dns/nicmanager/nicmanager.toml
+++ b/providers/dns/nicmanager/nicmanager.toml
@@ -13,7 +13,7 @@ NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
-lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run
+lego --dns nicmanager -d '*.example.com' -d example.com run
## Login using account name + username
@@ -24,14 +24,14 @@ NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
-lego --email you@example.com --dns nicmanager -d '*.example.com' -d example.com run
+lego --dns nicmanager -d '*.example.com' -d example.com run
'''
Additional = '''
## Description
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`.
+Optionally, if TOTP is configured for your account, set `NICMANAGER_API_OTP`.
'''
[Configuration]
@@ -42,11 +42,11 @@ Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`.
NICMANAGER_API_PASSWORD = "Password, always required"
[Configuration.Additional]
NICMANAGER_API_OTP = "TOTP Secret (optional)"
- NICMANAGER_API_MODE = "mode: 'anycast' or 'zone' (default: 'anycast')"
- NICMANAGER_POLLING_INTERVAL = "Time between DNS propagation check"
- NICMANAGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NICMANAGER_TTL = "The TTL of the TXT record used for the DNS challenge"
- NICMANAGER_HTTP_TIMEOUT = "API request timeout"
+ NICMANAGER_API_MODE = "mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast')"
+ NICMANAGER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NICMANAGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ NICMANAGER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 900)"
+ NICMANAGER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://api.nicmanager.com/docs/v1/"
diff --git a/providers/dns/nicmanager/nicmanager_test.go b/providers/dns/nicmanager/nicmanager_test.go
index bc2f50cc3..114cdb7ca 100644
--- a/providers/dns/nicmanager/nicmanager_test.go
+++ b/providers/dns/nicmanager/nicmanager_test.go
@@ -66,6 +66,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -159,6 +160,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -172,6 +174,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/nicru/internal/client.go b/providers/dns/nicru/internal/client.go
new file mode 100644
index 000000000..5d851fc76
--- /dev/null
+++ b/providers/dns/nicru/internal/client.go
@@ -0,0 +1,250 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const (
+ apiBaseURL = "https://api.nic.ru/dns-master"
+ tokenURL = "https://api.nic.ru/oauth/token"
+)
+
+const successStatus = "success"
+
+// Trimmer trim all XML fields.
+type Trimmer struct {
+ decoder *xml.Decoder
+}
+
+func (tr Trimmer) Token() (xml.Token, error) {
+ t, err := tr.decoder.Token()
+ if cd, ok := t.(xml.CharData); ok {
+ t = xml.CharData(bytes.TrimSpace(cd))
+ }
+
+ return t, err
+}
+
+type Client struct {
+ baseURL *url.URL
+ httpClient *http.Client
+}
+
+func NewClient(httpClient *http.Client) (*Client, error) {
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: 5 * time.Second}
+ }
+
+ baseURL, _ := url.Parse(apiBaseURL)
+
+ return &Client{
+ baseURL: baseURL,
+ httpClient: httpClient,
+ }, nil
+}
+
+func (c *Client) GetServices(ctx context.Context) ([]Service, error) {
+ endpoint := c.baseURL.JoinPath("services")
+
+ req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ apiResponse, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if apiResponse.Data == nil {
+ return nil, nil
+ }
+
+ return apiResponse.Data.Service, nil
+}
+
+func (c *Client) ListZones(ctx context.Context) ([]Zone, error) {
+ endpoint := c.baseURL.JoinPath("zones")
+
+ req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ apiResponse, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if apiResponse.Data == nil {
+ return nil, nil
+ }
+
+ return apiResponse.Data.Zone, nil
+}
+
+func (c *Client) GetZonesByService(ctx context.Context, serviceName string) ([]Zone, error) {
+ endpoint := c.baseURL.JoinPath("services", serviceName, "zones")
+
+ req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ apiResponse, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if apiResponse.Data == nil {
+ return nil, nil
+ }
+
+ return apiResponse.Data.Zone, nil
+}
+
+func (c *Client) GetRecords(ctx context.Context, serviceName, zoneName string) ([]RR, error) {
+ endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records")
+
+ req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ apiResponse, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if apiResponse.Data == nil {
+ return nil, nil
+ }
+
+ var records []RR
+ for _, zone := range apiResponse.Data.Zone {
+ records = append(records, zone.RR...)
+ }
+
+ return records, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, serviceName, zoneName, id string) error {
+ endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records", id)
+
+ req, err := newXMLRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ _, err = c.do(req)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) CommitZone(ctx context.Context, serviceName, zoneName string) error {
+ endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "commit")
+
+ req, err := newXMLRequest(ctx, http.MethodPost, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ _, err = c.do(req)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) AddRecords(ctx context.Context, serviceName, zoneName string, rrs []RR) ([]Zone, error) {
+ endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records")
+
+ payload := &Request{RRList: &RRList{RR: rrs}}
+
+ req, err := newXMLRequest(ctx, http.MethodPut, endpoint, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ apiResponse, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if apiResponse.Data == nil {
+ return nil, nil
+ }
+
+ return apiResponse.Data.Zone, nil
+}
+
+func (c *Client) do(req *http.Request) (*Response, error) {
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ apiResponse := &Response{}
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))})
+
+ err = decoder.Decode(apiResponse)
+ if err != nil {
+ return nil, fmt.Errorf("[status code=%d] decode XML response: %s", resp.StatusCode, string(raw))
+ }
+
+ if apiResponse.Status != successStatus {
+ return nil, fmt.Errorf("[status code=%d] %s: %w", resp.StatusCode, apiResponse.Status, apiResponse.Errors.Error)
+ }
+
+ return apiResponse, nil
+}
+
+func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ body := new(bytes.Buffer)
+
+ if payload != nil {
+ body.WriteString(xml.Header)
+
+ encoder := xml.NewEncoder(body)
+ encoder.Indent("", " ")
+
+ err := encoder.Encode(payload)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "text/xml")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "text/xml")
+ }
+
+ return req, nil
+}
diff --git a/providers/dns/nicru/internal/client_test.go b/providers/dns/nicru/internal/client_test.go
new file mode 100644
index 000000000..f01300406
--- /dev/null
+++ b/providers/dns/nicru/internal/client_test.go
@@ -0,0 +1,398 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.Client())
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithAccept("text/xml"),
+ )
+}
+
+func TestClient_GetServices(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /services", servermock.ResponseFromFixture("services_GET.xml")).
+ Build(t)
+
+ zones, err := client.GetServices(t.Context())
+ require.NoError(t, err)
+
+ expected := []Service{
+ {
+ Admin: "123/NIC-REG",
+ DomainsLimit: "12",
+ DomainsNum: "5",
+ Enable: "true",
+ HasPrimary: "false",
+ Name: "testservice",
+ Payer: "123/NIC-REG",
+ Tariff: "Secondary L",
+ },
+ {
+ Admin: "123/NIC-REG",
+ DomainsLimit: "150",
+ DomainsNum: "10",
+ Enable: "true",
+ HasPrimary: "true",
+ Name: "myservice",
+ Payer: "123/NIC-REG",
+ Tariff: "DNS-master XXL",
+ RRLimit: "7500",
+ RRNum: "1000",
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_ListZones(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones", servermock.ResponseFromFixture("zones_all_GET.xml")).
+ Build(t)
+
+ zones, err := client.ListZones(t.Context())
+ require.NoError(t, err)
+
+ expected := []Zone{
+ {
+ Admin: "123/NIC-REG",
+ Enable: "true",
+ HasChanges: "false",
+ HasPrimary: "true",
+ ID: "227645",
+ IDNName: "тест.рф",
+ Name: "xn—e1aybc.xn--p1ai",
+ Payer: "123/NIC-REG",
+ Service: "myservice",
+ },
+ {
+ Admin: "123/NIC-REG",
+ Enable: "true",
+ HasChanges: "false",
+ HasPrimary: "true",
+ ID: "227642",
+ IDNName: "example.ru",
+ Name: "example.ru",
+ Payer: "123/NIC-REG",
+ Service: "myservice",
+ },
+ {
+ Admin: "123/NIC-REG",
+ Enable: "true",
+ HasChanges: "false",
+ HasPrimary: "true",
+ ID: "227643",
+ IDNName: "test.su",
+ Name: "test.su",
+ Payer: "123/NIC-REG",
+ Service: "myservice",
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_ListZones_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones", servermock.ResponseFromFixture("errors.xml")).
+ Build(t)
+
+ _, err := client.ListZones(t.Context())
+ require.ErrorIs(t, err, Error{
+ Text: "Access token expired or not found",
+ Code: "4097",
+ })
+}
+
+func TestClient_GetZonesByService(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /services/test/zones",
+ servermock.ResponseFromFixture("zones_GET.xml")).
+ Build(t)
+
+ zones, err := client.GetZonesByService(t.Context(), "test")
+ require.NoError(t, err)
+
+ expected := []Zone{
+ {
+ Admin: "123/NIC-REG",
+ Enable: "true",
+ HasChanges: "false",
+ HasPrimary: "true",
+ ID: "227645",
+ IDNName: "тест.рф",
+ Name: "xn—e1aybc.xn--p1ai",
+ Payer: "123/NIC-REG",
+ Service: "myservice",
+ },
+ {
+ Admin: "123/NIC-REG",
+ Enable: "true",
+ HasChanges: "false",
+ HasPrimary: "true",
+ ID: "227642",
+ IDNName: "example.ru",
+ Name: "example.ru",
+ Payer: "123/NIC-REG",
+ Service: "myservice",
+ },
+ {
+ Admin: "123/NIC-REG",
+ Enable: "true",
+ HasChanges: "false",
+ HasPrimary: "true",
+ ID: "227643",
+ IDNName: "test.su",
+ Name: "test.su",
+ Payer: "123/NIC-REG",
+ Service: "myservice",
+ },
+ }
+
+ assert.Equal(t, expected, zones)
+}
+
+func TestClient_GetZonesByService_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /services/test/zones",
+ servermock.ResponseFromFixture("errors.xml")).
+ Build(t)
+
+ _, err := client.GetZonesByService(t.Context(), "test")
+ require.ErrorIs(t, err, Error{
+ Text: "Access token expired or not found",
+ Code: "4097",
+ })
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /services/test/zones/example.com./records",
+ servermock.ResponseFromFixture("records_GET.xml")).
+ Build(t)
+
+ records, err := client.GetRecords(t.Context(), "test", "example.com.")
+ require.NoError(t, err)
+
+ expected := []RR{
+ {
+ ID: "210074",
+ Name: "@",
+ IDNName: "@",
+ TTL: "",
+ Type: "SOA",
+ SOA: &SOA{
+ MName: &MName{
+ Name: "ns3-l2.nic.ru.",
+ IDNName: "ns3-l2.nic.ru.",
+ },
+ RName: &RName{
+ Name: "dns.nic.ru.",
+ IDNName: "dns.nic.ru.",
+ },
+ Serial: "2011112002",
+ Refresh: "1440",
+ Retry: "3600",
+ Expire: "2592000",
+ Minimum: "600",
+ },
+ },
+ {
+ ID: "210075",
+ Name: "@",
+ IDNName: "@",
+ Type: "NS",
+ NS: &NS{
+ Name: "ns3-l2.nic.ru.",
+ IDNName: "ns3- l2.nic.ru.",
+ },
+ },
+ {
+ ID: "210076",
+ Name: "@",
+ IDNName: "@",
+ Type: "NS",
+ NS: &NS{
+ Name: "ns4-l2.nic.ru.",
+ IDNName: "ns4-l2.nic.ru.",
+ },
+ },
+ {
+ ID: "210077",
+ Name: "@",
+ IDNName: "@",
+ Type: "NS",
+ NS: &NS{
+ Name: "ns8-l2.nic.ru.",
+ IDNName: "ns8- l2.nic.ru.",
+ },
+ },
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_GetRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /services/test/zones/example.com./records",
+ servermock.ResponseFromFixture("errors.xml")).
+ Build(t)
+
+ _, err := client.GetRecords(t.Context(), "test", "example.com.")
+ require.ErrorIs(t, err, Error{
+ Text: "Access token expired or not found",
+ Code: "4097",
+ })
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /services/test/zones/example.com./records",
+ servermock.ResponseFromFixture("records_PUT.xml"),
+ servermock.CheckHeader().
+ WithContentType("text/xml")).
+ Build(t)
+
+ rrs := []RR{
+ {
+ Name: "@",
+ Type: "NS",
+ NS: &NS{Name: "ns4-l2.nic.ru."},
+ },
+ {
+ Name: "@",
+ Type: "NS",
+ NS: &NS{Name: "ns8-l2.nic.ru."},
+ },
+ }
+
+ response, err := client.AddRecords(t.Context(), "test", "example.com.", rrs)
+ require.NoError(t, err)
+
+ expected := []Zone{
+ {
+ Admin: "123/NIC-REG",
+ HasChanges: "true",
+ ID: "228095",
+ IDNName: "test.ru",
+ Name: "test.ru",
+ Service: "testservice",
+ RR: []RR{
+ {
+ ID: "210076",
+ Name: "@",
+ IDNName: "@",
+ Type: "NS",
+ NS: &NS{
+ Name: "ns4-l2.nic.ru.",
+ IDNName: "ns4-l2.nic.ru.",
+ },
+ },
+ {
+ ID: "210077",
+ Name: "@",
+ IDNName: "@",
+ Type: "NS",
+ NS: &NS{
+ Name: "ns8-l2.nic.ru.",
+ IDNName: "ns8-l2.nic.ru.",
+ },
+ },
+ },
+ },
+ }
+
+ assert.Equal(t, expected, response)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /services/test/zones/example.com./records",
+ servermock.ResponseFromFixture("errors.xml"),
+ servermock.CheckHeader().
+ WithContentType("text/xml")).
+ Build(t)
+
+ rrs := []RR{
+ {
+ Name: "@",
+ Type: "NS",
+ NS: &NS{Name: "ns4-l2.nic.ru."},
+ },
+ {
+ Name: "@",
+ Type: "NS",
+ NS: &NS{Name: "ns8-l2.nic.ru."},
+ },
+ }
+
+ _, err := client.AddRecords(t.Context(), "test", "example.com.", rrs)
+ require.ErrorIs(t, err, Error{
+ Text: "Access token expired or not found",
+ Code: "4097",
+ })
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /services/test/zones/example.com./records/123",
+ servermock.ResponseFromFixture("record_DELETE.xml")).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "test", "example.com.", "123")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /services/test/zones/example.com./records/123",
+ servermock.ResponseFromFixture("errors.xml")).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "test", "example.com.", "123")
+ require.ErrorIs(t, err, Error{
+ Text: "Access token expired or not found",
+ Code: "4097",
+ })
+}
+
+func TestClient_CommitZone(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /services/test/zones/example.com./commit",
+ servermock.ResponseFromFixture("commit_POST.xml")).
+ Build(t)
+
+ err := client.CommitZone(t.Context(), "test", "example.com.")
+ require.NoError(t, err)
+}
+
+func TestClient_CommitZone_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /services/test/zones/example.com./commit",
+ servermock.ResponseFromFixture("errors.xml")).
+ Build(t)
+
+ err := client.CommitZone(t.Context(), "test", "example.com.")
+ require.ErrorIs(t, err, Error{
+ Text: "Access token expired or not found",
+ Code: "4097",
+ })
+}
diff --git a/providers/dns/nicru/internal/fixtures/commit_POST.xml b/providers/dns/nicru/internal/fixtures/commit_POST.xml
new file mode 100644
index 000000000..530a22d16
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/commit_POST.xml
@@ -0,0 +1,4 @@
+
+
+ success
+
diff --git a/providers/dns/nicru/internal/fixtures/errors.xml b/providers/dns/nicru/internal/fixtures/errors.xml
new file mode 100644
index 000000000..961b9a495
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/errors.xml
@@ -0,0 +1,7 @@
+
+
+ fail
+
+ Access token expired or not found
+
+
diff --git a/providers/dns/nicru/internal/fixtures/record_DELETE.xml b/providers/dns/nicru/internal/fixtures/record_DELETE.xml
new file mode 100644
index 000000000..530a22d16
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/record_DELETE.xml
@@ -0,0 +1,4 @@
+
+
+ success
+
diff --git a/providers/dns/nicru/internal/fixtures/records_GET.xml b/providers/dns/nicru/internal/fixtures/records_GET.xml
new file mode 100644
index 000000000..a9df348f9
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/records_GET.xml
@@ -0,0 +1,55 @@
+
+
+ success
+
+
+
+ @
+ @
+ SOA
+
+
+ ns3-l2.nic.ru.
+ ns3-l2.nic.ru.
+
+
+ dns.nic.ru.
+ dns.nic.ru.
+
+ 2011112002
+ 1440
+ 3600
+ 2592000
+ 600
+
+
+
+ @
+ @
+ NS
+
+ ns3-l2.nic.ru.
+ ns3- l2.nic.ru.
+
+
+
+ @
+ @
+ NS
+
+ ns4-l2.nic.ru.
+ ns4-l2.nic.ru.
+
+
+
+ @
+ @
+ NS
+
+ ns8-l2.nic.ru.
+ ns8- l2.nic.ru.
+
+
+
+
+
diff --git a/providers/dns/nicru/internal/fixtures/records_PUT.xml b/providers/dns/nicru/internal/fixtures/records_PUT.xml
new file mode 100644
index 000000000..a3417a8f3
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/records_PUT.xml
@@ -0,0 +1,10 @@
+
+
+ success
+
+
+ @@NSns4-l2.nic.ru.ns4-l2.nic.ru.
+ @@NSns8-l2.nic.ru.ns8-l2.nic.ru.
+
+
+
diff --git a/providers/dns/nicru/internal/fixtures/services_GET.xml b/providers/dns/nicru/internal/fixtures/services_GET.xml
new file mode 100644
index 000000000..9534b0b34
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/services_GET.xml
@@ -0,0 +1,12 @@
+
+
+ success
+
+
+
+
+
diff --git a/providers/dns/nicru/internal/fixtures/zones_GET.xml b/providers/dns/nicru/internal/fixtures/zones_GET.xml
new file mode 100644
index 000000000..efa2da9a2
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/zones_GET.xml
@@ -0,0 +1,12 @@
+
+
+ success
+
+
+
+
+
+
diff --git a/providers/dns/nicru/internal/fixtures/zones_all_GET.xml b/providers/dns/nicru/internal/fixtures/zones_all_GET.xml
new file mode 100644
index 000000000..efa2da9a2
--- /dev/null
+++ b/providers/dns/nicru/internal/fixtures/zones_all_GET.xml
@@ -0,0 +1,12 @@
+
+
+ success
+
+
+
+
+
+
diff --git a/providers/dns/nicru/internal/identity.go b/providers/dns/nicru/internal/identity.go
new file mode 100644
index 000000000..b4281adbe
--- /dev/null
+++ b/providers/dns/nicru/internal/identity.go
@@ -0,0 +1,64 @@
+package internal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "golang.org/x/oauth2"
+)
+
+// OauthConfiguration credentials.
+type OauthConfiguration struct {
+ OAuth2ClientID string
+ OAuth2SecretID string
+ Username string
+ Password string
+}
+
+func (config *OauthConfiguration) Validate() error {
+ msg := " is missing in credentials information"
+
+ if config.Username == "" {
+ return errors.New("username" + msg)
+ }
+
+ if config.Password == "" {
+ return errors.New("password" + msg)
+ }
+
+ if config.OAuth2ClientID == "" {
+ return errors.New("serviceID" + msg)
+ }
+
+ if config.OAuth2SecretID == "" {
+ return errors.New("secret" + msg)
+ }
+
+ return nil
+}
+
+func NewOauthClient(ctx context.Context, config *OauthConfiguration) (*http.Client, error) {
+ err := config.Validate()
+ if err != nil {
+ return nil, err
+ }
+
+ oauth2Config := oauth2.Config{
+ ClientID: config.OAuth2ClientID,
+ ClientSecret: config.OAuth2SecretID,
+ Endpoint: oauth2.Endpoint{
+ TokenURL: tokenURL,
+ AuthStyle: oauth2.AuthStyleInParams,
+ },
+ Scopes: []string{".+:/dns-master/.+"},
+ }
+
+ oauth2Token, err := oauth2Config.PasswordCredentialsToken(ctx, config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create oauth2 token: %w", err)
+ }
+
+ return oauth2Config.Client(ctx, oauth2Token), nil
+}
diff --git a/providers/dns/nicru/internal/types.go b/providers/dns/nicru/internal/types.go
new file mode 100644
index 000000000..ad3f8cc9a
--- /dev/null
+++ b/providers/dns/nicru/internal/types.go
@@ -0,0 +1,214 @@
+package internal
+
+import (
+ "encoding/xml"
+ "fmt"
+)
+
+type Request struct {
+ XMLName xml.Name `xml:"request"`
+ Text string `xml:",chardata"`
+ RRList *RRList `xml:"rr-list"`
+}
+
+type RRList struct {
+ Text string `xml:",chardata"`
+ RR []RR `xml:"rr"`
+}
+
+type RR struct {
+ Text string `xml:",chardata"`
+ ID string `xml:"id,attr,omitempty"`
+ Name string `xml:"name"`
+ IDNName string `xml:"idn-name"`
+ TTL string `xml:"ttl"`
+ Type string `xml:"type"`
+ SOA *SOA `xml:"soa,omitempty"`
+ A string `xml:"a,omitempty"`
+ AAAA string `xml:"aaaa,omitempty"`
+ CName *CName `xml:"cname,omitempty"`
+ NS *NS `xml:"ns,omitempty"`
+ MX *MX `xml:"mx,omitempty"`
+ SRV *SRV `xml:"srv,omitempty"`
+ PTR *PTR `xml:"ptr,omitempty"`
+ TXT *TXT `xml:"txt,omitempty"`
+ DName *DName `xml:"dname,omitempty"`
+ HInfo *HInfo `xml:"hinfo,omitempty"`
+ NAPTR *NAPTR `xml:"naptr,omitempty"`
+ RP *RP `xml:"rp,omitempty"`
+}
+
+type SOA struct {
+ Text string `xml:",chardata"`
+ MName *MName `xml:"mname"`
+ RName *RName `xml:"rname"`
+ Serial string `xml:"serial"`
+ Refresh string `xml:"refresh"`
+ Retry string `xml:"retry"`
+ Expire string `xml:"expire"`
+ Minimum string `xml:"minimum"`
+}
+
+type MName struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+ IDNName string `xml:"idn-name,omitempty"`
+}
+
+type RName struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+ IDNName string `xml:"idn-name,omitempty"`
+}
+
+type NS struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+ IDNName string `xml:"idn-name,omitempty"`
+}
+
+type MX struct {
+ Text string `xml:",chardata"`
+ Preference string `xml:"preference"`
+ Exchange *Exchange `xml:"exchange"`
+}
+
+type Exchange struct {
+ Name string `xml:"name"`
+}
+
+type SRV struct {
+ Text string `xml:",chardata"`
+ Priority string `xml:"priority"`
+ Weight string `xml:"weight"`
+ Port string `xml:"port"`
+ Target *Target `xml:"target"`
+}
+
+type Target struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+}
+
+type PTR struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+}
+
+type HInfo struct {
+ Text string `xml:",chardata"`
+ Hardware string `xml:"hardware"`
+ OS string `xml:"os"`
+}
+
+type NAPTR struct {
+ Text string `xml:",chardata"`
+ Order string `xml:"order"`
+ Preference string `xml:"preference"`
+ Flags string `xml:"flags"`
+ Service string `xml:"service"`
+ Regexp string `xml:"regexp"`
+ Replacement *Replacement `xml:"replacement"`
+}
+
+type Replacement struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+}
+
+type RP struct {
+ Text string `xml:",chardata"`
+ MboxDName *MboxDName `xml:"mbox-dname"`
+ TxtDName *TxtDName `xml:"txt-dname"`
+}
+
+type MboxDName struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+}
+
+type TxtDName struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+}
+
+type CName struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+ IDNName string `xml:"idn-name,omitempty"`
+}
+
+type DName struct {
+ Text string `xml:",chardata"`
+ Name string `xml:"name"`
+}
+
+type TXT struct {
+ Text string `xml:",chardata"`
+ String string `xml:"string"`
+}
+
+type Response struct {
+ XMLName xml.Name `xml:"response"`
+ Text string `xml:",chardata"`
+ Status string `xml:"status"`
+ Data *Data `xml:"data"`
+ Errors Errors `xml:"errors"`
+}
+
+type Data struct {
+ Text string `xml:",chardata"`
+ Service []Service `xml:"service"`
+ Zone []Zone `xml:"zone"`
+ Address []string `xml:"address"`
+ Revision []Revision `xml:"revision"`
+}
+
+type Errors struct {
+ Text string `xml:",chardata"`
+ Error Error `xml:"error"`
+}
+
+type Error struct {
+ Text string `xml:",chardata"`
+ Code string `xml:"code,attr"`
+}
+
+func (e Error) Error() string {
+ return fmt.Sprintf("%s (code %s)", e.Text, e.Code)
+}
+
+type Service struct {
+ Text string `xml:",chardata"`
+ Admin string `xml:"admin,attr"`
+ DomainsLimit string `xml:"domains-limit,attr"`
+ DomainsNum string `xml:"domains-num,attr"`
+ Enable string `xml:"enable,attr"`
+ HasPrimary string `xml:"has-primary,attr"`
+ Name string `xml:"name,attr"`
+ Payer string `xml:"payer,attr"`
+ Tariff string `xml:"tariff,attr"`
+ RRLimit string `xml:"rr-limit,attr"`
+ RRNum string `xml:"rr-num,attr"`
+}
+
+type Zone struct {
+ Text string `xml:",chardata"`
+ Admin string `xml:"admin,attr"`
+ Enable string `xml:"enable,attr"`
+ HasChanges string `xml:"has-changes,attr"`
+ HasPrimary string `xml:"has-primary,attr"`
+ ID string `xml:"id,attr"`
+ IDNName string `xml:"idn-name,attr"`
+ Name string `xml:"name,attr"`
+ Payer string `xml:"payer,attr"`
+ Service string `xml:"service,attr"`
+ RR []RR `xml:"rr"`
+}
+
+type Revision struct {
+ Text string `xml:",chardata"`
+ Date string `xml:"date,attr"`
+ IP string `xml:"ip,attr"`
+ Number string `xml:"number,attr"`
+}
diff --git a/providers/dns/nicru/nicru.go b/providers/dns/nicru/nicru.go
new file mode 100644
index 000000000..cf4255bdb
--- /dev/null
+++ b/providers/dns/nicru/nicru.go
@@ -0,0 +1,239 @@
+// Package nicru implements a DNS provider for solving the DNS-01 challenge using RU Center.
+package nicru
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/nicru/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "NICRU_"
+
+ EnvUsername = envNamespace + "USER"
+ EnvPassword = envNamespace + "PASSWORD"
+ EnvServiceID = envNamespace + "SERVICE_ID"
+ EnvSecret = envNamespace + "SECRET"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ TTL int
+ Username string
+ Password string
+ ServiceID string
+ Secret string
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, 30),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute),
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ client *internal.Client
+ config *Config
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for RU Center.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword, EnvServiceID, EnvSecret)
+ if err != nil {
+ return nil, fmt.Errorf("nicru: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+ config.ServiceID = values[EnvServiceID]
+ config.Secret = values[EnvSecret]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for RU Center.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("nicru: the configuration of the DNS provider is nil")
+ }
+
+ clientCfg := &internal.OauthConfiguration{
+ OAuth2ClientID: config.ServiceID,
+ OAuth2SecretID: config.Secret,
+ Username: config.Username,
+ Password: config.Password,
+ }
+
+ oauthClient, err := internal.NewOauthClient(context.Background(), clientCfg)
+ if err != nil {
+ return nil, fmt.Errorf("nicru: %w", err)
+ }
+
+ client, err := internal.NewClient(clientdebug.Wrap(oauthClient))
+ if err != nil {
+ return nil, fmt.Errorf("nicru: unable to build API client: %w", err)
+ }
+
+ return &DNSProvider{
+ client: client,
+ config: config,
+ }, nil
+}
+
+// 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("nicru: could not find zone for domain %q: %w", domain, err)
+ }
+
+ authZone = dns01.UnFqdn(authZone)
+
+ zone, err := d.findZone(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: find zone: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: %w", err)
+ }
+
+ records, err := d.client.GetRecords(ctx, zone.Service, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: get records: %w", err)
+ }
+
+ for _, record := range records {
+ if record.TXT == nil {
+ continue
+ }
+
+ if record.TXT.Text == subDomain && record.TXT.String == info.Value {
+ return nil
+ }
+ }
+
+ rrs := []internal.RR{{
+ Name: subDomain,
+ TTL: strconv.Itoa(d.config.TTL),
+ Type: "TXT",
+ TXT: &internal.TXT{String: info.Value},
+ }}
+
+ _, err = d.client.AddRecords(ctx, zone.Service, authZone, rrs)
+ if err != nil {
+ return fmt.Errorf("nicru: add records: %w", err)
+ }
+
+ err = d.client.CommitZone(ctx, zone.Service, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: commit zone: %w", 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("nicru: could not find zone for domain %q: %w", domain, err)
+ }
+
+ authZone = dns01.UnFqdn(authZone)
+
+ zone, err := d.findZone(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: find zone: %w", err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: %w", err)
+ }
+
+ records, err := d.client.GetRecords(ctx, zone.Service, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: get records: %w", err)
+ }
+
+ subDomain = dns01.UnFqdn(subDomain)
+
+ for _, record := range records {
+ if record.TXT == nil {
+ continue
+ }
+
+ if record.Name != subDomain || record.TXT.String != info.Value {
+ continue
+ }
+
+ err = d.client.DeleteRecord(ctx, zone.Service, authZone, record.ID)
+ if err != nil {
+ return fmt.Errorf("nicru: delete record: %w", err)
+ }
+ }
+
+ err = d.client.CommitZone(ctx, zone.Service, authZone)
+ if err != nil {
+ return fmt.Errorf("nicru: commit zone: %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findZone(ctx context.Context, authZone string) (*internal.Zone, error) {
+ zones, err := d.client.ListZones(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("unable to fetch dns zones: %w", err)
+ }
+
+ if len(zones) == 0 {
+ return nil, errors.New("no zones found")
+ }
+
+ for _, zone := range zones {
+ if zone.Name == authZone {
+ return &zone, nil
+ }
+ }
+
+ return nil, fmt.Errorf("zone not found for %s", authZone)
+}
diff --git a/providers/dns/nicru/nicru.toml b/providers/dns/nicru/nicru.toml
new file mode 100644
index 000000000..f955511a2
--- /dev/null
+++ b/providers/dns/nicru/nicru.toml
@@ -0,0 +1,41 @@
+Name = "RU CENTER"
+Description = ''''''
+URL = "https://nic.ru/"
+Code = "nicru"
+Since = "v4.24.0"
+
+Example = '''
+NICRU_USER="" \
+NICRU_PASSWORD="" \
+NICRU_SERVICE_ID="" \
+NICRU_SECRET="" \
+lego --dns nicru -d '*.example.com' -d example.com run
+'''
+
+Additional = '''
+## Credential information
+
+You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list
+
+| ENV Variable | Parameter from page | Example |
+|---------------------|--------------------------------|-------------------|
+| NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D |
+| NICRU_PASSWORD | Password account | |
+| NICRU_SERVICE_ID | Application ID | hex-based, len 32 |
+| NICRU_SECRET | Identity endpoint | string len 91 |
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ NICRU_USER = "Agreement for an account in RU CENTER"
+ NICRU_PASSWORD = "Password for an account in RU CENTER"
+ NICRU_SERVICE_ID = "Service ID for application in DNS-hosting RU CENTER"
+ NICRU_SECRET = "Secret for application in DNS-hosting RU CENTER"
+ NICRU_SERVICE_NAME = "Service Name for DNS-hosting RU CENTER"
+ [Configuration.Additional]
+ NICRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)"
+ NICRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)"
+ NICRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.nic.ru/help/api-dns-hostinga_3643.html"
diff --git a/providers/dns/nicru/nicru_test.go b/providers/dns/nicru/nicru_test.go
new file mode 100644
index 000000000..7e71f9d2c
--- /dev/null
+++ b/providers/dns/nicru/nicru_test.go
@@ -0,0 +1,195 @@
+package nicru
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ fakeServiceID = "2519234972459cdfa23423adf143324f"
+ fakeSecret = "oo5ahrie0aiPho3Vee4siupoPhahdahCh1thiesohru"
+ fakeUsername = "1234567/NIC-D"
+ fakePassword = "einge8Goo2eBaiXievuj"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvServiceID, EnvSecret).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvServiceID: fakeServiceID,
+ EnvSecret: fakeSecret,
+ EnvUsername: fakeUsername,
+ EnvPassword: fakePassword,
+ },
+ expected: "nicru: failed to create oauth2 token: oauth2: \"unauthorized_client\"",
+ },
+ {
+ desc: "missing serviceID",
+ envVars: map[string]string{
+ EnvSecret: fakeSecret,
+ EnvUsername: fakeUsername,
+ EnvPassword: fakePassword,
+ },
+ expected: "nicru: some credentials information are missing: NICRU_SERVICE_ID",
+ },
+ {
+ desc: "missing secret",
+ envVars: map[string]string{
+ EnvServiceID: fakeServiceID,
+ EnvUsername: fakeUsername,
+ EnvPassword: fakePassword,
+ },
+ expected: "nicru: some credentials information are missing: NICRU_SECRET",
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvServiceID: fakeServiceID,
+ EnvSecret: fakeSecret,
+ EnvPassword: fakePassword,
+ },
+ expected: "nicru: some credentials information are missing: NICRU_USER",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvServiceID: fakeServiceID,
+ EnvSecret: fakeSecret,
+ EnvUsername: fakeUsername,
+ },
+ expected: "nicru: some credentials information are missing: NICRU_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
+ config *Config
+ expected string
+ }{
+ {
+ desc: "success",
+ config: &Config{
+ ServiceID: fakeServiceID,
+ Secret: fakeSecret,
+ Username: fakeUsername,
+ Password: fakePassword,
+ },
+ expected: "nicru: failed to create oauth2 token: oauth2: \"unauthorized_client\"",
+ },
+ {
+ desc: "nil config",
+ config: nil,
+ expected: "nicru: the configuration of the DNS provider is nil",
+ },
+ {
+ desc: "missing username",
+ config: &Config{
+ ServiceID: fakeServiceID,
+ Password: fakePassword,
+ },
+ expected: "nicru: username is missing in credentials information",
+ },
+ {
+ desc: "missing password",
+ config: &Config{
+ ServiceID: fakeServiceID,
+ Secret: fakeSecret,
+ Username: fakeUsername,
+ },
+ expected: "nicru: password is missing in credentials information",
+ },
+ {
+ desc: "missing secret",
+ config: &Config{
+ ServiceID: fakeServiceID,
+ Username: fakeUsername,
+ Password: fakePassword,
+ },
+ expected: "nicru: secret is missing in credentials information",
+ },
+ {
+ desc: "missing serviceID",
+ config: &Config{
+ Secret: fakeSecret,
+ Username: fakeUsername,
+ Password: fakePassword,
+ },
+ expected: "nicru: serviceID is missing in credentials information",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ p, err := NewDNSProviderConfig(test.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)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/nifcloud/internal/client.go b/providers/dns/nifcloud/internal/client.go
index 4469a1f78..0f3851883 100644
--- a/providers/dns/nifcloud/internal/client.go
+++ b/providers/dns/nifcloud/internal/client.go
@@ -59,6 +59,7 @@ func (c *Client) ChangeResourceRecordSets(ctx context.Context, hostedZoneID stri
}
output := &ChangeResourceRecordSetsResponse{}
+
err = c.do(req, output)
if err != nil {
return nil, err
@@ -77,6 +78,7 @@ func (c *Client) GetChange(ctx context.Context, statusID string) (*GetChangeResp
}
output := &GetChangeResponse{}
+
err = c.do(req, output)
if err != nil {
return nil, err
@@ -129,6 +131,7 @@ func (c *Client) sign(req *http.Request) error {
}
mac := hmac.New(sha1.New, []byte(c.secretKey))
+
_, err := mac.Write([]byte(req.Header.Get("Date")))
if err != nil {
return err
@@ -148,6 +151,7 @@ func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payloa
if payload != nil {
body.WriteString(xml.Header)
+
err := xml.NewEncoder(body).Encode(payload)
if err != nil {
return nil, fmt.Errorf("failed to create request XML body: %w", err)
@@ -170,6 +174,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errResp := &ErrorResponse{}
+
err := xml.Unmarshal(raw, errResp)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/nifcloud/internal/client_test.go b/providers/dns/nifcloud/internal/client_test.go
index 06c4921e0..501265ada 100644
--- a/providers/dns/nifcloud/internal/client_test.go
+++ b/providers/dns/nifcloud/internal/client_test.go
@@ -1,38 +1,35 @@
package internal
import (
- "context"
- "fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, responseBody string, statusCode int) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("A", "B")
+ if err != nil {
+ return nil, err
+ }
- handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(statusCode)
- _, _ = fmt.Fprintln(w, responseBody)
- })
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- client, err := NewClient("A", "B")
- require.NoError(t, err)
-
- client.HTTPClient = server.Client()
- client.BaseURL, _ = url.Parse(server.URL)
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithRegexp("X-Nifty-Authorization", "NIFTY3-HTTPS NiftyAccessKeyId=A,Algorithm=HmacSHA1,Signature=.+"),
+ )
}
-func TestChangeResourceRecordSets(t *testing.T) {
+func TestClient_ChangeResourceRecordSets(t *testing.T) {
responseBody := `
@@ -43,9 +40,12 @@ func TestChangeResourceRecordSets(t *testing.T) {
`
- client := setupTest(t, responseBody, http.StatusOK)
+ client := mockBuilder().
+ Route("POST /", servermock.RawStringResponse(responseBody),
+ servermock.CheckHeader().WithContentType("text/xml; charset=utf-8")).
+ Build(t)
- res, err := client.ChangeResourceRecordSets(context.Background(), "example.com", ChangeResourceRecordSetsRequest{})
+ res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{})
require.NoError(t, err)
assert.Equal(t, "xxxxx", res.ChangeInfo.ID)
@@ -53,7 +53,7 @@ func TestChangeResourceRecordSets(t *testing.T) {
assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt)
}
-func TestChangeResourceRecordSetsErrors(t *testing.T) {
+func TestClient_ChangeResourceRecordSets_errors(t *testing.T) {
testCases := []struct {
desc string
responseBody string
@@ -90,16 +90,22 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := setupTest(t, test.responseBody, test.statusCode)
+ client := mockBuilder().
+ Route("POST /",
+ servermock.RawStringResponse(test.responseBody).
+ WithStatusCode(test.statusCode),
+ servermock.CheckHeader().
+ WithContentType("text/xml; charset=utf-8")).
+ Build(t)
- res, err := client.ChangeResourceRecordSets(context.Background(), "example.com", ChangeResourceRecordSetsRequest{})
+ res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{})
assert.Nil(t, res)
assert.EqualError(t, err, test.expected)
})
}
}
-func TestGetChange(t *testing.T) {
+func TestClient_GetChange(t *testing.T) {
responseBody := `
@@ -110,9 +116,11 @@ func TestGetChange(t *testing.T) {
`
- client := setupTest(t, responseBody, http.StatusOK)
+ client := mockBuilder().
+ Route("GET /", servermock.RawStringResponse(responseBody)).
+ Build(t)
- res, err := client.GetChange(context.Background(), "12345")
+ res, err := client.GetChange(t.Context(), "12345")
require.NoError(t, err)
assert.Equal(t, "xxxxx", res.ChangeInfo.ID)
@@ -120,7 +128,7 @@ func TestGetChange(t *testing.T) {
assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt)
}
-func TestGetChangeErrors(t *testing.T) {
+func TestClient_GetChange_errors(t *testing.T) {
testCases := []struct {
desc string
responseBody string
@@ -157,9 +165,12 @@ func TestGetChangeErrors(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- client := setupTest(t, test.responseBody, test.statusCode)
+ client := mockBuilder().
+ Route("GET /",
+ servermock.RawStringResponse(test.responseBody).WithStatusCode(test.statusCode)).
+ Build(t)
- res, err := client.GetChange(context.Background(), "12345")
+ res, err := client.GetChange(t.Context(), "12345")
assert.Nil(t, res)
assert.EqualError(t, err, test.expected)
})
diff --git a/providers/dns/nifcloud/nifcloud.go b/providers/dns/nifcloud/nifcloud.go
index e73333c52..ced7eff09 100644
--- a/providers/dns/nifcloud/nifcloud.go
+++ b/providers/dns/nifcloud/nifcloud.go
@@ -9,10 +9,12 @@ import (
"net/url"
"time"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/platform/wait"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/nifcloud/internal"
)
@@ -93,6 +95,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
if config.BaseURL != "" {
baseURL, err := url.Parse(config.BaseURL)
if err != nil {
@@ -107,23 +111,29 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
// 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)
- err := d.changeRecord("CREATE", info.EffectiveFQDN, info.Value, d.config.TTL)
+ err := d.changeRecord(ctx, "CREATE", info.EffectiveFQDN, info.Value, d.config.TTL)
if err != nil {
return fmt.Errorf("nifcloud: %w", err)
}
+
return err
}
// 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)
- err := d.changeRecord("DELETE", info.EffectiveFQDN, info.Value, d.config.TTL)
+ err := d.changeRecord(ctx, "DELETE", info.EffectiveFQDN, info.Value, d.config.TTL)
if err != nil {
return fmt.Errorf("nifcloud: %w", err)
}
+
return err
}
@@ -133,7 +143,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
-func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
+func (d *DNSProvider) changeRecord(ctx context.Context, action, fqdn, value string, ttl int) error {
authZone, err := dns01.FindZoneByFqdn(fqdn)
if err != nil {
return fmt.Errorf("could not find zone: %w", err)
@@ -170,8 +180,6 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
},
}
- ctx := context.Background()
-
resp, err := d.client.ChangeResourceRecordSets(ctx, dns01.UnFqdn(authZone), reqParams)
if err != nil {
return fmt.Errorf("failed to change record set: %w", err)
@@ -179,11 +187,20 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
statusID := resp.ChangeInfo.ID
- return wait.For("nifcloud", 120*time.Second, 4*time.Second, func() (bool, error) {
- resp, err := d.client.GetChange(ctx, statusID)
- if err != nil {
- return false, fmt.Errorf("failed to query change status: %w", err)
- }
- return resp.ChangeInfo.Status == "INSYNC", nil
- })
+ return wait.Retry(ctx,
+ func() error {
+ resp, err := d.client.GetChange(ctx, statusID)
+ if err != nil {
+ return fmt.Errorf("get change: %w", err)
+ }
+
+ if resp.ChangeInfo.Status != "INSYNC" {
+ return fmt.Errorf("change status: %s", resp.ChangeInfo.Status)
+ }
+
+ return nil
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(4*time.Second)),
+ backoff.WithMaxElapsedTime(120*time.Second),
+ )
}
diff --git a/providers/dns/nifcloud/nifcloud.toml b/providers/dns/nifcloud/nifcloud.toml
index 9966ce882..3c43b1dc0 100644
--- a/providers/dns/nifcloud/nifcloud.toml
+++ b/providers/dns/nifcloud/nifcloud.toml
@@ -7,7 +7,7 @@ Since = "v1.1.0"
Example = '''
NIFCLOUD_ACCESS_KEY_ID=xxxx \
NIFCLOUD_SECRET_ACCESS_KEY=yyyy \
-lego --email you@example.com --dns nifcloud -d '*.example.com' -d example.com run
+lego --dns nifcloud -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns nifcloud -d '*.example.com' -d example.com ru
NIFCLOUD_ACCESS_KEY_ID = "Access key"
NIFCLOUD_SECRET_ACCESS_KEY = "Secret access key"
[Configuration.Additional]
- NIFCLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- NIFCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NIFCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
- NIFCLOUD_HTTP_TIMEOUT = "API request timeout"
+ NIFCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NIFCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ NIFCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ NIFCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://mbaas.nifcloud.com/doc/current/rest/common/format.html"
diff --git a/providers/dns/nifcloud/nifcloud_test.go b/providers/dns/nifcloud/nifcloud_test.go
index 9b635edfc..0eff98a71 100644
--- a/providers/dns/nifcloud/nifcloud_test.go
+++ b/providers/dns/nifcloud/nifcloud_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/njalla/internal/client.go b/providers/dns/njalla/internal/client.go
index f7e0023ae..d2893253f 100644
--- a/providers/dns/njalla/internal/client.go
+++ b/providers/dns/njalla/internal/client.go
@@ -46,6 +46,7 @@ func (c *Client) AddRecord(ctx context.Context, record Record) (*Record, error)
}
var result APIResponse[*Record]
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -55,7 +56,7 @@ func (c *Client) AddRecord(ctx context.Context, record Record) (*Record, error)
}
// RemoveRecord removes a record.
-func (c *Client) RemoveRecord(ctx context.Context, id string, domain string) error {
+func (c *Client) RemoveRecord(ctx context.Context, id, domain string) error {
data := APIRequest{
Method: "remove-record",
Params: Record{
@@ -92,6 +93,7 @@ func (c *Client) ListRecords(ctx context.Context, domain string) ([]Record, erro
}
var result APIResponse[Records]
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -127,7 +129,7 @@ func (c *Client) do(req *http.Request, result Response) error {
return result.GetError()
}
-func newJSONRequest(ctx context.Context, method string, endpoint string, payload any) (*http.Request, error) {
+func newJSONRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
diff --git a/providers/dns/njalla/internal/client_test.go b/providers/dns/njalla/internal/client_test.go
index 3f173db62..a7e60aefd 100644
--- a/providers/dns/njalla/internal/client_test.go
+++ b/providers/dns/njalla/internal/client_test.go
@@ -1,76 +1,31 @@
package internal
import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- token := req.Header.Get(authorizationHeader)
- if token != "Njalla secret" {
- _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 403, "message": "Invalid token."}}`))
- return
- }
-
- if handler != nil {
- handler(rw, req)
- } else {
- _, _ = rw.Write([]byte(`{"jsonrpc":"2.0"}`))
- }
- })
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("secret")
client.apiEndpoint = server.URL
+ client.HTTPClient = server.Client()
- return client
+ return client, nil
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
- apiReq := struct {
- Method string `json:"method"`
- Params Record `json:"params"`
- }{}
-
- err := json.NewDecoder(req.Body).Decode(&apiReq)
- if err != nil {
- http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError)
- return
- }
-
- apiReq.Params.ID = "123"
-
- resp := map[string]interface{}{
- "jsonrpc": "2.0",
- "id": "897",
- "result": apiReq.Params,
- }
-
- err = json.NewEncoder(rw).Encode(resp)
- if err != nil {
- http.Error(rw, "failed to marshal test response", http.StatusInternalServerError)
- return
- }
- })
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Njalla secret"),
+ ).
+ Route("POST /",
+ servermock.ResponseFromFixture("add_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")).
+ Build(t)
record := Record{
Content: "foobar",
@@ -80,7 +35,7 @@ func TestClient_AddRecord(t *testing.T) {
Type: "TXT",
}
- result, err := client.AddRecord(context.Background(), record)
+ result, err := client.AddRecord(t.Context(), record)
require.NoError(t, err)
expected := &Record{
@@ -95,7 +50,13 @@ func TestClient_AddRecord(t *testing.T) {
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, nil)
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Njalla invalid"),
+ ).
+ Route("POST /", servermock.ResponseFromFixture("auth_error.json")).
+ Build(t)
+
client.token = "invalid"
record := Record{
@@ -106,58 +67,23 @@ func TestClient_AddRecord_error(t *testing.T) {
Type: "TXT",
}
- result, err := client.AddRecord(context.Background(), record)
- require.Error(t, err)
+ result, err := client.AddRecord(t.Context(), record)
+ require.EqualError(t, err, "code: 403, message: Invalid token.")
assert.Nil(t, result)
}
func TestClient_ListRecords(t *testing.T) {
- client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
- apiReq := struct {
- Method string `json:"method"`
- Params Record `json:"params"`
- }{}
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Njalla secret"),
+ ).
+ Route("POST /",
+ servermock.ResponseFromFixture("list_records.json"),
+ servermock.CheckRequestJSONBodyFromFixture("list_records-request.json")).
+ Build(t)
- err := json.NewDecoder(req.Body).Decode(&apiReq)
- if err != nil {
- http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError)
- return
- }
-
- resp := map[string]interface{}{
- "jsonrpc": "2.0",
- "id": "897",
- "result": Records{
- Records: []Record{
- {
- ID: "1",
- Domain: apiReq.Params.Domain,
- Content: "test",
- Name: "test01",
- TTL: 300,
- Type: "TXT",
- },
- {
- ID: "2",
- Domain: apiReq.Params.Domain,
- Content: "txtTxt",
- Name: "test02",
- TTL: 120,
- Type: "TXT",
- },
- },
- },
- }
-
- err = json.NewEncoder(rw).Encode(resp)
- if err != nil {
- http.Error(rw, "failed to marshal test response", http.StatusInternalServerError)
- return
- }
- })
-
- records, err := client.ListRecords(context.Background(), "example.com")
+ records, err := client.ListRecords(t.Context(), "example.com")
require.NoError(t, err)
expected := []Record{
@@ -183,49 +109,43 @@ func TestClient_ListRecords(t *testing.T) {
}
func TestClient_ListRecords_error(t *testing.T) {
- client := setupTest(t, nil)
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Njalla invalid"),
+ ).
+ Route("POST /", servermock.ResponseFromFixture("auth_error.json")).
+ Build(t)
+
client.token = "invalid"
- records, err := client.ListRecords(context.Background(), "example.com")
- require.Error(t, err)
+ records, err := client.ListRecords(t.Context(), "example.com")
+ require.EqualError(t, err, "code: 403, message: Invalid token.")
assert.Empty(t, records)
}
func TestClient_RemoveRecord(t *testing.T) {
- client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
- apiReq := struct {
- Method string `json:"method"`
- Params Record `json:"params"`
- }{}
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Njalla secret"),
+ ).
+ Route("POST /",
+ servermock.RawStringResponse(`{"jsonrpc":"2.0"}`),
+ servermock.CheckRequestJSONBodyFromFixture("remove_record-request.json")).
+ Build(t)
- err := json.NewDecoder(req.Body).Decode(&apiReq)
- if err != nil {
- http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError)
- return
- }
-
- if apiReq.Params.ID == "" {
- _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 400, "message": ""missing ID"}}`))
- return
- }
-
- if apiReq.Params.Domain == "" {
- _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 400, "message": ""missing domain"}}`))
- return
- }
-
- _, _ = rw.Write([]byte(`{"jsonrpc":"2.0"}`))
- })
-
- err := client.RemoveRecord(context.Background(), "123", "example.com")
+ err := client.RemoveRecord(t.Context(), "123", "example.com")
require.NoError(t, err)
}
func TestClient_RemoveRecord_error(t *testing.T) {
- client := setupTest(t, nil)
- client.token = "invalid"
+ client := servermock.NewBuilder[*Client](setupClient,
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Njalla secret"),
+ ).
+ Route("POST /", servermock.ResponseFromFixture("remove_record_error_missing_domain.json")).
+ Build(t)
- err := client.RemoveRecord(context.Background(), "123", "example.com")
- require.Error(t, err)
+ err := client.RemoveRecord(t.Context(), "123", "example.com")
+ require.EqualError(t, err, "code: 400, message: missing domain")
}
diff --git a/providers/dns/njalla/internal/fixtures/add_record-request.json b/providers/dns/njalla/internal/fixtures/add_record-request.json
new file mode 100644
index 000000000..a85e1aaf1
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/add_record-request.json
@@ -0,0 +1,10 @@
+{
+ "method": "add-record",
+ "params": {
+ "content": "foobar",
+ "domain": "test",
+ "name": "example.com",
+ "ttl": 300,
+ "type": "TXT"
+ }
+}
diff --git a/providers/dns/njalla/internal/fixtures/add_record.json b/providers/dns/njalla/internal/fixtures/add_record.json
new file mode 100644
index 000000000..a537762bf
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/add_record.json
@@ -0,0 +1,12 @@
+{
+ "id": "897",
+ "jsonrpc": "2.0",
+ "result": {
+ "id": "123",
+ "content": "foobar",
+ "domain": "test",
+ "name": "example.com",
+ "ttl": 300,
+ "type": "TXT"
+ }
+}
diff --git a/providers/dns/njalla/internal/fixtures/auth_error.json b/providers/dns/njalla/internal/fixtures/auth_error.json
new file mode 100644
index 000000000..e9d07be51
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/auth_error.json
@@ -0,0 +1,7 @@
+{
+ "jsonrpc": "2.0",
+ "Error": {
+ "code": 403,
+ "message": "Invalid token."
+ }
+}
diff --git a/providers/dns/njalla/internal/fixtures/list_records-request.json b/providers/dns/njalla/internal/fixtures/list_records-request.json
new file mode 100644
index 000000000..ebe5ccf72
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/list_records-request.json
@@ -0,0 +1,6 @@
+{
+ "method": "list-records",
+ "params": {
+ "domain": "example.com"
+ }
+}
diff --git a/providers/dns/njalla/internal/fixtures/list_records.json b/providers/dns/njalla/internal/fixtures/list_records.json
new file mode 100644
index 000000000..a280a4b3f
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/list_records.json
@@ -0,0 +1,24 @@
+{
+ "id": "897",
+ "jsonrpc": "2.0",
+ "result": {
+ "records": [
+ {
+ "id": "1",
+ "content": "test",
+ "domain": "example.com",
+ "name": "test01",
+ "ttl": 300,
+ "type": "TXT"
+ },
+ {
+ "id": "2",
+ "content": "txtTxt",
+ "domain": "example.com",
+ "name": "test02",
+ "ttl": 120,
+ "type": "TXT"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/njalla/internal/fixtures/remove_record-request.json b/providers/dns/njalla/internal/fixtures/remove_record-request.json
new file mode 100644
index 000000000..c96e94423
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/remove_record-request.json
@@ -0,0 +1,7 @@
+{
+ "method": "remove-record",
+ "params": {
+ "id": "123",
+ "domain": "example.com"
+ }
+}
diff --git a/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json
new file mode 100644
index 000000000..f65d254d0
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json
@@ -0,0 +1,7 @@
+{
+ "jsonrpc": "2.0",
+ "Error": {
+ "code": 400,
+ "message": "missing domain"
+ }
+}
diff --git a/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json
new file mode 100644
index 000000000..544cd4d1c
--- /dev/null
+++ b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json
@@ -0,0 +1,7 @@
+{
+ "jsonrpc": "2.0",
+ "Error": {
+ "code": 400,
+ "message": "missing ID"
+ }
+}
diff --git a/providers/dns/njalla/njalla.go b/providers/dns/njalla/njalla.go
index b08ce69de..2f9aef8ea 100644
--- a/providers/dns/njalla/njalla.go
+++ b/providers/dns/njalla/njalla.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/njalla/internal"
"github.com/miekg/dns"
)
@@ -90,6 +91,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -145,6 +148,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("njalla: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
diff --git a/providers/dns/njalla/njalla.toml b/providers/dns/njalla/njalla.toml
index a7e46c02d..ff4750b7d 100644
--- a/providers/dns/njalla/njalla.toml
+++ b/providers/dns/njalla/njalla.toml
@@ -6,17 +6,17 @@ Since = "v4.3.0"
Example = '''
NJALLA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns njalla -d '*.example.com' -d example.com run
+lego --dns njalla -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
NJALLA_TOKEN = "API token"
[Configuration.Additional]
- NJALLA_POLLING_INTERVAL = "Time between DNS propagation check"
- NJALLA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NJALLA_TTL = "The TTL of the TXT record used for the DNS challenge"
- NJALLA_HTTP_TIMEOUT = "API request timeout"
+ NJALLA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NJALLA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ NJALLA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ NJALLA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://njal.la/api/"
diff --git a/providers/dns/njalla/njalla_test.go b/providers/dns/njalla/njalla_test.go
index f1489257b..61f106d75 100644
--- a/providers/dns/njalla/njalla_test.go
+++ b/providers/dns/njalla/njalla_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/nodion/nodion.go b/providers/dns/nodion/nodion.go
index 1fdc8b87d..4bc887568 100644
--- a/providers/dns/nodion/nodion.go
+++ b/providers/dns/nodion/nodion.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/nrdcg/nodion"
)
@@ -93,6 +94,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -169,6 +172,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.zoneIDsMu.Lock()
zoneID, ok := d.zoneIDs[token]
d.zoneIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("nodion: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token)
}
@@ -204,5 +208,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("regru: failed to remove TXT records [domain: %s]: %w", dns01.UnFqdn(authZone), err)
}
+ d.zoneIDsMu.Lock()
+ delete(d.zoneIDs, token)
+ d.zoneIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/nodion/nodion.toml b/providers/dns/nodion/nodion.toml
index 5bf2e1df1..c9db46e61 100644
--- a/providers/dns/nodion/nodion.toml
+++ b/providers/dns/nodion/nodion.toml
@@ -6,17 +6,17 @@ Since = "v4.11.0"
Example = '''
NODION_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns nodion -d '*.example.com' -d example.com run
+lego --dns nodion -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
NODION_API_TOKEN = "The API token"
[Configuration.Additional]
- NODION_POLLING_INTERVAL = "Time between DNS propagation check"
- NODION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NODION_TTL = "The TTL of the TXT record used for the DNS challenge"
- NODION_HTTP_TIMEOUT = "API request timeout"
+ NODION_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NODION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ NODION_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ NODION_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.nodion.com/en/docs/dns/api/"
diff --git a/providers/dns/nodion/nodion_test.go b/providers/dns/nodion/nodion_test.go
index fbf4b89eb..0ec5c1627 100644
--- a/providers/dns/nodion/nodion_test.go
+++ b/providers/dns/nodion/nodion_test.go
@@ -34,6 +34,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -91,6 +92,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -104,6 +106,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/ns1/ns1.go b/providers/dns/ns1/ns1.go
index c3bf168cb..6a7846e85 100644
--- a/providers/dns/ns1/ns1.go
+++ b/providers/dns/ns1/ns1.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"gopkg.in/ns1/ns1-go.v2/rest"
"gopkg.in/ns1/ns1-go.v2/rest/model/dns"
)
@@ -80,7 +81,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("ns1: credentials missing")
}
- client := rest.NewClient(config.HTTPClient, rest.SetAPIKey(config.APIKey))
+ if config.HTTPClient == nil {
+ // Because the rest.NewClient uses the http.DefaultClient.
+ config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
+ }
+
+ client := rest.NewClient(clientdebug.Wrap(config.HTTPClient), rest.SetAPIKey(config.APIKey))
return &DNSProvider{client: client, config: config}, nil
}
@@ -141,10 +147,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
name := dns01.UnFqdn(info.EffectiveFQDN)
+
_, err = d.client.Records.Delete(zone.Zone, name, "TXT")
if err != nil {
return fmt.Errorf("ns1: failed to delete record [zone: %q, domain: %q]: %w", zone.Zone, name, err)
}
+
return nil
}
diff --git a/providers/dns/ns1/ns1.toml b/providers/dns/ns1/ns1.toml
index 9aeb0841e..829663bf5 100644
--- a/providers/dns/ns1/ns1.toml
+++ b/providers/dns/ns1/ns1.toml
@@ -6,17 +6,17 @@ Since = "v0.4.0"
Example = '''
NS1_API_KEY=xxxx \
-lego --email you@example.com --dns ns1 -d '*.example.com' -d example.com run
+lego --dns ns1 -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
NS1_API_KEY = "API key"
[Configuration.Additional]
- NS1_POLLING_INTERVAL = "Time between DNS propagation check"
- NS1_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- NS1_TTL = "The TTL of the TXT record used for the DNS challenge"
- NS1_HTTP_TIMEOUT = "API request timeout"
+ NS1_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ NS1_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ NS1_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ NS1_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://ns1.com/api"
diff --git a/providers/dns/ns1/ns1_test.go b/providers/dns/ns1/ns1_test.go
index 6df6b4afb..82fa70c52 100644
--- a/providers/dns/ns1/ns1_test.go
+++ b/providers/dns/ns1/ns1_test.go
@@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -96,6 +97,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -109,6 +111,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/octenium/fixtures/add_dns_record.json b/providers/dns/octenium/fixtures/add_dns_record.json
new file mode 100644
index 000000000..25edcdf11
--- /dev/null
+++ b/providers/dns/octenium/fixtures/add_dns_record.json
@@ -0,0 +1,14 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "record": {
+ "type": "TXT",
+ "name": "_acme-challenge.example.com.",
+ "ttl": 120,
+ "value": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI",
+ "raw": {
+ "txtdata": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"
+ }
+ }
+ }
+}
diff --git a/providers/dns/octenium/fixtures/delete_dns_record.json b/providers/dns/octenium/fixtures/delete_dns_record.json
new file mode 100644
index 000000000..2aa9415cc
--- /dev/null
+++ b/providers/dns/octenium/fixtures/delete_dns_record.json
@@ -0,0 +1,11 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "deleted": {
+ "count": 1,
+ "lines": [
+ 123
+ ]
+ }
+ }
+}
diff --git a/providers/dns/octenium/fixtures/list_dns_records.json b/providers/dns/octenium/fixtures/list_dns_records.json
new file mode 100644
index 000000000..405afff11
--- /dev/null
+++ b/providers/dns/octenium/fixtures/list_dns_records.json
@@ -0,0 +1,27 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "records": [
+ {
+ "line": 31,
+ "type": "TXT",
+ "name": "_dmarc.example.com.",
+ "ttl": 300,
+ "value": "xxx",
+ "raw": {
+ "txtdata": "xxx"
+ }
+ },
+ {
+ "line": 123,
+ "type": "TXT",
+ "name": "_acme-challenge.example.com.",
+ "ttl": 300,
+ "value": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI",
+ "raw": {
+ "txtdata": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"
+ }
+ }
+ ]
+ }
+}
diff --git a/providers/dns/octenium/fixtures/list_domains.json b/providers/dns/octenium/fixtures/list_domains.json
new file mode 100644
index 000000000..a62febcda
--- /dev/null
+++ b/providers/dns/octenium/fixtures/list_domains.json
@@ -0,0 +1,13 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "domains": {
+ "2976": {
+ "domain-name": "example.com",
+ "registration-date": "21\/08\/2025",
+ "expiration-date": "-",
+ "status": "active"
+ }
+ }
+ }
+}
diff --git a/providers/dns/octenium/internal/client.go b/providers/dns/octenium/internal/client.go
new file mode 100644
index 000000000..474770aeb
--- /dev/null
+++ b/providers/dns/octenium/internal/client.go
@@ -0,0 +1,204 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ querystring "github.com/google/go-querystring/query"
+)
+
+const defaultBaseURL = "https://api.panel.octenium.com/"
+
+const statusSuccess = "success"
+
+// Client the Octenium API client.
+type Client struct {
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey string) (*Client, error) {
+ if apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// ListDomains retrieves a list of domains.
+// https://octenium.com/api#tag/Domains/operation/listdomains
+func (c *Client) ListDomains(ctx context.Context, domain string) (map[string]Domain, error) {
+ endpoint := c.BaseURL.JoinPath("domains")
+
+ data := endpoint.Query()
+ data.Set("domain-name", domain)
+ endpoint.RawQuery = data.Encode()
+
+ req, err := newRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &DomainsResponse{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Domains, nil
+}
+
+// ListDNSRecords retrieves a list of DNS records.
+// https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-list
+func (c *Client) ListDNSRecords(ctx context.Context, orderID, recordType string) ([]Record, error) {
+ endpoint := c.BaseURL.JoinPath("domains", "dns-records", "list")
+
+ data := make(url.Values)
+ data.Set("order-id", orderID)
+ data.Set("types[]", recordType)
+
+ req, err := newRequest(ctx, http.MethodPost, endpoint, data)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &ListRecordsResponse{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Records, nil
+}
+
+// AddDNSRecord adds a DNS record.
+// https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-add
+func (c *Client) AddDNSRecord(ctx context.Context, orderID string, record Record) (*Record, error) {
+ endpoint := c.BaseURL.JoinPath("domains", "dns-records", "add")
+
+ data, err := querystring.Values(record)
+ if err != nil {
+ return nil, err
+ }
+
+ data.Set("order-id", orderID)
+
+ req, err := newRequest(ctx, http.MethodPost, endpoint, data)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &AddRecordResponse{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Record, nil
+}
+
+// DeleteDNSRecord deletes a DNS record.
+// https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-delete
+func (c *Client) DeleteDNSRecord(ctx context.Context, orderID string, recordID int) (*DeletedRecordInfo, error) {
+ endpoint := c.BaseURL.JoinPath("domains", "dns-records", "delete")
+
+ data := make(url.Values)
+ data.Set("order-id", orderID)
+ data.Set("line", strconv.Itoa(recordID))
+
+ req, err := newRequest(ctx, http.MethodPost, endpoint, data)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &DeleteRecordResponse{}
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Deleted, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Set("X-Api-Key", c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ var response APIResponse
+
+ err = json.Unmarshal(raw, &response)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ if response.Status != statusSuccess {
+ return fmt.Errorf("unexpected status: %s: %s", response.Status, response.Error)
+ }
+
+ err = json.Unmarshal(response.Response, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, response.Response, err)
+ }
+
+ return nil
+}
+
+func newRequest(ctx context.Context, method string, endpoint *url.URL, payload url.Values) (*http.Request, error) {
+ var body io.Reader = http.NoBody
+
+ if method == http.MethodPost && payload != nil {
+ body = strings.NewReader(payload.Encode())
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if method == http.MethodPost && payload != nil {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+
+ return req, nil
+}
diff --git a/providers/dns/octenium/internal/client_test.go b/providers/dns/octenium/internal/client_test.go
new file mode 100644
index 000000000..ff1b21961
--- /dev/null
+++ b/providers/dns/octenium/internal/client_test.go
@@ -0,0 +1,224 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithAccept("application/json").
+ With("X-Api-Key", "secret"),
+ )
+}
+
+func TestClient_ListDomains(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domains",
+ servermock.ResponseFromFixture("list_domains.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain-name", "example.com")).
+ Build(t)
+
+ domains, err := client.ListDomains(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := map[string]Domain{
+ "2976": {DomainName: "example.com", RegistrationDate: "12/09/2021", ExpirationDate: "12/09/2024", Status: "active"},
+ "2977": {DomainName: "example.org", RegistrationDate: "01/10/2021", ExpirationDate: "01/10/2024", Status: "active"},
+ "2978": {DomainName: "example.net", RegistrationDate: "21/08/2025", ExpirationDate: "-", Status: "active"},
+ }
+
+ assert.Equal(t, expected, domains)
+}
+
+func TestClient_ListDomains_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domains",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.ListDomains(t.Context(), "example.com")
+ require.EqualError(t, err, "unexpected status code: [status code: 400] body: ")
+}
+
+func TestClient_ListDomains_api_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domains",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
+
+ _, err := client.ListDomains(t.Context(), "example.com")
+ require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)")
+}
+
+func TestClient_ListDNSRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/list",
+ servermock.ResponseFromFixture("list_dns_records.json"),
+ servermock.CheckHeader().
+ WithContentType("application/x-www-form-urlencoded"),
+ servermock.CheckForm().Strict().
+ With("order-id", "abc").
+ With("types[]", "TXT")).
+ Build(t)
+
+ records, err := client.ListDNSRecords(t.Context(), "abc", "TXT")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {ID: 15, Type: "A", Name: "example.com.", TTL: 14400, Value: "203.0.113.10"},
+ {ID: 22, Type: "MX", Name: "example.com.", TTL: 14400, Value: "10 mail.example.com."},
+ {ID: 31, Type: "TXT", Name: "_dmarc.example.com.", TTL: 300, Value: "v=DMARC1; p=none; rua=mailto:dmarc@example.com"},
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_ListDNSRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/list",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.ListDNSRecords(t.Context(), "abc", "TXT")
+ require.EqualError(t, err, "unexpected status code: [status code: 400] body: ")
+}
+
+func TestClient_ListDNSRecords_api_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/list",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
+
+ _, err := client.ListDNSRecords(t.Context(), "abc", "TXT")
+ require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)")
+}
+
+func TestClient_AddDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/add",
+ servermock.ResponseFromFixture("add_dns_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/x-www-form-urlencoded"),
+ servermock.CheckForm().Strict().
+ With("order-id", "abc").
+ With("name", "example.com.").
+ With("ttl", "120").
+ With("type", "TXT").
+ With("value", "txtTXTtxt")).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "example.com.",
+ TTL: 120,
+ Value: "txtTXTtxt",
+ }
+
+ result, err := client.AddDNSRecord(t.Context(), "abc", record)
+ require.NoError(t, err)
+
+ expected := &Record{
+ Type: "A",
+ Name: "example.com.",
+ TTL: 14400,
+ Value: "203.0.113.10",
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_AddDNSRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/add",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "example.com.",
+ TTL: 120,
+ Value: "txtTXTtxt",
+ }
+
+ _, err := client.AddDNSRecord(t.Context(), "abc", record)
+ require.EqualError(t, err, "unexpected status code: [status code: 400] body: ")
+}
+
+func TestClient_AddDNSRecord_api_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/add",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "example.com.",
+ TTL: 120,
+ Value: "txtTXTtxt",
+ }
+
+ _, err := client.AddDNSRecord(t.Context(), "abc", record)
+ require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)")
+}
+
+func TestClient_DeleteDNSRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/delete",
+ servermock.ResponseFromFixture("delete_dns_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/x-www-form-urlencoded"),
+ servermock.CheckForm().Strict().
+ With("order-id", "abc").
+ With("line", "123")).
+ Build(t)
+
+ result, err := client.DeleteDNSRecord(t.Context(), "abc", 123)
+ require.NoError(t, err)
+
+ expected := &DeletedRecordInfo{
+ Count: 1,
+ Lines: []int{15},
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_DeleteDNSRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/delete",
+ servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.DeleteDNSRecord(t.Context(), "abc", 123)
+ require.EqualError(t, err, "unexpected status code: [status code: 400] body: ")
+}
+
+func TestClient_DeleteDNSRecord_api_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/dns-records/delete",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
+
+ _, err := client.DeleteDNSRecord(t.Context(), "abc", 123)
+ require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)")
+}
diff --git a/providers/dns/octenium/internal/fixtures/add_dns_record.json b/providers/dns/octenium/internal/fixtures/add_dns_record.json
new file mode 100644
index 000000000..6c73ea1f9
--- /dev/null
+++ b/providers/dns/octenium/internal/fixtures/add_dns_record.json
@@ -0,0 +1,14 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "record": {
+ "type": "A",
+ "name": "example.com.",
+ "ttl": 14400,
+ "value": "203.0.113.10",
+ "raw": {
+ "address": "203.0.113.10"
+ }
+ }
+ }
+}
diff --git a/providers/dns/octenium/internal/fixtures/delete_dns_record.json b/providers/dns/octenium/internal/fixtures/delete_dns_record.json
new file mode 100644
index 000000000..0d4692ffd
--- /dev/null
+++ b/providers/dns/octenium/internal/fixtures/delete_dns_record.json
@@ -0,0 +1,11 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "deleted": {
+ "count": 1,
+ "lines": [
+ 15
+ ]
+ }
+ }
+}
diff --git a/providers/dns/octenium/internal/fixtures/error.json b/providers/dns/octenium/internal/fixtures/error.json
new file mode 100644
index 000000000..85a90e425
--- /dev/null
+++ b/providers/dns/octenium/internal/fixtures/error.json
@@ -0,0 +1,5 @@
+{
+ "api-status": "error",
+ "api-response": [],
+ "api-error": "missing required fields (type, name, ttl)"
+}
diff --git a/providers/dns/octenium/internal/fixtures/list_dns_records.json b/providers/dns/octenium/internal/fixtures/list_dns_records.json
new file mode 100644
index 000000000..8fa60d86f
--- /dev/null
+++ b/providers/dns/octenium/internal/fixtures/list_dns_records.json
@@ -0,0 +1,38 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "records": [
+ {
+ "line": 15,
+ "type": "A",
+ "name": "example.com.",
+ "ttl": 14400,
+ "value": "203.0.113.10",
+ "raw": {
+ "address": "203.0.113.10"
+ }
+ },
+ {
+ "line": 22,
+ "type": "MX",
+ "name": "example.com.",
+ "ttl": 14400,
+ "value": "10 mail.example.com.",
+ "raw": {
+ "preference": 10,
+ "exchange": "mail.example.com."
+ }
+ },
+ {
+ "line": 31,
+ "type": "TXT",
+ "name": "_dmarc.example.com.",
+ "ttl": 300,
+ "value": "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
+ "raw": {
+ "txtdata": "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
+ }
+ }
+ ]
+ }
+}
diff --git a/providers/dns/octenium/internal/fixtures/list_domains.json b/providers/dns/octenium/internal/fixtures/list_domains.json
new file mode 100644
index 000000000..b10b705c9
--- /dev/null
+++ b/providers/dns/octenium/internal/fixtures/list_domains.json
@@ -0,0 +1,25 @@
+{
+ "api-status": "success",
+ "api-response": {
+ "domains": {
+ "2976": {
+ "domain-name": "example.com",
+ "registration-date": "12/09/2021",
+ "expiration-date": "12/09/2024",
+ "status": "active"
+ },
+ "2977": {
+ "domain-name": "example.org",
+ "registration-date": "01/10/2021",
+ "expiration-date": "01/10/2024",
+ "status": "active"
+ },
+ "2978": {
+ "domain-name": "example.net",
+ "registration-date": "21\/08\/2025",
+ "expiration-date": "-",
+ "status": "active"
+ }
+ }
+ }
+}
diff --git a/providers/dns/octenium/internal/types.go b/providers/dns/octenium/internal/types.go
new file mode 100644
index 000000000..a31e40921
--- /dev/null
+++ b/providers/dns/octenium/internal/types.go
@@ -0,0 +1,45 @@
+package internal
+
+import "encoding/json"
+
+type APIResponse struct {
+ Status string `json:"api-status,omitempty"`
+ Response json.RawMessage `json:"api-response,omitempty"`
+ Error string `json:"api-error,omitempty"`
+}
+
+type Domain struct {
+ DomainName string `json:"domain-name,omitempty"`
+ RegistrationDate string `json:"registration-date,omitempty"`
+ ExpirationDate string `json:"expiration-date,omitempty"`
+ Status string `json:"status,omitempty"`
+}
+
+type Record struct {
+ ID int `json:"line,omitempty" url:"-"`
+ Type string `json:"type,omitempty" url:"type,omitempty"`
+ Name string `json:"name,omitempty" url:"name,omitempty"`
+ TTL int `json:"ttl,omitempty" url:"ttl,omitempty"`
+ Value string `json:"value,omitempty" url:"value,omitempty"`
+}
+
+type DomainsResponse struct {
+ Domains map[string]Domain `json:"domains,omitempty"`
+}
+
+type AddRecordResponse struct {
+ Record *Record `json:"record,omitempty"`
+}
+
+type ListRecordsResponse struct {
+ Records []Record `json:"records,omitempty"`
+}
+
+type DeleteRecordResponse struct {
+ Deleted *DeletedRecordInfo `json:"deleted,omitempty"`
+}
+
+type DeletedRecordInfo struct {
+ Count int `json:"count,omitempty"`
+ Lines []int `json:"lines,omitempty"`
+}
diff --git a/providers/dns/octenium/octenium.go b/providers/dns/octenium/octenium.go
new file mode 100644
index 000000000..6032dcce1
--- /dev/null
+++ b/providers/dns/octenium/octenium.go
@@ -0,0 +1,204 @@
+// Package octenium implements a DNS provider for solving the DNS-01 challenge using Octenium.
+package octenium
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/log"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/octenium/internal"
+ "github.com/hashicorp/go-retryablehttp"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "OCTENIUM_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ domainIDs map[string]string
+ domainIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Octenium.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("octenium: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Octenium.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("octenium: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("octenium: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ retryClient := retryablehttp.NewClient()
+ retryClient.RetryMax = 5
+ retryClient.HTTPClient = client.HTTPClient
+ retryClient.Logger = log.Logger
+
+ client.HTTPClient = clientdebug.Wrap(retryClient.StandardClient())
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ domainIDs: make(map[string]string),
+ }, nil
+}
+
+// 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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("octenium: could not find zone for domain '%s': %w", domain, err)
+ }
+
+ domainID, err := d.getDomainID(ctx, authZone)
+ if err != nil {
+ return fmt.Errorf("octenium: get domain ID: %w", err)
+ }
+
+ d.domainIDsMu.Lock()
+ d.domainIDs[token] = domainID
+ d.domainIDsMu.Unlock()
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: info.EffectiveFQDN,
+ TTL: d.config.TTL,
+ Value: info.Value,
+ }
+
+ _, err = d.client.AddDNSRecord(ctx, domainID, record)
+ if err != nil {
+ return fmt.Errorf("octenium: 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)
+
+ d.domainIDsMu.Lock()
+ domainID, ok := d.domainIDs[token]
+ d.domainIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("octenium: unknown domain ID for '%s'", info.EffectiveFQDN)
+ }
+
+ records, err := d.client.ListDNSRecords(ctx, domainID, "TXT")
+ if err != nil {
+ return fmt.Errorf("octenium: list records: %w", err)
+ }
+
+ for _, record := range records {
+ if record.Type != "TXT" || record.Name != info.EffectiveFQDN || record.Value != info.Value {
+ continue
+ }
+
+ _, err = d.client.DeleteDNSRecord(ctx, domainID, record.ID)
+ if err != nil {
+ return fmt.Errorf("octenium: delete record: %w", err)
+ }
+
+ break
+ }
+
+ d.domainIDsMu.Lock()
+ delete(d.domainIDs, token)
+ d.domainIDsMu.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
+}
+
+func (d *DNSProvider) getDomainID(ctx context.Context, authZone string) (string, error) {
+ domains, err := d.client.ListDomains(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return "", fmt.Errorf("list domains: %w", err)
+ }
+
+ if len(domains) == 0 {
+ return "", errors.New("domain not found")
+ }
+
+ if len(domains) > 1 {
+ return "", errors.New("multiple domains found")
+ }
+
+ for id := range domains {
+ return id, nil
+ }
+
+ return "", errors.New("domain ID not found")
+}
diff --git a/providers/dns/octenium/octenium.toml b/providers/dns/octenium/octenium.toml
new file mode 100644
index 000000000..e3c9d894f
--- /dev/null
+++ b/providers/dns/octenium/octenium.toml
@@ -0,0 +1,22 @@
+Name = "Octenium"
+Description = ''''''
+URL = "https://octenium.com/"
+Code = "octenium"
+Since = "v4.27.0"
+
+Example = '''
+OCTENIUM_API_KEY="xxx" \
+lego --dns octenium -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ OCTENIUM_API_KEY = "API key"
+ [Configuration.Additional]
+ OCTENIUM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ OCTENIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ OCTENIUM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ OCTENIUM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://octenium.com/api#tag/Domains-DNS"
diff --git a/providers/dns/octenium/octenium_test.go b/providers/dns/octenium/octenium_test.go
new file mode 100644
index 000000000..dbb8d64b3
--- /dev/null
+++ b/providers/dns/octenium/octenium_test.go
@@ -0,0 +1,198 @@
+package octenium
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{
+ EnvAPIKey: "",
+ },
+ expected: "octenium: some credentials information are missing: OCTENIUM_API_KEY",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "octenium: some credentials information are missing: OCTENIUM_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing API key",
+ expected: "octenium: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "octenium: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithAccept("application/json").
+ With("X-Api-Key", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /domains",
+ servermock.ResponseFromFixture("list_domains.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("domain-name", "example.com")).
+ Route("POST /domains/dns-records/add",
+ servermock.ResponseFromFixture("add_dns_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/x-www-form-urlencoded"),
+ servermock.CheckForm().Strict().
+ With("order-id", "2976").
+ With("name", "_acme-challenge.example.com.").
+ With("ttl", "120").
+ With("type", "TXT").
+ With("value", "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI")).
+ Build(t)
+
+ err := provider.Present("example.com", "", "foobar")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /domains/dns-records/list",
+ servermock.ResponseFromFixture("list_dns_records.json"),
+ servermock.CheckHeader().
+ WithContentType("application/x-www-form-urlencoded"),
+ servermock.CheckForm().Strict().
+ With("order-id", "2976").
+ With("types[]", "TXT")).
+ Route("POST /domains/dns-records/delete",
+ servermock.ResponseFromFixture("delete_dns_record.json"),
+ servermock.CheckHeader().
+ WithContentType("application/x-www-form-urlencoded"),
+ servermock.CheckForm().Strict().
+ With("order-id", "2976").
+ With("line", "123")).
+ Build(t)
+
+ provider.domainIDs["token"] = "2976"
+
+ err := provider.CleanUp("example.com", "token", "foobar")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/oraclecloud/configprovider.go b/providers/dns/oraclecloud/configprovider.go
deleted file mode 100644
index 43d0cecc3..000000000
--- a/providers/dns/oraclecloud/configprovider.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package oraclecloud
-
-import (
- "crypto/rsa"
- "encoding/base64"
- "errors"
- "fmt"
- "os"
-
- "github.com/go-acme/lego/v4/platform/config/env"
- "github.com/oracle/oci-go-sdk/v65/common"
-)
-
-type configProvider struct {
- values map[string]string
- privateKeyPassphrase string
-}
-
-func newConfigProvider(values map[string]string) *configProvider {
- return &configProvider{
- values: values,
- privateKeyPassphrase: env.GetOrFile(EnvPrivKeyPass),
- }
-}
-
-func (p *configProvider) PrivateRSAKey() (*rsa.PrivateKey, error) {
- privateKey, err := getPrivateKey(envPrivKey)
- if err != nil {
- return nil, err
- }
-
- return common.PrivateKeyFromBytesWithPassword(privateKey, []byte(p.privateKeyPassphrase))
-}
-
-func (p *configProvider) KeyID() (string, error) {
- tenancy, err := p.TenancyOCID()
- if err != nil {
- return "", err
- }
-
- user, err := p.UserOCID()
- if err != nil {
- return "", err
- }
-
- fingerprint, err := p.KeyFingerprint()
- if err != nil {
- return "", err
- }
-
- return fmt.Sprintf("%s/%s/%s", tenancy, user, fingerprint), nil
-}
-
-func (p *configProvider) TenancyOCID() (value string, err error) {
- return p.values[EnvTenancyOCID], nil
-}
-
-func (p *configProvider) UserOCID() (string, error) {
- return p.values[EnvUserOCID], nil
-}
-
-func (p *configProvider) KeyFingerprint() (string, error) {
- return p.values[EnvPubKeyFingerprint], nil
-}
-
-func (p *configProvider) Region() (string, error) {
- return p.values[EnvRegion], nil
-}
-
-func (p *configProvider) AuthType() (common.AuthConfig, error) {
- // Inspired by https://github.com/oracle/oci-go-sdk/blob/e7635c292e60d0a9dcdd3a1e7de180d7c99b1eee/common/configuration.go#L231-L234
- return common.AuthConfig{AuthType: common.UnknownAuthenticationType}, errors.New("unsupported, keep the interface")
-}
-
-func getPrivateKey(envVar string) ([]byte, error) {
- envVarValue := os.Getenv(envVar)
- if envVarValue != "" {
- bytes, err := base64.StdEncoding.DecodeString(envVarValue)
- if err != nil {
- return nil, fmt.Errorf("failed to read base64 value %s (defined by env var %s): %w", envVarValue, envVar, err)
- }
- return bytes, nil
- }
-
- fileVar := envVar + "_FILE"
- fileVarValue := os.Getenv(fileVar)
- if fileVarValue == "" {
- return nil, fmt.Errorf("no value provided for: %s or %s", envVar, fileVar)
- }
-
- fileContents, err := os.ReadFile(fileVarValue)
- if err != nil {
- return nil, fmt.Errorf("failed to read the file %s (defined by env var %s): %w", fileVarValue, fileVar, err)
- }
-
- return fileContents, nil
-}
diff --git a/providers/dns/oraclecloud/configurationprovider.go b/providers/dns/oraclecloud/configurationprovider.go
new file mode 100644
index 000000000..97710108c
--- /dev/null
+++ b/providers/dns/oraclecloud/configurationprovider.go
@@ -0,0 +1,144 @@
+package oraclecloud
+
+import (
+ "crypto/rsa"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "os"
+ "slices"
+ "strings"
+
+ "github.com/go-acme/lego/v4/log"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/nrdcg/oci-go-sdk/common/v1065"
+)
+
+type environmentConfigurationProvider struct {
+ values map[string]string
+}
+
+func newEnvironmentConfigurationProvider() (*environmentConfigurationProvider, error) {
+ values, err := env.GetWithFallback(
+ []string{EnvRegion, altEnvTFVarRegion},
+ []string{EnvUserOCID, altEnvTFVarUserOCID},
+ []string{EnvTenancyOCID, altEnvTFVarTenancyOCID},
+ []string{EnvPubKeyFingerprint, altEnvFingerprint, altEnvTFVarFingerprint},
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &environmentConfigurationProvider{
+ values: values,
+ }, nil
+}
+
+func (p *environmentConfigurationProvider) PrivateRSAKey() (*rsa.PrivateKey, error) {
+ privateKey, err := getPrivateKey()
+ if err != nil {
+ return nil, err
+ }
+
+ return common.PrivateKeyFromBytesWithPassword(privateKey, []byte(p.privateKeyPassword()))
+}
+
+func (p *environmentConfigurationProvider) KeyID() (string, error) {
+ tenancy, err := p.TenancyOCID()
+ if err != nil {
+ return "", err
+ }
+
+ user, err := p.UserOCID()
+ if err != nil {
+ return "", err
+ }
+
+ fingerprint, err := p.KeyFingerprint()
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("%s/%s/%s", tenancy, user, fingerprint), nil
+}
+
+func (p *environmentConfigurationProvider) TenancyOCID() (string, error) {
+ return p.values[EnvTenancyOCID], nil
+}
+
+func (p *environmentConfigurationProvider) UserOCID() (string, error) {
+ return p.values[EnvUserOCID], nil
+}
+
+func (p *environmentConfigurationProvider) KeyFingerprint() (string, error) {
+ return p.values[EnvPubKeyFingerprint], nil
+}
+
+func (p *environmentConfigurationProvider) Region() (string, error) {
+ return p.values[EnvRegion], nil
+}
+
+func (p *environmentConfigurationProvider) AuthType() (common.AuthConfig, error) {
+ // Inspired by https://github.com/oracle/oci-go-sdk/blob/e7635c292e60d0a9dcdd3a1e7de180d7c99b1eee/common/configuration.go#L231-L234
+ return common.AuthConfig{AuthType: common.UnknownAuthenticationType}, errors.New("unsupported, keep the interface")
+}
+
+func (p *environmentConfigurationProvider) privateKeyPassword() string {
+ return env.GetOneWithFallback(EnvPrivKeyPass, "", env.ParseString, altEnvPrivateKeyPassword, altEnvTFVarPrivateKeyPassword)
+}
+
+func getPrivateKey() ([]byte, error) {
+ base64EnvKeys := []string{envPrivKey, altEnvPrivateKey}
+
+ envVarValue := getEnvWithStrictFallback(base64EnvKeys...)
+ if envVarValue != "" {
+ bytes, err := base64.StdEncoding.DecodeString(envVarValue)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read base64 value %s (defined by env vars %s): %w", envVarValue,
+ strings.Join(base64EnvKeys, " or "), err)
+ }
+
+ return bytes, nil
+ }
+
+ fileEnvKeys := []string{EnvPrivKeyFile, altEnvPrivateKeyPath, altEnvTFVarPrivateKeyPath}
+
+ fileVarValue := getEnvFileWithStrictFallback(fileEnvKeys...)
+ if len(fileVarValue) == 0 {
+ return nil, fmt.Errorf("no value provided for: %s",
+ strings.Join(slices.Concat(base64EnvKeys, fileEnvKeys), " or "),
+ )
+ }
+
+ return fileVarValue, nil
+}
+
+func getEnvWithStrictFallback(keys ...string) string {
+ for _, key := range keys {
+ envVarValue := os.Getenv(key)
+ if envVarValue != "" {
+ return envVarValue
+ }
+ }
+
+ return ""
+}
+
+func getEnvFileWithStrictFallback(keys ...string) []byte {
+ for _, key := range keys {
+ fileVarValue := os.Getenv(key)
+ if fileVarValue == "" {
+ continue
+ }
+
+ fileContents, err := os.ReadFile(fileVarValue)
+ if err != nil {
+ log.Printf("Failed to read the file %s (defined by env var %s): %s", fileVarValue, key, err)
+ return nil
+ }
+
+ return fileContents
+ }
+
+ return nil
+}
diff --git a/providers/dns/oraclecloud/fixtures/cert.pem b/providers/dns/oraclecloud/fixtures/cert.pem
new file mode 100644
index 000000000..fc1dcfb53
--- /dev/null
+++ b/providers/dns/oraclecloud/fixtures/cert.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDHzCCAgegAwIBAgIQKIExaCLIXtXecrT1dWGLszANBgkqhkiG9w0BAQsFADAS
+MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw
+MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAwM4wEPHOGAu8tZNNWx3cH6AMuqKwAmB2RwbA3OK034MzhydOjnDm
+igw93eUc4nd3dnICyNpb2rbP9FgGlAuMlJ8raHQkG4DSXF1Bf14neOhLpfBItaX9
++EB3oO0NupKZhaHrsTKzLGD7bauAPX6PDXuAPp3u5mgGGuZjpLZoKqg3//WImb/2
+xEMVsmvPKTb5FxS/tAMtywjGSUtCTCrudUEh4Gnj6IboVdwYmt539ETDK/Rerxf3
+/GsmEbuOkDUdBixQwLo0U+UAoMOw4zoyQDrrtyUmvffDxI50RAdZDFyFtqZ0ZQa8
+lQqrMdQdf+x1Wb7BKozSktAw4igRP/mknQIDAQABo28wbTAOBgNVHQ8BAf8EBAMC
+AqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
+FgQUcetTliVbYxxutNS8JRkotRY4DRkwFgYDVR0RBA8wDYILZXhhbXBsZS5vcmcw
+DQYJKoZIhvcNAQELBQADggEBAEJP74/XB+12aGQ+EMERIX2Pn6YaaBLt6rTLqV7A
+zFxI9YGIc4xlGa0qkpDhpz6RSypTQG6HN5aZ5b8dz3foMleUVP2cXd8zduc8GQCb
+p4/8PpEhSl6dQb5+mg/qyHGUAaDl40VAbTLXHtn98dhacaJc+TKuXVJAgYRU3Sm3
+wFJxULZSnx+aGdE9s2brOGhvz1fVWnhvWzDvJSM+8xDURz8UiEnimTpV6m3CKItz
+2GatNjM8ADKC7MHQI4I5v4fEwronN/g3NfPfFSmnOKk+lPSAW42WEvhFol+2VvdX
+3p5X2QracSLCIj/DUBebZP9110C8Lj/YfFtOjFokqtQ9Fh4=
+-----END CERTIFICATE-----
diff --git a/providers/dns/oraclecloud/fixtures/key.pem b/providers/dns/oraclecloud/fixtures/key.pem
new file mode 100644
index 000000000..1a56bb5a4
--- /dev/null
+++ b/providers/dns/oraclecloud/fixtures/key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAzjAQ8c4YC7y1
+k01bHdwfoAy6orACYHZHBsDc4rTfgzOHJ06OcOaKDD3d5Rzid3d2cgLI2lvats/0
+WAaUC4yUnytodCQbgNJcXUF/Xid46Eul8Ei1pf34QHeg7Q26kpmFoeuxMrMsYPtt
+q4A9fo8Ne4A+ne7maAYa5mOktmgqqDf/9YiZv/bEQxWya88pNvkXFL+0Ay3LCMZJ
+S0JMKu51QSHgaePohuhV3Bia3nf0RMMr9F6vF/f8ayYRu46QNR0GLFDAujRT5QCg
+w7DjOjJAOuu3JSa998PEjnREB1kMXIW2pnRlBryVCqsx1B1/7HVZvsEqjNKS0DDi
+KBE/+aSdAgMBAAECggEAWl2pWJ/ErS9/HIl0NbMKk0YEAUuz/AEzHnoTVdPp22KW
+eY+aOZe/7c7sBj7WqWw98SVhmbsCV0HcuNSzDJtXIedyRGw+6icYMVNCGgzKqlgR
+8K3snjq1DLBGgYXpq9r/Got4ON6e7LttzIqXufrB2JtcUbzbFmGGDwCRjkcyDl9l
+M8ufwD/Xgcd2L8jainU43d2pVxvxUIpRlRdoupCCSlkRYPsXiWlqav7YO4F/Txos
+z3gJyzkXzc3WwfNZdQtEMYwBwozO+Dp2p4TUBr0Ta3MbfrKfDoTs4XT/Ce9IwJJS
+/h6E9cxZD8t5oMT50quFjwhHBKodMiUqIlh2YQEAbwKBgQDIULzo/tgDgTwveyEn
+L9n8yVbEh/SfrE9QtXcjkDB5+tYmIsIaz16NRWlAqnJVGZvcanrCq7ZTxgUcs/hW
+Ag+sfWkeg7lmfeJAkiZ6kmi1h2qJjXMOBri+Cm6MTOsE6qdIc3eT4PnYkNpV7o6S
+70hWNncVadXLV4Thm9BLAbMbQwKBgQD2ZwKe/2zRQcbuBe1loF0HWIsJPxcKQ3LH
+hVf7f0YLQlIuzOhK8TQXgM0G4hxLlk1XeLjgf3z4Ju7hfh2JQLor1QYPRGUj66SX
+KTE5eDwE0yEX1c9m5PW6M+f8vkOU4LQ/OtPw5OrKyYxpLf9dp42nmDYY/8IvUk96
+iKZNY1sSnwKBgQC27tS2SxVmjf0yt1WdfdurOQueSzKhJzD/2djFh4Zdvy8WgKOW
+7E3C4eKvBXmIMezeq/cUFNBbTPmaLtjZYuSBd74p+c20xb17jnzJby9kqBgpKh4q
+bwUDuG8gfZYbVVgTmC9ZwxkoJ5Dc7RETKqZ65R53VcHDA1f82Nitxw2UFQKBgBDl
+c2qPvViEGC4OPf8wBfERA0e5Cc1sXpyL6kKWsajn/Va0OmGZNKc/788/Bg2w2tDa
+uGK8m0cw9ESGL2RQCfQjgWzelcjmybyL2JJGSmdSSvylbrlxjeAc2xWbvmqhFfsX
+/5yPNgJ926ECxHYZnT8W0u7X6urvy/9tC2pXG9GlAoGBAKOAfij4fMbHY+Z1m825
+VhY110FDnePYFJWmExP8GAVqOzhCs0mzyCnYh6nvS/OY8moH2LOuwPUlDfF3IzyT
+hTUuXnykWT3w40eYQXXIaXEGhue+guL8ch16vEEJy5ltwEdIPNMTErbqAAk2W6Ps
+NB46HzETzEIWnzoamX6iQVWj
+-----END PRIVATE KEY-----
diff --git a/providers/dns/oraclecloud/oraclecloud.go b/providers/dns/oraclecloud/oraclecloud.go
index 535c691ba..730b3f212 100644
--- a/providers/dns/oraclecloud/oraclecloud.go
+++ b/providers/dns/oraclecloud/oraclecloud.go
@@ -11,22 +11,32 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/oracle/oci-go-sdk/v65/common"
- "github.com/oracle/oci-go-sdk/v65/dns"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/nrdcg/oci-go-sdk/common/v1065"
+ "github.com/nrdcg/oci-go-sdk/common/v1065/auth"
+ "github.com/nrdcg/oci-go-sdk/dns/v1065"
)
// Environment variables names.
const (
envNamespace = "OCI_"
- EnvCompartmentOCID = envNamespace + "COMPARTMENT_OCID"
+ EnvAuthType = envNamespace + "AUTH_TYPE"
+
+ EnvCompartmentOCID = envNamespace + "COMPARTMENT_OCID"
+ EnvRegion = envNamespace + "REGION"
+
envPrivKey = envNamespace + "PRIVKEY"
EnvPrivKeyFile = envPrivKey + "_FILE"
EnvPrivKeyPass = envPrivKey + "_PASS"
EnvTenancyOCID = envNamespace + "TENANCY_OCID"
EnvUserOCID = envNamespace + "USER_OCID"
EnvPubKeyFingerprint = envNamespace + "PUBKEY_FINGERPRINT"
- EnvRegion = envNamespace + "REGION"
+
+ altEnvPrivateKey = envNamespace + "PRIVATE_KEY" // alias on OCI_PRIVKEY
+ altEnvPrivateKeyPath = altEnvPrivateKey + "_PATH" // alias on OCI_PRIVKEY_FILE
+ altEnvPrivateKeyPassword = altEnvPrivateKey + "_PASSWORD" // alias on OCI_PRIVKEY_PASS
+ altEnvFingerprint = envNamespace + "FINGERPRINT" // alias on OCI_PUBKEY_FINGERPRINT
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -34,12 +44,25 @@ const (
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
+// https://github.com/oracle/oci-go-sdk/blob/7f425f74c74fd0c6a5acb74466c85eb5346e0092/common/client.go#L350
+// https://github.com/oracle/oci-go-sdk/blob/7f425f74c74fd0c6a5acb74466c85eb5346e0092/common/configuration.go#L174-L175
+const (
+ altEnvTFVarNamespace = "TF_VAR_"
+ altEnvTFVarRegion = altEnvTFVarNamespace + "region" // alias on OCI_REGION
+ altEnvTFVarFingerprint = altEnvTFVarNamespace + "fingerprint" // alias on OCI_PUBKEY_FINGERPRINT
+ altEnvTFVarUserOCID = altEnvTFVarNamespace + "user_ocid" // alias on OCI_USER_OCID
+ altEnvTFVarTenancyOCID = altEnvTFVarNamespace + "tenancy_ocid" // alias on OCI_TENANCY_OCID
+ altEnvTFVarPrivateKeyPath = altEnvTFVarNamespace + "private_key_path" // alias on OCI_PRIVKEY_FILE
+ altEnvTFVarPrivateKeyPassword = altEnvTFVarNamespace + "private_key_password" // alias on OCI_PRIVKEY_PASS
+)
+
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- CompartmentID string
- OCIConfigProvider common.ConfigurationProvider
+ CompartmentID string
+ OCIConfigProvider common.ConfigurationProvider
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@@ -53,7 +76,7 @@ func NewDefaultConfig() *Config {
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
},
}
}
@@ -66,14 +89,41 @@ type DNSProvider struct {
// NewDNSProvider returns a DNSProvider instance configured for OracleCloud.
func NewDNSProvider() (*DNSProvider, error) {
- values, err := env.Get(envPrivKey, EnvTenancyOCID, EnvUserOCID, EnvPubKeyFingerprint, EnvRegion, EnvCompartmentOCID)
- if err != nil {
- return nil, fmt.Errorf("oraclecloud: %w", err)
- }
-
config := NewDefaultConfig()
- config.CompartmentID = values[EnvCompartmentOCID]
- config.OCIConfigProvider = newConfigProvider(values)
+
+ switch env.GetOrFile(EnvAuthType) {
+ case string(common.InstancePrincipal):
+ values, err := env.Get(EnvCompartmentOCID)
+ if err != nil {
+ return nil, fmt.Errorf("oraclecloud: %w", err)
+ }
+
+ config.CompartmentID = values[EnvCompartmentOCID]
+
+ region := env.GetOneWithFallback(EnvRegion, "", env.ParseString, altEnvTFVarRegion)
+
+ configurationProvider, err := auth.InstancePrincipalConfigurationProviderForRegion(common.Region(region))
+ if err != nil {
+ return nil, fmt.Errorf("oraclecloud: %w", err)
+ }
+
+ config.OCIConfigProvider = configurationProvider
+
+ default:
+ values, err := env.Get(EnvCompartmentOCID)
+ if err != nil {
+ return nil, fmt.Errorf("oraclecloud: %w", err)
+ }
+
+ config.CompartmentID = values[EnvCompartmentOCID]
+
+ ecp, err := newEnvironmentConfigurationProvider()
+ if err != nil {
+ return nil, fmt.Errorf("oraclecloud: %w", err)
+ }
+
+ config.OCIConfigProvider = ecp
+ }
return NewDNSProviderConfig(config)
}
@@ -98,7 +148,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
+ client.HTTPClient = clientdebug.Wrap(config.HTTPClient)
}
return &DNSProvider{client: &client, config: config}, nil
@@ -168,7 +218,8 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
var deleteHash *string
- for _, record := range domainRecords.RecordCollection.Items {
+
+ for _, record := range domainRecords.Items {
if record.Rdata != nil && *record.Rdata == `"`+info.Value+`"` {
deleteHash = record.RecordHash
break
diff --git a/providers/dns/oraclecloud/oraclecloud.toml b/providers/dns/oraclecloud/oraclecloud.toml
index 70b776554..f6155052e 100644
--- a/providers/dns/oraclecloud/oraclecloud.toml
+++ b/providers/dns/oraclecloud/oraclecloud.toml
@@ -5,29 +5,43 @@ Code = "oraclecloud"
Since = "v2.3.0"
Example = '''
-OCI_PRIVKEY_FILE="~/.oci/oci_api_key.pem" \
-OCI_PRIVKEY_PASS="secret" \
+# Using API Key authentication:
+OCI_PRIVATE_KEY_PATH="~/.oci/oci_api_key.pem" \
+OCI_PRIVATE_KEY_PASSWORD="secret" \
OCI_TENANCY_OCID="ocid1.tenancy.oc1..secret" \
OCI_USER_OCID="ocid1.user.oc1..secret" \
-OCI_PUBKEY_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \
+OCI_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \
OCI_REGION="us-phoenix-1" \
OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \
-lego --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com run
+lego --dns oraclecloud -d '*.example.com' -d example.com run
+
+# Using Instance Principal authentication (when running on OCI compute instances):
+# https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm
+OCI_AUTH_TYPE="instance_principal" \
+OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \
+lego --dns oraclecloud -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
- OCI_PRIVKEY_FILE = "Private key file"
- OCI_PRIVKEY_PASS = "Private key password"
- OCI_TENANCY_OCID = "Tenancy OCID"
- OCI_USER_OCID = "User OCID"
- OCI_PUBKEY_FINGERPRINT = "Public key fingerprint"
- OCI_REGION = "Region"
OCI_COMPARTMENT_OCID = "Compartment OCID"
+ OCI_REGION = "Region (it can be empty if `OCI_AUTH_TYPE=instance_principal`)."
+ OCI_PRIVATE_KEY_PATH = "Private key file (ignored if `OCI_AUTH_TYPE=instance_principal`)"
+ OCI_PRIVATE_KEY_PASSWORD = "Private key password (ignored if `OCI_AUTH_TYPE=instance_principal`)"
+ OCI_TENANCY_OCID = "Tenancy OCID (ignored if `OCI_AUTH_TYPE=instance_principal`)"
+ OCI_USER_OCID = "User OCID (ignored if `OCI_AUTH_TYPE=instance_principal`)"
+ OCI_FINGERPRINT = "Public key fingerprint (ignored if `OCI_AUTH_TYPE=instance_principal`)"
[Configuration.Additional]
- OCI_POLLING_INTERVAL = "Time between DNS propagation check"
- OCI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- OCI_TTL = "The TTL of the TXT record used for the DNS challenge"
+ OCI_AUTH_TYPE = "Authorization type. Possible values: 'instance_principal', '' (Default: '')"
+ TF_VAR_region = "Alias on `OCI_REGION`"
+ TF_VAR_fingerprint = "Alias on `OCI_FINGERPRINT`"
+ TF_VAR_user_ocid = "Alias on `OCI_USER_OCID`"
+ TF_VAR_tenancy_ocid = "Alias on `OCI_TENANCY_OCID`"
+ TF_VAR_private_key_path = "Alias on `OCI_PRIVATE_KEY_PATH`"
+ OCI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ OCI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ OCI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ OCI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)"
[Links]
API = "https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm"
diff --git a/providers/dns/oraclecloud/oraclecloud_test.go b/providers/dns/oraclecloud/oraclecloud_test.go
index 9fff79ea1..74ee06eac 100644
--- a/providers/dns/oraclecloud/oraclecloud_test.go
+++ b/providers/dns/oraclecloud/oraclecloud_test.go
@@ -6,19 +6,31 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
+ "maps"
+ "net/http/httptest"
"os"
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
- "github.com/oracle/oci-go-sdk/v65/common"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/nrdcg/oci-go-sdk/common/v1065"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
+// Used by Instance Principal authentication.
+const (
+ envMetadataBaseURL = "OCI_METADATA_BASE_URL"
+ envSDKAuthClientRegionURL = "OCI_SDK_AUTH_CLIENT_REGION_URL"
+)
+
var envTest = tester.NewEnvTest(
envPrivKey,
+ EnvAuthType,
+ envMetadataBaseURL,
+ envSDKAuthClientRegionURL,
EnvPrivKeyFile,
EnvPrivKeyPass,
EnvTenancyOCID,
@@ -49,7 +61,7 @@ func TestNewDNSProvider(t *testing.T) {
{
desc: "success file",
envVars: map[string]string{
- EnvPrivKeyFile: mustGeneratePrivateKeyFile("secret1"),
+ EnvPrivKeyFile: mustGeneratePrivateKeyFile(t, "secret1"),
EnvPrivKeyPass: "secret1",
EnvTenancyOCID: "ocid1.tenancy.oc1..secret",
EnvUserOCID: "ocid1.user.oc1..secret",
@@ -61,7 +73,7 @@ func TestNewDNSProvider(t *testing.T) {
{
desc: "missing credentials",
envVars: map[string]string{},
- expected: "oraclecloud: some credentials information are missing: OCI_PRIVKEY,OCI_TENANCY_OCID,OCI_USER_OCID,OCI_PUBKEY_FINGERPRINT,OCI_REGION,OCI_COMPARTMENT_OCID",
+ expected: "oraclecloud: some credentials information are missing: OCI_COMPARTMENT_OCID",
},
{
desc: "missing CompartmentID",
@@ -87,7 +99,7 @@ func TestNewDNSProvider(t *testing.T) {
EnvRegion: "us-phoenix-1",
EnvCompartmentOCID: "123",
},
- expected: "oraclecloud: some credentials information are missing: OCI_PRIVKEY",
+ expected: "oraclecloud: can not create client, bad configuration: no value provided for: OCI_PRIVKEY or OCI_PRIVATE_KEY or OCI_PRIVKEY_FILE or OCI_PRIVATE_KEY_PATH or TF_VAR_private_key_path",
},
{
desc: "missing OCI_PRIVKEY_PASS",
@@ -176,8 +188,10 @@ func TestNewDNSProvider(t *testing.T) {
if privKeyFile != "" {
_ = os.Remove(privKeyFile)
}
+
envTest.RestoreEnv()
}()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -197,6 +211,74 @@ func TestNewDNSProvider(t *testing.T) {
}
}
+func TestNewDNSProvider_instance_principal(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAuthType: "instance_principal",
+ EnvCompartmentOCID: "123",
+ },
+ },
+ {
+ desc: "missing CompartmentID",
+ envVars: map[string]string{
+ EnvAuthType: "instance_principal",
+ },
+ expected: "oraclecloud: some credentials information are missing: OCI_COMPARTMENT_OCID",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer func() {
+ envTest.RestoreEnv()
+ }()
+
+ envTest.ClearEnv()
+
+ serverURL := servermock.NewBuilder(
+ func(server *httptest.Server) (string, error) {
+ return server.URL, nil
+ }).
+ Route("GET /instance/region", servermock.RawStringResponse("oc1")).
+ // To generate fake certificates:
+ // go run `go env GOROOT`/src/crypto/tls/generate_cert.go --host example.org --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h
+ Route("GET /identity/cert.pem", servermock.ResponseFromFixture("cert.pem")).
+ Route("GET /identity/key.pem", servermock.ResponseFromFixture("key.pem")).
+ Route("GET /identity/intermediate.pem", servermock.ResponseFromFixture("cert.pem")).
+ // https://github.com/oracle/oci-go-sdk/blob/413a2f277f95c5eb76e26a0e0833c396a518bf50/common/auth/jwt_test.go#L12
+ Route("POST /v1/x509", servermock.RawStringResponse(`{"token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFzdyIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJvcGMub3JhY2xlLmNvbSIsImV4cCI6MTUxMTgzODc5MywiaWF0IjoxNTExODE3MTkzLCJpc3MiOiJhdXRoU2VydmljZS5vcmFjbGUuY29tIiwib3BjLWNlcnR0eXBlIjoiaW5zdGFuY2UiLCJvcGMtY29tcGFydG1lbnQiOiJvY2lkMS5jb21wYXJ0bWVudC5vYzEuLmJsdWhibHVoYmx1aCIsIm9wYy1pbnN0YW5jZSI6Im9jaWQxLmluc3RhbmNlLm9jMS5waHguYmx1aGJsdWhibHVoIiwib3BjLXRlbmFudCI6Im9jaWR2MTp0ZW5hbmN5Om9jMTpwaHg6MTIzNDU2Nzg5MDpibHVoYmx1aGJsdWgiLCJwdHlwZSI6Imluc3RhbmNlIiwic3ViIjoib2NpZDEuaW5zdGFuY2Uub2MxLnBoeC5ibHVoYmx1aGJsdWgiLCJ0ZW5hbnQiOiJvY2lkdjE6dGVuYW5jeTpvYzE6cGh4OjEyMzQ1Njc4OTA6Ymx1aGJsdWhibHVoIiwidHR5cGUiOiJ4NTA5In0.zen7q2yJSpMjzH4ym_H7VEwZA0-vTT4Wcild-HRfLxX6A1ej4tlpACa7A24j5JoZYI4mHooZVJ8e7ZezFenK0zZx5j8RbIjsqJKwroYXExOiBXLCUwMWOLXIndEsUzzGLqnPfKHXd80vrhMLmtkVTCJqBMzvPUSYkH_ciWgmjP9m0YETdQ9ifghkADhZGt9IlnOswg0s3Bx9ASwxFZEtom0BmU9GwEuITTTZfKvndk785BlNeZMOjhovaD97-LYpv5B_PiWEz8zialK5zxjijLCw06zyA8CQRQqmVCagNUPilfz_BcPyImzvFDuzQcPyDkTcsB7weX35tafHmA_Ul"}`)).
+ Build(t)
+
+ envVars := map[string]string{
+ envMetadataBaseURL: serverURL,
+ envSDKAuthClientRegionURL: serverURL,
+ }
+
+ maps.Copy(envVars, test.envVars)
+
+ envTest.Apply(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.Error(t, err)
+ require.Contains(t, err.Error(), test.expected)
+ }
+ })
+ }
+}
+
func TestNewDNSProviderConfig(t *testing.T) {
envTest.ClearEnv()
defer envTest.RestoreEnv()
@@ -251,6 +333,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -264,6 +347,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -273,21 +357,20 @@ func TestLiveCleanUp(t *testing.T) {
require.NoError(t, err)
}
-func mockConfigurationProvider(keyPassphrase string) *configProvider {
+func mockConfigurationProvider(keyPassphrase string) *environmentConfigurationProvider {
envTest.Apply(map[string]string{
envPrivKey: mustGeneratePrivateKey("secret"),
})
- return &configProvider{
+ return &environmentConfigurationProvider{
values: map[string]string{
EnvCompartmentOCID: "test",
- EnvPrivKeyPass: "test",
+ EnvPrivKeyPass: keyPassphrase,
EnvTenancyOCID: "test",
EnvUserOCID: "test",
EnvPubKeyFingerprint: "test",
EnvRegion: "test",
},
- privateKeyPassphrase: keyPassphrase,
}
}
@@ -300,27 +383,27 @@ func mustGeneratePrivateKey(pwd string) string {
return base64.StdEncoding.EncodeToString(pem.EncodeToMemory(block))
}
-func mustGeneratePrivateKeyFile(pwd string) string {
- block, err := generatePrivateKey(pwd)
- if err != nil {
- panic(err)
- }
+func mustGeneratePrivateKeyFile(t *testing.T, pwd string) string {
+ t.Helper()
- file, err := os.CreateTemp("", "lego_oci_*.pem")
- if err != nil {
- panic(err)
- }
+ block, err := generatePrivateKey(pwd)
+ require.NoError(t, err)
+
+ file, err := os.CreateTemp(t.TempDir(), "lego_oci_*.pem")
+ require.NoError(t, err)
+
+ defer func() {
+ _ = file.Close()
+ }()
err = pem.Encode(file, block)
- if err != nil {
- panic(err)
- }
+ require.NoError(t, err)
return file.Name()
}
func generatePrivateKey(pwd string) (*pem.Block, error) {
- key, err := rsa.GenerateKey(rand.Reader, 512)
+ key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
return nil, err
}
diff --git a/providers/dns/otc/internal/client.go b/providers/dns/otc/internal/client.go
index 59a685140..adb0682e1 100644
--- a/providers/dns/otc/internal/client.go
+++ b/providers/dns/otc/internal/client.go
@@ -31,7 +31,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(username string, password string, domainName string, projectName string) *Client {
+func NewClient(username, password, domainName, projectName string) *Client {
return &Client{
username: username,
password: password,
@@ -42,8 +42,8 @@ func NewClient(username string, password string, domainName string, projectName
}
}
-func (c *Client) GetZoneID(ctx context.Context, zone string) (string, error) {
- zonesResp, err := c.getZones(ctx, zone)
+func (c *Client) GetZoneID(ctx context.Context, zone string, privateZone bool) (string, error) {
+ zonesResp, err := c.getZones(ctx, zone, privateZone)
if err != nil {
return "", err
}
@@ -62,13 +62,18 @@ func (c *Client) GetZoneID(ctx context.Context, zone string) (string, error) {
}
// https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/public_zone_management/querying_public_zones.html
-func (c *Client) getZones(ctx context.Context, zone string) (*ZonesResponse, error) {
+func (c *Client) getZones(ctx context.Context, zone string, privateZone bool) (*ZonesResponse, error) {
c.muBaseURL.Lock()
endpoint := c.baseURL.JoinPath("zones")
c.muBaseURL.Unlock()
query := endpoint.Query()
query.Set("name", zone)
+
+ if privateZone {
+ query.Set("type", "private")
+ }
+
endpoint.RawQuery = query.Encode()
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -77,6 +82,7 @@ func (c *Client) getZones(ctx context.Context, zone string) (*ZonesResponse, err
}
var zones ZonesResponse
+
err = c.do(req, &zones)
if err != nil {
return nil, err
@@ -123,6 +129,7 @@ func (c *Client) getRecordSet(ctx context.Context, zoneID, fqdn string) (*Record
}
var recordSetsRes RecordSetsResponse
+
err = c.do(req, &recordSetsRes)
if err != nil {
return nil, err
@@ -163,9 +170,11 @@ func (c *Client) DeleteRecordSet(ctx context.Context, zoneID, recordID string) e
func (c *Client) do(req *http.Request, result any) error {
c.muToken.Lock()
+
if c.token != "" {
req.Header.Set("X-Auth-Token", c.token)
}
+
c.muToken.Unlock()
resp, err := c.HTTPClient.Do(req)
@@ -196,7 +205,7 @@ func (c *Client) do(req *http.Request, result any) error {
return nil
}
-func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload interface{}) (*http.Request, error) {
+func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
diff --git a/providers/dns/otc/internal/client_test.go b/providers/dns/otc/internal/client_test.go
new file mode 100644
index 000000000..74b5bb3af
--- /dev/null
+++ b/providers/dns/otc/internal/client_test.go
@@ -0,0 +1,125 @@
+package internal
+
+import (
+ "context"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret", "example.com", "test")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
+}
+
+func TestClient_GetZoneID(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("zones_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.")).
+ Build(t)
+
+ zoneID, err := client.GetZoneID(context.Background(), "example.com.", false)
+ require.NoError(t, err)
+
+ assert.Equal(t, "123123", zoneID)
+}
+
+func TestClient_GetZoneID_private(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("zones_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.").
+ With("type", "private")).
+ Build(t)
+
+ zoneID, err := client.GetZoneID(context.Background(), "example.com.", true)
+ require.NoError(t, err)
+
+ assert.Equal(t, "123123", zoneID)
+}
+
+func TestClient_GetZoneID_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones",
+ servermock.ResponseFromFixture("zones_GET_empty.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.")).
+ Build(t)
+
+ _, err := client.GetZoneID(context.Background(), "example.com.", false)
+ require.EqualError(t, err, "zone example.com. not found")
+}
+
+func TestClient_GetRecordSetID(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones/123123/recordsets",
+ servermock.ResponseFromFixture("zones-recordsets_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.").
+ With("type", "TXT"),
+ ).
+ Build(t)
+
+ recordSetID, err := client.GetRecordSetID(context.Background(), "123123", "example.com.")
+ require.NoError(t, err)
+
+ assert.Equal(t, "321321", recordSetID)
+}
+
+func TestClient_GetRecordSetID_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /zones/123123/recordsets",
+ servermock.ResponseFromFixture("zones-recordsets_GET_empty.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.").
+ With("type", "TXT"),
+ ).
+ Build(t)
+
+ _, err := client.GetRecordSetID(context.Background(), "123123", "example.com.")
+ require.EqualError(t, err, "record not found")
+}
+
+func TestClient_CreateRecordSet(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/123123/recordsets",
+ servermock.ResponseFromFixture("zones-recordsets_POST.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zones-recordsets_POST-request.json")).
+ Build(t)
+
+ rs := RecordSets{
+ Name: "_acme-challenge.example.com.",
+ Description: "Added TXT record for ACME dns-01 challenge using lego client",
+ Type: "TXT",
+ TTL: 300,
+ Records: []string{strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")},
+ }
+ err := client.CreateRecordSet(context.Background(), "123123", rs)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecordSet(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /zones/123123/recordsets/321321",
+ servermock.ResponseFromFixture("zones-recordsets_DELETE.json")).
+ Build(t)
+
+ err := client.DeleteRecordSet(context.Background(), "123123", "321321")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/otc/internal/fixtures/zones-recordsets_POST-request.json b/providers/dns/otc/internal/fixtures/zones-recordsets_POST-request.json
new file mode 100644
index 000000000..41cab72a8
--- /dev/null
+++ b/providers/dns/otc/internal/fixtures/zones-recordsets_POST-request.json
@@ -0,0 +1,9 @@
+{
+ "name": "_acme-challenge.example.com.",
+ "description": "Added TXT record for ACME dns-01 challenge using lego client",
+ "type": "TXT",
+ "ttl": 300,
+ "records": [
+ "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\""
+ ]
+}
diff --git a/providers/dns/otc/internal/identity.go b/providers/dns/otc/internal/identity.go
index f9e7cb08f..154ec65e2 100644
--- a/providers/dns/otc/internal/identity.go
+++ b/providers/dns/otc/internal/identity.go
@@ -46,6 +46,7 @@ func (c *Client) Login(ctx context.Context) error {
c.muToken.Lock()
defer c.muToken.Unlock()
+
c.token = token
if c.token == "" {
@@ -96,6 +97,7 @@ func (c *Client) obtainUserToken(ctx context.Context, payload LoginRequest) (*To
}
var newToken TokenResponse
+
err = json.Unmarshal(raw, &newToken)
if err != nil {
return nil, "", errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -106,6 +108,7 @@ func (c *Client) obtainUserToken(ctx context.Context, payload LoginRequest) (*To
func getBaseURL(tokenResp *TokenResponse) (*url.URL, error) {
var endpoints []Endpoint
+
for _, v := range tokenResp.Token.Catalog {
if v.Type == "dns" {
endpoints = append(endpoints, v.Endpoints...)
diff --git a/providers/dns/otc/internal/identity_test.go b/providers/dns/otc/internal/identity_test.go
index 18627869a..4dce72afc 100644
--- a/providers/dns/otc/internal/identity_test.go
+++ b/providers/dns/otc/internal/identity_test.go
@@ -1,25 +1,36 @@
package internal
import (
- "context"
+ "net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_Login(t *testing.T) {
- mock := NewDNSServerMock(t)
- mock.HandleAuthSuccessfully()
+ var serverURL *url.URL
- client := NewClient("user", "secret", "example.com", "test")
- client.IdentityEndpoint, _ = url.JoinPath(mock.GetServerURL(), "/v3/auth/token")
+ client := servermock.NewBuilder(
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret", "example.com", "test")
+ client.HTTPClient = server.Client()
+ client.IdentityEndpoint = server.URL + "/v3/auth/token"
- err := client.Login(context.Background())
+ serverURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ ).
+ Route("POST /v3/auth/token", IdentityHandlerMock()).
+ Build(t)
+
+ err := client.Login(t.Context())
require.NoError(t, err)
- serverURL, _ := url.Parse(mock.GetServerURL())
assert.Equal(t, serverURL.JoinPath("v2").String(), client.baseURL.String())
assert.Equal(t, fakeOTCToken, client.token)
}
diff --git a/providers/dns/otc/internal/mock.go b/providers/dns/otc/internal/mock.go
index 2ed7f84de..46da61e4c 100644
--- a/providers/dns/otc/internal/mock.go
+++ b/providers/dns/otc/internal/mock.go
@@ -2,62 +2,13 @@ package internal
import (
"fmt"
- "io"
"net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
const fakeOTCToken = "62244bc21da68d03ebac94e6636ff01f"
-func writeFixture(rw http.ResponseWriter, filename string) {
- file, err := os.Open(filepath.Join("internal", "fixtures", filename))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- defer func() { _ = file.Close() }()
-
- _, _ = io.Copy(rw, file)
-}
-
-// DNSServerMock mock.
-type DNSServerMock struct {
- t *testing.T
- server *httptest.Server
- mux *http.ServeMux
-}
-
-// NewDNSServerMock create a new DNSServerMock.
-func NewDNSServerMock(t *testing.T) *DNSServerMock {
- t.Helper()
-
- mux := http.NewServeMux()
-
- return &DNSServerMock{
- t: t,
- server: httptest.NewServer(mux),
- mux: mux,
- }
-}
-
-func (m *DNSServerMock) GetServerURL() string {
- return m.server.URL
-}
-
-// ShutdownServer creates the mock server.
-func (m *DNSServerMock) ShutdownServer() {
- m.server.Close()
-}
-
-// HandleAuthSuccessfully Handle auth successfully.
-func (m *DNSServerMock) HandleAuthSuccessfully() {
- m.mux.HandleFunc("/v3/auth/token", func(w http.ResponseWriter, _ *http.Request) {
+func IdentityHandlerMock() http.HandlerFunc {
+ return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("X-Subject-Token", fakeOTCToken)
_, _ = fmt.Fprintf(w, `{
@@ -69,7 +20,7 @@ func (m *DNSServerMock) HandleAuthSuccessfully() {
"name": "",
"endpoints": [
{
- "url": "%s",
+ "url": "http://%s",
"region": "eu-de",
"region_id": "eu-de",
"interface": "public",
@@ -78,87 +29,6 @@ func (m *DNSServerMock) HandleAuthSuccessfully() {
]
}
]
- }}`, m.server.URL)
- })
-}
-
-// HandleListZonesSuccessfully Handle list zones successfully.
-func (m *DNSServerMock) HandleListZonesSuccessfully() {
- m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(m.t, http.MethodGet, r.Method)
- assert.Equal(m.t, "/v2/zones", r.URL.Path)
- assert.Equal(m.t, "name=example.com.", r.URL.RawQuery)
- assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
-
- writeFixture(w, "zones_GET.json")
- })
-}
-
-// HandleListZonesEmpty Handle list zones empty.
-func (m *DNSServerMock) HandleListZonesEmpty() {
- m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(m.t, http.MethodGet, r.Method)
- assert.Equal(m.t, "/v2/zones", r.URL.Path)
- assert.Equal(m.t, "name=example.com.", r.URL.RawQuery)
- assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
-
- writeFixture(w, "zones_GET_empty.json")
- })
-}
-
-// HandleDeleteRecordsetsSuccessfully Handle delete recordsets successfully.
-func (m *DNSServerMock) HandleDeleteRecordsetsSuccessfully() {
- m.mux.HandleFunc("/v2/zones/123123/recordsets/321321", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(m.t, http.MethodDelete, r.Method)
- assert.Equal(m.t, "/v2/zones/123123/recordsets/321321", r.URL.Path)
- assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
-
- writeFixture(w, "zones-recordsets_DELETE.json")
- })
-}
-
-// HandleListRecordsetsEmpty Handle list recordsets empty.
-func (m *DNSServerMock) HandleListRecordsetsEmpty() {
- m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path)
- assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery)
-
- writeFixture(w, "zones-recordsets_GET_empty.json")
- })
-}
-
-// HandleListRecordsetsSuccessfully Handle list recordsets successfully.
-func (m *DNSServerMock) HandleListRecordsetsSuccessfully() {
- m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) {
- assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
-
- if r.Method == http.MethodGet {
- assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path)
- assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery)
-
- writeFixture(w, "zones-recordsets_GET.json")
- return
- }
-
- if r.Method == http.MethodPost {
- assert.Equal(m.t, "application/json", r.Header.Get("Content-Type"))
-
- raw, err := io.ReadAll(r.Body)
- require.NoError(m.t, err)
- exceptedString := `{
- "name": "_acme-challenge.example.com.",
- "description": "Added TXT record for ACME dns-01 challenge using lego client",
- "type": "TXT",
- "ttl": 300,
- "records": ["\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\""]
- }`
-
- assert.JSONEq(m.t, exceptedString, string(raw))
-
- writeFixture(w, "zones-recordsets_POST.json")
- return
- }
-
- http.Error(w, fmt.Sprintf("Expected method to be 'GET' or 'POST' but got '%s'", r.Method), http.StatusBadRequest)
- })
+ }}`, req.Context().Value(http.LocalAddrContextKey))
+ }
}
diff --git a/providers/dns/otc/internal/types.go b/providers/dns/otc/internal/types.go
index 38da4f110..e7bfe8fcb 100644
--- a/providers/dns/otc/internal/types.go
+++ b/providers/dns/otc/internal/types.go
@@ -41,8 +41,8 @@ type TokenResponse struct {
}
type Token struct {
- User UserR `json:"user,omitempty"`
- Domain Domain `json:"domain,omitempty"`
+ User UserR `json:"user"`
+ Domain Domain `json:"domain"`
Catalog []Catalog `json:"catalog,omitempty"`
Methods []string `json:"methods,omitempty"`
Roles []Role `json:"roles,omitempty"`
@@ -59,7 +59,7 @@ type Catalog struct {
type UserR struct {
ID string `json:"id,omitempty"`
- Domain Domain `json:"domain,omitempty"`
+ Domain Domain `json:"domain"`
Name string `json:"name,omitempty"`
PasswordExpiresAt string `json:"password_expires_at,omitempty"`
}
@@ -106,7 +106,7 @@ type RecordSets struct {
// ZonesResponse
type ZonesResponse struct {
- Links Links `json:"links,omitempty"`
+ Links Links `json:"links"`
Zones []Zone `json:"zones"`
Metadata Metadata `json:"metadata"`
}
diff --git a/providers/dns/otc/otc.go b/providers/dns/otc/otc.go
index 3bb11cecc..65b362124 100644
--- a/providers/dns/otc/otc.go
+++ b/providers/dns/otc/otc.go
@@ -5,13 +5,13 @@ import (
"context"
"errors"
"fmt"
- "net"
"net/http"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/otc/internal"
)
@@ -24,6 +24,7 @@ const (
EnvPassword = envNamespace + "PASSWORD"
EnvProjectName = envNamespace + "PROJECT_NAME"
EnvIdentityEndpoint = envNamespace + "IDENTITY_ENDPOINT"
+ EnvPrivateZone = envNamespace + "PRIVATE_ZONE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -41,11 +42,13 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- IdentityEndpoint string
- DomainName string
- ProjectName string
- UserName string
- Password string
+ DomainName string
+ ProjectName string
+ UserName string
+ Password string
+ IdentityEndpoint string
+ PrivateZone bool
+
PropagationTimeout time.Duration
PollingInterval time.Duration
SequenceInterval time.Duration
@@ -55,28 +58,27 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
+ tr := &http.Transport{}
+
+ defaultTransport, ok := http.DefaultTransport.(*http.Transport)
+ if ok {
+ tr = defaultTransport.Clone()
+ }
+
+ // Workaround for keep alive bug in otc api
+ tr.DisableKeepAlives = true
+
return &Config{
+ PrivateZone: env.GetOrDefaultBool(EnvPrivateZone, false),
+ IdentityEndpoint: env.GetOrDefaultString(EnvIdentityEndpoint, defaultIdentityEndpoint),
+
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
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{
- Proxy: http.ProxyFromEnvironment,
- DialContext: (&net.Dialer{
- Timeout: 30 * time.Second,
- KeepAlive: 30 * time.Second,
- }).DialContext,
- MaxIdleConns: 100,
- IdleConnTimeout: 90 * time.Second,
- TLSHandshakeTimeout: 10 * time.Second,
- ExpectContinueTimeout: 1 * time.Second,
-
- // Workaround for keep alive bug in otc api
- DisableKeepAlives: true,
- },
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
+ Transport: tr,
},
}
}
@@ -129,6 +131,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -148,7 +152,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("otc: %w", err)
}
- zoneID, err := d.client.GetZoneID(ctx, authZone)
+ zoneID, err := d.client.GetZoneID(ctx, authZone, d.config.PrivateZone)
if err != nil {
return fmt.Errorf("otc: unable to get zone: %w", err)
}
@@ -185,7 +189,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("otc: %w", err)
}
- zoneID, err := d.client.GetZoneID(ctx, authZone)
+ zoneID, err := d.client.GetZoneID(ctx, authZone, d.config.PrivateZone)
if err != nil {
return fmt.Errorf("otc: %w", err)
}
diff --git a/providers/dns/otc/otc.toml b/providers/dns/otc/otc.toml
index e3c60158c..e63077fda 100644
--- a/providers/dns/otc/otc.toml
+++ b/providers/dns/otc/otc.toml
@@ -4,7 +4,13 @@ URL = "https://cloud.telekom.de/en"
Code = "otc"
Since = "v0.4.1"
-Example = ''''''
+Example = '''
+OTC_DOMAIN_NAME=domain_name \
+OTC_USER_NAME=user_name \
+OTC_PASSWORD=password \
+OTC_PROJECT_NAME=project_name \
+lego --dns otc -d '*.example.com' -d example.com run
+'''
[Configuration]
[Configuration.Credentials]
@@ -12,13 +18,14 @@ Example = ''''''
OTC_PASSWORD = "Password"
OTC_PROJECT_NAME = "Project name"
OTC_DOMAIN_NAME = "Domain name"
- OTC_IDENTITY_ENDPOINT = "Identity endpoint URL"
[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"
+ OTC_IDENTITY_ENDPOINT = "Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens)"
+ OTC_PRIVATE_ZONE = "Set to true to use private zones only (default: use public zones only)"
+ OTC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ OTC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ OTC_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ OTC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ OTC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://docs.otc.t-systems.com/domain-name-service/api-ref/index.html"
diff --git a/providers/dns/otc/otc_test.go b/providers/dns/otc/otc_test.go
index 54907b69e..518ce0f19 100644
--- a/providers/dns/otc/otc_test.go
+++ b/providers/dns/otc/otc_test.go
@@ -2,129 +2,334 @@ package otc
import (
"fmt"
- "os"
+ "net/http/httptest"
"testing"
+ "time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-acme/lego/v4/providers/dns/otc/internal"
- "github.com/stretchr/testify/suite"
+ "github.com/stretchr/testify/require"
)
-type OTCSuite struct {
- suite.Suite
+const envDomain = envNamespace + "DOMAIN"
- mock *internal.DNSServerMock
- envTest *tester.EnvTest
+var envTest = tester.NewEnvTest(
+ EnvDomainName,
+ EnvUserName,
+ EnvPassword,
+ EnvPrivateZone,
+ EnvProjectName,
+ EnvIdentityEndpoint).
+ WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvDomainName: "example.com",
+ EnvUserName: "user",
+ EnvPassword: "secret",
+ EnvProjectName: "test",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvDomainName: "",
+ EnvUserName: "",
+ EnvPassword: "",
+ EnvProjectName: "",
+ },
+ expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME",
+ },
+ {
+ desc: "missing domain name",
+ envVars: map[string]string{
+ EnvDomainName: "",
+ EnvUserName: "user",
+ EnvPassword: "secret",
+ EnvProjectName: "test",
+ },
+ expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME",
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvDomainName: "example.com",
+ EnvUserName: "",
+ EnvPassword: "secret",
+ EnvProjectName: "test",
+ },
+ expected: "otc: some credentials information are missing: OTC_USER_NAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvDomainName: "example.com",
+ EnvUserName: "user",
+ EnvPassword: "",
+ EnvProjectName: "test",
+ },
+ expected: "otc: some credentials information are missing: OTC_PASSWORD",
+ },
+ {
+ desc: "missing project name",
+ envVars: map[string]string{
+ EnvDomainName: "example.com",
+ EnvUserName: "user",
+ EnvPassword: "secret",
+ EnvProjectName: "",
+ },
+ expected: "otc: some credentials information are missing: OTC_PROJECT_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 (s *OTCSuite) SetupTest() {
- s.mock = internal.NewDNSServerMock(s.T())
- s.mock.HandleAuthSuccessfully()
- s.envTest = tester.NewEnvTest(
- EnvDomainName,
- EnvUserName,
- EnvPassword,
- EnvProjectName,
- EnvIdentityEndpoint,
- )
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ domainName string
+ projectName string
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ domainName: "example.com",
+ projectName: "test",
+ username: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "otc: credentials missing",
+ },
+ {
+ desc: "missing domain name",
+ domainName: "",
+ projectName: "test",
+ username: "user",
+ password: "secret",
+ expected: "otc: credentials missing",
+ },
+ {
+ desc: "missing project name",
+ domainName: "example.com",
+ projectName: "",
+ username: "user",
+ password: "secret",
+ expected: "otc: credentials missing",
+ },
+ {
+ desc: "missing username",
+ domainName: "example.com",
+ projectName: "test",
+ username: "",
+ password: "secret",
+ expected: "otc: credentials missing",
+ },
+ {
+ desc: "missing password ",
+ domainName: "example.com",
+ projectName: "test",
+ username: "user",
+ password: "",
+ expected: "otc: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.DomainName = test.domainName
+ config.ProjectName = test.projectName
+ config.UserName = test.username
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
}
-func (s *OTCSuite) TearDownTest() {
- s.envTest.RestoreEnv()
- s.mock.ShutdownServer()
-}
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
-func TestTestSuite(t *testing.T) {
- suite.Run(t, new(OTCSuite))
-}
-
-func (s *OTCSuite) createDNSProvider() (*DNSProvider, error) {
- config := NewDefaultConfig()
- config.UserName = "UserName"
- config.Password = "Password"
- config.DomainName = "DomainName"
- config.ProjectName = "ProjectName"
- config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", s.mock.GetServerURL())
-
- return NewDNSProviderConfig(config)
-}
-
-func (s *OTCSuite) TestLoginEnv() {
- s.envTest.ClearEnv()
-
- s.envTest.Apply(map[string]string{
- EnvDomainName: "unittest1",
- EnvUserName: "unittest2",
- EnvPassword: "unittest3",
- EnvProjectName: "unittest4",
- EnvIdentityEndpoint: "unittest5",
- })
+ envTest.RestoreEnv()
provider, err := NewDNSProvider()
- s.Require().NoError(err)
+ require.NoError(t, err)
- 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("https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens", provider.config.IdentityEndpoint)
+ err = provider.Present(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
}
-func (s *OTCSuite) TestLoginEnvEmpty() {
- s.envTest.ClearEnv()
+func TestLiveCleanUp(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
- _, err := NewDNSProvider()
- s.EqualError(err, "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME")
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ time.Sleep(1 * time.Second)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
}
-func (s *OTCSuite) TestDNSProvider_Present() {
- s.mock.HandleListZonesSuccessfully()
- s.mock.HandleListRecordsetsSuccessfully()
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder(false).
+ Route("GET /v2/zones",
+ servermock.ResponseFromInternal("zones_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.")).
+ Route("POST /v2/zones/123123/recordsets",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromInternal("zones-recordsets_POST-request.json")).
+ Build(t)
- provider, err := s.createDNSProvider()
- s.Require().NoError(err)
-
- err = provider.Present("example.com", "", "foobar")
- s.Require().NoError(err)
+ err := provider.Present("example.com", "", "123d==")
+ require.NoError(t, err)
}
-func (s *OTCSuite) TestDNSProvider_Present_EmptyZone() {
- s.mock.HandleListZonesEmpty()
- s.mock.HandleListRecordsetsSuccessfully()
+func TestDNSProvider_Present_private(t *testing.T) {
+ provider := mockBuilder(true).
+ Route("GET /v2/zones",
+ servermock.ResponseFromInternal("zones_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.").
+ With("type", "private")).
+ Route("POST /v2/zones/123123/recordsets",
+ servermock.Noop(),
+ servermock.CheckRequestJSONBodyFromInternal("zones-recordsets_POST-request.json")).
+ Build(t)
- provider, err := s.createDNSProvider()
- s.Require().NoError(err)
-
- err = provider.Present("example.com", "", "foobar")
- s.Error(err)
+ err := provider.Present("example.com", "", "123d==")
+ require.NoError(t, err)
}
-func (s *OTCSuite) TestDNSProvider_CleanUp() {
- s.mock.HandleListZonesSuccessfully()
- s.mock.HandleListRecordsetsSuccessfully()
- s.mock.HandleDeleteRecordsetsSuccessfully()
+func TestDNSProvider_Present_emptyZone(t *testing.T) {
+ provider := mockBuilder(false).
+ Route("GET /v2/zones",
+ servermock.ResponseFromInternal("zones_GET_empty.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.")).
+ Build(t)
- provider, err := s.createDNSProvider()
- s.Require().NoError(err)
-
- err = provider.CleanUp("example.com", "", "foobar")
- s.Require().NoError(err)
+ err := provider.Present("example.com", "", "123d==")
+ require.EqualError(t, err, "otc: unable to get zone: zone example.com. not found")
}
-func (s *OTCSuite) TestDNSProvider_CleanUp_EmptyRecordset() {
- s.mock.HandleListZonesSuccessfully()
- s.mock.HandleListRecordsetsEmpty()
+func TestDNSProvider_Cleanup(t *testing.T) {
+ provider := mockBuilder(false).
+ Route("GET /v2/zones",
+ servermock.ResponseFromInternal("zones_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.")).
+ Route("GET /v2/zones/123123/recordsets",
+ servermock.ResponseFromInternal("zones-recordsets_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge.example.com.").
+ With("type", "TXT")).
+ Route("DELETE /v2/zones/123123/recordsets/321321",
+ servermock.ResponseFromInternal("zones-recordsets_DELETE.json")).
+ Build(t)
- provider, err := s.createDNSProvider()
- s.Require().NoError(err)
-
- err = provider.CleanUp("example.com", "", "foobar")
- s.Require().Error(err)
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_Cleanup_private(t *testing.T) {
+ provider := mockBuilder(true).
+ Route("GET /v2/zones",
+ servermock.ResponseFromInternal("zones_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.").
+ With("type", "private")).
+ Route("GET /v2/zones/123123/recordsets",
+ servermock.ResponseFromInternal("zones-recordsets_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge.example.com.").
+ With("type", "TXT")).
+ Route("DELETE /v2/zones/123123/recordsets/321321",
+ servermock.ResponseFromInternal("zones-recordsets_DELETE.json")).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_Cleanup_emptyRecordset(t *testing.T) {
+ provider := mockBuilder(false).
+ Route("GET /v2/zones",
+ servermock.ResponseFromInternal("zones_GET.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com.")).
+ Route("GET /v2/zones/123123/recordsets",
+ servermock.ResponseFromInternal("zones-recordsets_GET_empty.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge.example.com.").
+ With("type", "TXT")).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.EqualError(t, err, "otc: unable to get record _acme-challenge.example.com. for zone example.com: record not found")
+}
+
+func mockBuilder(private bool) *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.UserName = "user"
+ config.Password = "secret"
+ config.DomainName = "example.com"
+ config.ProjectName = "test"
+ config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", server.URL)
+ config.PrivateZone = private
+
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ ).
+ Route("POST /v3/auth/token", internal.IdentityHandlerMock())
}
diff --git a/providers/dns/ovh/ovh.go b/providers/dns/ovh/ovh.go
index 547a1a47d..a8d12d819 100644
--- a/providers/dns/ovh/ovh.go
+++ b/providers/dns/ovh/ovh.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
"github.com/ovh/go-ovh/ovh"
)
@@ -83,10 +84,6 @@ type Config struct {
HTTPClient *http.Client
}
-func (c *Config) hasAppKeyAuth() bool {
- return c.ApplicationKey != "" || c.ApplicationSecret != "" || c.ConsumerKey != ""
-}
-
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
@@ -99,10 +96,15 @@ func NewDefaultConfig() *Config {
}
}
+func (c *Config) hasAppKeyAuth() bool {
+ return c.ApplicationKey != "" || c.ApplicationSecret != "" || c.ConsumerKey != ""
+}
+
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *ovh.Client
+ config *Config
+ client *ovh.Client
+
recordIDs map[string]int64
recordIDsMu sync.Mutex
}
@@ -190,6 +192,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// Create TXT record
var respData Record
+
err = d.client.Post(reqURL, reqData, &respData)
if err != nil {
return fmt.Errorf("ovh: error when call api to add record (%s): %w", reqURL, err)
@@ -197,6 +200,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// Apply the change
reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone)
+
err = d.client.Post(reqURL, nil, nil)
if err != nil {
return fmt.Errorf("ovh: error when call api to refresh zone (%s): %w", reqURL, err)
@@ -217,6 +221,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("ovh: unknown record ID for '%s'", info.EffectiveFQDN)
}
@@ -237,6 +242,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
// Apply the change
reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone)
+
err = d.client.Post(reqURL, nil, nil)
if err != nil {
return fmt.Errorf("ovh: error when call api to refresh zone (%s): %w", reqURL, err)
@@ -257,8 +263,10 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
}
func newClient(config *Config) (*ovh.Client, error) {
- var client *ovh.Client
- var err error
+ var (
+ client *ovh.Client
+ err error
+ )
switch {
case config.hasAppKeyAuth():
@@ -277,5 +285,11 @@ func newClient(config *Config) (*ovh.Client, error) {
client.UserAgent = useragent.Get()
+ if config.HTTPClient != nil {
+ client.Client = config.HTTPClient
+ }
+
+ client.Client = clientdebug.Wrap(client.Client)
+
return client, nil
}
diff --git a/providers/dns/ovh/ovh.toml b/providers/dns/ovh/ovh.toml
index cbdcb43ae..abf22bd7a 100644
--- a/providers/dns/ovh/ovh.toml
+++ b/providers/dns/ovh/ovh.toml
@@ -11,20 +11,20 @@ OVH_APPLICATION_KEY=1234567898765432 \
OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \
OVH_CONSUMER_KEY=256vfsd347245sdfg \
OVH_ENDPOINT=ovh-eu \
-lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run
+lego --dns ovh -d '*.example.com' -d example.com run
# Or Access Token:
OVH_ACCESS_TOKEN=xxx \
OVH_ENDPOINT=ovh-eu \
-lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run
+lego --dns ovh -d '*.example.com' -d example.com run
# Or OAuth2:
OVH_CLIENT_ID=yyy \
OVH_CLIENT_SECRET=xxx \
OVH_ENDPOINT=ovh-eu \
-lego --email you@example.com --dns ovh -d '*.example.com' -d example.com run
+lego --dns ovh -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -76,10 +76,10 @@ Both authentication methods cannot be used at the same time.
OVH_CLIENT_SECRET = "Client secret (OAuth2)"
OVH_ACCESS_TOKEN = "Access token"
[Configuration.Additional]
- OVH_POLLING_INTERVAL = "Time between DNS propagation check"
- OVH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- OVH_TTL = "The TTL of the TXT record used for the DNS challenge"
- OVH_HTTP_TIMEOUT = "API request timeout"
+ OVH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ OVH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ OVH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ OVH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 180)"
[Links]
API = "https://eu.api.ovh.com/"
diff --git a/providers/dns/ovh/ovh_test.go b/providers/dns/ovh/ovh_test.go
index f070f2e85..332e7f192 100644
--- a/providers/dns/ovh/ovh_test.go
+++ b/providers/dns/ovh/ovh_test.go
@@ -162,6 +162,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -201,12 +202,11 @@ func TestNewDNSProviderConfig(t *testing.T) {
consumerKey: "D",
},
{
- desc: "application key: missing api endpoint",
+ desc: "application key: default api endpoint",
apiEndpoint: "",
applicationKey: "B",
applicationSecret: "C",
consumerKey: "D",
- expected: "ovh: new client: unknown endpoint '', consider checking 'Endpoints' list or using an URL",
},
{
desc: "application key: invalid api endpoint",
@@ -239,11 +239,10 @@ func TestNewDNSProviderConfig(t *testing.T) {
clientSecret: "C",
},
{
- desc: "oauth2: missing api endpoint",
+ desc: "oauth2: default api endpoint",
apiEndpoint: "",
clientID: "B",
clientSecret: "C",
- expected: "ovh: new client: unknown endpoint '', consider checking 'Endpoints' list or using an URL",
},
{
desc: "oauth2: invalid api endpoint",
@@ -317,6 +316,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
// The OVH client use the same env vars than lego, so it requires to clean them.
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
for _, test := range testCases {
@@ -356,6 +356,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -369,6 +370,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/pdns/internal/client.go b/providers/dns/pdns/internal/client.go
index bc525c578..f72dd4d78 100644
--- a/providers/dns/pdns/internal/client.go
+++ b/providers/dns/pdns/internal/client.go
@@ -18,6 +18,9 @@ import (
"github.com/miekg/dns"
)
+// APIKeyHeader API key header.
+const APIKeyHeader = "X-Api-Key"
+
// Client the PowerDNS API client.
type Client struct {
serverName string
@@ -66,6 +69,7 @@ func (c *Client) getAPIVersion(ctx context.Context) (int, error) {
}
var versions []apiVersion
+
err = json.Unmarshal(result, &versions)
if err != nil {
return 0, err
@@ -95,6 +99,7 @@ func (c *Client) GetHostedZone(ctx context.Context, authZone string) (*HostedZon
}
var zone HostedZone
+
err = json.Unmarshal(result, &zone)
if err != nil {
return nil, err
@@ -163,7 +168,7 @@ func (c *Client) joinPath(elem ...string) *url.URL {
}
func (c *Client) do(req *http.Request) (json.RawMessage, error) {
- req.Header.Set("X-API-Key", c.apiKey)
+ req.Header.Set(APIKeyHeader, c.apiKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
@@ -177,6 +182,7 @@ func (c *Client) do(req *http.Request) (json.RawMessage, error) {
}
var msg json.RawMessage
+
err = json.NewDecoder(resp.Body).Decode(&msg)
if err != nil {
if errors.Is(err, io.EOF) {
@@ -190,10 +196,12 @@ func (c *Client) do(req *http.Request) (json.RawMessage, error) {
// check for PowerDNS error message
if len(msg) > 0 && msg[0] == '{' {
var errInfo apiError
+
err = json.Unmarshal(msg, &errInfo)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, msg, err)
}
+
if errInfo.ShortMsg != "" {
return nil, fmt.Errorf("error talking to PDNS API: %w", errInfo)
}
diff --git a/providers/dns/pdns/internal/client_test.go b/providers/dns/pdns/internal/client_test.go
index b0eb9d2ed..17f05095f 100644
--- a/providers/dns/pdns/internal/client_test.go
+++ b/providers/dns/pdns/internal/client_test.go
@@ -1,66 +1,27 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ serverURL, _ := url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client := NewClient(serverURL, "server", 0, "secret")
+ client.HTTPClient = server.Client()
- 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
- }
-
- apiKey := req.Header.Get("X-API-Key")
- if apiKey != "secret" {
- http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiKey), 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
- }
- })
-
- serverURL, _ := url.Parse(server.URL)
-
- client := NewClient(serverURL, "server", 0, "secret")
- client.HTTPClient = server.Client()
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().With(APIKeyHeader, "secret"))
}
func TestClient_joinPath(t *testing.T) {
@@ -160,10 +121,14 @@ func TestClient_joinPath(t *testing.T) {
}
func TestClient_GetHostedZone(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusOK, "zone.json")
+ client := mockBuilder().
+ Route("GET /api/v1/servers/server/zones/example.org.",
+ servermock.ResponseFromFixture("zone.json")).
+ Build(t)
+
client.apiVersion = 1
- zone, err := client.GetHostedZone(context.Background(), "example.org.")
+ zone, err := client.GetHostedZone(t.Context(), "example.org.")
require.NoError(t, err)
expected := &HostedZone{
@@ -203,18 +168,27 @@ func TestClient_GetHostedZone(t *testing.T) {
}
func TestClient_GetHostedZone_error(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusUnprocessableEntity, "error.json")
+ client := mockBuilder().
+ Route("GET /api/v1/servers/server/zones/example.org.",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnprocessableEntity)).
+ Build(t)
+
client.apiVersion = 1
- _, err := client.GetHostedZone(context.Background(), "example.org.")
+ _, err := client.GetHostedZone(t.Context(), "example.org.")
require.ErrorAs(t, err, &apiError{})
}
func TestClient_GetHostedZone_v0(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/servers/server/zones/example.org.", http.StatusOK, "zone.json")
+ client := mockBuilder().
+ Route("GET /servers/server/zones/example.org.",
+ servermock.ResponseFromFixture("zone.json")).
+ Build(t)
+
client.apiVersion = 0
- zone, err := client.GetHostedZone(context.Background(), "example.org.")
+ zone, err := client.GetHostedZone(t.Context(), "example.org.")
require.NoError(t, err)
expected := &HostedZone{
@@ -254,7 +228,12 @@ func TestClient_GetHostedZone_v0(t *testing.T) {
}
func TestClient_UpdateRecords(t *testing.T) {
- client := setupTest(t, http.MethodPatch, "/api/v1/servers/localhost/zones/example.org.", http.StatusOK, "zone.json")
+ client := mockBuilder().
+ Route("PATCH /api/v1/servers/localhost/zones/example.org.",
+ servermock.ResponseFromFixture("zone.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zone-request.json")).
+ Build(t)
+
client.apiVersion = 1
client.serverName = "localhost"
@@ -279,12 +258,17 @@ func TestClient_UpdateRecords(t *testing.T) {
}},
}
- err := client.UpdateRecords(context.Background(), zone, rrSets)
+ err := client.UpdateRecords(t.Context(), zone, rrSets)
require.NoError(t, err)
}
func TestClient_UpdateRecords_NonRootApi(t *testing.T) {
- client := setupTest(t, http.MethodPatch, "/some/path/api/v1/servers/localhost/zones/example.org.", http.StatusOK, "zone.json")
+ client := mockBuilder().
+ Route("PATCH /some/path/api/v1/servers/localhost/zones/example.org.",
+ servermock.ResponseFromFixture("zone.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zone-request.json")).
+ Build(t)
+
client.Host = client.Host.JoinPath("some", "path")
client.apiVersion = 1
client.serverName = "localhost"
@@ -310,12 +294,17 @@ func TestClient_UpdateRecords_NonRootApi(t *testing.T) {
}},
}
- err := client.UpdateRecords(context.Background(), zone, rrSets)
+ err := client.UpdateRecords(t.Context(), zone, rrSets)
require.NoError(t, err)
}
func TestClient_UpdateRecords_v0(t *testing.T) {
- client := setupTest(t, http.MethodPatch, "/servers/localhost/zones/example.org.", http.StatusOK, "zone.json")
+ client := mockBuilder().
+ Route("PATCH /servers/localhost/zones/example.org.",
+ servermock.ResponseFromFixture("zone.json"),
+ servermock.CheckRequestJSONBodyFromFixture("zone-request.json")).
+ Build(t)
+
client.apiVersion = 0
client.serverName = "localhost"
@@ -340,12 +329,15 @@ func TestClient_UpdateRecords_v0(t *testing.T) {
}},
}
- err := client.UpdateRecords(context.Background(), zone, rrSets)
+ err := client.UpdateRecords(t.Context(), zone, rrSets)
require.NoError(t, err)
}
func TestClient_Notify(t *testing.T) {
- client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "")
+ client := mockBuilder().
+ Route("PUT /api/v1/servers/localhost/zones/example.org./notify", nil).
+ Build(t)
+
client.apiVersion = 1
client.serverName = "localhost"
@@ -356,12 +348,15 @@ func TestClient_Notify(t *testing.T) {
Kind: "Master",
}
- err := client.Notify(context.Background(), zone)
+ err := client.Notify(t.Context(), zone)
require.NoError(t, err)
}
func TestClient_Notify_NonRootApi(t *testing.T) {
- client := setupTest(t, http.MethodPut, "/some/path/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "")
+ client := mockBuilder().
+ Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil).
+ Build(t)
+
client.Host = client.Host.JoinPath("some", "path")
client.apiVersion = 1
client.serverName = "localhost"
@@ -373,12 +368,15 @@ func TestClient_Notify_NonRootApi(t *testing.T) {
Kind: "Master",
}
- err := client.Notify(context.Background(), zone)
+ err := client.Notify(t.Context(), zone)
require.NoError(t, err)
}
func TestClient_Notify_v0(t *testing.T) {
- client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "")
+ client := mockBuilder().
+ Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil).
+ Build(t)
+
client.apiVersion = 0
zone := &HostedZone{
@@ -388,14 +386,17 @@ func TestClient_Notify_v0(t *testing.T) {
Kind: "Master",
}
- err := client.Notify(context.Background(), zone)
+ err := client.Notify(t.Context(), zone)
require.NoError(t, err)
}
func TestClient_getAPIVersion(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/api", http.StatusOK, "versions.json")
+ client := mockBuilder().
+ Route("GET /api",
+ servermock.ResponseFromFixture("versions.json")).
+ Build(t)
- version, err := client.getAPIVersion(context.Background())
+ version, err := client.getAPIVersion(t.Context())
require.NoError(t, err)
assert.Equal(t, 4, version)
diff --git a/providers/dns/pdns/internal/fixtures/zone-request.json b/providers/dns/pdns/internal/fixtures/zone-request.json
new file mode 100644
index 000000000..5e4a6d2b9
--- /dev/null
+++ b/providers/dns/pdns/internal/fixtures/zone-request.json
@@ -0,0 +1,19 @@
+{
+ "rrsets": [
+ {
+ "name": "example.org.",
+ "type": "NS",
+ "kind": "",
+ "changetype": "REPLACE",
+ "records": [
+ {
+ "content": "192.0.2.5",
+ "disabled": false,
+ "name": "ns1.example.org.",
+ "type": "A",
+ "ttl": 86400
+ }
+ ]
+ }
+ ]
+}
diff --git a/providers/dns/pdns/pdns.go b/providers/dns/pdns/pdns.go
index 07bc663f1..e7ead7078 100644
--- a/providers/dns/pdns/pdns.go
+++ b/providers/dns/pdns/pdns.go
@@ -7,12 +7,14 @@ import (
"fmt"
"net/http"
"net/url"
+ "strconv"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/pdns/internal"
)
@@ -102,6 +104,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := internal.NewClient(config.Host, config.ServerName, config.APIVersion, config.APIKey)
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
if config.APIVersion <= 0 {
err := client.SetAPIVersion(context.Background())
if err != nil {
@@ -120,6 +128,8 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// 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)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -127,11 +137,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err)
}
- ctx := context.Background()
-
zone, err := d.client.GetHostedZone(ctx, authZone)
if err != nil {
- return fmt.Errorf("pdns: %w", err)
+ return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err)
}
name := info.EffectiveFQDN
@@ -143,45 +151,49 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// Look for existing records.
existingRRSet := findTxtRecord(zone, info.EffectiveFQDN)
- // merge the existing and new records
var records []internal.Record
if existingRRSet != nil {
records = existingRRSet.Records
}
- rec := internal.Record{
- Content: "\"" + info.Value + "\"",
+ records = append(records, internal.Record{
+ Content: strconv.Quote(info.Value),
Disabled: false,
// pre-v1 API
Type: "TXT",
Name: name,
TTL: d.config.TTL,
- }
+ })
rrSets := internal.RRSets{
- RRSets: []internal.RRSet{
- {
- Name: name,
- ChangeType: "REPLACE",
- Type: "TXT",
- Kind: "Master",
- TTL: d.config.TTL,
- Records: append(records, rec),
- },
- },
+ RRSets: []internal.RRSet{{
+ Name: name,
+ ChangeType: "REPLACE",
+ Type: "TXT",
+ Kind: "Master",
+ TTL: d.config.TTL,
+ Records: records,
+ }},
}
err = d.client.UpdateRecords(ctx, zone, rrSets)
if err != nil {
- return fmt.Errorf("pdns: %w", err)
+ return fmt.Errorf("pdns: update records: %w", err)
}
- return d.client.Notify(ctx, zone)
+ err = d.client.Notify(ctx, zone)
+ if err != nil {
+ return fmt.Errorf("pdns: notify: %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)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -189,35 +201,49 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err)
}
- ctx := context.Background()
-
zone, err := d.client.GetHostedZone(ctx, authZone)
if err != nil {
- return fmt.Errorf("pdns: %w", err)
+ return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err)
}
+ // Look for existing records.
set := findTxtRecord(zone, info.EffectiveFQDN)
-
if set == nil {
return fmt.Errorf("pdns: no existing record found for %s", info.EffectiveFQDN)
}
- rrSets := internal.RRSets{
- RRSets: []internal.RRSet{
- {
- Name: set.Name,
- Type: set.Type,
- ChangeType: "DELETE",
- },
- },
+ var records []internal.Record
+
+ for _, r := range set.Records {
+ if r.Content != strconv.Quote(info.Value) {
+ records = append(records, r)
+ }
}
- err = d.client.UpdateRecords(ctx, zone, rrSets)
+ rrSet := internal.RRSet{
+ Name: set.Name,
+ Type: set.Type,
+ }
+
+ if len(records) > 0 {
+ rrSet.ChangeType = "REPLACE"
+ rrSet.TTL = d.config.TTL
+ rrSet.Records = records
+ } else {
+ rrSet.ChangeType = "DELETE"
+ }
+
+ err = d.client.UpdateRecords(ctx, zone, internal.RRSets{RRSets: []internal.RRSet{rrSet}})
if err != nil {
- return fmt.Errorf("pdns: %w", err)
+ return fmt.Errorf("pdns: update records: %w", err)
}
- return d.client.Notify(ctx, zone)
+ err = d.client.Notify(ctx, zone)
+ if err != nil {
+ return fmt.Errorf("pdns: notify: %w", err)
+ }
+
+ return nil
}
func findTxtRecord(zone *internal.HostedZone, fqdn string) *internal.RRSet {
diff --git a/providers/dns/pdns/pdns.toml b/providers/dns/pdns/pdns.toml
index 81158c444..a83d80922 100644
--- a/providers/dns/pdns/pdns.toml
+++ b/providers/dns/pdns/pdns.toml
@@ -7,7 +7,7 @@ Since = "v0.4.0"
Example = '''
PDNS_API_URL=http://pdns-server:80/ \
PDNS_API_KEY=xxxx \
-lego --email you@example.com --dns pdns -d '*.example.com' -d example.com run
+lego --dns pdns -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -28,10 +28,10 @@ PowerDNS Notes:
[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_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ PDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ PDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ PDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://doc.powerdns.com/md/httpapi/README/"
diff --git a/providers/dns/pdns/pdns_test.go b/providers/dns/pdns/pdns_test.go
index 70b386b81..0213ba17c 100644
--- a/providers/dns/pdns/pdns_test.go
+++ b/providers/dns/pdns/pdns_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -136,14 +137,19 @@ func TestLivePresentAndCleanup(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.Present(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
+ err = provider.Present(envTest.GetDomain(), "", "123e==")
+ require.NoError(t, err)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
+ err = provider.CleanUp(envTest.GetDomain(), "", "123e==")
+ require.NoError(t, err)
}
func mustParse(rawURL string) *url.URL {
@@ -151,5 +157,6 @@ func mustParse(rawURL string) *url.URL {
if err != nil {
panic(err)
}
+
return u
}
diff --git a/providers/dns/plesk/internal/client.go b/providers/dns/plesk/internal/client.go
index 9dd9d5ee3..47abba805 100644
--- a/providers/dns/plesk/internal/client.go
+++ b/providers/dns/plesk/internal/client.go
@@ -24,7 +24,7 @@ type Client struct {
}
// NewClient created a new Client.
-func NewClient(baseURL *url.URL, login string, password string) *Client {
+func NewClient(baseURL *url.URL, login, password string) *Client {
return &Client{
login: login,
password: password,
@@ -35,7 +35,7 @@ func NewClient(baseURL *url.URL, login string, password string) *Client {
// GetSite gets a site.
// https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-sites-domains/getting-information-about-sites.66583/
-func (c Client) GetSite(ctx context.Context, domain string) (int, error) {
+func (c *Client) GetSite(ctx context.Context, domain string) (int, error) {
payload := RequestPacketType{Site: &SiteTypeRequest{Get: SiteGetRequest{Filter: &SiteFilterType{
Name: domain,
}}}}
@@ -62,7 +62,7 @@ func (c Client) GetSite(ctx context.Context, domain string) (int, error) {
// AddRecord adds a TXT record.
// https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/adding-dns-record.34798/
-func (c Client) AddRecord(ctx context.Context, siteID int, host, value string) (int, error) {
+func (c *Client) AddRecord(ctx context.Context, siteID int, host, value string) (int, error) {
payload := RequestPacketType{DNS: &DNSInputType{AddRec: []AddRecRequest{{
SiteID: siteID,
Type: "TXT",
@@ -92,7 +92,7 @@ func (c Client) AddRecord(ctx context.Context, siteID int, host, value string) (
// DeleteRecord Deletes a TXT record.
// https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/deleting-dns-records.34864/
-func (c Client) DeleteRecord(ctx context.Context, recordID int) (int, error) {
+func (c *Client) DeleteRecord(ctx context.Context, recordID int) (int, error) {
payload := RequestPacketType{DNS: &DNSInputType{DelRec: []DelRecRequest{{Filter: DNSSelectionFilterType{
ID: recordID,
}}}}}
@@ -117,10 +117,11 @@ func (c Client) DeleteRecord(ctx context.Context, recordID int) (int, error) {
return response.DNS.DelRec[0].Result.ID, nil
}
-func (c Client) doRequest(ctx context.Context, payload RequestPacketType) (*ResponsePacketType, error) {
+func (c *Client) doRequest(ctx context.Context, payload RequestPacketType) (*ResponsePacketType, error) {
endpoint := c.baseURL.JoinPath("/enterprise/control/agent.php")
body := new(bytes.Buffer)
+
err := xml.NewEncoder(body).Encode(payload)
if err != nil {
return nil, err
@@ -153,6 +154,7 @@ func (c Client) doRequest(ctx context.Context, payload RequestPacketType) (*Resp
}
var response ResponsePacketType
+
err = xml.Unmarshal(raw, &response)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/plesk/internal/client_test.go b/providers/dns/plesk/internal/client_test.go
index 5d59a4c87..14cadd0e0 100644
--- a/providers/dns/plesk/internal/client_test.go
+++ b/providers/dns/plesk/internal/client_test.go
@@ -1,144 +1,125 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, filename string) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ serverURL, _ := url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client := NewClient(serverURL, "user", "secret")
+ client.HTTPClient = server.Client()
- serverURL, err := url.Parse(server.URL)
- require.NoError(t, err)
-
- client := NewClient(serverURL, "user", "secret")
- client.HTTPClient = server.Client()
-
- mux.HandleFunc("/enterprise/control/agent.php", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
- return
- }
-
- login := req.Header.Get("Http_auth_login")
- if login != "user" {
- http.Error(rw, fmt.Sprintf("invalid login: %s", login), http.StatusUnauthorized)
- return
- }
-
- password := req.Header.Get("Http_auth_passwd")
- if password != "secret" {
- http.Error(rw, fmt.Sprintf("invalid password: %s", password), 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() }()
-
- _, err = io.Copy(rw, file)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().WithContentType("text/xml").
+ With("Http_auth_login", "user").
+ With("Http_auth_passwd", "secret"),
+ )
}
func TestClient_GetSite(t *testing.T) {
- client := setupTest(t, "get-site.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site.xml")).
+ Build(t)
- siteID, err := client.GetSite(context.Background(), "example.com")
+ siteID, err := client.GetSite(t.Context(), "example.com")
require.NoError(t, err)
assert.Equal(t, 82, siteID)
}
func TestClient_GetSite_error(t *testing.T) {
- client := setupTest(t, "get-site-error.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site-error.xml")).
+ Build(t)
- siteID, err := client.GetSite(context.Background(), "example.com")
+ siteID, err := client.GetSite(t.Context(), "example.com")
require.Error(t, err)
assert.Equal(t, 0, siteID)
}
func TestClient_GetSite_system_error(t *testing.T) {
- client := setupTest(t, "global-error.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")).
+ Build(t)
- siteID, err := client.GetSite(context.Background(), "example.com")
+ siteID, err := client.GetSite(t.Context(), "example.com")
require.Error(t, err)
assert.Equal(t, 0, siteID)
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "add-record.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record.xml")).
+ Build(t)
- recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt")
+ recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt")
require.NoError(t, err)
assert.Equal(t, 4537, recordID)
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "add-record-error.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record-error.xml")).
+ Build(t)
- recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt")
+ recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt")
require.ErrorAs(t, err, new(RecResult))
assert.Equal(t, 0, recordID)
}
func TestClient_AddRecord_system_error(t *testing.T) {
- client := setupTest(t, "global-error.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")).
+ Build(t)
- recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt")
+ recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt")
require.ErrorAs(t, err, new(*System))
assert.Equal(t, 0, recordID)
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, "delete-record.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record.xml")).
+ Build(t)
- recordID, err := client.DeleteRecord(context.Background(), 4537)
+ recordID, err := client.DeleteRecord(t.Context(), 4537)
require.NoError(t, err)
assert.Equal(t, 4537, recordID)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, "delete-record-error.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record-error.xml")).
+ Build(t)
- recordID, err := client.DeleteRecord(context.Background(), 4537)
+ recordID, err := client.DeleteRecord(t.Context(), 4537)
require.ErrorAs(t, err, new(RecResult))
assert.Equal(t, 0, recordID)
}
func TestClient_DeleteRecord_system_error(t *testing.T) {
- client := setupTest(t, "global-error.xml")
+ client := mockBuilder().
+ Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")).
+ Build(t)
- recordID, err := client.DeleteRecord(context.Background(), 4537)
+ recordID, err := client.DeleteRecord(t.Context(), 4537)
require.ErrorAs(t, err, new(*System))
assert.Equal(t, 0, recordID)
diff --git a/providers/dns/plesk/plesk.go b/providers/dns/plesk/plesk.go
index b7a7ebf77..5f07dcb50 100644
--- a/providers/dns/plesk/plesk.go
+++ b/providers/dns/plesk/plesk.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/plesk/internal"
)
@@ -107,6 +108,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -160,6 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("plesk: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
@@ -169,5 +173,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("plesk: failed to delete record (%d): %w", recordID, err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/plesk/plesk.toml b/providers/dns/plesk/plesk.toml
index 3a67065d6..0ef89d6b7 100644
--- a/providers/dns/plesk/plesk.toml
+++ b/providers/dns/plesk/plesk.toml
@@ -8,7 +8,7 @@ Example = '''
PLESK_SERVER_BASE_URL="https://plesk.myserver.com:8443" \
PLESK_USERNAME=xxxxxx \
PLESK_PASSWORD=yyyyyy \
-lego --email you@example.com --dns plesk -d '*.example.com' -d example.com run
+lego --dns plesk -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,10 @@ lego --email you@example.com --dns plesk -d '*.example.com' -d example.com run
PLESK_USERNAME = "API username"
PLESK_PASSWORD = "API password"
[Configuration.Additional]
- PLESK_POLLING_INTERVAL = "Time between DNS propagation check"
- PLESK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- PLESK_TTL = "The TTL of the TXT record used for the DNS challenge"
- PLESK_HTTP_TIMEOUT = "API request timeout"
+ PLESK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ PLESK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ PLESK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ PLESK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference.28784/"
diff --git a/providers/dns/plesk/plesk_test.go b/providers/dns/plesk/plesk_test.go
index 417e2c1da..506a26a2a 100644
--- a/providers/dns/plesk/plesk_test.go
+++ b/providers/dns/plesk/plesk_test.go
@@ -67,6 +67,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -149,6 +150,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -162,6 +164,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/porkbun/porkbun.go b/providers/dns/porkbun/porkbun.go
index 44bf1857b..2f999ebcc 100644
--- a/providers/dns/porkbun/porkbun.go
+++ b/providers/dns/porkbun/porkbun.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/nrdcg/porkbun"
)
@@ -100,6 +101,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -151,6 +154,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("porkbun: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
@@ -167,6 +171,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("porkbun: failed to delete record: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/porkbun/porkbun.toml b/providers/dns/porkbun/porkbun.toml
index 91b0b1329..9ae036da6 100644
--- a/providers/dns/porkbun/porkbun.toml
+++ b/providers/dns/porkbun/porkbun.toml
@@ -1,5 +1,6 @@
Name = "Porkbun"
Description = ''''''
+# This URL is NOT the API URL.
URL = "https://porkbun.com/"
Code = "porkbun"
Since = "v4.4.0"
@@ -7,7 +8,7 @@ Since = "v4.4.0"
Example = '''
PORKBUN_SECRET_API_KEY=xxxxxx \
PORKBUN_API_KEY=yyyyyy \
-lego --email you@example.com --dns porkbun -d '*.example.com' -d example.com run
+lego --dns porkbun -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +16,10 @@ lego --email you@example.com --dns porkbun -d '*.example.com' -d example.com run
PORKBUN_SECRET_API_KEY = "secret API key"
PORKBUN_API_KEY = "API key"
[Configuration.Additional]
- PORKBUN_POLLING_INTERVAL = "Time between DNS propagation check"
- PORKBUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- PORKBUN_TTL = "The TTL of the TXT record used for the DNS challenge"
- PORKBUN_HTTP_TIMEOUT = "API request timeout"
+ PORKBUN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ PORKBUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)"
+ PORKBUN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ PORKBUN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://porkbun.com/api/json/v3/documentation"
diff --git a/providers/dns/porkbun/porkbun_test.go b/providers/dns/porkbun/porkbun_test.go
index cdf022b5d..7c69edfdb 100644
--- a/providers/dns/porkbun/porkbun_test.go
+++ b/providers/dns/porkbun/porkbun_test.go
@@ -54,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -124,6 +125,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -137,6 +139,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/rackspace/fixtures/delete.json b/providers/dns/rackspace/fixtures/delete.json
new file mode 100644
index 000000000..7e2f2ac53
--- /dev/null
+++ b/providers/dns/rackspace/fixtures/delete.json
@@ -0,0 +1,7 @@
+{
+ "status": "RUNNING",
+ "verb": "DELETE",
+ "jobId": "00000000-0000-0000-0000-0000000000",
+ "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000",
+ "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321"
+}
diff --git a/providers/dns/rackspace/fixtures/identity.json b/providers/dns/rackspace/fixtures/identity.json
new file mode 100644
index 000000000..5a459d13c
--- /dev/null
+++ b/providers/dns/rackspace/fixtures/identity.json
@@ -0,0 +1,31 @@
+{
+ "access": {
+ "token": {
+ "id": "testToken",
+ "expires": "1970-01-01T00:00:00.000Z",
+ "tenant": {
+ "id": "123456",
+ "name": "123456"
+ },
+ "RAX-AUTH:authenticatedBy": [
+ "APIKEY"
+ ]
+ },
+ "serviceCatalog": [
+ {
+ "type": "rax:dns",
+ "endpoints": [
+ {
+ "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456",
+ "tenantId": "123456"
+ }
+ ],
+ "name": "cloudDNS"
+ }
+ ],
+ "user": {
+ "id": "fakeUseID",
+ "name": "testUser"
+ }
+ }
+}
diff --git a/providers/dns/rackspace/fixtures/record.json b/providers/dns/rackspace/fixtures/record.json
new file mode 100644
index 000000000..4d76aa0c8
--- /dev/null
+++ b/providers/dns/rackspace/fixtures/record.json
@@ -0,0 +1,8 @@
+{
+ "request": "{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}",
+ "status": "RUNNING",
+ "verb": "POST",
+ "jobId": "00000000-0000-0000-0000-0000000000",
+ "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000",
+ "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records"
+}
diff --git a/providers/dns/rackspace/fixtures/record_details.json b/providers/dns/rackspace/fixtures/record_details.json
new file mode 100644
index 000000000..e53cf1330
--- /dev/null
+++ b/providers/dns/rackspace/fixtures/record_details.json
@@ -0,0 +1,13 @@
+{
+ "records": [
+ {
+ "name": "_acme-challenge.example.com",
+ "id": "TXT-654321",
+ "type": "TXT",
+ "data": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM",
+ "ttl": 300,
+ "updated": "1970-01-01T00:00:00.000+0000",
+ "created": "1970-01-01T00:00:00.000+0000"
+ }
+ ]
+}
diff --git a/providers/dns/rackspace/fixtures/zone_details.json b/providers/dns/rackspace/fixtures/zone_details.json
new file mode 100644
index 000000000..f68f23aa0
--- /dev/null
+++ b/providers/dns/rackspace/fixtures/zone_details.json
@@ -0,0 +1,12 @@
+{
+ "domains": [
+ {
+ "name": "example.com",
+ "id": "112233",
+ "emailAddress": "hostmaster@example.com",
+ "updated": "1970-01-01T00:00:00.000+0000",
+ "created": "1970-01-01T00:00:00.000+0000"
+ }
+ ],
+ "totalEntries": 1
+}
diff --git a/providers/dns/rackspace/internal/client.go b/providers/dns/rackspace/internal/client.go
index cbfdd1bfa..4a1872484 100644
--- a/providers/dns/rackspace/internal/client.go
+++ b/providers/dns/rackspace/internal/client.go
@@ -14,6 +14,8 @@ import (
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
+const AuthToken = "X-Auth-Token"
+
type Client struct {
token string
@@ -21,7 +23,7 @@ type Client struct {
HTTPClient *http.Client
}
-func NewClient(endpoint string, token string) (*Client, error) {
+func NewClient(endpoint, token string) (*Client, error) {
baseURL, err := url.Parse(endpoint)
if err != nil {
return nil, err
@@ -34,7 +36,7 @@ func NewClient(endpoint string, token string) (*Client, error) {
}, nil
}
-// AddRecord Adds one record to a specified domain.
+// AddRecord Adds one record to a specified domain.
// https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#add-records
func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) error {
endpoint := c.baseURL.JoinPath("domains", zoneID, "records")
@@ -111,6 +113,7 @@ func (c *Client) listDomainsByName(ctx context.Context, domain string) (*ZoneSea
}
var zoneSearchResponse ZoneSearchResponse
+
err = c.do(req, &zoneSearchResponse)
if err != nil {
return nil, err
@@ -120,7 +123,7 @@ func (c *Client) listDomainsByName(ctx context.Context, domain string) (*ZoneSea
}
// FindTxtRecord searches a DNS zone for a TXT record with a specific name.
-func (c *Client) FindTxtRecord(ctx context.Context, fqdn string, zoneID string) (*Record, error) {
+func (c *Client) FindTxtRecord(ctx context.Context, fqdn, zoneID string) (*Record, error) {
records, err := c.searchRecords(ctx, zoneID, dns01.UnFqdn(fqdn), "TXT")
if err != nil {
return nil, err
@@ -152,6 +155,7 @@ func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordTy
}
var records Records
+
err = c.do(req, &records)
if err != nil {
return nil, err
@@ -161,7 +165,7 @@ func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordTy
}
func (c *Client) do(req *http.Request, result any) error {
- req.Header.Set("X-Auth-Token", c.token)
+ req.Header.Set(AuthToken, c.token)
resp, err := c.HTTPClient.Do(req)
if err != nil {
@@ -191,7 +195,7 @@ func (c *Client) do(req *http.Request, result any) error {
return nil
}
-func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload interface{}) (*http.Request, error) {
+func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
diff --git a/providers/dns/rackspace/internal/client_test.go b/providers/dns/rackspace/internal/client_test.go
index 993d34d9f..c14c4d360 100644
--- a/providers/dns/rackspace/internal/client_test.go
+++ b/providers/dns/rackspace/internal/client_test.go
@@ -1,81 +1,64 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "secret")
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client.HTTPClient = server.Client()
- client, err := NewClient(server.URL, "secret")
- require.NoError(t, err)
-
- client.HTTPClient = server.Client()
-
- mux.HandleFunc(pattern, handler)
-
- 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
- }
-
- if req.Header.Get("X-Auth-Token") != "secret" {
- http.Error(rw, fmt.Sprintf("invalid token: %q", req.Header.Get("X-Auth-Token")), http.StatusUnauthorized)
- return
- }
-
- if filename == "" {
- 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)
- }
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With(AuthToken, "secret"))
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodPost, "add-records.json"))
+ client := mockBuilder().
+ Route("POST /domains/1234/records",
+ servermock.ResponseFromFixture("add-records.json"),
+ servermock.CheckRequestJSONBody(`{"records":[{"name":"exmaple.com","type":"TXT","data":"value1","ttl":120,"id":"abc"}]}`)).
+ Build(t)
- err := client.AddRecord(context.Background(), "1234", Record{})
+ record := Record{
+ Name: "exmaple.com",
+ Type: "TXT",
+ Data: "value1",
+ TTL: 120,
+ ID: "abc",
+ }
+
+ err := client.AddRecord(t.Context(), "1234", record)
require.NoError(t, err)
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodDelete, ""))
+ client := mockBuilder().
+ Route("DELETE /domains/1234/records", nil).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "1234", "2725233")
+ err := client.DeleteRecord(t.Context(), "1234", "2725233")
require.NoError(t, err)
}
func TestClient_searchRecords(t *testing.T) {
- client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodGet, "search-records.json"))
+ client := mockBuilder().
+ Route("GET /domains/1234/records", servermock.ResponseFromFixture("search-records.json")).
+ Build(t)
- records, err := client.searchRecords(context.Background(), "1234", "2725233", "A")
+ records, err := client.searchRecords(t.Context(), "1234", "2725233", "A")
require.NoError(t, err)
expected := &Records{
@@ -94,9 +77,11 @@ func TestClient_searchRecords(t *testing.T) {
}
func TestClient_listDomainsByName(t *testing.T) {
- client := setupTest(t, "/domains", writeFixtureHandler(http.MethodGet, "list-domains-by-name.json"))
+ client := mockBuilder().
+ Route("GET /domains", servermock.ResponseFromFixture("list-domains-by-name.json")).
+ Build(t)
- domains, err := client.listDomainsByName(context.Background(), "1234")
+ domains, err := client.listDomainsByName(t.Context(), "1234")
require.NoError(t, err)
expected := &ZoneSearchResponse{
diff --git a/providers/dns/rackspace/internal/identity.go b/providers/dns/rackspace/internal/identity.go
index 062350df5..3ff667fb8 100644
--- a/providers/dns/rackspace/internal/identity.go
+++ b/providers/dns/rackspace/internal/identity.go
@@ -65,6 +65,7 @@ func (a *Identifier) Login(ctx context.Context, apiUser, apiKey string) (*Identi
}
var identity Identity
+
err = json.Unmarshal(raw, &identity)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/rackspace/internal/identity_test.go b/providers/dns/rackspace/internal/identity_test.go
index 9ba5abb50..44a8d75fc 100644
--- a/providers/dns/rackspace/internal/identity_test.go
+++ b/providers/dns/rackspace/internal/identity_test.go
@@ -1,51 +1,24 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func writeIdentityFixtureHandler(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
- }
-
- if filename == "" {
- 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 setupIdentifier(server *httptest.Server) (*Identifier, error) {
+ return NewIdentifier(server.Client(), server.URL), nil
}
func TestIdentifier_Login(t *testing.T) {
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ identifier := servermock.NewBuilder[*Identifier](setupIdentifier, servermock.CheckHeader().WithJSONHeaders()).
+ Route("POST /", servermock.ResponseFromFixture("tokens.json")).
+ Build(t)
- identifier := NewIdentifier(server.Client(), server.URL)
-
- mux.HandleFunc("/", writeIdentityFixtureHandler(http.MethodPost, "tokens.json"))
-
- identity, err := identifier.Login(context.Background(), "user", "secret")
+ identity, err := identifier.Login(t.Context(), "user", "secret")
require.NoError(t, err)
expected := &Identity{
diff --git a/providers/dns/rackspace/rackspace.go b/providers/dns/rackspace/rackspace.go
index b9ce8f6e3..b4c7b4a0f 100644
--- a/providers/dns/rackspace/rackspace.go
+++ b/providers/dns/rackspace/rackspace.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/rackspace/internal"
)
@@ -98,6 +99,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
// Iterate through the Service Catalog to get the DNS Endpoint
var dnsEndpoint string
+
for _, service := range identity.Access.ServiceCatalog {
if service.Name == "cloudDNS" {
dnsEndpoint = service.Endpoints[0].PublicURL
@@ -118,6 +120,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
diff --git a/providers/dns/rackspace/rackspace.toml b/providers/dns/rackspace/rackspace.toml
index ae0b0fca4..0a4a80ffc 100644
--- a/providers/dns/rackspace/rackspace.toml
+++ b/providers/dns/rackspace/rackspace.toml
@@ -7,7 +7,7 @@ Since = "v0.4.0"
Example = '''
RACKSPACE_USER=xxxx \
RACKSPACE_API_KEY=yyyy \
-lego --email you@example.com --dns rackspace -d '*.example.com' -d example.com run
+lego --dns rackspace -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns rackspace -d '*.example.com' -d example.com r
RACKSPACE_USER = "API user"
RACKSPACE_API_KEY = "API key"
[Configuration.Additional]
- RACKSPACE_POLLING_INTERVAL = "Time between DNS propagation check"
- RACKSPACE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- RACKSPACE_TTL = "The TTL of the TXT record used for the DNS challenge"
- RACKSPACE_HTTP_TIMEOUT = "API request timeout"
+ RACKSPACE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 3)"
+ RACKSPACE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ RACKSPACE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ RACKSPACE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developer.rackspace.com/docs/cloud-dns/v1/"
diff --git a/providers/dns/rackspace/rackspace_mock_test.go b/providers/dns/rackspace/rackspace_mock_test.go
deleted file mode 100644
index 790d52498..000000000
--- a/providers/dns/rackspace/rackspace_mock_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package rackspace
-
-const recordDeleteMock = `
-{
- "status": "RUNNING",
- "verb": "DELETE",
- "jobId": "00000000-0000-0000-0000-0000000000",
- "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000",
- "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321"
-}
-`
-
-const recordDetailsMock = `
-{
- "records": [
- {
- "name": "_acme-challenge.example.com",
- "id": "TXT-654321",
- "type": "TXT",
- "data": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM",
- "ttl": 300,
- "updated": "1970-01-01T00:00:00.000+0000",
- "created": "1970-01-01T00:00:00.000+0000"
- }
- ]
-}
-`
-
-const zoneDetailsMock = `
-{
- "domains": [
- {
- "name": "example.com",
- "id": "112233",
- "emailAddress": "hostmaster@example.com",
- "updated": "1970-01-01T00:00:00.000+0000",
- "created": "1970-01-01T00:00:00.000+0000"
- }
- ],
- "totalEntries": 1
-}
-`
-
-const identityResponseMock = `
-{
- "access": {
- "token": {
- "id": "testToken",
- "expires": "1970-01-01T00:00:00.000Z",
- "tenant": {
- "id": "123456",
- "name": "123456"
- },
- "RAX-AUTH:authenticatedBy": [
- "APIKEY"
- ]
- },
- "serviceCatalog": [
- {
- "type": "rax:dns",
- "endpoints": [
- {
- "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456",
- "tenantId": "123456"
- }
- ],
- "name": "cloudDNS"
- }
- ],
- "user": {
- "id": "fakeUseID",
- "name": "testUser"
- }
- }
-}
-`
-
-const recordResponseMock = `
-{
- "request": "{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}",
- "status": "RUNNING",
- "verb": "POST",
- "jobId": "00000000-0000-0000-0000-0000000000",
- "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000",
- "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records"
-}
-`
diff --git a/providers/dns/rackspace/rackspace_test.go b/providers/dns/rackspace/rackspace_test.go
index cbc57b472..de0749fd3 100644
--- a/providers/dns/rackspace/rackspace_test.go
+++ b/providers/dns/rackspace/rackspace_test.go
@@ -1,9 +1,7 @@
package rackspace
import (
- "bytes"
"fmt"
- "io"
"net/http"
"net/http/httptest"
"strings"
@@ -11,6 +9,7 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -23,11 +22,7 @@ var envTest = tester.NewEnvTest(
WithDomain(envDomain)
func TestNewDNSProviderConfig(t *testing.T) {
- config := setupTest(t)
-
- provider, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
- assert.NotNil(t, provider.config)
+ provider := mockBuilder().Build(t)
assert.Equal(t, "testToken", provider.token, "The token should match")
}
@@ -38,25 +33,40 @@ func TestNewDNSProviderConfig_MissingCredErr(t *testing.T) {
}
func TestDNSProvider_Present(t *testing.T) {
- config := setupTest(t)
+ provider := mockBuilder().
+ Route("GET /123456/domains",
+ servermock.ResponseFromFixture("zone_details.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com")).
+ Route("POST /123456/domains/112233/records",
+ servermock.ResponseFromFixture("record.json").
+ WithStatusCode(http.StatusAccepted),
+ servermock.CheckRequestJSONBody(`{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}`)).
+ Build(t)
- provider, err := NewDNSProviderConfig(config)
-
- if assert.NoError(t, err) {
- err = provider.Present("example.com", "token", "keyAuth")
- require.NoError(t, err)
- }
+ err := provider.Present("example.com", "token", "keyAuth")
+ require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
- config := setupTest(t)
+ provider := mockBuilder().
+ Route("GET /123456/domains",
+ servermock.ResponseFromFixture("zone_details.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "example.com")).
+ Route("GET /123456/domains/112233/records",
+ servermock.ResponseFromFixture("record_details.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("type", "TXT").
+ With("name", "_acme-challenge.example.com")).
+ Route("DELETE /123456/domains/112233/records",
+ servermock.ResponseFromFixture("delete.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("id", "TXT-654321")).
+ Build(t)
- provider, err := NewDNSProviderConfig(config)
-
- if assert.NoError(t, err) {
- err = provider.CleanUp("example.com", "token", "keyAuth")
- require.NoError(t, err)
- }
+ err := provider.CleanUp("example.com", "token", "keyAuth")
+ require.NoError(t, err)
}
func TestLiveNewDNSProvider_ValidEnv(t *testing.T) {
@@ -65,6 +75,7 @@ func TestLiveNewDNSProvider_ValidEnv(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -77,6 +88,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -90,6 +102,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -99,99 +112,60 @@ func TestLiveCleanUp(t *testing.T) {
require.NoError(t, err)
}
-func setupTest(t *testing.T) *Config {
- t.Helper()
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.APIUser = "testUser"
+ config.APIKey = "testKey"
+ config.HTTPClient = server.Client()
+ config.BaseURL = server.URL + "/v2.0/tokens"
- dnsAPI := httptest.NewServer(dnsHandler())
- t.Cleanup(dnsAPI.Close)
+ return NewDNSProviderConfig(config)
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ ).
+ Route("POST /v2.0/tokens",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ apiURL := fmt.Sprintf("http://%s/123456", req.Context().Value(http.LocalAddrContextKey))
- identityAPI := httptest.NewServer(identityHandler(dnsAPI.URL + "/123456"))
- t.Cleanup(identityAPI.Close)
-
- config := NewDefaultConfig()
- config.APIUser = "testUser"
- config.APIKey = "testKey"
- config.HTTPClient = identityAPI.Client()
- config.BaseURL = identityAPI.URL + "/"
-
- return config
+ resp := strings.Replace(`
+{
+ "access": {
+ "token": {
+ "id": "testToken",
+ "expires": "1970-01-01T00:00:00.000Z",
+ "tenant": {
+ "id": "123456",
+ "name": "123456"
+ },
+ "RAX-AUTH:authenticatedBy": [
+ "APIKEY"
+ ]
+ },
+ "serviceCatalog": [
+ {
+ "type": "rax:dns",
+ "endpoints": [
+ {
+ "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456",
+ "tenantId": "123456"
+ }
+ ],
+ "name": "cloudDNS"
+ }
+ ],
+ "user": {
+ "id": "fakeUseID",
+ "name": "testUser"
+ }
+ }
}
+`, "https://dns.api.rackspacecloud.com/v1.0/123456", apiURL, 1)
-func identityHandler(dnsEndpoint string) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- reqBody, err := io.ReadAll(r.Body)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- return
- }
-
- if string(bytes.TrimSpace(reqBody)) != `{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}` {
- http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest)
- return
- }
-
- resp := strings.Replace(identityResponseMock, "https://dns.api.rackspacecloud.com/v1.0/123456", dnsEndpoint, 1)
- w.WriteHeader(http.StatusOK)
- _, _ = fmt.Fprint(w, resp)
- })
-}
-
-func dnsHandler() *http.ServeMux {
- mux := http.NewServeMux()
-
- // Used by `getHostedZoneID()` finding `zoneID` "?name=example.com"
- mux.HandleFunc("/123456/domains", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Query().Get("name") == "example.com" {
- w.WriteHeader(http.StatusOK)
- _, _ = fmt.Fprint(w, zoneDetailsMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/123456/domains/112233/records", func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- // Used by `Present()` creating the TXT record
- case http.MethodPost:
- reqBody, err := io.ReadAll(r.Body)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if string(bytes.TrimSpace(reqBody)) != `{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}` {
- http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest)
- return
- }
-
- w.WriteHeader(http.StatusAccepted)
- _, _ = fmt.Fprint(w, recordResponseMock)
-
- // Used by `findTxtRecord()` finding `record.ID` "?type=TXT&name=_acme-challenge.example.com"
- case http.MethodGet:
- if r.URL.Query().Get("type") == "TXT" && r.URL.Query().Get("name") == "_acme-challenge.example.com" {
- w.WriteHeader(http.StatusOK)
- _, _ = fmt.Fprint(w, recordDetailsMock)
- return
- }
-
- w.WriteHeader(http.StatusBadRequest)
- return
-
- // Used by `CleanUp()` deleting the TXT record "?id=445566"
- case http.MethodDelete:
- if r.URL.Query().Get("id") == "TXT-654321" {
- w.WriteHeader(http.StatusOK)
- _, _ = fmt.Fprint(w, recordDeleteMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- }
- })
-
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, fmt.Sprintf("Not Found for Request: (%+v)", r), http.StatusNotFound)
- })
-
- return mux
+ rw.WriteHeader(http.StatusOK)
+ _, _ = fmt.Fprint(rw, resp)
+ }),
+ servermock.CheckRequestJSONBody(`{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}`))
}
diff --git a/providers/dns/rainyun/internal/client.go b/providers/dns/rainyun/internal/client.go
new file mode 100644
index 000000000..595b39f29
--- /dev/null
+++ b/providers/dns/rainyun/internal/client.go
@@ -0,0 +1,184 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ querystring "github.com/google/go-querystring/query"
+)
+
+const defaultBaseURL = "https://api.v2.rainyun.com/product/"
+
+// Client the Rain Yun API client.
+type Client struct {
+ apiKey string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey string) (*Client, error) {
+ if apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, domainID int, record Record) error {
+ endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error {
+ endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns")
+
+ values, err := querystring.Values(Record{ID: recordID})
+ if err != nil {
+ return err
+ }
+
+ endpoint.RawQuery = values.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) ListRecords(ctx context.Context, domainID int) ([]Record, error) {
+ endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns")
+
+ query := endpoint.Query()
+ query.Set("limit", "100")
+ query.Set("page_no", "1")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var recordData APIResponse[Record]
+
+ err = c.do(req, &recordData)
+ if err != nil {
+ return nil, err
+ }
+
+ return recordData.Data.Records, nil
+}
+
+func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
+ endpoint := c.baseURL.JoinPath("domain")
+
+ query := endpoint.Query()
+ query.Set("options", `{"columnFilters":{"domains.Domain":""},"sort":[],"page":1,"perPage":100}`)
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var domainData APIResponse[Domain]
+
+ err = c.do(req, &domainData)
+ if err != nil {
+ return nil, err
+ }
+
+ return domainData.Data.Records, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Add("x-api-key", c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/rainyun/internal/client_test.go b/providers/dns/rainyun/internal/client_test.go
new file mode 100644
index 000000000..8246001af
--- /dev/null
+++ b/providers/dns/rainyun/internal/client_test.go
@@ -0,0 +1,167 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders())
+}
+
+func TestClient_ListDomains(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domain",
+ servermock.ResponseFromFixture("domains.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("options", `{"columnFilters":{"domains.Domain":""},"sort":[],"page":1,"perPage":100}`)).
+ Build(t)
+
+ domains, err := client.ListDomains(t.Context())
+ require.NoError(t, err)
+
+ expected := []Domain{
+ {ID: 1, Domain: "example.com"},
+ {ID: 2, Domain: "example.org"},
+ }
+
+ assert.Equal(t, expected, domains)
+}
+
+func TestClient_ListDomains_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domain",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusForbidden)).
+ Build(t)
+
+ _, err := client.ListDomains(t.Context())
+ require.Error(t, err)
+
+ assert.EqualError(t, err, "30039: 密钥认证错误或已失效")
+}
+
+func TestClient_ListRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domain/123/dns",
+ servermock.ResponseFromFixture("records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("limit", "100").
+ With("page_no", "1")).
+ Build(t)
+
+ records, err := client.ListRecords(t.Context(), 123)
+ require.NoError(t, err)
+
+ expected := []Record{
+ {
+ ID: 1,
+ Host: "_acme-challenge.foo.example.com",
+ Line: "DEFAULT",
+ TTL: 120,
+ Type: "TXT",
+ Value: "foo",
+ },
+ {
+ ID: 2,
+ Host: "_acme-challenge.bar.example.com",
+ Line: "DEFAULT",
+ TTL: 300,
+ Type: "TXT",
+ Value: "bar",
+ },
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_ListRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /domain/123/dns",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusForbidden)).
+ Build(t)
+
+ _, err := client.ListRecords(t.Context(), 123)
+ require.Error(t, err)
+
+ assert.EqualError(t, err, "30039: 密钥认证错误或已失效")
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domain/123/dns", nil).
+ Build(t)
+
+ record := Record{
+ Host: "_acme-challenge.foo.example.com",
+ Line: "DEFAULT",
+ TTL: 120,
+ Type: "TXT",
+ Value: "foo",
+ }
+
+ err := client.AddRecord(t.Context(), 123, record)
+ require.NoError(t, err)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domain/123/dns",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusForbidden)).
+ Build(t)
+
+ record := Record{
+ Host: "_acme-challenge.foo.example.com",
+ Line: "DEFAULT",
+ TTL: 120,
+ Type: "TXT",
+ Value: "foo",
+ }
+
+ err := client.AddRecord(t.Context(), 123, record)
+ require.Error(t, err)
+
+ assert.EqualError(t, err, "30039: 密钥认证错误或已失效")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domain/123/dns", nil).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), 123, 456)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domain/123/dns",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusForbidden)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), 123, 456)
+ require.Error(t, err)
+
+ assert.EqualError(t, err, "30039: 密钥认证错误或已失效")
+}
diff --git a/providers/dns/rainyun/internal/fixtures/domains.json b/providers/dns/rainyun/internal/fixtures/domains.json
new file mode 100644
index 000000000..930e4e189
--- /dev/null
+++ b/providers/dns/rainyun/internal/fixtures/domains.json
@@ -0,0 +1,16 @@
+{
+ "code": 0,
+ "data": {
+ "TotalRecords": 2,
+ "Records": [
+ {
+ "id": 1,
+ "domain": "example.com"
+ },
+ {
+ "id": 2,
+ "domain": "example.org"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/rainyun/internal/fixtures/error.json b/providers/dns/rainyun/internal/fixtures/error.json
new file mode 100644
index 000000000..31e9f7138
--- /dev/null
+++ b/providers/dns/rainyun/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+ "code": 30039,
+ "message": "密钥认证错误或已失效"
+}
diff --git a/providers/dns/rainyun/internal/fixtures/records.json b/providers/dns/rainyun/internal/fixtures/records.json
new file mode 100644
index 000000000..d24c0c9ec
--- /dev/null
+++ b/providers/dns/rainyun/internal/fixtures/records.json
@@ -0,0 +1,24 @@
+{
+ "code": 0,
+ "data": {
+ "TotalRecords": 2,
+ "Records": [
+ {
+ "record_id": 1,
+ "host": "_acme-challenge.foo.example.com",
+ "type": "TXT",
+ "TTL": 120,
+ "value": "foo",
+ "line": "DEFAULT"
+ },
+ {
+ "record_id": 2,
+ "host": "_acme-challenge.bar.example.com",
+ "type": "TXT",
+ "TTL": 300,
+ "value": "bar",
+ "line": "DEFAULT"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/rainyun/internal/types.go b/providers/dns/rainyun/internal/types.go
new file mode 100644
index 000000000..8ce559112
--- /dev/null
+++ b/providers/dns/rainyun/internal/types.go
@@ -0,0 +1,37 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%d: %s", a.Code, a.Message)
+}
+
+type Record struct {
+ ID int `json:"record_id,omitempty" url:"record_id,omitempty"`
+ Host string `json:"host,omitempty" url:"host,omitempty"`
+ Priority int `json:"level,omitempty" url:"level,omitempty"`
+ Line string `json:"line,omitempty" url:"line,omitempty"`
+ TTL int `json:"ttl,omitempty" url:"ttl,omitempty"`
+ Type string `json:"type,omitempty" url:"type,omitempty"`
+ Value string `json:"value,omitempty" url:"value,omitempty"`
+}
+
+type Domain struct {
+ ID int `json:"id,omitempty"`
+ Domain string `json:"domain,omitempty"`
+}
+
+type APIResponse[T any] struct {
+ Code int `json:"code"`
+ Data *Data[T] `json:"data"`
+}
+
+type Data[T any] struct {
+ TotalRecords int `json:"TotalRecords"`
+ Records []T `json:"Records"`
+}
diff --git a/providers/dns/rainyun/rainyun.go b/providers/dns/rainyun/rainyun.go
new file mode 100644
index 000000000..a4d1c4035
--- /dev/null
+++ b/providers/dns/rainyun/rainyun.go
@@ -0,0 +1,200 @@
+// Package rainyun implements a DNS provider for solving the DNS-01 challenge using Rain Yun.
+package rainyun
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/rainyun/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "RAINYUN_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 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 *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Rain Yun.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("rainyun: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Rain Yun.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("rainyun: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("rainyun: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(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("rainyun: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("rainyun: %w", err)
+ }
+
+ domainID, err := d.findDomainID(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("rainyun: find domain ID: %w", err)
+ }
+
+ record := internal.Record{
+ Host: subDomain,
+ Priority: 10,
+ Line: "DEFAULT",
+ TTL: d.config.TTL,
+ Type: "TXT",
+ Value: info.Value,
+ }
+
+ err = d.client.AddRecord(ctx, domainID, record)
+ if err != nil {
+ return fmt.Errorf("rainyun: add record: %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("rainyun: could not find zone for domain %q: %w", domain, err)
+ }
+
+ domainID, err := d.findDomainID(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("rainyun: find domain ID: %w", err)
+ }
+
+ recordID, err := d.findRecordID(ctx, domainID, info)
+ if err != nil {
+ return fmt.Errorf("rainyun: find record ID: %w", err)
+ }
+
+ err = d.client.DeleteRecord(ctx, domainID, recordID)
+ if err != nil {
+ return fmt.Errorf("rainyun: 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
+}
+
+func (d *DNSProvider) findDomainID(ctx context.Context, domain string) (int, error) {
+ domains, err := d.client.ListDomains(ctx)
+ if err != nil {
+ return 0, err
+ }
+
+ for _, dom := range domains {
+ if dom.Domain == domain {
+ return dom.ID, nil
+ }
+ }
+
+ return 0, fmt.Errorf("domain not found: %s", domain)
+}
+
+func (d *DNSProvider) findRecordID(ctx context.Context, domainID int, info dns01.ChallengeInfo) (int, error) {
+ records, err := d.client.ListRecords(ctx, domainID)
+ if err != nil {
+ return 0, fmt.Errorf("list records: %w", err)
+ }
+
+ zone := dns01.UnFqdn(info.EffectiveFQDN)
+
+ for _, record := range records {
+ if strings.HasPrefix(zone, record.Host) && record.Value == info.Value {
+ return record.ID, nil
+ }
+ }
+
+ return 0, fmt.Errorf("record not found: domainID=%d, fqdn=%s", domainID, info.EffectiveFQDN)
+}
diff --git a/providers/dns/rainyun/rainyun.toml b/providers/dns/rainyun/rainyun.toml
new file mode 100644
index 000000000..fe2b3c07d
--- /dev/null
+++ b/providers/dns/rainyun/rainyun.toml
@@ -0,0 +1,22 @@
+Name = "Rain Yun/雨云"
+Description = ''''''
+URL = "https://www.rainyun.com"
+Code = "rainyun"
+Since = "v4.21.0"
+
+Example = '''
+RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns rainyun -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ RAINYUN_API_KEY = "API key"
+ [Configuration.Additional]
+ RAINYUN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ RAINYUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ RAINYUN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ RAINYUN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.apifox.cn/apidoc/shared-a4595cc8-44c5-4678-a2a3-eed7738dab03/api-151416609"
diff --git a/providers/dns/rainyun/rainyun_test.go b/providers/dns/rainyun/rainyun_test.go
new file mode 100644
index 000000000..d27d47e81
--- /dev/null
+++ b/providers/dns/rainyun/rainyun_test.go
@@ -0,0 +1,116 @@
+package rainyun
+
+import (
+ "testing"
+
+ "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: "secret",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "rainyun: some credentials information are missing: RAINYUN_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing credentials",
+ expected: "rainyun: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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/dns/rcodezero/internal/client.go b/providers/dns/rcodezero/internal/client.go
index d37fec2dd..5cf39907e 100644
--- a/providers/dns/rcodezero/internal/client.go
+++ b/providers/dns/rcodezero/internal/client.go
@@ -64,6 +64,7 @@ func (c *Client) do(req *http.Request) (*APIResponse, error) {
}
result := &APIResponse{}
+
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
@@ -105,6 +106,7 @@ 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)
diff --git a/providers/dns/rcodezero/internal/client_test.go b/providers/dns/rcodezero/internal/client_test.go
index c19e6e5b8..b70107072 100644
--- a/providers/dns/rcodezero/internal/client_test.go
+++ b/providers/dns/rcodezero/internal/client_test.go
@@ -1,69 +1,30 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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
- }
- })
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("secret")
client.HTTPClient = server.Client()
client.baseURL, _ = url.Parse(server.URL)
- return client
+ return client, nil
}
func TestClient_UpdateRecords_error(t *testing.T) {
- client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusUnprocessableEntity, "error.json")
+ client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()).
+ Route("PATCH /v1/acme/zones/example.org/rrsets",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnprocessableEntity)).
+ Build(t)
rrSet := []UpdateRRSet{{
Name: "acme.example.org.",
@@ -72,13 +33,16 @@ func TestClient_UpdateRecords_error(t *testing.T) {
Records: []Record{{Content: `"my-acme-challenge"`}},
}}
- resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet)
+ resp, err := client.UpdateRecords(t.Context(), "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")
+ client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()).
+ Route("PATCH /v1/acme/zones/example.org/rrsets",
+ servermock.ResponseFromFixture("rrsets-response.json")).
+ Build(t)
rrSet := []UpdateRRSet{{
Name: "acme.example.org.",
@@ -87,7 +51,7 @@ func TestClient_UpdateRecords(t *testing.T) {
Records: []Record{{Content: `"my-acme-challenge"`}},
}}
- resp, err := client.UpdateRecords(context.Background(), "example.org", rrSet)
+ resp, err := client.UpdateRecords(t.Context(), "example.org", rrSet)
require.NoError(t, err)
expected := &APIResponse{Status: "ok", Message: "RRsets updated"}
diff --git a/providers/dns/rcodezero/rcodezero.go b/providers/dns/rcodezero/rcodezero.go
index c88caefe4..010a6dadc 100644
--- a/providers/dns/rcodezero/rcodezero.go
+++ b/providers/dns/rcodezero/rcodezero.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/rcodezero/internal"
)
@@ -41,7 +42,7 @@ type Config struct {
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 240*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 4*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
@@ -86,6 +87,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/rcodezero/rcodezero.toml b/providers/dns/rcodezero/rcodezero.toml
index 7ab451e5f..c2a4a1e7b 100644
--- a/providers/dns/rcodezero/rcodezero.toml
+++ b/providers/dns/rcodezero/rcodezero.toml
@@ -6,7 +6,7 @@ Since = "v4.13"
Example = '''
RCODEZERO_API_TOKEN= \
-lego --email you@example.com --dns rcodezero -d '*.example.com' -d example.com run
+lego --dns rcodezero -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -23,10 +23,10 @@ RcodeZero is an Anycast Network so the distribution of the DNS01-Challenge can t
[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"
+ RCODEZERO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ RCODEZERO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)"
+ RCODEZERO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ RCODEZERO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
# Note: the API endpoint used inside the client is not documented.
diff --git a/providers/dns/rcodezero/rcodezero_test.go b/providers/dns/rcodezero/rcodezero_test.go
index 1f0946072..a4a242c30 100644
--- a/providers/dns/rcodezero/rcodezero_test.go
+++ b/providers/dns/rcodezero/rcodezero_test.go
@@ -37,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -94,6 +95,7 @@ func TestLivePresentAndCleanup(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/regfish/regfish.go b/providers/dns/regfish/regfish.go
index 6a8ccee98..85aac92e5 100644
--- a/providers/dns/regfish/regfish.go
+++ b/providers/dns/regfish/regfish.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
regfishapi "github.com/regfish/regfish-dnsapi-go"
)
@@ -84,6 +85,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := regfishapi.NewClient(config.APIKey)
+ if config.HTTPClient != nil {
+ client.Client = config.HTTPClient
+ } else {
+ // Because the regfishapi.NewClient uses an empty http.Client.
+ client.Client = &http.Client{Timeout: 30 * time.Second}
+ }
+
+ client.Client = clientdebug.Wrap(client.Client)
+
return &DNSProvider{
config: config,
client: client,
@@ -122,6 +132,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("regfish: unknown record ID for '%s'", info.EffectiveFQDN)
}
diff --git a/providers/dns/regfish/regfish.toml b/providers/dns/regfish/regfish.toml
index fbc4bdd70..fbaacbde4 100644
--- a/providers/dns/regfish/regfish.toml
+++ b/providers/dns/regfish/regfish.toml
@@ -6,17 +6,17 @@ Since = "v4.20.0"
Example = '''
REGFISH_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns regfish -d '*.example.com' -d example.com run
+lego --dns regfish -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
REGFISH_API_KEY = "API key"
[Configuration.Additional]
- REGFISH_POLLING_INTERVAL = "Time between DNS propagation check"
- REGFISH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- REGFISH_TTL = "The TTL of the TXT record used for the DNS challenge"
- REGFISH_HTTP_TIMEOUT = "API request timeout"
+ REGFISH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ REGFISH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ REGFISH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ REGFISH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://regfish.readme.io/"
diff --git a/providers/dns/regfish/regfish_test.go b/providers/dns/regfish/regfish_test.go
index 80928048f..6613bd508 100644
--- a/providers/dns/regfish/regfish_test.go
+++ b/providers/dns/regfish/regfish_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -92,6 +93,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -105,6 +107,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/regru/internal/client.go b/providers/dns/regru/internal/client.go
index 7ce633b05..b0b86d567 100644
--- a/providers/dns/regru/internal/client.go
+++ b/providers/dns/regru/internal/client.go
@@ -38,7 +38,7 @@ func NewClient(username, password string) *Client {
// RemoveTxtRecord removes a TXT record.
// https://www.reg.ru/support/help/api2#zone_remove_record
-func (c Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content string) error {
+func (c *Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content string) error {
request := RemoveRecordRequest{
Domains: []Domain{{DName: domain}},
SubDomain: subDomain,
@@ -57,7 +57,7 @@ func (c Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content
// AddTXTRecord adds a TXT record.
// https://www.reg.ru/support/help/api2#zone_add_txt
-func (c Client) AddTXTRecord(ctx context.Context, domain, subDomain, content string) error {
+func (c *Client) AddTXTRecord(ctx context.Context, domain, subDomain, content string) error {
request := AddTxtRequest{
Domains: []Domain{{DName: domain}},
SubDomain: subDomain,
@@ -73,7 +73,7 @@ func (c Client) AddTXTRecord(ctx context.Context, domain, subDomain, content str
return resp.HasError()
}
-func (c Client) doRequest(ctx context.Context, request any, fragments ...string) (*APIResponse, error) {
+func (c *Client) doRequest(ctx context.Context, request any, fragments ...string) (*APIResponse, error) {
endpoint := c.baseURL.JoinPath(fragments...)
inputData, err := json.Marshal(request)
@@ -111,6 +111,7 @@ func (c Client) doRequest(ctx context.Context, request any, fragments ...string)
}
var apiResp APIResponse
+
err = json.Unmarshal(raw, &apiResp)
if err != nil {
return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
@@ -123,6 +124,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIResponse
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/regru/internal/client_test.go b/providers/dns/regru/internal/client_test.go
index fa3f16702..002da0185 100644
--- a/providers/dns/regru/internal/client_test.go
+++ b/providers/dns/regru/internal/client_test.go
@@ -1,61 +1,60 @@
package internal
import (
- "context"
- "net/http"
+ "net/http/httptest"
"net/url"
- "os"
"testing"
- "time"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-const (
- noopBaseURL = "https://api.reg.ru/api/regru2/nop"
- officialTestUser = "test"
- officialTestPassword = "test"
-)
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ )
+}
func TestRemoveRecord(t *testing.T) {
- // TODO(ldez): remove skip when the reg.ru API will be fixed.
- t.Skip("there is a bug with the reg.ru API: INTERNAL_API_ERROR: Внутренняя ошибка, status code: 503")
+ client := mockBuilder().
+ Route("POST /zone/remove_record",
+ servermock.ResponseFromFixture("remove_record.json"),
+ servermock.CheckForm().Strict().
+ With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","content":"txttxttxt","record_type":"TXT","output_content_type":"plain"}`).
+ With("username", "user").
+ With("password", "secret").
+ With("input_format", "json")).
+ Build(t)
- client := NewClient(officialTestUser, officialTestPassword)
- client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
-
- err := client.RemoveTxtRecord(context.Background(), "test.ru", "_acme-challenge", "txttxttxt")
+ err := client.RemoveTxtRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt")
require.NoError(t, err)
}
func TestRemoveRecord_errors(t *testing.T) {
- // TODO(ldez): remove skip when the reg.ru API will be fixed.
- if os.Getenv("CI") == "true" {
- t.Skip("there is a bug with the reg.ru and GitHub action: dial tcp 194.58.116.30:443: i/o timeout")
- }
-
testCases := []struct {
desc string
domain string
- username string
- password string
- baseURL string
+ response string
expected string
}{
{
desc: "authentication failed",
domain: "test.ru",
- username: "",
- password: "",
- baseURL: noopBaseURL,
+ response: "remove_record_error_auth.json",
expected: "API error: NO_AUTH: No authorization mechanism selected",
},
{
desc: "domain error",
domain: "",
- username: officialTestUser,
- password: officialTestPassword,
- baseURL: defaultBaseURL,
+ response: "remove_record_error_domain.json",
expected: "API error: NO_DOMAIN: domain_name not given or empty",
},
}
@@ -64,55 +63,48 @@ func TestRemoveRecord_errors(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client := NewClient(test.username, test.username)
- client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
- client.baseURL, _ = url.Parse(test.baseURL)
+ client := mockBuilder().
+ Route("POST /zone/remove_record", servermock.ResponseFromFixture(test.response)).
+ Build(t)
- err := client.RemoveTxtRecord(context.Background(), test.domain, "_acme-challenge", "txttxttxt")
+ err := client.RemoveTxtRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt")
require.EqualError(t, err, test.expected)
})
}
}
func TestAddTXTRecord(t *testing.T) {
- // TODO(ldez): remove skip when the reg.ru API will be fixed.
- t.Skip("there is a bug with the reg.ru API: INTERNAL_API_ERROR: Внутренняя ошибка, status code: 503")
+ client := mockBuilder().
+ Route("POST /zone/add_txt",
+ servermock.ResponseFromFixture("add_txt_record.json"),
+ servermock.CheckForm().Strict().
+ With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","text":"txttxttxt","output_content_type":"plain"}`).
+ With("username", "user").
+ With("password", "secret").
+ With("input_format", "json")).
+ Build(t)
- client := NewClient(officialTestUser, officialTestPassword)
- client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
-
- err := client.AddTXTRecord(context.Background(), "test.ru", "_acme-challenge", "txttxttxt")
+ err := client.AddTXTRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt")
require.NoError(t, err)
}
func TestAddTXTRecord_errors(t *testing.T) {
- // TODO(ldez): remove skip when the reg.ru API will be fixed.
- if os.Getenv("CI") == "true" {
- t.Skip("there is a bug with the reg.ru and GitHub action: dial tcp 194.58.116.30:443: i/o timeout")
- }
-
testCases := []struct {
desc string
domain string
- username string
- password string
- baseURL string
+ response string
expected string
}{
{
desc: "authentication failed",
domain: "test.ru",
- username: "",
- password: "",
- baseURL: noopBaseURL,
+ response: "add_txt_record_error_auth.json",
expected: "API error: NO_AUTH: No authorization mechanism selected",
},
{
desc: "domain error",
domain: "",
- username: officialTestUser,
- password: officialTestPassword,
- baseURL: defaultBaseURL,
+ response: "add_txt_record_error_domain.json",
expected: "API error: NO_DOMAIN: domain_name not given or empty",
},
}
@@ -121,11 +113,11 @@ func TestAddTXTRecord_errors(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client := NewClient(test.username, test.username)
- client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
- client.baseURL, _ = url.Parse(test.baseURL)
+ client := mockBuilder().
+ Route("POST /zone/add_txt", servermock.ResponseFromFixture(test.response)).
+ Build(t)
- err := client.AddTXTRecord(context.Background(), test.domain, "_acme-challenge", "txttxttxt")
+ err := client.AddTXTRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt")
require.EqualError(t, err, test.expected)
})
}
diff --git a/providers/dns/regru/internal/fixtures/add_txt_record.json b/providers/dns/regru/internal/fixtures/add_txt_record.json
new file mode 100644
index 000000000..06306b4c4
--- /dev/null
+++ b/providers/dns/regru/internal/fixtures/add_txt_record.json
@@ -0,0 +1,14 @@
+{
+ "answer": {
+ "domains": [
+ {
+ "dname": "test.ru",
+ "result": "success",
+ "service_id": 12345
+ }
+ ]
+ },
+ "charset": "utf-8",
+ "messagestore": null,
+ "result": "success"
+}
diff --git a/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json b/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json
new file mode 100644
index 000000000..2d5314bf3
--- /dev/null
+++ b/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json
@@ -0,0 +1,10 @@
+{
+ "charset": "utf-8",
+ "error_code": "NO_AUTH",
+ "error_params": {
+ "command_name": "nop/zone/add_txt"
+ },
+ "error_text": "No authorization mechanism selected",
+ "messagestore": null,
+ "result": "error"
+}
diff --git a/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json b/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json
new file mode 100644
index 000000000..305846ed1
--- /dev/null
+++ b/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json
@@ -0,0 +1,14 @@
+{
+ "answer": {
+ "domains": [
+ {
+ "error_code": "NO_DOMAIN",
+ "error_text": "domain_name not given or empty",
+ "result": "error"
+ }
+ ]
+ },
+ "charset": "utf-8",
+ "messagestore": null,
+ "result": "success"
+}
diff --git a/providers/dns/regru/internal/fixtures/remove_record.json b/providers/dns/regru/internal/fixtures/remove_record.json
new file mode 100644
index 000000000..06306b4c4
--- /dev/null
+++ b/providers/dns/regru/internal/fixtures/remove_record.json
@@ -0,0 +1,14 @@
+{
+ "answer": {
+ "domains": [
+ {
+ "dname": "test.ru",
+ "result": "success",
+ "service_id": 12345
+ }
+ ]
+ },
+ "charset": "utf-8",
+ "messagestore": null,
+ "result": "success"
+}
diff --git a/providers/dns/regru/internal/fixtures/remove_record_error_auth.json b/providers/dns/regru/internal/fixtures/remove_record_error_auth.json
new file mode 100644
index 000000000..98c429c53
--- /dev/null
+++ b/providers/dns/regru/internal/fixtures/remove_record_error_auth.json
@@ -0,0 +1,10 @@
+{
+ "charset" : "utf-8",
+ "error_code" : "NO_AUTH",
+ "error_params" : {
+ "command_name" : "nop/zone/remove_record"
+ },
+ "error_text" : "No authorization mechanism selected",
+ "messagestore" : null,
+ "result" : "error"
+}
diff --git a/providers/dns/regru/internal/fixtures/remove_record_error_domain.json b/providers/dns/regru/internal/fixtures/remove_record_error_domain.json
new file mode 100644
index 000000000..a9ca88ff7
--- /dev/null
+++ b/providers/dns/regru/internal/fixtures/remove_record_error_domain.json
@@ -0,0 +1,14 @@
+{
+ "answer" : {
+ "domains" : [
+ {
+ "error_code" : "NO_DOMAIN",
+ "error_text" : "domain_name not given or empty",
+ "result" : "error"
+ }
+ ]
+ },
+ "charset" : "utf-8",
+ "messagestore" : null,
+ "result" : "success"
+}
diff --git a/providers/dns/regru/internal/readme.md b/providers/dns/regru/internal/readme.md
new file mode 100644
index 000000000..5f13012d2
--- /dev/null
+++ b/providers/dns/regru/internal/readme.md
@@ -0,0 +1,6 @@
+Test account (with the default endpoint):
+- user: `test`
+- password: `test`
+
+Noop endpoint:
+- https://api.reg.ru/api/regru2/nop
diff --git a/providers/dns/regru/regru.go b/providers/dns/regru/regru.go
index 1501863bd..b06b355c1 100644
--- a/providers/dns/regru/regru.go
+++ b/providers/dns/regru/regru.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/regru/internal"
)
@@ -97,6 +98,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
if config.TLSCert != "" || config.TLSKey != "" {
if config.TLSCert == "" {
return nil, errors.New("regru: TLS certificate is missing")
diff --git a/providers/dns/regru/regru.toml b/providers/dns/regru/regru.toml
index 16d8e4e3a..728bb2bf7 100644
--- a/providers/dns/regru/regru.toml
+++ b/providers/dns/regru/regru.toml
@@ -7,7 +7,7 @@ Since = "v3.5.0"
Example = '''
REGRU_USERNAME=xxxxxx \
REGRU_PASSWORD=yyyyyy \
-lego --email you@example.com --dns regru -d '*.example.com' -d example.com run
+lego --dns regru -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,10 @@ lego --email you@example.com --dns regru -d '*.example.com' -d example.com run
[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"
- REGRU_HTTP_TIMEOUT = "API request timeout"
+ REGRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ REGRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ REGRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ REGRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.reg.ru/support/help/api2"
diff --git a/providers/dns/regru/regru_test.go b/providers/dns/regru/regru_test.go
index 15d86d75c..762eeb4d3 100644
--- a/providers/dns/regru/regru_test.go
+++ b/providers/dns/regru/regru_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/rfc2136/rfc2136.go b/providers/dns/rfc2136/rfc2136.go
index d533f4d16..2c4fe7aeb 100644
--- a/providers/dns/rfc2136/rfc2136.go
+++ b/providers/dns/rfc2136/rfc2136.go
@@ -58,8 +58,8 @@ func NewDefaultConfig() *Config {
return &Config{
TSIGAlgorithm: env.GetOrDefaultString(EnvTSIGAlgorithm, dns.HmacSHA1),
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, env.GetOrDefaultSecond("RFC2136_TIMEOUT", 60*time.Second)),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, env.GetOrDefaultSecond("RFC2136_TIMEOUT", dns01.DefaultPropagationTimeout)),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
DNSTimeout: env.GetOrDefaultSecond(EnvDNSTimeout, 10*time.Second),
}
@@ -131,7 +131,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
config.TSIGSecret = ""
} else {
// zonename must be in canonical form (lowercase, fqdn, see RFC 4034 Section 6.2)
- config.TSIGKey = strings.ToLower(dns.Fqdn(config.TSIGKey))
+ config.TSIGKey = dns.CanonicalName(config.TSIGKey)
}
if config.TSIGAlgorithm == "" {
@@ -171,6 +171,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("rfc2136: failed to insert: %w", err)
}
+
return nil
}
@@ -182,6 +183,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("rfc2136: failed to remove: %w", err)
}
+
return nil
}
@@ -193,14 +195,14 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
}
// Create RR
- rr := new(dns.TXT)
- rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}
- rr.Txt = []string{value}
- rrs := []dns.RR{rr}
+ rrs := []dns.RR{&dns.TXT{
+ Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)},
+ Txt: []string{value},
+ }}
// Create dynamic update packet
- m := new(dns.Msg)
- m.SetUpdate(zone)
+ m := new(dns.Msg).SetUpdate(zone)
+
switch action {
case "INSERT":
// Always remove old challenge left over from who knows what.
@@ -228,6 +230,7 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
if err != nil {
return fmt.Errorf("DNS update failed: %w", err)
}
+
if reply != nil && reply.Rcode != dns.RcodeSuccess {
return fmt.Errorf("DNS update failed: server replied: %s", dns.RcodeToString[reply.Rcode])
}
diff --git a/providers/dns/rfc2136/rfc2136.toml b/providers/dns/rfc2136/rfc2136.toml
index df313fde7..6b5bbe599 100644
--- a/providers/dns/rfc2136/rfc2136.toml
+++ b/providers/dns/rfc2136/rfc2136.toml
@@ -9,7 +9,7 @@ RFC2136_NAMESERVER=127.0.0.1 \
RFC2136_TSIG_KEY=example.com \
RFC2136_TSIG_ALGORITHM=hmac-sha256. \
RFC2136_TSIG_SECRET=YWJjZGVmZGdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNDU= \
-lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run
+lego --dns rfc2136 -d '*.example.com' -d example.com run
## ---
@@ -17,7 +17,7 @@ keyname=example.com; keyfile=example.com.key; tsig-keygen $keyname > $keyfile
RFC2136_NAMESERVER=127.0.0.1 \
RFC2136_TSIG_FILE="$keyfile" \
-lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run
+lego --dns rfc2136 -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -28,11 +28,11 @@ lego --email you@example.com --dns rfc2136 -d '*.example.com' -d example.com run
RFC2136_NAMESERVER = 'Network address in the form "host" or "host:port"'
[Configuration.Additional]
RFC2136_TSIG_FILE = "Path to a key file generated by tsig-keygen"
- RFC2136_POLLING_INTERVAL = "Time between DNS propagation check"
- RFC2136_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- RFC2136_TTL = "The TTL of the TXT record used for the DNS challenge"
- RFC2136_DNS_TIMEOUT = "API request timeout"
- RFC2136_SEQUENCE_INTERVAL = "Time between sequential requests"
+ RFC2136_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ RFC2136_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ RFC2136_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ RFC2136_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ RFC2136_DNS_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://www.rfc-editor.org/rfc/rfc2136.html"
diff --git a/providers/dns/rfc2136/rfc2136_test.go b/providers/dns/rfc2136/rfc2136_test.go
index 80fdc69cb..ce4859e84 100644
--- a/providers/dns/rfc2136/rfc2136_test.go
+++ b/providers/dns/rfc2136/rfc2136_test.go
@@ -2,24 +2,21 @@ package rfc2136
import (
"bytes"
- "fmt"
- "net"
"strings"
- "sync"
"testing"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/dnsmock"
"github.com/miekg/dns"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
fakeDomain = "123456789.www.example.com"
fakeKeyAuth = "123d=="
- fakeValue = "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"
+ fakeValue = "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
fakeFqdn = "_acme-challenge.123456789.www.example.com."
fakeZone = "example.com."
fakeTTL = 120
@@ -87,6 +84,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -163,39 +161,16 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
}
-func TestCanaryLocalTestServer(t *testing.T) {
+func TestDNSProvider_Present_success(t *testing.T) {
dns01.ClearFqdnCache()
- dns.HandleFunc("example.com.", serverHandlerHello)
- defer dns.HandleRemove("example.com.")
- server, addr, err := runLocalDNSTestServer(false)
- require.NoError(t, err, "Failed to start test server")
- defer func() { _ = server.Shutdown() }()
-
- c := new(dns.Client)
- m := new(dns.Msg)
-
- m.SetQuestion("example.com.", dns.TypeTXT)
-
- r, _, err := c.Exchange(m, addr)
- require.NoError(t, err, "Failed to communicate with test server")
- assert.Len(t, r.Extra, 1, "Failed to communicate with test server")
-
- txt := r.Extra[0].(*dns.TXT).Txt[0]
- assert.Equal(t, "Hello world", txt)
-}
-
-func TestServerSuccess(t *testing.T) {
- dns01.ClearFqdnCache()
- dns.HandleFunc(fakeZone, serverHandlerReturnSuccess)
- defer dns.HandleRemove(fakeZone)
-
- server, addr, err := runLocalDNSTestServer(false)
- require.NoError(t, err, "Failed to start test server")
- defer func() { _ = server.Shutdown() }()
+ addr := dnsmock.NewServer().
+ Query(fakeZone+" SOA", dnsmock.SOA("")).
+ Update(fakeZone+" SOA", dnsmock.Noop).
+ Build(t)
config := NewDefaultConfig()
- config.Nameserver = addr
+ config.Nameserver = addr.String()
provider, err := NewDNSProviderConfig(config)
require.NoError(t, err)
@@ -204,39 +179,98 @@ func TestServerSuccess(t *testing.T) {
require.NoError(t, err)
}
-func TestServerError(t *testing.T) {
+func TestDNSProvider_Present_success_updatePacket(t *testing.T) {
dns01.ClearFqdnCache()
- dns.HandleFunc(fakeZone, serverHandlerReturnErr)
- defer dns.HandleRemove(fakeZone)
- server, addr, err := runLocalDNSTestServer(false)
- require.NoError(t, err, "Failed to start test server")
- defer func() { _ = server.Shutdown() }()
+ reqChan := make(chan *dns.Msg, 1)
+
+ addr := dnsmock.NewServer().
+ Query("_acme-challenge.123456789.www.example.com. SOA", dnsmock.SOA(fakeZone)).
+ Update(fakeZone+" SOA", func(w dns.ResponseWriter, req *dns.Msg) {
+ dnsmock.Noop(w, req)
+
+ // Only talk back when it is not the SOA RR.
+ reqChan <- req
+ }).
+ Build(t)
config := NewDefaultConfig()
- config.Nameserver = addr
+ config.Nameserver = addr.String()
+
+ provider, err := NewDNSProviderConfig(config)
+ require.NoError(t, err)
+
+ err = provider.Present(fakeDomain, "", fakeKeyAuth)
+ require.NoError(t, err)
+
+ select {
+ case <-time.After(time.Second):
+ t.Fatal("timeout waiting for request")
+
+ case rcvMsg := <-reqChan:
+ txtRR := &dns.TXT{
+ Hdr: dns.RR_Header{Name: fakeFqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: fakeTTL},
+ Txt: []string{fakeValue},
+ }
+
+ m := new(dns.Msg).SetUpdate(fakeZone)
+
+ m.RemoveRRset([]dns.RR{txtRR})
+ m.Insert([]dns.RR{txtRR})
+
+ expected, err := m.Pack()
+ require.NoError(t, err, "error packing")
+
+ rcvMsg.Id = m.Id
+
+ actual, err := rcvMsg.Pack()
+ require.NoError(t, err, "error packing")
+
+ if !bytes.Equal(actual, expected) {
+ tmp := new(dns.Msg)
+ require.NoError(t, tmp.Unpack(actual))
+
+ t.Errorf("Expected msg:\n%s", m)
+ t.Errorf("Actual msg:\n%s", tmp)
+ }
+ }
+}
+
+func TestDNSProvider_Present_error(t *testing.T) {
+ dns01.ClearFqdnCache()
+
+ addr := dnsmock.NewServer().
+ Query(fakeZone+" SOA", dnsmock.Error(dns.RcodeNotZone)).
+ Build(t)
+
+ config := NewDefaultConfig()
+ config.Nameserver = addr.String()
provider, err := NewDNSProviderConfig(config)
require.NoError(t, err)
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)
}
}
-func TestTsigClient(t *testing.T) {
+func TestDNSProvider_Present_tsig_success(t *testing.T) {
dns01.ClearFqdnCache()
- dns.HandleFunc(fakeZone, serverHandlerReturnSuccess)
- defer dns.HandleRemove(fakeZone)
- server, addr, err := runLocalDNSTestServer(true)
- require.NoError(t, err, "Failed to start test server")
- defer func() { _ = server.Shutdown() }()
+ addr := dnsmock.NewServer().
+ Query(fakeZone+" SOA", dnsmock.SOA("")).
+ Update(fakeZone+" SOA", handleTSIG).
+ Build(t, func(server *dns.Server) error {
+ server.TsigSecret = map[string]string{fakeTsigKey: fakeTsigSecret}
+
+ return nil
+ })
config := NewDefaultConfig()
- config.Nameserver = addr
+ config.Nameserver = addr.String()
config.TSIGKey = fakeTsigKey
config.TSIGSecret = fakeTsigSecret
@@ -247,143 +281,50 @@ func TestTsigClient(t *testing.T) {
require.NoError(t, err)
}
-func TestValidUpdatePacket(t *testing.T) {
- reqChan := make(chan *dns.Msg, 10)
-
+func TestDNSProvider_Present_tsig_error(t *testing.T) {
dns01.ClearFqdnCache()
- dns.HandleFunc(fakeZone, serverHandlerPassBackRequest(reqChan))
- defer dns.HandleRemove(fakeZone)
- server, addr, err := runLocalDNSTestServer(false)
- require.NoError(t, err, "Failed to start test server")
- defer func() { _ = server.Shutdown() }()
+ addr := dnsmock.NewServer().
+ Query(fakeZone+" SOA", dnsmock.SOA("")).
+ Update(fakeZone+" SOA", handleTSIG).
+ Build(t, func(server *dns.Server) error {
+ server.TsigSecret = map[string]string{"example.org": fakeTsigSecret}
- txtRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN TXT %s", fakeFqdn, fakeTTL, fakeValue))
- rrs := []dns.RR{txtRR}
- m := new(dns.Msg)
- m.SetUpdate(fakeZone)
- m.RemoveRRset(rrs)
- m.Insert(rrs)
- expectStr := m.String()
-
- expect, err := m.Pack()
- require.NoError(t, err, "error packing")
+ return nil
+ })
config := NewDefaultConfig()
- config.Nameserver = addr
+ config.Nameserver = addr.String()
+ config.TSIGKey = fakeTsigKey
+ config.TSIGSecret = fakeTsigSecret
provider, err := NewDNSProviderConfig(config)
require.NoError(t, err)
- err = provider.Present(fakeDomain, "", "1234d==")
- require.NoError(t, err)
-
- rcvMsg := <-reqChan
- rcvMsg.Id = m.Id
-
- actual, err := rcvMsg.Pack()
- require.NoError(t, err, "error packing")
-
- if !bytes.Equal(actual, expect) {
- tmp := new(dns.Msg)
- if err := tmp.Unpack(actual); err != nil {
- t.Fatalf("Error unpacking actual msg: %v", err)
- }
- t.Errorf("Expected msg:\n%s", expectStr)
- t.Errorf("Actual msg:\n%v", tmp)
- }
+ err = provider.Present(fakeDomain, "", fakeKeyAuth)
+ require.Error(t, err)
+ require.EqualError(t, err, "rfc2136: failed to insert: DNS update failed: server replied: NOTZONE")
}
-func runLocalDNSTestServer(tsig bool) (*dns.Server, string, error) {
- pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+func handleTSIG(w dns.ResponseWriter, req *dns.Msg) {
+ m := new(dns.Msg)
+
+ tsig := req.IsTsig()
+ if tsig == nil {
+ _ = w.WriteMsg(m.SetRcode(req, dns.RcodeRefused))
+ return
+ }
+
+ err := w.TsigStatus()
if err != nil {
- return nil, "", err
+ _ = w.WriteMsg(m.SetRcode(req, dns.RcodeNotZone))
+
+ return
}
- server := &dns.Server{
- PacketConn: pc,
- ReadTimeout: time.Hour,
- WriteTimeout: time.Hour,
- MsgAcceptFunc: func(dh dns.Header) dns.MsgAcceptAction {
- // bypass defaultMsgAcceptFunc to allow dynamic update (https://github.com/miekg/dns/pull/830)
- return dns.MsgAccept
- },
- }
-
- if tsig {
- server.TsigSecret = map[string]string{fakeTsigKey: fakeTsigSecret}
- }
-
- waitLock := sync.Mutex{}
- waitLock.Lock()
- server.NotifyStartedFunc = waitLock.Unlock
-
- go func() {
- _ = server.ActivateAndServe()
- pc.Close()
- }()
-
- waitLock.Lock()
- return server, pc.LocalAddr().String(), nil
-}
-
-func serverHandlerHello(w dns.ResponseWriter, req *dns.Msg) {
- m := new(dns.Msg)
- m.SetReply(req)
- m.Extra = make([]dns.RR, 1)
- m.Extra[0] = &dns.TXT{
- Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0},
- Txt: []string{"Hello world"},
- }
- _ = w.WriteMsg(m)
-}
-
-func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) {
- m := new(dns.Msg)
- m.SetReply(req)
- if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET {
- // Return SOA to appease findZoneByFqdn()
- soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", fakeZone, fakeTTL, fakeZone, fakeZone))
- m.Answer = []dns.RR{soaRR}
- }
-
- if t := req.IsTsig(); t != nil {
- if w.TsigStatus() == nil {
- // Validated
- m.SetTsig(fakeZone, dns.HmacSHA1, 300, time.Now().Unix())
- }
- }
-
- _ = w.WriteMsg(m)
-}
-
-func serverHandlerReturnErr(w dns.ResponseWriter, req *dns.Msg) {
- m := new(dns.Msg)
- m.SetRcode(req, dns.RcodeNotZone)
- _ = w.WriteMsg(m)
-}
-
-func serverHandlerPassBackRequest(reqChan chan *dns.Msg) func(w dns.ResponseWriter, req *dns.Msg) {
- return func(w dns.ResponseWriter, req *dns.Msg) {
- m := new(dns.Msg)
- m.SetReply(req)
- if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET {
- // Return SOA to appease findZoneByFqdn()
- soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", fakeZone, fakeTTL, fakeZone, fakeZone))
- m.Answer = []dns.RR{soaRR}
- }
-
- if t := req.IsTsig(); t != nil {
- if w.TsigStatus() == nil {
- // Validated
- m.SetTsig(fakeZone, dns.HmacSHA1, 300, time.Now().Unix())
- }
- }
-
- _ = w.WriteMsg(m)
- if req.Opcode != dns.OpcodeQuery || req.Question[0].Qtype != dns.TypeSOA || req.Question[0].Qclass != dns.ClassINET {
- // Only talk back when it is not the SOA RR.
- reqChan <- req
- }
- }
+ // Validated
+ _ = w.WriteMsg(m.
+ SetReply(req).
+ SetTsig(tsig.Hdr.Name, tsig.Algorithm, tsig.Fudge, time.Now().Unix()),
+ )
}
diff --git a/providers/dns/rimuhosting/rimuhosting.go b/providers/dns/rimuhosting/rimuhosting.go
index 9051d0add..7a7e99f60 100644
--- a/providers/dns/rimuhosting/rimuhosting.go
+++ b/providers/dns/rimuhosting/rimuhosting.go
@@ -2,7 +2,6 @@
package rimuhosting
import (
- "context"
"errors"
"fmt"
"net/http"
@@ -29,19 +28,12 @@ const (
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
-type Config struct {
- APIKey string
-
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = rimuhosting.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, 3600),
+ TTL: env.GetOrDefaultInt(EnvTTL, rimuhosting.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
@@ -52,8 +44,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *rimuhosting.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for RimuHosting.
@@ -76,48 +67,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("rimuhosting: the configuration of the DNS provider is nil")
}
- if config.APIKey == "" {
- return nil, errors.New("rimuhosting: incomplete credentials, missing API key")
+ provider, err := rimuhosting.NewDNSProviderConfig(config, "")
+ if err != nil {
+ return nil, fmt.Errorf("rimuhosting: %w", err)
}
- client := rimuhosting.NewClient(config.APIKey)
- client.BaseURL = rimuhosting.DefaultRimuHostingBaseURL
-
- 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
+ return &DNSProvider{prv: provider}, nil
}
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN))
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("rimuhosting: failed to find record(s) for %s: %w", domain, err)
- }
-
- actions := []rimuhosting.ActionParameter{
- rimuhosting.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL),
- }
-
- for _, record := range records {
- actions = append(actions, rimuhosting.NewAddRecordAction(record.Name, record.Content, d.config.TTL))
- }
-
- _, err = d.client.DoActions(ctx, actions...)
- if err != nil {
- return fmt.Errorf("rimuhosting: failed to add record(s) for %s: %w", domain, err)
+ return fmt.Errorf("rimuhosting: %w", err)
}
return nil
@@ -125,14 +87,16 @@ 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 {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- action := rimuhosting.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
-
- _, err := d.client.DoActions(context.Background(), action)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("rimuhosting: failed to delete record for %s: %w", domain, err)
+ return fmt.Errorf("rimuhosting: %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.prv.Timeout()
+}
diff --git a/providers/dns/rimuhosting/rimuhosting.toml b/providers/dns/rimuhosting/rimuhosting.toml
index 4b4fa5ea7..c1994e2cc 100644
--- a/providers/dns/rimuhosting/rimuhosting.toml
+++ b/providers/dns/rimuhosting/rimuhosting.toml
@@ -6,17 +6,17 @@ Since = "v0.3.5"
Example = '''
RIMUHOSTING_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns rimuhosting -d '*.example.com' -d example.com run
+lego --dns rimuhosting -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
RIMUHOSTING_API_KEY = "User API key"
[Configuration.Additional]
- RIMUHOSTING_POLLING_INTERVAL = "Time between DNS propagation check"
- RIMUHOSTING_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- RIMUHOSTING_TTL = "The TTL of the TXT record used for the DNS challenge"
- RIMUHOSTING_HTTP_TIMEOUT = "API request timeout"
+ RIMUHOSTING_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ RIMUHOSTING_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ RIMUHOSTING_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ RIMUHOSTING_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://rimuhosting.com/dns/dyndns.jsp"
diff --git a/providers/dns/rimuhosting/rimuhosting_test.go b/providers/dns/rimuhosting/rimuhosting_test.go
index cbdacedc4..878ec14da 100644
--- a/providers/dns/rimuhosting/rimuhosting_test.go
+++ b/providers/dns/rimuhosting/rimuhosting_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -45,7 +46,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -83,7 +84,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -97,6 +98,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -110,6 +112,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml b/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml
new file mode 100644
index 000000000..68dba580f
--- /dev/null
+++ b/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml
@@ -0,0 +1,8 @@
+
+
+
+ /change/123456
+ PENDING
+ 2016-02-10T01:36:41.958Z
+
+
diff --git a/providers/dns/route53/fixtures/getChangeResponse.xml b/providers/dns/route53/fixtures/getChangeResponse.xml
new file mode 100644
index 000000000..f22c09460
--- /dev/null
+++ b/providers/dns/route53/fixtures/getChangeResponse.xml
@@ -0,0 +1,8 @@
+
+
+
+ 123456
+ INSYNC
+ 2016-02-10T01:36:41.958Z
+
+
diff --git a/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml b/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml
new file mode 100644
index 000000000..db47ba1e1
--- /dev/null
+++ b/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ /hostedzone/ABCDEFG
+ example.com.
+ D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A
+
+ Test comment
+ false
+
+ 10
+
+
+ true
+ example2.com
+ ZLT12321321124
+ 1
+
diff --git a/providers/dns/route53/fixtures_test.go b/providers/dns/route53/fixtures_test.go
deleted file mode 100644
index 444a88003..000000000
--- a/providers/dns/route53/fixtures_test.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package route53
-
-const ChangeResourceRecordSetsResponse = `
-
-
- /change/123456
- PENDING
- 2016-02-10T01:36:41.958Z
-
-`
-
-const ListHostedZonesByNameResponse = `
-
-
-
- /hostedzone/ABCDEFG
- example.com.
- D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A
-
- Test comment
- false
-
- 10
-
-
- true
- example2.com
- ZLT12321321124
- 1
-`
-
-const GetChangeResponse = `
-
-
- 123456
- INSYNC
- 2016-02-10T01:36:41.958Z
-
-`
diff --git a/providers/dns/route53/mock_test.go b/providers/dns/route53/mock_test.go
deleted file mode 100644
index 022767385..000000000
--- a/providers/dns/route53/mock_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package route53
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/stretchr/testify/require"
-)
-
-// MockResponse represents a predefined response used by a mock server.
-type MockResponse struct {
- StatusCode int
- Body string
-}
-
-// MockResponseMap maps request paths to responses.
-type MockResponseMap map[string]MockResponse
-
-func setupTest(t *testing.T, responses MockResponseMap) string {
- t.Helper()
-
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- path := r.URL.Path
- resp, ok := responses[path]
- if !ok {
- resp, ok = responses[r.RequestURI]
- if !ok {
- msg := fmt.Sprintf("Requested path not found in response map: %s", path)
- require.FailNow(t, msg)
- }
- }
-
- w.Header().Set("Content-Type", "application/xml")
- w.WriteHeader(resp.StatusCode)
- _, err := w.Write([]byte(resp.Body))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- time.Sleep(100 * time.Millisecond)
-
- return server.URL
-}
diff --git a/providers/dns/route53/route53.go b/providers/dns/route53/route53.go
index c0a3146a0..b41c95dac 100644
--- a/providers/dns/route53/route53.go
+++ b/providers/dns/route53/route53.go
@@ -17,10 +17,12 @@ import (
"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/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/platform/wait"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
)
// Environment variables names.
@@ -34,6 +36,7 @@ const (
EnvMaxRetries = envNamespace + "MAX_RETRIES"
EnvAssumeRoleArn = envNamespace + "ASSUME_ROLE_ARN"
EnvExternalID = envNamespace + "EXTERNAL_ID"
+ EnvPrivateZone = envNamespace + "PRIVATE_ZONE"
EnvWaitForRecordSetsChanged = envNamespace + "WAIT_FOR_RECORD_SETS_CHANGED"
@@ -57,6 +60,7 @@ type Config struct {
MaxRetries int
AssumeRoleArn string
ExternalID string
+ PrivateZone bool
WaitForRecordSetsChanged bool
@@ -74,6 +78,7 @@ func NewDefaultConfig() *Config {
MaxRetries: env.GetOrDefaultInt(EnvMaxRetries, 5),
AssumeRoleArn: env.GetOrDefaultString(EnvAssumeRoleArn, ""),
ExternalID: env.GetOrDefaultString(EnvExternalID, ""),
+ PrivateZone: env.GetOrDefaultBool(EnvPrivateZone, false),
WaitForRecordSetsChanged: env.GetOrDefaultBool(EnvWaitForRecordSetsChanged, true),
@@ -150,8 +155,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
realValue := `"` + info.Value + `"`
var found bool
+
for _, record := range records {
- if deref(record.Value) == realValue {
+ if ptr.Deref(record.Value) == realValue {
found = true
}
}
@@ -195,8 +201,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
var nonLegoRecords []awstypes.ResourceRecord
+
for _, record := range existingRecords {
- if deref(record.Value) != `"`+info.Value+`"` {
+ if ptr.Deref(record.Value) != `"`+info.Value+`"` {
nonLegoRecords = append(nonLegoRecords, record)
}
}
@@ -245,18 +252,22 @@ func (d *DNSProvider) changeRecord(ctx context.Context, action awstypes.ChangeAc
changeID := resp.ChangeInfo.Id
if d.config.WaitForRecordSetsChanged {
- return wait.For("route53", d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) {
- resp, err := d.client.GetChange(ctx, &route53.GetChangeInput{Id: changeID})
- if err != nil {
- return false, fmt.Errorf("failed to query change status: %w", err)
- }
+ return wait.Retry(ctx,
+ func() error {
+ resp, err := d.client.GetChange(ctx, &route53.GetChangeInput{Id: changeID})
+ if err != nil {
+ return fmt.Errorf("failed to query change status: %w", err)
+ }
- if resp.ChangeInfo.Status == awstypes.ChangeStatusInsync {
- return true, nil
- }
+ if resp.ChangeInfo.Status != awstypes.ChangeStatusInsync {
+ return fmt.Errorf("unable to retrieve change: ID=%s, status=%s", ptr.Deref(changeID), resp.ChangeInfo.Status)
+ }
- return false, fmt.Errorf("unable to retrieve change: ID=%s", deref(changeID))
- })
+ return nil
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
+ backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
+ )
}
return nil
@@ -281,7 +292,7 @@ func (d *DNSProvider) getExistingRecordSets(ctx context.Context, hostedZoneID, f
var records []awstypes.ResourceRecord
for _, recordSet := range recordSetsOutput.ResourceRecordSets {
- if deref(recordSet.Name) == fqdn {
+ if ptr.Deref(recordSet.Name) == fqdn {
records = append(records, recordSet.ResourceRecords...)
}
}
@@ -303,16 +314,18 @@ func (d *DNSProvider) getHostedZoneID(ctx context.Context, fqdn string) (string,
reqParams := &route53.ListHostedZonesByNameInput{
DNSName: aws.String(dns01.UnFqdn(authZone)),
}
+
resp, err := d.client.ListHostedZonesByName(ctx, reqParams)
if err != nil {
return "", err
}
var hostedZoneID string
+
for _, hostedZone := range resp.HostedZones {
// .Name has a trailing dot
- if !hostedZone.Config.PrivateZone && deref(hostedZone.Name) == authZone {
- hostedZoneID = deref(hostedZone.Id)
+ if ptr.Deref(hostedZone.Name) == authZone && d.config.PrivateZone == hostedZone.Config.PrivateZone {
+ hostedZoneID = ptr.Deref(hostedZone.Id)
break
}
}
@@ -341,12 +354,10 @@ func createAWSConfig(ctx context.Context, config *Config) (aws.Config, error) {
// 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
- }
+ retryCount := min(attempt, 7)
delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200)
+
return time.Duration(delay) * time.Millisecond, nil
})
})
@@ -394,12 +405,3 @@ func createAWSConfigCheckParams(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/dns/route53/route53.toml b/providers/dns/route53/route53.toml
index 53c1d61d1..607d9ef31 100644
--- a/providers/dns/route53/route53.toml
+++ b/providers/dns/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 \
-lego --email you@example.com --dns route53 -d '*.example.com' -d example.com run
+lego --dns route53 -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -133,11 +133,12 @@ Replace `Z11111112222222333333` with your hosted zone ID and `example.com` with
AWS_EXTERNAL_ID = "Managed by STS AssumeRole API operation (`AWS_EXTERNAL_ID_FILE` is not supported)"
AWS_WAIT_FOR_RECORD_SETS_CHANGED = "Wait for changes to be INSYNC (it can be unstable)"
[Configuration.Additional]
+ AWS_PRIVATE_ZONE = "Set to true to use private zones only (default: use public zones only)"
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"
- AWS_POLLING_INTERVAL = "Time between DNS propagation check"
- AWS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- AWS_TTL = "The TTL of the TXT record used for the DNS challenge"
+ AWS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)"
+ AWS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ AWS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)"
[Links]
API = "https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html"
diff --git a/providers/dns/route53/route53_integration_test.go b/providers/dns/route53/route53_integration_test.go
index 2fbcf5206..b80294013 100644
--- a/providers/dns/route53/route53_integration_test.go
+++ b/providers/dns/route53/route53_integration_test.go
@@ -1,12 +1,12 @@
package route53
import (
- "context"
"testing"
"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/go-acme/lego/v4/providers/dns/internal/ptr"
"github.com/stretchr/testify/require"
)
@@ -28,7 +28,7 @@ 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 + "."
- ctx := context.Background()
+ ctx := t.Context()
cfg, err := awsconfig.LoadDefaultConfig(ctx)
require.NoError(t, err)
@@ -42,7 +42,7 @@ func TestLiveTTL(t *testing.T) {
}
}()
- zoneID, err := provider.getHostedZoneID(context.Background(), fqdn)
+ zoneID, err := provider.getHostedZoneID(t.Context(), fqdn)
require.NoError(t, err)
params := &route53.ListResourceRecordSetsInput{
@@ -52,7 +52,7 @@ func TestLiveTTL(t *testing.T) {
require.NoError(t, err)
for _, v := range resp.ResourceRecordSets {
- if deref(v.Name) == fqdn && v.Type == "TXT" && deref(v.TTL) == 10 {
+ if ptr.Deref(v.Name) == fqdn && v.Type == "TXT" && ptr.Deref(v.TTL) == 10 {
return
}
}
diff --git a/providers/dns/route53/route53_test.go b/providers/dns/route53/route53_test.go
index 1c835ac37..41ed824bc 100644
--- a/providers/dns/route53/route53_test.go
+++ b/providers/dns/route53/route53_test.go
@@ -1,7 +1,7 @@
package route53
import (
- "context"
+ "net/http/httptest"
"os"
"testing"
"time"
@@ -11,6 +11,7 @@ import (
"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/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -23,6 +24,7 @@ var envTest = tester.NewEnvTest(
EnvRegion,
EnvHostedZoneID,
EnvMaxRetries,
+ EnvPrivateZone,
EnvTTL,
EnvPropagationTimeout,
EnvPollingInterval,
@@ -30,31 +32,16 @@ var envTest = tester.NewEnvTest(
WithDomain(envDomain).
WithLiveTestRequirements(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion, envDomain)
-func makeTestProvider(t *testing.T, serverURL string) *DNSProvider {
- t.Helper()
-
- cfg := aws.Config{
- Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "),
- Region: "mock-region",
- BaseEndpoint: aws.String(serverURL),
- RetryMaxAttempts: 1,
- }
-
- return &DNSProvider{
- client: route53.NewFromConfig(cfg),
- config: NewDefaultConfig(),
- }
-}
-
func Test_loadCredentials_FromEnv(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
_ = os.Setenv(EnvAccessKeyID, "123")
_ = os.Setenv(EnvSecretAccessKey, "456")
_ = os.Setenv(EnvRegion, "us-east-1")
- ctx := context.Background()
+ ctx := t.Context()
cfg, err := awsconfig.LoadDefaultConfig(ctx)
require.NoError(t, err)
@@ -74,11 +61,12 @@ func Test_loadCredentials_FromEnv(t *testing.T) {
func Test_loadRegion_FromEnv(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
_ = os.Setenv(EnvRegion, "foo")
- cfg, err := awsconfig.LoadDefaultConfig(context.Background())
+ cfg, err := awsconfig.LoadDefaultConfig(t.Context())
require.NoError(t, err)
assert.Equal(t, "foo", cfg.Region, "Region")
@@ -86,6 +74,7 @@ func Test_loadRegion_FromEnv(t *testing.T) {
func Test_getHostedZoneID_FromEnv(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
expectedZoneID := "zoneID"
@@ -95,8 +84,8 @@ func Test_getHostedZoneID_FromEnv(t *testing.T) {
provider, err := NewDNSProvider()
require.NoError(t, err)
- hostedZoneID, err := provider.getHostedZoneID(context.Background(), "whatever")
- require.NoError(t, err, "HostedZoneID")
+ hostedZoneID, err := provider.getHostedZoneID(t.Context(), "whatever")
+ require.NoError(t, err)
assert.Equal(t, expectedZoneID, hostedZoneID)
}
@@ -142,6 +131,7 @@ func TestNewDefaultConfig(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
envTest.ClearEnv()
+
for key, value := range test.envVars {
_ = os.Setenv(key, value)
}
@@ -154,27 +144,50 @@ 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/hostedzone/ABCDEFG/rrset?name=_acme-challenge.example.com.&type=TXT": {
- StatusCode: 200,
- Body: "",
- },
- }
-
- serverURL := setupTest(t, mockResponses)
-
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
- provider := makeTestProvider(t, serverURL)
+
+ provider := servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ cfg := aws.Config{
+ HTTPClient: server.Client(),
+ Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "),
+ Region: "mock-region",
+ BaseEndpoint: aws.String(server.URL),
+ RetryMaxAttempts: 1,
+ }
+
+ return &DNSProvider{
+ client: route53.NewFromConfig(cfg),
+ config: NewDefaultConfig(),
+ }, nil
+ },
+ ).
+ Route("GET /2013-04-01/hostedzonesbyname",
+ servermock.ResponseFromFixture("listHostedZonesByNameResponse.xml").
+ WithHeader("Content-Type", "application/xml"),
+ servermock.CheckQueryParameter().Strict().
+ With("dnsname", "example.com")).
+ Route("POST /2013-04-01/hostedzone/ABCDEFG/rrset",
+ servermock.ResponseFromFixture("changeResourceRecordSetsResponse.xml").
+ WithHeader("Content-Type", "application/xml")).
+ Route("GET /2013-04-01/change/123456",
+ servermock.ResponseFromFixture("getChangeResponse.xml").
+ WithHeader("Content-Type", "application/xml")).
+ Route("GET /2013-04-01/hostedzone/ABCDEFG/rrset",
+ servermock.Noop().
+ WithHeader("Content-Type", "application/xml"),
+ servermock.CheckQueryParameter().Strict().
+ With("name", "_acme-challenge.example.com.").
+ With("type", "TXT")).
+ Build(t)
domain := "example.com"
keyAuth := "123456d=="
err := provider.Present(domain, "", keyAuth)
- require.NoError(t, err, "Expected Present to return no error")
+ require.NoError(t, err)
}
func Test_createAWSConfig(t *testing.T) {
@@ -263,11 +276,12 @@ func Test_createAWSConfig(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.env)
- ctx := context.Background()
+ ctx := t.Context()
cfg, err := createAWSConfig(ctx, test.config)
requireErr(t, err, test.wantErr)
diff --git a/providers/dns/safedns/internal/client.go b/providers/dns/safedns/internal/client.go
index 3e6f99919..628618032 100644
--- a/providers/dns/safedns/internal/client.go
+++ b/providers/dns/safedns/internal/client.go
@@ -19,7 +19,7 @@ const defaultBaseURL = "https://api.ukfast.io/safedns/v1"
const authorizationHeader = "Authorization"
-// Client the UKFast SafeDNS client.
+// Client the ANS SafeDNS client.
type Client struct {
authToken string
@@ -48,6 +48,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("add record: %w", err)
@@ -132,6 +133,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/safedns/internal/client_test.go b/providers/dns/safedns/internal/client_test.go
index 6709277cd..161a9f078 100644
--- a/providers/dns/safedns/internal/client_test.go
+++ b/providers/dns/safedns/internal/client_test.go
@@ -1,75 +1,37 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "strings"
"testing"
"github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("secret")
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestClient_AddRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/zones/example.com/records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- if req.Header.Get(authorizationHeader) != "secret" {
- http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
- return
- }
-
- reqBody, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- expectedReqBody := `{"name":"_acme-challenge.example.com","type":"TXT","content":"\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"","ttl":120}`
- if strings.TrimSpace(string(reqBody)) != expectedReqBody {
- http.Error(rw, `{"message":"invalid request"}`, http.StatusBadRequest)
- return
- }
-
- resp := `{
- "data": {
- "id": 1234567
- },
- "meta": {
- "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567"
- }
- }`
-
- rw.WriteHeader(http.StatusCreated)
- _, err = fmt.Fprint(rw, resp)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /zones/example.com/records",
+ servermock.ResponseFromFixture("add_record.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")).
+ Build(t)
record := Record{
Name: "_acme-challenge.example.com",
@@ -78,7 +40,7 @@ func TestClient_AddRecord(t *testing.T) {
TTL: dns01.DefaultTTL,
}
- response, err := client.AddRecord(context.Background(), "example.com", record)
+ response, err := client.AddRecord(t.Context(), "example.com", record)
require.NoError(t, err)
expected := &AddRecordResponse{
@@ -97,23 +59,42 @@ func TestClient_AddRecord(t *testing.T) {
assert.Equal(t, expected, response)
}
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /zones/example.com/records",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ record := Record{
+ Name: "_acme-challenge.example.com",
+ Type: "TXT",
+ Content: `"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"`,
+ TTL: dns01.DefaultTTL,
+ }
+
+ _, err := client.AddRecord(t.Context(), "example.com", record)
+ require.EqualError(t, err, "add record: [status code: 401] Unauthenticated")
+}
+
func TestClient_RemoveRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /zones/example.com/records/1234567",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
- mux.HandleFunc("/zones/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- if req.Header.Get(authorizationHeader) != "secret" {
- http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(http.StatusNoContent)
- })
-
- err := client.RemoveRecord(context.Background(), "example.com", 1234567)
+ err := client.RemoveRecord(t.Context(), "example.com", 1234567)
require.NoError(t, err)
}
+
+func TestClient_RemoveRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /zones/example.com/records/1234567",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ err := client.RemoveRecord(t.Context(), "example.com", 1234567)
+ require.EqualError(t, err, "remove record: [status code: 401] Unauthenticated")
+}
diff --git a/providers/dns/safedns/internal/fixtures/add_record-request.json b/providers/dns/safedns/internal/fixtures/add_record-request.json
new file mode 100644
index 000000000..71c8813f2
--- /dev/null
+++ b/providers/dns/safedns/internal/fixtures/add_record-request.json
@@ -0,0 +1,6 @@
+{
+ "name": "_acme-challenge.example.com",
+ "type": "TXT",
+ "content": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"",
+ "ttl": 120
+}
diff --git a/providers/dns/safedns/internal/fixtures/add_record.json b/providers/dns/safedns/internal/fixtures/add_record.json
new file mode 100644
index 000000000..f3c4ad883
--- /dev/null
+++ b/providers/dns/safedns/internal/fixtures/add_record.json
@@ -0,0 +1,8 @@
+{
+ "data": {
+ "id": 1234567
+ },
+ "meta": {
+ "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567"
+ }
+}
diff --git a/providers/dns/safedns/internal/fixtures/error.json b/providers/dns/safedns/internal/fixtures/error.json
new file mode 100644
index 000000000..47fb5916c
--- /dev/null
+++ b/providers/dns/safedns/internal/fixtures/error.json
@@ -0,0 +1,3 @@
+{
+ "message": "Unauthenticated"
+}
diff --git a/providers/dns/safedns/safedns.go b/providers/dns/safedns/safedns.go
index 5066db59f..154cfc5ee 100644
--- a/providers/dns/safedns/safedns.go
+++ b/providers/dns/safedns/safedns.go
@@ -1,4 +1,4 @@
-// Package safedns implements a DNS provider for solving the DNS-01 challenge using UKFast SafeDNS.
+// Package safedns implements a DNS provider for solving the DNS-01 challenge using ANS SafeDNS.
package safedns
import (
@@ -12,7 +12,9 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/safedns/internal"
+ "github.com/miekg/dns"
)
// Environment variables.
@@ -73,7 +75,7 @@ func NewDNSProvider() (*DNSProvider, error) {
return NewDNSProviderConfig(config)
}
-// NewDNSProviderConfig return a DNSProvider instance configured for UKFast SafeDNS.
+// NewDNSProviderConfig return a DNSProvider instance configured for ANS SafeDNS.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("safedns: supplied configuration was nil")
@@ -89,6 +91,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -106,7 +110,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN))
+ zone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN))
if err != nil {
return fmt.Errorf("safedns: could not find zone for domain %q: %w", domain, err)
}
@@ -142,6 +146,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("safedns: unknown record ID for '%s'", info.EffectiveFQDN)
}
diff --git a/providers/dns/safedns/safedns.toml b/providers/dns/safedns/safedns.toml
index 11b2a289c..f387f2535 100644
--- a/providers/dns/safedns/safedns.toml
+++ b/providers/dns/safedns/safedns.toml
@@ -1,22 +1,22 @@
-Name = "UKFast SafeDNS"
+Name = "ANS SafeDNS"
Description = ''''''
-URL = "https://www.ukfast.co.uk/dns-hosting.html"
+URL = "https://www.ans.co.uk/"
Code = "safedns"
Since = "v4.6.0"
Example = '''
SAFEDNS_AUTH_TOKEN=xxxxxx \
-lego --email you@example.com --dns safedns -d '*.example.com' -d example.com run
+lego --dns safedns -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
SAFEDNS_AUTH_TOKEN = "Authentication token"
[Configuration.Additional]
- SAFEDNS_POLLING_INTERVAL = "Time between DNS propagation check"
- SAFEDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SAFEDNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- SAFEDNS_HTTP_TIMEOUT = "API request timeout"
+ SAFEDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ SAFEDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ SAFEDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SAFEDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developers.ukfast.io/documentation/safedns"
diff --git a/providers/dns/safedns/safedns_test.go b/providers/dns/safedns/safedns_test.go
index dcb374718..ce7568056 100644
--- a/providers/dns/safedns/safedns_test.go
+++ b/providers/dns/safedns/safedns_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/sakuracloud/sakuracloud.go b/providers/dns/sakuracloud/sakuracloud.go
index 498f76c42..1adbe3a88 100644
--- a/providers/dns/sakuracloud/sakuracloud.go
+++ b/providers/dns/sakuracloud/sakuracloud.go
@@ -2,17 +2,21 @@
package sakuracloud
import (
+ "context"
"errors"
"fmt"
"net/http"
+ "strings"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
client "github.com/sacloud/api-client-go"
"github.com/sacloud/iaas-api-go"
+ "github.com/sacloud/iaas-api-go/defaults"
"github.com/sacloud/iaas-api-go/helper/api"
)
@@ -98,13 +102,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
Options: &client.Options{
AccessToken: config.Token,
AccessTokenSecret: config.Secret,
- HttpClient: config.HTTPClient,
+ HttpClient: clientdebug.Wrap(config.HTTPClient),
UserAgent: fmt.Sprintf("%s %s", iaas.DefaultUserAgent, useragent.Get()),
},
}
return &DNSProvider{
- client: iaas.NewDNSOp(api.NewCallerWithOptions(api.MergeOptions(defaultOption, options))),
+ client: iaas.NewDNSOp(newCallerWithOptions(api.MergeOptions(defaultOption, options))),
config: config,
}, nil
}
@@ -113,7 +117,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- err := d.addTXTRecord(info.EffectiveFQDN, info.Value, d.config.TTL)
+ err := d.addTXTRecord(context.Background(), info.EffectiveFQDN, info.Value, d.config.TTL)
if err != nil {
return fmt.Errorf("sakuracloud: %w", err)
}
@@ -125,7 +129,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- err := d.cleanupTXTRecord(info.EffectiveFQDN, info.Value)
+ err := d.cleanupTXTRecord(context.Background(), info.EffectiveFQDN, info.Value)
if err != nil {
return fmt.Errorf("sakuracloud: %w", err)
}
@@ -138,3 +142,38 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
+
+// Extracted from https://github.com/sacloud/iaas-api-go/blob/af06b3ccc2c38625d2dc684ad39590d0ae13eed3/helper/api/caller.go#L36-L81
+// Trace and fake are removed.
+// Related to https://github.com/sacloud/iaas-api-go/issues/376.
+func newCallerWithOptions(opts *api.CallerOptions) iaas.APICaller {
+ return newCaller(opts)
+}
+
+func newCaller(opts *api.CallerOptions) iaas.APICaller {
+ if opts.UserAgent == "" {
+ opts.UserAgent = iaas.DefaultUserAgent
+ }
+
+ caller := iaas.NewClientWithOptions(opts.Options)
+
+ defaults.DefaultStatePollingTimeout = 72 * time.Hour
+
+ if opts.DefaultZone != "" {
+ iaas.APIDefaultZone = opts.DefaultZone
+ }
+
+ if len(opts.Zones) > 0 {
+ iaas.SakuraCloudZones = opts.Zones
+ }
+
+ if opts.APIRootURL != "" {
+ if strings.HasSuffix(opts.APIRootURL, "/") {
+ opts.APIRootURL = strings.TrimRight(opts.APIRootURL, "/")
+ }
+
+ iaas.SakuraCloudAPIRoot = opts.APIRootURL
+ }
+
+ return caller
+}
diff --git a/providers/dns/sakuracloud/sakuracloud.toml b/providers/dns/sakuracloud/sakuracloud.toml
index f86f215e5..a197cd27c 100644
--- a/providers/dns/sakuracloud/sakuracloud.toml
+++ b/providers/dns/sakuracloud/sakuracloud.toml
@@ -7,7 +7,7 @@ Since = "v1.1.0"
Example = '''
SAKURACLOUD_ACCESS_TOKEN=xxxxx \
SAKURACLOUD_ACCESS_TOKEN_SECRET=yyyyy \
-lego --email you@example.com --dns sakuracloud -d '*.example.com' -d example.com run
+lego --dns sakuracloud -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns sakuracloud -d '*.example.com' -d example.com
SAKURACLOUD_ACCESS_TOKEN = "Access token"
SAKURACLOUD_ACCESS_TOKEN_SECRET = "Access token secret"
[Configuration.Additional]
- SAKURACLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- SAKURACLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SAKURACLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
- SAKURACLOUD_HTTP_TIMEOUT = "API request timeout"
+ SAKURACLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ SAKURACLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ SAKURACLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SAKURACLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://developer.sakura.ad.jp/cloud/api/1.1/"
diff --git a/providers/dns/sakuracloud/sakuracloud_test.go b/providers/dns/sakuracloud/sakuracloud_test.go
index 93cf20ea1..789a27544 100644
--- a/providers/dns/sakuracloud/sakuracloud_test.go
+++ b/providers/dns/sakuracloud/sakuracloud_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/sakuracloud/wrapper.go b/providers/dns/sakuracloud/wrapper.go
index a74478f6c..ff0b78e09 100644
--- a/providers/dns/sakuracloud/wrapper.go
+++ b/providers/dns/sakuracloud/wrapper.go
@@ -14,11 +14,11 @@ import (
// see: https://github.com/go-acme/lego/pull/850
var mu sync.Mutex
-func (d *DNSProvider) addTXTRecord(fqdn, value string, ttl int) error {
+func (d *DNSProvider) addTXTRecord(ctx context.Context, fqdn, value string, ttl int) error {
mu.Lock()
defer mu.Unlock()
- zone, err := d.getHostedZone(fqdn)
+ zone, err := d.getHostedZone(ctx, fqdn)
if err != nil {
return err
}
@@ -35,7 +35,7 @@ func (d *DNSProvider) addTXTRecord(fqdn, value string, ttl int) error {
TTL: ttl,
})
- _, err = d.client.UpdateSettings(context.Background(), zone.ID, &iaas.DNSUpdateSettingsRequest{
+ _, err = d.client.UpdateSettings(ctx, zone.ID, &iaas.DNSUpdateSettingsRequest{
Records: records,
SettingsHash: zone.SettingsHash,
})
@@ -46,11 +46,11 @@ func (d *DNSProvider) addTXTRecord(fqdn, value string, ttl int) error {
return nil
}
-func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error {
+func (d *DNSProvider) cleanupTXTRecord(ctx context.Context, fqdn, value string) error {
mu.Lock()
defer mu.Unlock()
- zone, err := d.getHostedZone(fqdn)
+ zone, err := d.getHostedZone(ctx, fqdn)
if err != nil {
return err
}
@@ -61,8 +61,9 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error {
}
var updRecords iaas.DNSRecords
+
for _, r := range zone.Records {
- if !(r.Name == subDomain && r.Type == "TXT" && r.RData == value) {
+ if !(r.Name == subDomain && r.Type == "TXT" && r.RData == value) { //nolint:staticcheck // Clearer without De Morgan's law.
updRecords = append(updRecords, r)
}
}
@@ -71,7 +72,8 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error {
Records: updRecords,
SettingsHash: zone.SettingsHash,
}
- _, err = d.client.UpdateSettings(context.Background(), zone.ID, settings)
+
+ _, err = d.client.UpdateSettings(ctx, zone.ID, settings)
if err != nil {
return fmt.Errorf("API call failed: %w", err)
}
@@ -79,7 +81,7 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error {
return nil
}
-func (d *DNSProvider) getHostedZone(domain string) (*iaas.DNS, error) {
+func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*iaas.DNS, error) {
authZone, err := dns01.FindZoneByFqdn(domain)
if err != nil {
return nil, fmt.Errorf("could not find zone: %w", err)
@@ -93,7 +95,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*iaas.DNS, error) {
},
}
- res, err := d.client.Find(context.Background(), conditions)
+ res, err := d.client.Find(ctx, conditions)
if err != nil {
if iaas.IsNotFoundError(err) {
return nil, fmt.Errorf("zone %s not found on SakuraCloud DNS: %w", zoneName, err)
diff --git a/providers/dns/sakuracloud/wrapper_test.go b/providers/dns/sakuracloud/wrapper_test.go
index 91cd3ce0a..7432c67a6 100644
--- a/providers/dns/sakuracloud/wrapper_test.go
+++ b/providers/dns/sakuracloud/wrapper_test.go
@@ -1,7 +1,6 @@
package sakuracloud
import (
- "context"
"fmt"
"sync"
"testing"
@@ -33,7 +32,7 @@ func fakeCaller() iaas.APICaller {
func createDummyZone(t *testing.T, caller iaas.APICaller) {
t.Helper()
- ctx := context.Background()
+ ctx := t.Context()
dnsOp := iaas.NewDNSOp(caller)
@@ -45,12 +44,13 @@ func createDummyZone(t *testing.T, caller iaas.APICaller) {
if zone.Name == "example.com" {
err = dnsOp.Delete(ctx, zone.ID)
require.NoError(t, err)
+
break
}
}
// create dummy zone
- _, err = iaas.NewDNSOp(caller).Create(context.Background(), &iaas.DNSCreateRequest{Name: "example.com"})
+ _, err = iaas.NewDNSOp(caller).Create(t.Context(), &iaas.DNSCreateRequest{Name: "example.com"})
require.NoError(t, err)
}
@@ -65,10 +65,12 @@ func TestDNSProvider_addAndCleanupRecords(t *testing.T) {
require.NoError(t, err)
t.Run("addTXTRecord", func(t *testing.T) {
- err = p.addTXTRecord("test.example.com.", "dummyValue", 10)
+ ctx := t.Context()
+
+ err = p.addTXTRecord(ctx, "test.example.com.", "dummyValue", 10)
require.NoError(t, err)
- updZone, e := p.getHostedZone("test.example.com.")
+ updZone, e := p.getHostedZone(ctx, "test.example.com.")
require.NoError(t, e)
require.NotNil(t, updZone)
@@ -76,10 +78,12 @@ func TestDNSProvider_addAndCleanupRecords(t *testing.T) {
})
t.Run("cleanupTXTRecord", func(t *testing.T) {
- err = p.cleanupTXTRecord("test.example.com.", "dummyValue")
+ ctx := t.Context()
+
+ err = p.cleanupTXTRecord(ctx, "test.example.com.", "dummyValue")
require.NoError(t, err)
- updZone, e := p.getHostedZone("test.example.com.")
+ updZone, e := p.getHostedZone(ctx, "test.example.com.")
require.NoError(t, e)
require.NotNil(t, updZone)
@@ -93,6 +97,7 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) {
dummyRecordCount := 10
var providers []*DNSProvider
+
for range dummyRecordCount {
config := NewDefaultConfig()
config.Token = "token3"
@@ -109,9 +114,11 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) {
t.Run("addTXTRecord", func(t *testing.T) {
wg.Add(len(providers))
+ ctx := t.Context()
+
for i, p := range providers {
go func(j int, client *DNSProvider) {
- err := client.addTXTRecord(fmt.Sprintf("test%d.example.com.", j), "dummyValue", 10)
+ err := client.addTXTRecord(ctx, fmt.Sprintf("test%d.example.com.", j), "dummyValue", 10)
require.NoError(t, err)
wg.Done()
}(i, p)
@@ -119,7 +126,7 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) {
wg.Wait()
- updZone, err := providers[0].getHostedZone("example.com.")
+ updZone, err := providers[0].getHostedZone(ctx, "example.com.")
require.NoError(t, err)
require.NotNil(t, updZone)
@@ -129,9 +136,11 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) {
t.Run("cleanupTXTRecord", func(t *testing.T) {
wg.Add(len(providers))
+ ctx := t.Context()
+
for i, p := range providers {
go func(i int, client *DNSProvider) {
- err := client.cleanupTXTRecord(fmt.Sprintf("test%d.example.com.", i), "dummyValue")
+ err := client.cleanupTXTRecord(ctx, fmt.Sprintf("test%d.example.com.", i), "dummyValue")
require.NoError(t, err)
wg.Done()
}(i, p)
@@ -139,7 +148,7 @@ func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) {
wg.Wait()
- updZone, err := providers[0].getHostedZone("example.com.")
+ updZone, err := providers[0].getHostedZone(ctx, "example.com.")
require.NoError(t, err)
require.NotNil(t, updZone)
diff --git a/providers/dns/scaleway/scaleway.go b/providers/dns/scaleway/scaleway.go
index 5976e77a2..9d08f93b9 100644
--- a/providers/dns/scaleway/scaleway.go
+++ b/providers/dns/scaleway/scaleway.go
@@ -5,6 +5,7 @@ package scaleway
import (
"errors"
"fmt"
+ "net/http"
"strconv"
"strings"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
scwdomain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1"
"github.com/scaleway/scaleway-sdk-go/scw"
@@ -32,6 +34,7 @@ const (
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
const (
@@ -47,12 +50,14 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- ProjectID string
- Token string // TODO(ldez) rename to SecretKey in the next major.
- AccessKey string
+ ProjectID string
+ Token string // TODO(ldez) rename to SecretKey in the next major.
+ AccessKey string
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
+ HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -62,6 +67,9 @@ func NewDefaultConfig() *Config {
TTL: env.GetOneWithFallback(EnvTTL, minTTL, strconv.Atoi, altEnvName(EnvTTL)),
PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, defaultPropagationTimeout, env.ParseSecond, altEnvName(EnvPropagationTimeout)),
PollingInterval: env.GetOneWithFallback(EnvPollingInterval, defaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
}
}
@@ -107,6 +115,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
scw.WithUserAgent(useragent.Get()),
}
+ if config.HTTPClient != nil {
+ configuration = append(configuration, scw.WithHTTPClient(clientdebug.Wrap(config.HTTPClient)))
+ }
+
if config.ProjectID != "" {
configuration = append(configuration, scw.WithDefaultProjectID(config.ProjectID))
}
diff --git a/providers/dns/scaleway/scaleway.toml b/providers/dns/scaleway/scaleway.toml
index a13a34d22..8b556e8b1 100644
--- a/providers/dns/scaleway/scaleway.toml
+++ b/providers/dns/scaleway/scaleway.toml
@@ -6,7 +6,7 @@ Since = "v3.4.0"
Example = '''
SCW_SECRET_KEY=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \
-lego --email you@example.com --dns scaleway -d '*.example.com' -d example.com run
+lego --dns scaleway -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,9 +15,10 @@ lego --email you@example.com --dns scaleway -d '*.example.com' -d example.com ru
SCW_PROJECT_ID = "Project to use (optional)"
[Configuration.Additional]
SCW_ACCESS_KEY = "Access key"
- SCW_POLLING_INTERVAL = "Time between DNS propagation check"
- SCW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SCW_TTL = "The TTL of the TXT record used for the DNS challenge"
+ SCW_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ SCW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ SCW_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ SCW_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developers.scaleway.com/en/products/domain/dns/api/"
diff --git a/providers/dns/scaleway/scaleway_test.go b/providers/dns/scaleway/scaleway_test.go
index bf950e84e..b683d751a 100644
--- a/providers/dns/scaleway/scaleway_test.go
+++ b/providers/dns/scaleway/scaleway_test.go
@@ -41,6 +41,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -105,6 +106,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -118,6 +120,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/selectel/selectel.go b/providers/dns/selectel/selectel.go
index 744523230..63ddd81ac 100644
--- a/providers/dns/selectel/selectel.go
+++ b/providers/dns/selectel/selectel.go
@@ -4,11 +4,9 @@
package selectel
import (
- "context"
"errors"
"fmt"
"net/http"
- "net/url"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -30,27 +28,18 @@ const (
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
-const minTTL = 60
-
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
-type Config struct {
- BaseURL string
- Token string
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = selectel.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- BaseURL: env.GetOrDefaultString(EnvBaseURL, selectel.DefaultSelectelBaseURL),
- TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
+ BaseURL: env.GetOrDefaultString(EnvBaseURL, ""),
+ TTL: env.GetOrDefaultInt(EnvTTL, selectel.MinTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -59,8 +48,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *selectel.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for Selectel Domains API.
@@ -83,53 +71,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("selectel: the configuration of the DNS provider is nil")
}
- if config.Token == "" {
- return nil, errors.New("selectel: credentials missing")
- }
-
- if config.TTL < minTTL {
- return nil, fmt.Errorf("selectel: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
- }
-
- client := selectel.NewClient(config.Token)
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- var err error
- client.BaseURL, err = url.Parse(config.BaseURL)
+ provider, err := selectel.NewDNSProviderConfig(config)
if err != nil {
return nil, fmt.Errorf("selectel: %w", err)
}
- return &DNSProvider{config: config, client: client}, nil
+ return &DNSProvider{prv: provider}, 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 DNS-01 challenge.
+// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- // TODO(ldez) replace domain by FQDN to follow CNAME.
- domainObj, err := d.client.GetDomainByName(ctx, domain)
- if err != nil {
- return fmt.Errorf("selectel: %w", err)
- }
-
- txtRecord := selectel.Record{
- Type: "TXT",
- TTL: d.config.TTL,
- Name: info.EffectiveFQDN,
- Content: info.Value,
- }
- _, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord)
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("selectel: %w", err)
}
@@ -137,35 +89,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return nil
}
-// CleanUp removes a TXT record used for DNS-01 challenge.
+// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- recordName := dns01.UnFqdn(info.EffectiveFQDN)
-
- ctx := context.Background()
-
- // TODO(ldez) replace domain by FQDN to follow CNAME.
- domainObj, err := d.client.GetDomainByName(ctx, domain)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("selectel: %w", err)
}
- records, err := d.client.ListRecords(ctx, domainObj.ID)
- if err != nil {
- return fmt.Errorf("selectel: %w", err)
- }
-
- // Delete records with specific FQDN
- var lastErr error
- for _, record := range records {
- if record.Name == recordName {
- err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID)
- if err != nil {
- lastErr = fmt.Errorf("selectel: %w", err)
- }
- }
- }
-
- return lastErr
+ 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.prv.Timeout()
}
diff --git a/providers/dns/selectel/selectel.toml b/providers/dns/selectel/selectel.toml
index a37565d4d..087c97b5b 100644
--- a/providers/dns/selectel/selectel.toml
+++ b/providers/dns/selectel/selectel.toml
@@ -6,7 +6,7 @@ Since = "v1.2.0"
Example = '''
SELECTEL_API_TOKEN=xxxxx \
-lego --email you@example.com --dns selectel -d '*.example.com' -d example.com run
+lego --dns selectel -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns selectel -d '*.example.com' -d example.com ru
SELECTEL_API_TOKEN = "API token"
[Configuration.Additional]
SELECTEL_BASE_URL = "API endpoint URL"
- SELECTEL_POLLING_INTERVAL = "Time between DNS propagation check"
- SELECTEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SELECTEL_TTL = "The TTL of the TXT record used for the DNS challenge"
- SELECTEL_HTTP_TIMEOUT = "API request timeout"
+ SELECTEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ SELECTEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ SELECTEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ SELECTEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://kb.selectel.com/23136054.html"
diff --git a/providers/dns/selectel/selectel_test.go b/providers/dns/selectel/selectel_test.go
index 0e2de2dbe..a456f1358 100644
--- a/providers/dns/selectel/selectel_test.go
+++ b/providers/dns/selectel/selectel_test.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/providers/dns/internal/selectel"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -36,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -45,8 +47,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- assert.NotNil(t, p.config)
- assert.NotNil(t, p.client)
+ assert.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -76,7 +77,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
desc: "bad TTL value",
token: "123",
ttl: 59,
- expected: fmt.Sprintf("selectel: invalid TTL, TTL (59) must be greater than %d", minTTL),
+ expected: fmt.Sprintf("selectel: invalid TTL, TTL (59) must be greater than %d", selectel.MinTTL),
},
}
@@ -91,8 +92,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- assert.NotNil(t, p.config)
- assert.NotNil(t, p.client)
+ assert.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -106,6 +106,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -119,6 +120,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/selectelv2/selectelv2.go b/providers/dns/selectelv2/selectelv2.go
index f638b0a3f..1fcb48583 100644
--- a/providers/dns/selectelv2/selectelv2.go
+++ b/providers/dns/selectelv2/selectelv2.go
@@ -11,20 +11,25 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ "github.com/miekg/dns"
selectelapi "github.com/selectel/domains-go/pkg/v2"
- "github.com/selectel/go-selvpcclient/v3/selvpcclient"
+ "github.com/selectel/go-selvpcclient/v4/selvpcclient"
"golang.org/x/net/idna"
)
const (
envNamespace = "SELECTELV2_"
- EnvBaseURL = envNamespace + "BASE_URL"
- EnvUsernameOS = envNamespace + "USERNAME"
- EnvPasswordOS = envNamespace + "PASSWORD"
- EnvAccount = envNamespace + "ACCOUNT_ID"
- EnvProjectID = envNamespace + "PROJECT_ID"
+ EnvBaseURL = envNamespace + "BASE_URL"
+ EnvUsernameOS = envNamespace + "USERNAME"
+ EnvPasswordOS = envNamespace + "PASSWORD"
+ EnvDomainName = envNamespace + "ACCOUNT_ID"
+ EnvProjectID = envNamespace + "PROJECT_ID"
+ EnvAuthRegion = envNamespace + "AUTH_REGION"
+ EnvAuthURL = envNamespace + "AUTH_URL"
+ EnvUserDomainName = envNamespace + "USER_DOMAIN_NAME"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -33,7 +38,12 @@ const (
)
const (
- defaultBaseURL = "https://api.selectel.ru/domains/v2"
+ defaultBaseURL = "https://api.selectel.ru/domains/v2"
+ defaultAuthRegion = "ru-1"
+ defaultAuthURL = "https://cloud.api.selcloud.ru/identity/v3/"
+)
+
+const (
defaultTTL = 60
defaultPropagationTimeout = 120 * time.Second
defaultPollingInterval = 5 * time.Second
@@ -46,11 +56,15 @@ var errNotFound = errors.New("rrset not found")
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- BaseURL string
- Username string
- Password string
- Account string
- ProjectID string
+ BaseURL string
+ Username string
+ Password string
+ DomainName string
+ ProjectID string
+ AuthURL string
+ AuthRegion string
+ UserDomainName string
+
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
@@ -60,7 +74,10 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL),
+ BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL),
+ AuthRegion: env.GetOrDefaultString(EnvAuthRegion, defaultAuthRegion),
+ AuthURL: env.GetOrDefaultString(EnvAuthURL, defaultAuthURL),
+
TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval),
@@ -77,7 +94,7 @@ type DNSProvider struct {
// NewDNSProvider returns a DNSProvider instance configured for Selectel Domains APIv2.
func NewDNSProvider() (*DNSProvider, error) {
- values, err := env.Get(EnvUsernameOS, EnvPasswordOS, EnvAccount, EnvProjectID)
+ values, err := env.Get(EnvUsernameOS, EnvPasswordOS, EnvDomainName, EnvProjectID)
if err != nil {
return nil, fmt.Errorf("selectelv2: %w", err)
}
@@ -85,8 +102,9 @@ func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.Username = values[EnvUsernameOS]
config.Password = values[EnvPasswordOS]
- config.Account = values[EnvAccount]
+ config.DomainName = values[EnvDomainName]
config.ProjectID = values[EnvProjectID]
+ config.UserDomainName = env.GetOrDefaultString(EnvUserDomainName, "")
return NewDNSProviderConfig(config)
}
@@ -105,8 +123,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("selectelv2: missing password")
}
- if config.Account == "" {
- return nil, errors.New("selectelv2: missing account")
+ if config.DomainName == "" {
+ return nil, errors.New("selectelv2: missing account ID")
}
if config.ProjectID == "" {
@@ -117,22 +135,22 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
useragent.SetHeader(headers)
return &DNSProvider{
- baseClient: selectelapi.NewClient(config.BaseURL, config.HTTPClient, headers),
+ baseClient: selectelapi.NewClient(config.BaseURL, clientdebug.Wrap(config.HTTPClient), headers),
config: config,
}, nil
}
// Timeout returns the Timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
-func (p *DNSProvider) Timeout() (timeout, interval time.Duration) {
- return p.config.PropagationTimeout, p.config.PollingInterval
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
}
// Present creates a TXT record to fulfill DNS-01 challenge.
-func (p *DNSProvider) Present(domain, _, keyAuth string) error {
+func (d *DNSProvider) Present(domain, _, keyAuth string) error {
ctx := context.Background()
- client, err := p.authorize()
+ client, err := d.authorize(ctx)
if err != nil {
return fmt.Errorf("selectelv2: authorize: %w", err)
}
@@ -153,7 +171,7 @@ func (p *DNSProvider) Present(domain, _, keyAuth string) error {
newRRSet := &selectelapi.RRSet{
Name: info.EffectiveFQDN,
Type: selectelapi.TXT,
- TTL: p.config.TTL,
+ TTL: d.config.TTL,
Records: []selectelapi.RecordItem{{Content: fmt.Sprintf("%q", info.Value)}},
}
@@ -176,10 +194,10 @@ func (p *DNSProvider) Present(domain, _, keyAuth string) error {
}
// CleanUp removes a TXT record used for DNS-01 challenge.
-func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error {
+func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
ctx := context.Background()
- client, err := p.authorize()
+ client, err := d.authorize(ctx)
if err != nil {
return fmt.Errorf("selectelv2: authorize: %w", err)
}
@@ -220,8 +238,8 @@ func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error {
return nil
}
-func (p *DNSProvider) authorize() (*clientWrapper, error) {
- token, err := obtainOpenstackToken(p.config)
+func (d *DNSProvider) authorize(ctx context.Context) (*clientWrapper, error) {
+ token, err := obtainOpenstackToken(ctx, d.config)
if err != nil {
return nil, err
}
@@ -230,16 +248,20 @@ func (p *DNSProvider) authorize() (*clientWrapper, error) {
extraHeaders.Set(tokenHeader, token)
return &clientWrapper{
- DNSClient: p.baseClient.WithHeaders(extraHeaders),
+ DNSClient: d.baseClient.WithHeaders(extraHeaders),
}, nil
}
-func obtainOpenstackToken(config *Config) (string, error) {
+func obtainOpenstackToken(ctx context.Context, config *Config) (string, error) {
vpcClient, err := selvpcclient.NewClient(&selvpcclient.ClientOptions{
+ Context: ctx,
+ DomainName: config.DomainName,
+ AuthURL: config.AuthURL,
+ AuthRegion: config.AuthRegion,
Username: config.Username,
Password: config.Password,
- UserDomainName: config.Account,
ProjectID: config.ProjectID,
+ UserDomainName: config.UserDomainName,
})
if err != nil {
return "", fmt.Errorf("new VPC client: %w", err)
@@ -266,7 +288,7 @@ func (w *clientWrapper) getZone(ctx context.Context, name string) (*selectelapi.
}
for _, zone := range zones.GetItems() {
- if zone.Name == dns01.ToFqdn(unicodeName) {
+ if zone.Name == dns.Fqdn(unicodeName) {
return zone, nil
}
}
@@ -275,10 +297,10 @@ func (w *clientWrapper) getZone(ctx context.Context, name string) (*selectelapi.
return nil, fmt.Errorf("zone '%s' for challenge has not been found", name)
}
- // -1 can not be returned since if no dots present we exit above
- i := strings.Index(name, ".")
+ // after is always defined since if no dots present we exit above.
+ _, after, _ := strings.Cut(name, ".")
- return w.getZone(ctx, name[i+1:])
+ return w.getZone(ctx, after)
}
func (w *clientWrapper) getRRset(ctx context.Context, name, zoneID string) (*selectelapi.RRSet, error) {
@@ -295,7 +317,7 @@ func (w *clientWrapper) getRRset(ctx context.Context, name, zoneID string) (*sel
}
for _, rrset := range resp.GetItems() {
- if rrset.Name == dns01.ToFqdn(unicodeName) {
+ if rrset.Name == dns.Fqdn(unicodeName) {
return rrset, nil
}
}
diff --git a/providers/dns/selectelv2/selectelv2.toml b/providers/dns/selectelv2/selectelv2.toml
index 4c06949f4..480c7756e 100644
--- a/providers/dns/selectelv2/selectelv2.toml
+++ b/providers/dns/selectelv2/selectelv2.toml
@@ -9,7 +9,7 @@ SELECTELV2_USERNAME=trex \
SELECTELV2_PASSWORD=xxxxx \
SELECTELV2_ACCOUNT_ID=1234567 \
SELECTELV2_PROJECT_ID=111a11111aaa11aa1a11aaa11111aa1a \
-lego --email you@example.com --dns selectelv2 -d '*.example.com' -d example.com run
+lego --dns selectelv2 -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -20,10 +20,13 @@ lego --email you@example.com --dns selectelv2 -d '*.example.com' -d example.com
SELECTELV2_PROJECT_ID = "Cloud project ID (UUID)"
[Configuration.Additional]
SELECTELV2_BASE_URL = "API endpoint URL"
- SELECTELV2_POLLING_INTERVAL = "Time between DNS propagation check"
- SELECTELV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SELECTELV2_TTL = "The TTL of the TXT record used for the DNS challenge"
- SELECTELV2_HTTP_TIMEOUT = "API request timeout"
+ SELECTELV2_AUTH_REGION = "Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1')"
+ SELECTELV2_AUTH_URL = "Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/')"
+ SELECTELV2_USER_DOMAIN_NAME = "To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID)"
+ SELECTELV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ SELECTELV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ SELECTELV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ SELECTELV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developers.selectel.ru/docs/cloud-services/dns_api/dns_api_actual/"
diff --git a/providers/dns/selectelv2/selectelv2_test.go b/providers/dns/selectelv2/selectelv2_test.go
index 4859b9932..2627fa023 100644
--- a/providers/dns/selectelv2/selectelv2_test.go
+++ b/providers/dns/selectelv2/selectelv2_test.go
@@ -11,7 +11,15 @@ import (
const envDomain = envNamespace + "DOMAIN"
-var envTest = tester.NewEnvTest(EnvUsernameOS, EnvPasswordOS, EnvAccount, EnvProjectID).
+var envTest = tester.NewEnvTest(
+ EnvUsernameOS,
+ EnvPasswordOS,
+ EnvDomainName,
+ EnvUserDomainName,
+ EnvProjectID,
+ EnvAuthRegion,
+ EnvAuthURL,
+).
WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
@@ -25,7 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
envVars: map[string]string{
EnvUsernameOS: "someName",
EnvPasswordOS: "qwerty",
- EnvAccount: "1",
+ EnvDomainName: "1",
EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a",
},
},
@@ -33,7 +41,7 @@ func TestNewDNSProvider(t *testing.T) {
desc: "missing username",
envVars: map[string]string{
EnvPasswordOS: "qwerty",
- EnvAccount: "1",
+ EnvDomainName: "1",
EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a",
},
expected: "selectelv2: some credentials information are missing: SELECTELV2_USERNAME",
@@ -42,7 +50,7 @@ func TestNewDNSProvider(t *testing.T) {
desc: "missing password",
envVars: map[string]string{
EnvUsernameOS: "someName",
- EnvAccount: "1",
+ EnvDomainName: "1",
EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a",
},
expected: "selectelv2: some credentials information are missing: SELECTELV2_PASSWORD",
@@ -61,7 +69,7 @@ func TestNewDNSProvider(t *testing.T) {
envVars: map[string]string{
EnvUsernameOS: "someName",
EnvPasswordOS: "qwerty",
- EnvAccount: "1",
+ EnvDomainName: "1",
},
expected: "selectelv2: some credentials information are missing: SELECTELV2_PROJECT_ID",
},
@@ -70,6 +78,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -123,7 +132,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
username: "user",
password: "secret",
projectID: "111a11111aaa11aa1a11aaa11111aa1a",
- expected: "selectelv2: missing account",
+ expected: "selectelv2: missing account ID",
},
{
desc: "missing projectID",
@@ -139,7 +148,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
config := NewDefaultConfig()
config.Username = test.username
config.Password = test.password
- config.Account = test.account
+ config.DomainName = test.account
config.ProjectID = test.projectID
p, err := NewDNSProviderConfig(config)
@@ -162,6 +171,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -175,6 +185,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/selfhostde/internal/client_test.go b/providers/dns/selfhostde/internal/client_test.go
index 8abda8fb6..22949728c 100644
--- a/providers/dns/selfhostde/internal/client_test.go
+++ b/providers/dns/selfhostde/internal/client_test.go
@@ -1,65 +1,41 @@
package internal
import (
- "context"
- "fmt"
"net/http"
"net/http/httptest"
- "net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("user", "secret")
- serverURL, err := url.Parse(server.URL)
- require.NoError(t, err)
+ client.baseURL = server.URL
+ client.HTTPClient = server.Client()
- client.baseURL = serverURL.String()
-
- return client, mux
+ return client, nil
}
func TestClient_UpdateTXTRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", nil, servermock.CheckQueryParameter().Strict().
+ With("rid", "123456").
+ With("content", "txt").
+ With("username", "user").
+ With("password", "secret"),
+ ).
+ Build(t)
- mux.HandleFunc("GET /", func(rw http.ResponseWriter, req *http.Request) {
- query := req.URL.Query()
-
- fields := map[string]string{
- "username": "user",
- "password": "secret",
- "rid": "123456",
- "content": "txt",
- }
-
- for k, v := range fields {
- value := query.Get(k)
- if value != v {
- http.Error(rw, fmt.Sprintf("%s: unexpected value: %s (%s)", k, value, v), http.StatusBadRequest)
- return
- }
- }
- })
-
- err := client.UpdateTXTRecord(context.Background(), "123456", "txt")
+ err := client.UpdateTXTRecord(t.Context(), "123456", "txt")
require.NoError(t, err)
}
func TestClient_UpdateTXTRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /", servermock.Noop().WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("GET /", func(rw http.ResponseWriter, _ *http.Request) {
- http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- })
-
- err := client.UpdateTXTRecord(context.Background(), "123456", "txt")
- require.Error(t, err)
+ err := client.UpdateTXTRecord(t.Context(), "123456", "txt")
+ require.EqualError(t, err, "unexpected status code: [status code: 400] body: ")
}
diff --git a/providers/dns/selfhostde/mapping.go b/providers/dns/selfhostde/mapping.go
index 0984419ef..fe11ceda1 100644
--- a/providers/dns/selfhostde/mapping.go
+++ b/providers/dns/selfhostde/mapping.go
@@ -88,8 +88,10 @@ func parseLine(line string) (string, *Seq, error) {
name, rawIDs := line[:idx], line[idx+1:]
- var ids []string
- var count int
+ var (
+ ids []string
+ count int
+ )
for {
idx, err = safeIndex(rawIDs, recordSep)
diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go
index 0fea9f1d0..035cd5363 100644
--- a/providers/dns/selfhostde/selfhostde.go
+++ b/providers/dns/selfhostde/selfhostde.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/selfhostde/internal"
)
@@ -132,6 +133,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -173,6 +176,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("selfhostde: unknown record ID for %q", dns01.UnFqdn(info.EffectiveFQDN))
}
@@ -182,5 +186,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("selfhostde: emptied DNS TXT record (id=%s): %w", recordID, err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/selfhostde/selfhostde.toml b/providers/dns/selfhostde/selfhostde.toml
index eba96fce2..bd22c6c41 100644
--- a/providers/dns/selfhostde/selfhostde.toml
+++ b/providers/dns/selfhostde/selfhostde.toml
@@ -8,7 +8,7 @@ Example = '''
SELFHOSTDE_USERNAME=xxx \
SELFHOSTDE_PASSWORD=yyy \
SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \
-lego --email you@example.com --dns selfhostde -d '*.example.com' -d example.com run
+lego --dns selfhostde -d '*.example.com' -d example.com run
'''
Additional = """
@@ -48,7 +48,7 @@ The resulting environment variable would then be: `SELFHOSTDE_RECORDS_MAPPING=my
SELFHOSTDE_PASSWORD = "Password"
SELFHOSTDE_RECORDS_MAPPING = "Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147)"
[Configuration.Additional]
- SELFHOSTDE_POLLING_INTERVAL = "Time between DNS propagation check"
- SELFHOSTDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SELFHOSTDE_TTL = "The TTL of the TXT record used for the DNS challenge"
- SELFHOSTDE_HTTP_TIMEOUT = "API request timeout"
+ SELFHOSTDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)"
+ SELFHOSTDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)"
+ SELFHOSTDE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SELFHOSTDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
diff --git a/providers/dns/selfhostde/selfhostde_test.go b/providers/dns/selfhostde/selfhostde_test.go
index 1161049b0..7c12195fa 100644
--- a/providers/dns/selfhostde/selfhostde_test.go
+++ b/providers/dns/selfhostde/selfhostde_test.go
@@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -185,6 +186,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -198,6 +200,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/servercow/internal/client.go b/providers/dns/servercow/internal/client.go
index 3695b0979..e15237201 100644
--- a/providers/dns/servercow/internal/client.go
+++ b/providers/dns/servercow/internal/client.go
@@ -47,6 +47,7 @@ func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error
}
var records []Record
+
err = c.do(req, &records)
if err != nil {
return nil, err
@@ -65,6 +66,7 @@ func (c *Client) CreateUpdateRecord(ctx context.Context, domain string, data Rec
}
var msg Message
+
err = c.do(req, &msg)
if err != nil {
return nil, err
@@ -87,6 +89,7 @@ func (c *Client) DeleteRecord(ctx context.Context, domain string, data Record) (
}
var msg Message
+
err = c.do(req, &msg)
if err != nil {
return nil, err
@@ -168,6 +171,7 @@ func unmarshal(raw []byte, v any) error {
}
var apiErr Message
+
errU := json.Unmarshal(raw, &apiErr)
if errU != nil {
return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
diff --git a/providers/dns/servercow/internal/client_test.go b/providers/dns/servercow/internal/client_test.go
index 8597d7e12..3733ccad1 100644
--- a/providers/dns/servercow/internal/client_test.go
+++ b/providers/dns/servercow/internal/client_test.go
@@ -1,57 +1,38 @@
package internal
import (
- "context"
"encoding/json"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("", "")
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With("X-Auth-Username", "user").
+ With("X-Auth-Password", "secret"),
+ )
}
func TestClient_GetRecords(t *testing.T) {
- client, handler := setupTest(t)
+ client := mockBuilder().
+ Route("GET /example.com", servermock.ResponseFromFixture("records-01.json")).
+ Build(t)
- handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- file, err := os.Open("./fixtures/records-01.json")
- 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
- }
- })
-
- records, err := client.GetRecords(context.Background(), "lego.wtf")
+ records, err := client.GetRecords(t.Context(), "example.com")
require.NoError(t, err)
recordsJSON, err := json.Marshal(records)
@@ -64,55 +45,22 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecords_error(t *testing.T) {
- client, handler := setupTest(t)
+ client := mockBuilder().
+ Route("GET /example.com", servermock.JSONEncode(Message{ErrorMsg: "authentication failed"})).
+ Build(t)
- handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "authentication failed"})
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- records, err := client.GetRecords(context.Background(), "lego.wtf")
+ records, err := client.GetRecords(t.Context(), "example.com")
require.Error(t, err)
assert.Nil(t, records)
}
func TestClient_CreateUpdateRecord(t *testing.T) {
- client, handler := setupTest(t)
-
- handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- content, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- expectedRequest := `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}`
-
- if !assert.JSONEq(t, expectedRequest, string(content)) {
- http.Error(rw, "invalid content", http.StatusBadRequest)
- return
- }
-
- err = json.NewEncoder(rw).Encode(Message{Message: "ok"})
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /example.com",
+ servermock.JSONEncode(Message{Message: "ok"}),
+ servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}`)).
+ Build(t)
record := Record{
Name: "_acme-challenge.www",
@@ -121,7 +69,7 @@ func TestClient_CreateUpdateRecord(t *testing.T) {
Content: Value{"aaa", "bbb"},
}
- msg, err := client.CreateUpdateRecord(context.Background(), "lego.wtf", record)
+ msg, err := client.CreateUpdateRecord(t.Context(), "example.com", record)
require.NoError(t, err)
expected := &Message{Message: "ok"}
@@ -129,66 +77,34 @@ func TestClient_CreateUpdateRecord(t *testing.T) {
}
func TestClient_CreateUpdateRecord_error(t *testing.T) {
- client, handler := setupTest(t)
-
- handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /example.com",
+ servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})).
+ Build(t)
record := Record{
Name: "_acme-challenge.www",
}
- msg, err := client.CreateUpdateRecord(context.Background(), "lego.wtf", record)
+ msg, err := client.CreateUpdateRecord(t.Context(), "example.com", record)
require.Error(t, err)
assert.Nil(t, msg)
}
func TestClient_DeleteRecord(t *testing.T) {
- client, handler := setupTest(t)
-
- handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- content, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- expectedRequest := `{"name":"_acme-challenge.www","type":"TXT"}`
-
- if !assert.JSONEq(t, expectedRequest, string(content)) {
- http.Error(rw, "invalid content", http.StatusBadRequest)
- return
- }
-
- err = json.NewEncoder(rw).Encode(Message{Message: "ok"})
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("DELETE /example.com",
+ servermock.JSONEncode(Message{Message: "ok"}),
+ servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT"}`)).
+ Build(t)
record := Record{
Name: "_acme-challenge.www",
Type: "TXT",
}
- msg, err := client.DeleteRecord(context.Background(), "lego.wtf", record)
+ msg, err := client.DeleteRecord(t.Context(), "example.com", record)
require.NoError(t, err)
expected := &Message{Message: "ok"}
@@ -196,26 +112,16 @@ func TestClient_DeleteRecord(t *testing.T) {
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client, handler := setupTest(t)
-
- handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("DELETE /example.com",
+ servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})).
+ Build(t)
record := Record{
Name: "_acme-challenge.www",
}
- msg, err := client.DeleteRecord(context.Background(), "lego.wtf", record)
+ msg, err := client.DeleteRecord(t.Context(), "example.com", record)
require.Error(t, err)
assert.Nil(t, msg)
diff --git a/providers/dns/servercow/internal/types.go b/providers/dns/servercow/internal/types.go
index 5a8fb6ff8..9a951e806 100644
--- a/providers/dns/servercow/internal/types.go
+++ b/providers/dns/servercow/internal/types.go
@@ -43,6 +43,7 @@ func (v *Value) UnmarshalJSON(b []byte) error {
}
*v = append(*v, s)
+
return nil
}
diff --git a/providers/dns/servercow/servercow.go b/providers/dns/servercow/servercow.go
index c0c1662f6..557c6b1ec 100644
--- a/providers/dns/servercow/servercow.go
+++ b/providers/dns/servercow/servercow.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/servercow/internal"
)
@@ -44,7 +45,7 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, 120),
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
@@ -85,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -137,6 +140,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("servercow: failed to update TXT records: %w", err)
}
+
return nil
}
@@ -191,6 +195,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("servercow: failed to delete TXT records: %w", err)
}
+
return nil
}
diff --git a/providers/dns/servercow/servercow.toml b/providers/dns/servercow/servercow.toml
index e9ec36be9..5cbacbb88 100644
--- a/providers/dns/servercow/servercow.toml
+++ b/providers/dns/servercow/servercow.toml
@@ -7,7 +7,7 @@ Since = "v3.4.0"
Example = '''
SERVERCOW_USERNAME=xxxxxxxx \
SERVERCOW_PASSWORD=xxxxxxxx \
-lego --email you@example.com --dns servercow -d '*.example.com' -d example.com run
+lego --dns servercow -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns servercow -d '*.example.com' -d example.com r
SERVERCOW_USERNAME = "API username"
SERVERCOW_PASSWORD = "API password"
[Configuration.Additional]
- SERVERCOW_POLLING_INTERVAL = "Time between DNS propagation check"
- SERVERCOW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SERVERCOW_TTL = "The TTL of the TXT record used for the DNS challenge"
- SERVERCOW_HTTP_TIMEOUT = "API request timeout"
+ SERVERCOW_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ SERVERCOW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ SERVERCOW_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SERVERCOW_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
- API = "https://cp.servercow.de/client/plugin/support_manager/knowledgebase/view/34/dns-api-v1/7/"
+ API = "https://wiki.servercow.de/en/domains/dns_api/api-syntax/"
diff --git a/providers/dns/servercow/servercow_test.go b/providers/dns/servercow/servercow_test.go
index 1c3facad9..f2328fe1a 100644
--- a/providers/dns/servercow/servercow_test.go
+++ b/providers/dns/servercow/servercow_test.go
@@ -57,6 +57,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -129,6 +130,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -142,6 +144,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/shellrent/internal/client.go b/providers/dns/shellrent/internal/client.go
index a361ccf1d..a70ff5452 100644
--- a/providers/dns/shellrent/internal/client.go
+++ b/providers/dns/shellrent/internal/client.go
@@ -29,7 +29,7 @@ type Client struct {
}
// NewClient Creates a new Client.
-func NewClient(username string, token string) *Client {
+func NewClient(username, token string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
@@ -42,7 +42,7 @@ func NewClient(username string, token string) *Client {
// ListServices lists service IDs.
// https://api.shellrent.com/elenco-dei-servizi-acquistati
-func (c Client) ListServices(ctx context.Context) ([]int, error) {
+func (c *Client) ListServices(ctx context.Context) ([]int, error) {
endpoint := c.baseURL.JoinPath("purchase")
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -72,7 +72,7 @@ func (c Client) ListServices(ctx context.Context) ([]int, error) {
// GetServiceDetails gets service details.
// https://api.shellrent.com/dettagli-servizio-acquistato
-func (c Client) GetServiceDetails(ctx context.Context, serviceID int) (*ServiceDetails, error) {
+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)
@@ -96,7 +96,7 @@ func (c Client) GetServiceDetails(ctx context.Context, serviceID int) (*ServiceD
// GetDomainDetails gets domain details.
// https://api.shellrent.com/dettagli-dominio
-func (c Client) GetDomainDetails(ctx context.Context, domainID int) (*DomainDetails, error) {
+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)
@@ -114,12 +114,13 @@ func (c Client) GetDomainDetails(ctx context.Context, domainID int) (*DomainDeta
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) {
+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)
@@ -137,12 +138,13 @@ func (c Client) CreateRecord(ctx context.Context, domainID int, record Record) (
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 {
+func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error {
endpoint := c.baseURL.JoinPath("dns_record", "remove", strconv.Itoa(domainID), strconv.Itoa(recordID))
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -164,7 +166,7 @@ func (c Client) DeleteRecord(ctx context.Context, domainID int, recordID int) er
return nil
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
req.Header.Set(authorizationHeader, c.username+"."+c.token)
resp, err := c.HTTPClient.Do(req)
@@ -219,6 +221,7 @@ 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)
diff --git a/providers/dns/shellrent/internal/client_test.go b/providers/dns/shellrent/internal/client_test.go
index 0fe77c6fc..7047ce835 100644
--- a/providers/dns/shellrent/internal/client_test.go
+++ b/providers/dns/shellrent/internal/client_test.go
@@ -1,71 +1,35 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("user.secret"))
}
func TestClient_ListServices(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "purchase.json")
+ client := mockBuilder().
+ Route("GET /purchase", servermock.ResponseFromFixture("purchase.json")).
+ Build(t)
- services, err := client.ListServices(context.Background())
+ services, err := client.ListServices(t.Context())
require.NoError(t, err)
expected := []int{2018, 10039, 10128}
@@ -74,23 +38,31 @@ func TestClient_ListServices(t *testing.T) {
}
func TestClient_ListServices_error(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "error.json")
+ client := mockBuilder().
+ Route("GET /purchase", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- _, err := client.ListServices(context.Background())
+ _, err := client.ListServices(t.Context())
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")
+ client := mockBuilder().
+ Route("GET /purchase",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- _, err := client.ListServices(context.Background())
+ _, err := client.ListServices(t.Context())
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")
+ client := mockBuilder().
+ Route("GET /purchase/details/123", servermock.ResponseFromFixture("purchase-details.json")).
+ Build(t)
- services, err := client.GetServiceDetails(context.Background(), 123)
+ services, err := client.GetServiceDetails(t.Context(), 123)
require.NoError(t, err)
expected := &ServiceDetails{ID: 123, Name: "example", DomainID: 456}
@@ -99,23 +71,31 @@ func TestClient_GetServiceDetails(t *testing.T) {
}
func TestClient_GetServiceDetails_error(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusOK, "error.json")
+ client := mockBuilder().
+ Route("GET /purchase/details/123", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- _, err := client.GetServiceDetails(context.Background(), 123)
+ _, err := client.GetServiceDetails(t.Context(), 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")
+ client := mockBuilder().
+ Route("GET /purchase/details/123",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- _, err := client.GetServiceDetails(context.Background(), 123)
+ _, err := client.GetServiceDetails(t.Context(), 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")
+ client := mockBuilder().
+ Route("GET /domain/details/123", servermock.ResponseFromFixture("domain-details.json")).
+ Build(t)
- services, err := client.GetDomainDetails(context.Background(), 123)
+ services, err := client.GetDomainDetails(t.Context(), 123)
require.NoError(t, err)
expected := &DomainDetails{ID: 123, DomainName: "example.com", DomainNameASCII: "example.com"}
@@ -124,23 +104,31 @@ func TestClient_GetDomainDetails(t *testing.T) {
}
func TestClient_GetDomainDetails_error(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusOK, "error.json")
+ client := mockBuilder().
+ Route("GET /domain/details/123", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- _, err := client.GetDomainDetails(context.Background(), 123)
+ _, err := client.GetDomainDetails(t.Context(), 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")
+ client := mockBuilder().
+ Route("GET /domain/details/123",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- _, err := client.GetDomainDetails(context.Background(), 123)
+ _, err := client.GetDomainDetails(t.Context(), 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")
+ client := mockBuilder().
+ Route("POST /dns_record/store/123", servermock.ResponseFromFixture("dns_record-store.json")).
+ Build(t)
- services, err := client.CreateRecord(context.Background(), 123, Record{})
+ services, err := client.CreateRecord(t.Context(), 123, Record{})
require.NoError(t, err)
expected := 2255674
@@ -149,37 +137,51 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_CreateRecord_error(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusOK, "error.json")
+ client := mockBuilder().
+ Route("POST /dns_record/store/123", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- _, err := client.CreateRecord(context.Background(), 123, Record{})
+ _, err := client.CreateRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("POST /dns_record/store/123",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- _, err := client.CreateRecord(context.Background(), 123, Record{})
+ _, err := client.CreateRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("dns_record-remove.json")).
+ Build(t)
- err := client.DeleteRecord(context.Background(), 123, 456)
+ err := client.DeleteRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("error.json")).
+ Build(t)
- err := client.DeleteRecord(context.Background(), 123, 456)
+ err := client.DeleteRecord(t.Context(), 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")
+ client := mockBuilder().
+ Route("DELETE /dns_record/remove/123/456",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), 123, 456)
+ err := client.DeleteRecord(t.Context(), 123, 456)
require.EqualError(t, err, "code 2: Token di autorizzazione non valido")
}
diff --git a/providers/dns/shellrent/internal/types.go b/providers/dns/shellrent/internal/types.go
index a27b06347..6bdd82330 100644
--- a/providers/dns/shellrent/internal/types.go
+++ b/providers/dns/shellrent/internal/types.go
@@ -7,6 +7,7 @@ import (
type Response[T any] struct {
Base
+
Data T `json:"data"`
}
@@ -57,6 +58,7 @@ func (m *IntOrString) UnmarshalJSON(data []byte) error {
raw := string(data)
if data[0] == '"' {
var err error
+
raw, err = strconv.Unquote(string(data))
if err != nil {
return err
diff --git a/providers/dns/shellrent/shellrent.go b/providers/dns/shellrent/shellrent.go
index dec1540c8..0cd33e19a 100644
--- a/providers/dns/shellrent/shellrent.go
+++ b/providers/dns/shellrent/shellrent.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/shellrent/internal"
)
@@ -103,6 +104,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -123,9 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
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)
- }
+ return fmt.Errorf("shellrent: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.DomainName)
@@ -161,6 +162,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
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)
}
@@ -170,6 +172,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("shellrent: delete record: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/shellrent/shellrent.toml b/providers/dns/shellrent/shellrent.toml
index 1e19e2d0d..05b6517fc 100644
--- a/providers/dns/shellrent/shellrent.toml
+++ b/providers/dns/shellrent/shellrent.toml
@@ -7,7 +7,7 @@ Since = "v4.16.0"
Example = '''
SHELLRENT_USERNAME=xxxx \
SHELLRENT_TOKEN=yyyy \
-lego --email you@example.com --dns shellrent -d '*.example.com' -d example.com run
+lego --dns shellrent -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns shellrent -d '*.example.com' -d example.com r
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"
+ SHELLRENT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ SHELLRENT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ SHELLRENT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ SHELLRENT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.shellrent.com/section/api2"
diff --git a/providers/dns/shellrent/shellrent_test.go b/providers/dns/shellrent/shellrent_test.go
index e5d529917..8c4e3f6bf 100644
--- a/providers/dns/shellrent/shellrent_test.go
+++ b/providers/dns/shellrent/shellrent_test.go
@@ -47,6 +47,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -117,6 +118,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -130,6 +132,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/simply/internal/client.go b/providers/dns/simply/internal/client.go
index b57bf2102..0c0655463 100644
--- a/providers/dns/simply/internal/client.go
+++ b/providers/dns/simply/internal/client.go
@@ -16,7 +16,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
-const defaultBaseURL = "https://api.simply.com/1/"
+const defaultBaseURL = "https://api.simply.com/2/"
// Client is a Simply.com API client.
type Client struct {
@@ -28,7 +28,7 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(accountName string, apiKey string) (*Client, error) {
+func NewClient(accountName, apiKey string) (*Client, error) {
if accountName == "" {
return nil, errors.New("credentials missing: accountName")
}
@@ -60,6 +60,7 @@ func (c *Client) GetRecords(ctx context.Context, zoneName string) ([]Record, err
}
result := &apiResponse[[]Record, json.RawMessage]{}
+
err = c.do(req, result)
if err != nil {
return nil, err
@@ -78,6 +79,7 @@ func (c *Client) AddRecord(ctx context.Context, zoneName string, record Record)
}
result := &apiResponse[json.RawMessage, recordHeader]{}
+
err = c.do(req, result)
if err != nil {
return 0, err
@@ -110,11 +112,13 @@ func (c *Client) DeleteRecord(ctx context.Context, zoneName string, id int64) er
return c.do(req, &apiResponse[json.RawMessage, json.RawMessage]{})
}
-func (c *Client) createEndpoint(zoneName string, uri string) *url.URL {
- return c.baseURL.JoinPath(c.accountName, c.apiKey, "my", "products", zoneName, "dns", "records", strings.TrimSuffix(uri, "/"))
+func (c *Client) createEndpoint(zoneName, uri string) *url.URL {
+ return c.baseURL.JoinPath("my", "products", zoneName, "dns", "records", strings.TrimSuffix(uri, "/"))
}
func (c *Client) do(req *http.Request, result Response) error {
+ req.SetBasicAuth(c.accountName, c.apiKey)
+
resp, err := c.HTTPClient.Do(req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
diff --git a/providers/dns/simply/internal/client_test.go b/providers/dns/simply/internal/client_test.go
index c9b97e94c..b0bdac6b3 100644
--- a/providers/dns/simply/internal/client_test.go
+++ b/providers/dns/simply/internal/client_test.go
@@ -1,27 +1,40 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("accountname", "apikey")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("accountname", "apikey"))
+}
+
func TestClient_GetRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /my/products/azone01/dns/records",
+ servermock.ResponseFromFixture("get_records.json")).
+ Build(t)
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusOK, "get_records.json"))
-
- records, err := client.GetRecords(context.Background(), "azone01")
+ records, err := client.GetRecords(t.Context(), "azone01")
require.NoError(t, err)
expected := []Record{
@@ -63,20 +76,23 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecords_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /my/products/azone01/dns/records",
+ servermock.ResponseFromFixture("bad_auth_error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusBadRequest, "bad_auth_error.json"))
-
- records, err := client.GetRecords(context.Background(), "azone01")
+ records, err := client.GetRecords(t.Context(), "azone01")
require.Error(t, err)
assert.Nil(t, records)
}
func TestClient_AddRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusOK, "add_record.json"))
+ client := mockBuilder().
+ Route("POST /my/products/azone01/dns/records",
+ servermock.ResponseFromFixture("add_record.json")).
+ Build(t)
record := Record{
Name: "arecord01",
@@ -86,16 +102,18 @@ func TestClient_AddRecord(t *testing.T) {
Priority: 0,
}
- recordID, err := client.AddRecord(context.Background(), "azone01", record)
+ recordID, err := client.AddRecord(t.Context(), "azone01", record)
require.NoError(t, err)
assert.EqualValues(t, 123456789, recordID)
}
func TestClient_AddRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusNotFound, "bad_zone_error.json"))
+ client := mockBuilder().
+ Route("POST /my/products/azone01/dns/records",
+ servermock.ResponseFromFixture("bad_zone_error.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
record := Record{
Name: "arecord01",
@@ -105,16 +123,17 @@ func TestClient_AddRecord_error(t *testing.T) {
Priority: 0,
}
- recordID, err := client.AddRecord(context.Background(), "azone01", record)
+ recordID, err := client.AddRecord(t.Context(), "azone01", record)
require.Error(t, err)
assert.Zero(t, recordID)
}
func TestClient_EditRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusOK, "success.json"))
+ client := mockBuilder().
+ Route("PUT /my/products/azone01/dns/records/123456789",
+ servermock.ResponseFromFixture("success.json")).
+ Build(t)
record := Record{
Name: "arecord01",
@@ -124,14 +143,16 @@ func TestClient_EditRecord(t *testing.T) {
Priority: 0,
}
- err := client.EditRecord(context.Background(), "azone01", 123456789, record)
+ err := client.EditRecord(t.Context(), "azone01", 123456789, record)
require.NoError(t, err)
}
func TestClient_EditRecord_error(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusNotFound, "invalid_record_id.json"))
+ client := mockBuilder().
+ Route("PUT /my/products/azone01/dns/records/123456789",
+ servermock.ResponseFromFixture("invalid_record_id.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
record := Record{
Name: "arecord01",
@@ -141,68 +162,27 @@ func TestClient_EditRecord_error(t *testing.T) {
Priority: 0,
}
- err := client.EditRecord(context.Background(), "azone01", 123456789, record)
+ err := client.EditRecord(t.Context(), "azone01", 123456789, record)
require.Error(t, err)
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /my/products/azone01/dns/records/123456789",
+ servermock.ResponseFromFixture("success.json")).
+ Build(t)
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusOK, "success.json"))
-
- err := client.DeleteRecord(context.Background(), "azone01", 123456789)
+ err := client.DeleteRecord(t.Context(), "azone01", 123456789)
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /my/products/azone01/dns/records/123456789",
+ servermock.ResponseFromFixture("invalid_record_id.json").
+ WithStatusCode(http.StatusNotFound)).
+ Build(t)
- mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusNotFound, "invalid_record_id.json"))
-
- err := client.DeleteRecord(context.Background(), "azone01", 123456789)
+ err := client.DeleteRecord(t.Context(), "azone01", 123456789)
require.Error(t, err)
}
-
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client, err := NewClient("accountname", "apikey")
- require.NoError(t, err)
-
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) {
- 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
- }
-
- if filename == "" {
- rw.WriteHeader(statusCode)
- return
- }
-
- file, err := os.Open(filepath.FromSlash(path.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
- }
- }
-}
diff --git a/providers/dns/simply/simply.go b/providers/dns/simply/simply.go
index d2bfb1874..fc3afd310 100644
--- a/providers/dns/simply/simply.go
+++ b/providers/dns/simply/simply.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/simply/internal"
)
@@ -99,6 +100,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -162,6 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("simply: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
diff --git a/providers/dns/simply/simply.toml b/providers/dns/simply/simply.toml
index 15cf7feb2..a838e245a 100644
--- a/providers/dns/simply/simply.toml
+++ b/providers/dns/simply/simply.toml
@@ -7,7 +7,7 @@ Since = "v4.4.0"
Example = '''
SIMPLY_ACCOUNT_NAME=xxxxxx \
SIMPLY_API_KEY=yyyyyy \
-lego --email you@example.com --dns simply -d '*.example.com' -d example.com run
+lego --dns simply -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,11 @@ lego --email you@example.com --dns simply -d '*.example.com' -d example.com run
SIMPLY_ACCOUNT_NAME = "Account name"
SIMPLY_API_KEY = "API key"
[Configuration.Additional]
- SIMPLY_POLLING_INTERVAL = "Time between DNS propagation check"
- SIMPLY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SIMPLY_TTL = "The TTL of the TXT record used for the DNS challenge"
- SIMPLY_HTTP_TIMEOUT = "API request timeout"
+ SIMPLY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ SIMPLY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ SIMPLY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SIMPLY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.simply.com/en/docs/api/"
+ Spec = "https://generator.swagger.io/?url=https://api.simply.com/2/openapi.json#/"
diff --git a/providers/dns/simply/simply_test.go b/providers/dns/simply/simply_test.go
index ace8e0b72..e6de60d43 100644
--- a/providers/dns/simply/simply_test.go
+++ b/providers/dns/simply/simply_test.go
@@ -53,6 +53,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -121,6 +122,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -134,6 +136,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/sonic/internal/client.go b/providers/dns/sonic/internal/client.go
index aac85c636..cf8f7f067 100644
--- a/providers/dns/sonic/internal/client.go
+++ b/providers/dns/sonic/internal/client.go
@@ -42,7 +42,7 @@ func NewClient(userID, apiKey string) (*Client, error) {
// SetRecord creates or updates a TXT records.
// Sonic does not provide a delete record API endpoint.
// https://public-api.sonic.net/dyndns#updating_or_adding_host_records
-func (c *Client) SetRecord(ctx context.Context, hostname string, value string, ttl int) error {
+func (c *Client) SetRecord(ctx context.Context, hostname, value string, ttl int) error {
payload := &Record{
UserID: c.userID,
APIKey: c.apiKey,
@@ -83,6 +83,7 @@ func (c *Client) SetRecord(ctx context.Context, hostname string, value string, t
}
r := APIResponse{}
+
err = json.Unmarshal(raw, &r)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/sonic/internal/client_test.go b/providers/dns/sonic/internal/client_test.go
index ac711387e..751ccee8f 100644
--- a/providers/dns/sonic/internal/client_test.go
+++ b/providers/dns/sonic/internal/client_test.go
@@ -1,32 +1,23 @@
package internal
import (
- "context"
- "fmt"
- "net/http"
"net/http/httptest"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, body string) *Client {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/host", func(rw http.ResponseWriter, req *http.Request) {
- _, _ = fmt.Fprintln(rw, body)
- })
-
+func setupClient(server *httptest.Server) (*Client, error) {
client, err := NewClient("foo", "secret")
- require.NoError(t, err)
+ if err != nil {
+ return nil, err
+ }
client.baseURL = server.URL
+ client.HTTPClient = server.Client()
- return client
+ return client, nil
}
func TestClient_SetRecord(t *testing.T) {
@@ -51,9 +42,13 @@ func TestClient_SetRecord(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- client := setupTest(t, test.response)
+ client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()).
+ Route("PUT /host",
+ servermock.RawStringResponse(test.response),
+ servermock.CheckRequestJSONBody(`{"userid":"foo","apikey":"secret","hostname":"example.com","value":"txttxttxt","ttl":10,"type":"TXT"}`)).
+ Build(t)
- err := client.SetRecord(context.Background(), "example.com", "txttxttxt", 10)
+ err := client.SetRecord(t.Context(), "example.com", "txttxttxt", 10)
test.assert(t, err)
})
}
diff --git a/providers/dns/sonic/sonic.go b/providers/dns/sonic/sonic.go
index 80f5ea295..5bda2b533 100644
--- a/providers/dns/sonic/sonic.go
+++ b/providers/dns/sonic/sonic.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/sonic/internal"
)
@@ -91,6 +92,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{client: client, config: config}, nil
}
diff --git a/providers/dns/sonic/sonic.toml b/providers/dns/sonic/sonic.toml
index f871d3f94..cb501e923 100644
--- a/providers/dns/sonic/sonic.toml
+++ b/providers/dns/sonic/sonic.toml
@@ -7,7 +7,7 @@ Since = "v4.4.0"
Example = '''
SONIC_USER_ID=12345 \
SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \
-lego --email you@example.com --dns sonic -d '*.example.com' -d example.com run
+lego --dns sonic -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -34,11 +34,11 @@ Hostname should be the toplevel domain managed e.g. `example.com` not `www.examp
SONIC_USER_ID = "User ID"
SONIC_API_KEY = "API Key"
[Configuration.Additional]
- SONIC_POLLING_INTERVAL = "Time between DNS propagation check"
- SONIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- SONIC_TTL = "The TTL of the TXT record used for the DNS challenge"
- SONIC_HTTP_TIMEOUT = "API request timeout"
- SONIC_SEQUENCE_INTERVAL = "Time between sequential requests"
+ SONIC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ SONIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ SONIC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SONIC_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ SONIC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://public-api.sonic.net/dyndns/"
diff --git a/providers/dns/sonic/sonic_test.go b/providers/dns/sonic/sonic_test.go
index f9087f8e3..7dc7fc586 100644
--- a/providers/dns/sonic/sonic_test.go
+++ b/providers/dns/sonic/sonic_test.go
@@ -49,6 +49,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -119,6 +120,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -132,6 +134,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/spaceship/internal/client.go b/providers/dns/spaceship/internal/client.go
new file mode 100644
index 000000000..e690fa467
--- /dev/null
+++ b/providers/dns/spaceship/internal/client.go
@@ -0,0 +1,161 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://spaceship.dev/api/v1/"
+
+// Client the Spaceship API client.
+type Client struct {
+ apiKey string
+ apiSecret string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey, apiSecret string) (*Client, error) {
+ if apiKey == "" || apiSecret == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ apiSecret: apiSecret,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Add("X-Api-Secret", c.apiSecret)
+ req.Header.Add("X-Api-Key", c.apiKey)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error {
+ endpoint := c.baseURL.JoinPath("dns", "records", domain)
+
+ req, err := newJSONRequest(ctx, http.MethodPut, endpoint, Foo{Items: []Record{record}})
+ if err != nil {
+ return err
+ }
+
+ err = c.do(req, nil)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error {
+ endpoint := c.baseURL.JoinPath("dns", "records", domain)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, []Record{record})
+ if err != nil {
+ return err
+ }
+
+ err = c.do(req, nil)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) {
+ endpoint := c.baseURL.JoinPath("dns", "records", domain)
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result GetRecordsResponse
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Items, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/spaceship/internal/client_test.go b/providers/dns/spaceship/internal/client_test.go
new file mode 100644
index 000000000..f32843652
--- /dev/null
+++ b/providers/dns/spaceship/internal/client_test.go
@@ -0,0 +1,124 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("key", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ With("X-Api-Key", "key").
+ With("X-Api-Secret", "secret"),
+ )
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /dns/records/example.com", nil,
+ servermock.CheckRequestJSONBody(`{"items":[{"type":"TXT","name":"@","ttl":60}]}`)).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "@",
+ TTL: 60,
+ }
+
+ err := client.AddRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("PUT /dns/records/example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnprocessableEntity)).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "@",
+ TTL: 60,
+ }
+
+ err := client.AddRecord(t.Context(), "example.com", record)
+ require.EqualError(t, err, "^$, name: The domain name contains invalid characters")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/records/example.com", nil,
+ servermock.CheckRequestJSONBody(`[{"type":"TXT","name":"@","ttl":60}]`)).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "@",
+ TTL: 60,
+ }
+
+ err := client.DeleteRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/records/example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnprocessableEntity)).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Name: "@",
+ TTL: 60,
+ }
+
+ err := client.DeleteRecord(t.Context(), "example.com", record)
+ require.EqualError(t, err, "^$, name: The domain name contains invalid characters")
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/records/example.com",
+ servermock.ResponseFromFixture("get-records.json")).
+ Build(t)
+
+ records, err := client.GetRecords(t.Context(), "example.com")
+ require.NoError(t, err)
+
+ expected := []Record{
+ {Type: "A", Name: "@", TTL: 3600},
+ }
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_GetRecords_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/records/example.com",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnprocessableEntity)).
+ Build(t)
+
+ _, err := client.GetRecords(t.Context(), "example.com")
+ require.EqualError(t, err, "^$, name: The domain name contains invalid characters")
+}
diff --git a/providers/dns/spaceship/internal/fixtures/error.json b/providers/dns/spaceship/internal/fixtures/error.json
new file mode 100644
index 000000000..facf97e58
--- /dev/null
+++ b/providers/dns/spaceship/internal/fixtures/error.json
@@ -0,0 +1,9 @@
+{
+ "detail": "^$",
+ "data": [
+ {
+ "field": "name",
+ "details": "The domain name contains invalid characters"
+ }
+ ]
+}
diff --git a/providers/dns/spaceship/internal/fixtures/get-records.json b/providers/dns/spaceship/internal/fixtures/get-records.json
new file mode 100644
index 000000000..cea2a895a
--- /dev/null
+++ b/providers/dns/spaceship/internal/fixtures/get-records.json
@@ -0,0 +1,13 @@
+{
+ "items": [
+ {
+ "type": "A",
+ "name": "@",
+ "ttl": 3600,
+ "group": {
+ "type": "custom"
+ }
+ }
+ ],
+ "total": 100
+}
diff --git a/providers/dns/spaceship/internal/types.go b/providers/dns/spaceship/internal/types.go
new file mode 100644
index 000000000..bd318bb87
--- /dev/null
+++ b/providers/dns/spaceship/internal/types.go
@@ -0,0 +1,47 @@
+package internal
+
+import (
+ "fmt"
+ "strings"
+)
+
+type APIError struct {
+ Detail string `json:"detail"`
+ Data []struct {
+ Field string `json:"field"`
+ Details string `json:"details"`
+ } `json:"data"`
+}
+
+func (a *APIError) Error() string {
+ msg := []string{a.Detail}
+
+ for _, datum := range a.Data {
+ msg = append(msg, fmt.Sprintf("%s: %s", datum.Field, datum.Details))
+ }
+
+ return strings.Join(msg, ", ")
+}
+
+type Foo struct {
+ Force bool `json:"force,omitempty"`
+ Items []Record `json:"items,omitempty"`
+}
+
+type Record struct {
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Value string `json:"value,omitempty"`
+ Address string `json:"address,omitempty"`
+ Nameserver string `json:"nameserver,omitempty"`
+ AliasName string `json:"aliasName,omitempty"`
+ Pointer string `json:"pointer,omitempty"`
+ CName string `json:"cname,omitempty"`
+ Exchange string `json:"exchange,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+}
+
+type GetRecordsResponse struct {
+ Items []Record `json:"items"`
+ Total int `json:"total"`
+}
diff --git a/providers/dns/spaceship/spaceship.go b/providers/dns/spaceship/spaceship.go
new file mode 100644
index 000000000..e34c584c5
--- /dev/null
+++ b/providers/dns/spaceship/spaceship.go
@@ -0,0 +1,157 @@
+// Package spaceship implements a DNS provider for solving the DNS-01 challenge using Spaceship.
+package spaceship
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/spaceship/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "SPACESHIP_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+ EnvAPISecret = envNamespace + "API_SECRET"
+
+ 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
+ APISecret string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Spaceship.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey, EnvAPISecret)
+ if err != nil {
+ return nil, fmt.Errorf("spaceship: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+ config.APISecret = values[EnvAPISecret]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Spaceship.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("spaceship: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIKey, config.APISecret)
+ if err != nil {
+ return nil, fmt.Errorf("spaceship: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("spaceship: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("spaceship: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: subDomain,
+ Value: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("spaceship: %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("spaceship: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("spaceship: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: subDomain,
+ Value: info.Value,
+ }
+
+ err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("spaceship: %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/dns/spaceship/spaceship.toml b/providers/dns/spaceship/spaceship.toml
new file mode 100644
index 000000000..e9abcd408
--- /dev/null
+++ b/providers/dns/spaceship/spaceship.toml
@@ -0,0 +1,24 @@
+Name = "Spaceship"
+Description = ''''''
+URL = "https://www.spaceship.com/"
+Code = "spaceship"
+Since = "v4.22.0"
+
+Example = '''
+SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
+SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns spaceship -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ SPACESHIP_API_KEY = "API key"
+ SPACESHIP_API_SECRET = "API secret"
+ [Configuration.Additional]
+ SPACESHIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ SPACESHIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ SPACESHIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SPACESHIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://docs.spaceship.dev/#tag/DNS-records"
diff --git a/providers/dns/spaceship/spaceship_test.go b/providers/dns/spaceship/spaceship_test.go
new file mode 100644
index 000000000..d4eb37d88
--- /dev/null
+++ b/providers/dns/spaceship/spaceship_test.go
@@ -0,0 +1,146 @@
+package spaceship
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIKey, EnvAPISecret).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIKey: "key",
+ EnvAPISecret: "secret",
+ },
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{
+ EnvAPIKey: "",
+ EnvAPISecret: "secret",
+ },
+ expected: "spaceship: some credentials information are missing: SPACESHIP_API_KEY",
+ },
+ {
+ desc: "missing API secret",
+ envVars: map[string]string{
+ EnvAPIKey: "key",
+ EnvAPISecret: "",
+ },
+ expected: "spaceship: some credentials information are missing: SPACESHIP_API_SECRET",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "spaceship: some credentials information are missing: SPACESHIP_API_KEY,SPACESHIP_API_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
+ apiKey string
+ apiSecret string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "key",
+ apiSecret: "secret",
+ },
+ {
+ desc: "missing API key",
+ apiSecret: "secret",
+ expected: "spaceship: credentials missing",
+ },
+ {
+ desc: "missing API secret",
+ apiKey: "key",
+ expected: "spaceship: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "spaceship: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+ config.APISecret = test.apiSecret
+
+ 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/dns/stackpath/internal/client.go b/providers/dns/stackpath/internal/client.go
index bd11bf235..8a40a4093 100644
--- a/providers/dns/stackpath/internal/client.go
+++ b/providers/dns/stackpath/internal/client.go
@@ -25,13 +25,13 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(ctx context.Context, stackID, clientID, clientSecret string) *Client {
+func NewClient(stackID string, hc *http.Client) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
baseURL: baseURL,
stackID: stackID,
- httpClient: createOAuthClient(ctx, clientID, clientSecret),
+ httpClient: hc,
}
}
@@ -55,6 +55,7 @@ func (c *Client) GetZones(ctx context.Context, domain string) (*Zone, error) {
req.URL.RawQuery = query.Encode()
var zones Zones
+
err = c.do(req, &zones)
if err != nil {
return nil, err
@@ -82,6 +83,7 @@ func (c *Client) GetZoneRecords(ctx context.Context, name string, zone *Zone) ([
req.URL.RawQuery = query.Encode()
var records Records
+
err = c.do(req, &records)
if err != nil {
return nil, err
@@ -177,6 +179,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
errResp := &ErrorResponse{}
+
err := json.Unmarshal(raw, errResp)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/stackpath/internal/client_test.go b/providers/dns/stackpath/internal/client_test.go
index 2de1d4761..baac84397 100644
--- a/providers/dns/stackpath/internal/client_test.go
+++ b/providers/dns/stackpath/internal/client_test.go
@@ -1,50 +1,38 @@
package internal
import (
- "context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("STACK_ID", server.Client())
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client.baseURL, _ = url.Parse(server.URL + "/")
- client := NewClient(context.Background(), "STACK_ID", "CLIENT_ID", "CLIENT_SECRET")
- client.httpClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL + "/")
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders(),
+ )
}
func TestClient_GetZoneRecords(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /STACK_ID/zones/A/records",
+ servermock.ResponseFromFixture("get_zone_records.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("page_request.filter", "name='foo1' and type='TXT'")).
+ Build(t)
- mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) {
- content := `
- {
- "records": [
- {"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"},
- {"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"}
- ]
- }`
-
- _, err := w.Write([]byte(content))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- records, err := client.GetZoneRecords(context.Background(), "foo1", &Zone{ID: "A", Domain: "test"})
+ records, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"})
require.NoError(t, err)
expected := []Record{
@@ -56,73 +44,30 @@ func TestClient_GetZoneRecords(t *testing.T) {
}
func TestClient_GetZoneRecords_apiError(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) {
- content := `
+ client := mockBuilder().
+ Route("GET /STACK_ID/zones/A/records",
+ servermock.RawStringResponse(`
{
"code": 401,
"error": "an unauthorized request is attempted."
-}`
+}`).WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- w.WriteHeader(http.StatusUnauthorized)
- _, err := w.Write([]byte(content))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- _, err := client.GetZoneRecords(context.Background(), "foo1", &Zone{ID: "A", Domain: "test"})
+ _, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"})
expected := &ErrorResponse{Code: 401, Message: "an unauthorized request is attempted."}
assert.Equal(t, expected, err)
}
func TestClient_GetZones(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /STACK_ID/zones",
+ servermock.ResponseFromFixture("get_zones.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("page_request.filter", "domain='foo.com'")).
+ Build(t)
- mux.HandleFunc("/STACK_ID/zones", func(w http.ResponseWriter, _ *http.Request) {
- content := `
-{
- "pageInfo": {
- "totalCount": "5",
- "hasPreviousPage": false,
- "hasNextPage": false,
- "startCursor": "1",
- "endCursor": "1"
- },
- "zones": [
- {
- "stackId": "my_stack",
- "accountId": "my_account",
- "id": "A",
- "domain": "foo.com",
- "version": "1",
- "labels": {
- "property1": "val1",
- "property2": "val2"
- },
- "created": "2018-10-07T02:31:49Z",
- "updated": "2018-10-07T02:31:49Z",
- "nameservers": [
- "1.1.1.1"
- ],
- "verified": "2018-10-07T02:31:49Z",
- "status": "ACTIVE",
- "disabled": false
- }
- ]
-}`
-
- _, err := w.Write([]byte(content))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- zone, err := client.GetZones(context.Background(), "sub.foo.com")
+ zone, err := client.GetZones(t.Context(), "sub.foo.com")
require.NoError(t, err)
expected := &Zone{ID: "A", Domain: "foo.com"}
diff --git a/providers/dns/stackpath/internal/fixtures/get_zone_records.json b/providers/dns/stackpath/internal/fixtures/get_zone_records.json
new file mode 100644
index 000000000..1556d08fe
--- /dev/null
+++ b/providers/dns/stackpath/internal/fixtures/get_zone_records.json
@@ -0,0 +1,6 @@
+{
+ "records": [
+ {"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"},
+ {"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"}
+ ]
+}
diff --git a/providers/dns/stackpath/internal/fixtures/get_zones.json b/providers/dns/stackpath/internal/fixtures/get_zones.json
new file mode 100644
index 000000000..7630ef4fe
--- /dev/null
+++ b/providers/dns/stackpath/internal/fixtures/get_zones.json
@@ -0,0 +1,30 @@
+{
+ "pageInfo": {
+ "totalCount": "5",
+ "hasPreviousPage": false,
+ "hasNextPage": false,
+ "startCursor": "1",
+ "endCursor": "1"
+ },
+ "zones": [
+ {
+ "stackId": "my_stack",
+ "accountId": "my_account",
+ "id": "A",
+ "domain": "foo.com",
+ "version": "1",
+ "labels": {
+ "property1": "val1",
+ "property2": "val2"
+ },
+ "created": "2018-10-07T02:31:49Z",
+ "updated": "2018-10-07T02:31:49Z",
+ "nameservers": [
+ "1.1.1.1"
+ ],
+ "verified": "2018-10-07T02:31:49Z",
+ "status": "ACTIVE",
+ "disabled": false
+ }
+ ]
+}
diff --git a/providers/dns/stackpath/internal/identity.go b/providers/dns/stackpath/internal/identity.go
index 5c6e6ab17..fa3e9df07 100644
--- a/providers/dns/stackpath/internal/identity.go
+++ b/providers/dns/stackpath/internal/identity.go
@@ -9,7 +9,7 @@ import (
const defaultAuthURL = "https://gateway.stackpath.com/identity/v1/oauth2/token"
-func createOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client {
+func CreateOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client {
config := &clientcredentials.Config{
TokenURL: defaultAuthURL,
ClientID: clientID,
diff --git a/providers/dns/stackpath/stackpath.go b/providers/dns/stackpath/stackpath.go
index 8a1a2d09e..2e193b8a9 100644
--- a/providers/dns/stackpath/stackpath.go
+++ b/providers/dns/stackpath/stackpath.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/stackpath/internal"
)
@@ -43,7 +44,7 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, 120),
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
}
@@ -86,9 +87,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("stackpath: stack id missing")
}
- client := internal.NewClient(context.Background(), config.StackID, config.ClientID, config.ClientSecret)
-
- return &DNSProvider{config: config, client: client}, nil
+ return &DNSProvider{
+ config: config,
+ client: internal.NewClient(config.StackID,
+ clientdebug.Wrap(
+ internal.CreateOAuthClient(context.Background(), config.ClientID, config.ClientSecret),
+ ),
+ ),
+ }, nil
}
// Present creates a TXT record to fulfill the dns-01 challenge.
diff --git a/providers/dns/stackpath/stackpath.toml b/providers/dns/stackpath/stackpath.toml
index 307922ee2..b50e7035f 100644
--- a/providers/dns/stackpath/stackpath.toml
+++ b/providers/dns/stackpath/stackpath.toml
@@ -8,7 +8,7 @@ Example = '''
STACKPATH_CLIENT_ID=xxxxx \
STACKPATH_CLIENT_SECRET=yyyyy \
STACKPATH_STACK_ID=zzzzz \
-lego --email you@example.com --dns stackpath -d '*.example.com' -d example.com run
+lego --dns stackpath -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,9 +17,9 @@ lego --email you@example.com --dns stackpath -d '*.example.com' -d example.com r
STACKPATH_CLIENT_SECRET = "Client secret"
STACKPATH_STACK_ID = "Stack ID"
[Configuration.Additional]
- STACKPATH_POLLING_INTERVAL = "Time between DNS propagation check"
- STACKPATH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- STACKPATH_TTL = "The TTL of the TXT record used for the DNS challenge"
+ STACKPATH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ STACKPATH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ STACKPATH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
[Links]
API = "https://developer.stackpath.com/en/api/dns/#tag/Zone"
diff --git a/providers/dns/stackpath/stackpath_test.go b/providers/dns/stackpath/stackpath_test.go
index f8b83140f..a4b959222 100644
--- a/providers/dns/stackpath/stackpath_test.go
+++ b/providers/dns/stackpath/stackpath_test.go
@@ -72,6 +72,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -137,6 +138,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -150,6 +152,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/syse/internal/client.go b/providers/dns/syse/internal/client.go
new file mode 100644
index 000000000..8cb801469
--- /dev/null
+++ b/providers/dns/syse/internal/client.go
@@ -0,0 +1,131 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://www.syse.no/api"
+
+// Client the Syse API client.
+type Client struct {
+ credentials map[string]string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(credentials map[string]string) (*Client, error) {
+ if len(credentials) == 0 {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ credentials: credentials,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) (*Record, error) {
+ endpoint := c.BaseURL.JoinPath("dns", zone)
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return nil, err
+ }
+
+ req.SetBasicAuth(zone, c.credentials[zone])
+
+ result := new(Record)
+
+ err = c.do(req, result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error {
+ endpoint := c.BaseURL.JoinPath("dns", zone, recordID)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ req.SetBasicAuth(zone, c.credentials[zone])
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ 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
+}
diff --git a/providers/dns/syse/internal/client_test.go b/providers/dns/syse/internal/client_test.go
new file mode 100644
index 000000000..88416aa88
--- /dev/null
+++ b/providers/dns/syse/internal/client_test.go
@@ -0,0 +1,102 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(map[string]string{
+ "example.com": "secret",
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/example.com",
+ servermock.ResponseFromFixture("create_record.json"),
+ servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Prefix: "_acme-challenge",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Active: true,
+ TTL: 120,
+ }
+
+ result, err := client.CreateRecord(t.Context(), "example.com", record)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: "1234",
+ Type: "TXT",
+ Prefix: "_acme-challenge",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Active: true,
+ TTL: 120,
+ }
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_CreateRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /dns/example.com",
+ servermock.RawStringResponse(http.StatusText(http.StatusUnauthorized)).
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ record := Record{
+ Type: "TXT",
+ Prefix: "_acme-challenge",
+ Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ Active: true,
+ TTL: 120,
+ }
+
+ _, err := client.CreateRecord(t.Context(), "example.com", record)
+ require.EqualError(t, err, "unexpected status code: [status code: 401] body: Unauthorized")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/example.com/1234",
+ servermock.Noop()).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "example.com", "1234")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /dns/example.com/1234",
+ servermock.RawStringResponse(http.StatusText(http.StatusUnauthorized)).
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), "example.com", "1234")
+ require.EqualError(t, err, "unexpected status code: [status code: 401] body: Unauthorized")
+}
diff --git a/providers/dns/syse/internal/fixtures/create_record-request.json b/providers/dns/syse/internal/fixtures/create_record-request.json
new file mode 100644
index 000000000..549a0f60f
--- /dev/null
+++ b/providers/dns/syse/internal/fixtures/create_record-request.json
@@ -0,0 +1,7 @@
+{
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "active": true,
+ "ttl": 120,
+ "prefix": "_acme-challenge",
+ "type": "TXT"
+}
diff --git a/providers/dns/syse/internal/fixtures/create_record.json b/providers/dns/syse/internal/fixtures/create_record.json
new file mode 100644
index 000000000..b598779c6
--- /dev/null
+++ b/providers/dns/syse/internal/fixtures/create_record.json
@@ -0,0 +1,8 @@
+{
+ "id": "1234",
+ "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ "active": true,
+ "ttl": 120,
+ "prefix": "_acme-challenge",
+ "type": "TXT"
+}
diff --git a/providers/dns/syse/internal/types.go b/providers/dns/syse/internal/types.go
new file mode 100644
index 000000000..4b90205e1
--- /dev/null
+++ b/providers/dns/syse/internal/types.go
@@ -0,0 +1,11 @@
+package internal
+
+type Record struct {
+ ID string `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Prefix string `json:"prefix,omitempty"`
+ Content string `json:"content,omitempty"`
+ Priority int `json:"prio,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Active bool `json:"active,omitempty"`
+}
diff --git a/providers/dns/syse/syse.go b/providers/dns/syse/syse.go
new file mode 100644
index 000000000..29633280c
--- /dev/null
+++ b/providers/dns/syse/syse.go
@@ -0,0 +1,186 @@
+// Package syse implements a DNS provider for solving the DNS-01 challenge using Syse.
+package syse
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/syse/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "SYSE_"
+
+ EnvCredentials = envNamespace + "CREDENTIALS"
+
+ 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 {
+ Credentials map[string]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, 1200*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
+
+ recordIDs map[string]string
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Syse.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvCredentials)
+ if err != nil {
+ return nil, fmt.Errorf("syse: %w", err)
+ }
+
+ config := NewDefaultConfig()
+
+ credentials, err := env.ParsePairs(values[EnvCredentials])
+ if err != nil {
+ return nil, fmt.Errorf("syse: credentials: %w", err)
+ }
+
+ config.Credentials = credentials
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Syse.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("syse: the configuration of the DNS provider is nil")
+ }
+
+ if len(config.Credentials) == 0 {
+ return nil, errors.New("syse: missing credentials")
+ }
+
+ for domain, password := range config.Credentials {
+ if domain == "" {
+ return nil, fmt.Errorf(`syse: missing domain: "%s:%s"`, domain, password)
+ }
+
+ if password == "" {
+ return nil, fmt.Errorf(`syse: missing password: "%s:%s"`, domain, password)
+ }
+ }
+
+ client, err := internal.NewClient(config.Credentials)
+ if err != nil {
+ return nil, fmt.Errorf("syse: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]string),
+ }, 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("syse: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("syse: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Prefix: subDomain,
+ Content: info.Value,
+ TTL: d.config.TTL,
+ Active: true,
+ }
+
+ newRecord, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record)
+ if err != nil {
+ return fmt.Errorf("syse: create 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(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("syse: could not find zone for domain %q: %w", domain, err)
+ }
+
+ // gets the record's unique ID from when we created it
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("syse: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
+ if err != nil {
+ return fmt.Errorf("syse: delete 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/dns/syse/syse.toml b/providers/dns/syse/syse.toml
new file mode 100644
index 000000000..b5b1fdf47
--- /dev/null
+++ b/providers/dns/syse/syse.toml
@@ -0,0 +1,25 @@
+Name = "Syse"
+Description = ''''''
+URL = "https://www.syse.no/"
+Code = "syse"
+Since = "v4.30.0"
+
+Example = '''
+SYSE_CREDENTIALS=example.com:password \
+lego --dns syse -d '*.example.com' -d example.com run
+
+SYSE_CREDENTIALS=example.org:password1,example.com:password2 \
+lego --dns syse -d '*.example.org' -d example.org -d '*.example.com' -d example.com
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ SYSE_CREDENTIALS = "Comma-separated list of `zone:password` credential pairs"
+ [Configuration.Additional]
+ SYSE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ SYSE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)"
+ SYSE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ SYSE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.syse.no/api/dns"
diff --git a/providers/dns/syse/syse_test.go b/providers/dns/syse/syse_test.go
new file mode 100644
index 000000000..a4472aa7c
--- /dev/null
+++ b/providers/dns/syse/syse_test.go
@@ -0,0 +1,220 @@
+package syse
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvCredentials).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvCredentials: "example.org:123",
+ },
+ },
+ {
+ desc: "success multiple domains",
+ envVars: map[string]string{
+ EnvCredentials: "example.org:123,example.com:456,example.net:789",
+ },
+ },
+ {
+ desc: "invalid credentials",
+ envVars: map[string]string{
+ EnvCredentials: ",",
+ },
+ expected: `syse: credentials: incorrect pair: `,
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvCredentials: "example.org:",
+ },
+ expected: `syse: missing password: "example.org:"`,
+ },
+ {
+ desc: "missing domain",
+ envVars: map[string]string{
+ EnvCredentials: ":123",
+ },
+ expected: `syse: missing domain: ":123"`,
+ },
+ {
+ desc: "invalid credentials, partial",
+ envVars: map[string]string{
+ EnvCredentials: "example.org:123,example.net",
+ },
+ expected: "syse: credentials: incorrect pair: example.net",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvCredentials: "",
+ },
+ expected: "syse: some credentials information are missing: SYSE_CREDENTIALS",
+ },
+ }
+
+ 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
+ creds map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ creds: map[string]string{"example.org": "123"},
+ },
+ {
+ desc: "success multiple domains",
+ creds: map[string]string{
+ "example.org": "123",
+ "example.com": "456",
+ "example.net": "789",
+ },
+ },
+ {
+ desc: "missing credentials",
+ expected: "syse: missing credentials",
+ },
+ {
+ desc: "missing domain",
+ creds: map[string]string{"": "123"},
+ expected: `syse: missing domain: ":123"`,
+ },
+ {
+ desc: "missing password",
+ creds: map[string]string{"example.org": ""},
+ expected: `syse: missing password: "example.org:"`,
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Credentials = test.creds
+
+ 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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Credentials = map[string]string{
+ "example.org": "secret",
+ }
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("/", servermock.DumpRequest()).
+ Route("POST /dns/example.com",
+ servermock.ResponseFromInternal("create_record.json"),
+ servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /dns/example.com/1234",
+ servermock.Noop()).
+ Build(t)
+
+ provider.recordIDs["abc"] = "1234"
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/technitium/internal/client.go b/providers/dns/technitium/internal/client.go
index a68008d34..965638b1d 100644
--- a/providers/dns/technitium/internal/client.go
+++ b/providers/dns/technitium/internal/client.go
@@ -125,6 +125,7 @@ func (c *Client) newFormRequest(ctx context.Context, endpoint *url.URL, payload
if payload != nil {
var err error
+
values, err = querystring.Values(payload)
if err != nil {
return nil, fmt.Errorf("failed to create request body: %w", err)
@@ -149,6 +150,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIResponse[any]
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/technitium/internal/client_test.go b/providers/dns/technitium/internal/client_test.go
index 326c1e8eb..cd6914918 100644
--- a/providers/dns/technitium/internal/client_test.go
+++ b/providers/dns/technitium/internal/client_test.go
@@ -1,51 +1,39 @@
package internal
import (
- "context"
- "io"
- "net/http"
"net/http/httptest"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, filename string) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient(server.URL, "secret")
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client.HTTPClient = server.Client()
- mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
- file, err := os.Open(filepath.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
- }
- })
-
- client, err := NewClient(server.URL, "secret")
- require.NoError(t, err)
-
- client.HTTPClient = server.Client()
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().WithContentTypeFromURLEncoded())
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "POST /api/zones/records/add", "add-record.json")
+ client := mockBuilder().
+ Route("POST /api/zones/records/add",
+ servermock.ResponseFromFixture("add-record.json"),
+ servermock.CheckForm().Strict().
+ With("domain", "_acme-challenge.example.com").
+ With("text", "txtTXTtxt").
+ With("type", "TXT").
+ With("token", "secret")).
+ Build(t)
record := Record{
Domain: "_acme-challenge.example.com",
@@ -53,7 +41,7 @@ func TestClient_AddRecord(t *testing.T) {
Text: "txtTXTtxt",
}
- newRecord, err := client.AddRecord(context.Background(), record)
+ newRecord, err := client.AddRecord(t.Context(), record)
require.NoError(t, err)
expected := &Record{Name: "example.com", Type: "A"}
@@ -62,7 +50,10 @@ func TestClient_AddRecord(t *testing.T) {
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "POST /api/zones/records/add", "error.json")
+ client := mockBuilder().
+ Route("POST /api/zones/records/add",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
record := Record{
Domain: "_acme-challenge.example.com",
@@ -70,14 +61,22 @@ func TestClient_AddRecord_error(t *testing.T) {
Text: "txtTXTtxt",
}
- _, err := client.AddRecord(context.Background(), record)
+ _, err := client.AddRecord(t.Context(), record)
require.Error(t, err)
assert.EqualError(t, err, "Status: error, ErrorMessage: error message, StackTrace: application stack trace, InnerErrorMessage: inner exception message")
}
func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, "POST /api/zones/records/delete", "delete-record.json")
+ client := mockBuilder().
+ Route("POST /api/zones/records/delete",
+ servermock.ResponseFromFixture("delete-record.json"),
+ servermock.CheckForm().Strict().
+ With("domain", "_acme-challenge.example.com").
+ With("text", "txtTXTtxt").
+ With("type", "TXT").
+ With("token", "secret")).
+ Build(t)
record := Record{
Domain: "_acme-challenge.example.com",
@@ -85,12 +84,15 @@ func TestClient_DeleteRecord(t *testing.T) {
Text: "txtTXTtxt",
}
- err := client.DeleteRecord(context.Background(), record)
+ err := client.DeleteRecord(t.Context(), record)
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, "POST /api/zones/records/delete", "error.json")
+ client := mockBuilder().
+ Route("POST /api/zones/records/delete",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
record := Record{
Domain: "_acme-challenge.example.com",
@@ -98,7 +100,7 @@ func TestClient_DeleteRecord_error(t *testing.T) {
Text: "txtTXTtxt",
}
- err := client.DeleteRecord(context.Background(), record)
+ err := client.DeleteRecord(t.Context(), record)
require.Error(t, err)
assert.EqualError(t, err, "Status: error, ErrorMessage: error message, StackTrace: application stack trace, InnerErrorMessage: inner exception message")
diff --git a/providers/dns/technitium/technitium.go b/providers/dns/technitium/technitium.go
index b2cf2d701..fc60c09ad 100644
--- a/providers/dns/technitium/technitium.go
+++ b/providers/dns/technitium/technitium.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/technitium/internal"
)
@@ -87,6 +88,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
diff --git a/providers/dns/technitium/technitium.toml b/providers/dns/technitium/technitium.toml
index 54502957f..ac1fc6466 100644
--- a/providers/dns/technitium/technitium.toml
+++ b/providers/dns/technitium/technitium.toml
@@ -7,7 +7,7 @@ Since = "v4.20.0"
Example = '''
TECHNITIUM_SERVER_BASE_URL="https://localhost:5380" \
TECHNITIUM_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns technitium -d '*.example.com' -d example.com run
+lego --dns technitium -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -23,10 +23,10 @@ Technitium recommends to use it in production over the HTTP API.
TECHNITIUM_SERVER_BASE_URL = "Server base URL"
TECHNITIUM_API_TOKEN = "API token"
[Configuration.Additional]
- TECHNITIUM_POLLING_INTERVAL = "Time between DNS propagation check"
- TECHNITIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- TECHNITIUM_TTL = "The TTL of the TXT record used for the DNS challenge"
- TECHNITIUM_HTTP_TIMEOUT = "API request timeout"
+ TECHNITIUM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ TECHNITIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ TECHNITIUM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ TECHNITIUM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://github.com/TechnitiumSoftware/DnsServer/blob/0f83d23e605956b66ac76921199e241d9cc061bd/APIDOCS.md"
diff --git a/providers/dns/technitium/technitium_test.go b/providers/dns/technitium/technitium_test.go
index da50b6fe6..4eee530fd 100644
--- a/providers/dns/technitium/technitium_test.go
+++ b/providers/dns/technitium/technitium_test.go
@@ -50,6 +50,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -122,6 +123,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -135,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/tencentcloud/tencentcloud.go b/providers/dns/tencentcloud/tencentcloud.go
index 0b662f8c7..00e41e93e 100644
--- a/providers/dns/tencentcloud/tencentcloud.go
+++ b/providers/dns/tencentcloud/tencentcloud.go
@@ -2,6 +2,7 @@
package tencentcloud
import (
+ "context"
"errors"
"fmt"
"math"
@@ -10,9 +11,9 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ dnspod "github.com/go-acme/tencentclouddnspod/v20210323"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
- dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323"
)
// Environment variables names.
@@ -117,7 +118,9 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- zone, err := d.getHostedZone(info.EffectiveFQDN)
+ ctx := context.Background()
+
+ zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("tencentcloud: failed to get hosted zone: %w", err)
}
@@ -136,7 +139,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
request.Value = common.StringPtr(info.Value)
request.TTL = common.Uint64Ptr(uint64(d.config.TTL))
- _, err = d.client.CreateRecord(request)
+ _, err = dnspod.CreateRecordWithContext(ctx, d.client, request)
if err != nil {
return fmt.Errorf("dnspod: API call failed: %w", err)
}
@@ -148,12 +151,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- zone, err := d.getHostedZone(info.EffectiveFQDN)
+ ctx := context.Background()
+
+ zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("tencentcloud: failed to get hosted zone: %w", err)
}
- records, err := d.findTxtRecords(zone, info.EffectiveFQDN)
+ records, err := d.findTxtRecords(ctx, zone, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("tencentcloud: failed to find TXT records: %w", err)
}
@@ -164,7 +169,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
request.DomainId = zone.DomainId
request.RecordId = record.RecordId
- _, err := d.client.DeleteRecord(request)
+ _, err := dnspod.DeleteRecordWithContext(ctx, d.client, request)
if err != nil {
return fmt.Errorf("tencentcloud: delete record failed: %w", err)
}
diff --git a/providers/dns/tencentcloud/tencentcloud.toml b/providers/dns/tencentcloud/tencentcloud.toml
index beb138e91..50f4ee9d5 100644
--- a/providers/dns/tencentcloud/tencentcloud.toml
+++ b/providers/dns/tencentcloud/tencentcloud.toml
@@ -1,13 +1,13 @@
Name = "Tencent Cloud DNS"
Description = ''''''
-URL = "https://cloud.tencent.com/product/cns"
+URL = "https://cloud.tencent.com/product/dns"
Code = "tencentcloud"
Since = "v4.6.0"
Example = '''
TENCENTCLOUD_SECRET_ID=abcdefghijklmnopqrstuvwx \
TENCENTCLOUD_SECRET_KEY=your-secret-key \
-lego --email you@example.com --dns tencentcloud -d '*.example.com' -d example.com run
+lego --dns tencentcloud -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,10 +17,10 @@ lego --email you@example.com --dns tencentcloud -d '*.example.com' -d example.co
[Configuration.Additional]
TENCENTCLOUD_SESSION_TOKEN = "Access Key token"
TENCENTCLOUD_REGION = "Region"
- TENCENTCLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- TENCENTCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- TENCENTCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
- TENCENTCLOUD_HTTP_TIMEOUT = "API request timeout"
+ TENCENTCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ TENCENTCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ TENCENTCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ TENCENTCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://cloud.tencent.com/document/product/1427/56153"
diff --git a/providers/dns/tencentcloud/tencentcloud_test.go b/providers/dns/tencentcloud/tencentcloud_test.go
index c5a2fd974..ce6358174 100644
--- a/providers/dns/tencentcloud/tencentcloud_test.go
+++ b/providers/dns/tencentcloud/tencentcloud_test.go
@@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -127,6 +128,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -140,6 +142,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/tencentcloud/wrapper.go b/providers/dns/tencentcloud/wrapper.go
index 32b66d523..6a66bc1c6 100644
--- a/providers/dns/tencentcloud/wrapper.go
+++ b/providers/dns/tencentcloud/wrapper.go
@@ -1,23 +1,24 @@
package tencentcloud
import (
+ "context"
"errors"
"fmt"
"github.com/go-acme/lego/v4/challenge/dns01"
+ dnspod "github.com/go-acme/tencentclouddnspod/v20210323"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
errorsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
- dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323"
"golang.org/x/net/idna"
)
-func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, error) {
+func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*dnspod.DomainListItem, error) {
request := dnspod.NewDescribeDomainListRequest()
var domains []*dnspod.DomainListItem
for {
- response, err := d.client.DescribeDomainList(request)
+ response, err := dnspod.DescribeDomainListWithContext(ctx, d.client, request)
if err != nil {
return nil, fmt.Errorf("API call failed: %w", err)
}
@@ -37,6 +38,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, erro
}
var hostedZone *dnspod.DomainListItem
+
for _, zone := range domains {
unfqdn := dns01.UnFqdn(authZone)
if *zone.Name == unfqdn || *zone.Punycode == unfqdn {
@@ -51,7 +53,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, erro
return hostedZone, nil
}
-func (d *DNSProvider) findTxtRecords(zone *dnspod.DomainListItem, fqdn string) ([]*dnspod.RecordListItem, error) {
+func (d *DNSProvider) findTxtRecords(ctx context.Context, zone *dnspod.DomainListItem, fqdn string) ([]*dnspod.RecordListItem, error) {
recordName, err := extractRecordName(fqdn, *zone.Name)
if err != nil {
return nil, err
@@ -64,7 +66,7 @@ func (d *DNSProvider) findTxtRecords(zone *dnspod.DomainListItem, fqdn string) (
request.RecordType = common.StringPtr("TXT")
request.RecordLine = common.StringPtr("默认")
- response, err := d.client.DescribeRecordList(request)
+ response, err := dnspod.DescribeRecordListWithContext(ctx, d.client, request)
if err != nil {
var sdkError *errorsdk.TencentCloudSDKError
if errors.As(err, &sdkError) {
@@ -72,6 +74,7 @@ func (d *DNSProvider) findTxtRecords(zone *dnspod.DomainListItem, fqdn string) (
return nil, nil
}
}
+
return nil, err
}
diff --git a/providers/dns/timewebcloud/internal/client.go b/providers/dns/timewebcloud/internal/client.go
index b3030861e..ec3c8703d 100644
--- a/providers/dns/timewebcloud/internal/client.go
+++ b/providers/dns/timewebcloud/internal/client.go
@@ -49,6 +49,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone string, record DNSRecord
}
respData := &CreateRecordResponse{}
+
err = c.do(req, respData)
if err != nil {
return nil, err
@@ -127,6 +128,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var response ErrorResponse
+
err := json.Unmarshal(raw, &response)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/timewebcloud/internal/client_test.go b/providers/dns/timewebcloud/internal/client_test.go
index 5bfa97fa0..9d16ba4c5 100644
--- a/providers/dns/timewebcloud/internal/client_test.go
+++ b/providers/dns/timewebcloud/internal/client_test.go
@@ -1,87 +1,35 @@
package internal
import (
- "bytes"
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func checkAuthorizationHeader(req *http.Request) error {
- val := req.Header.Get("Authorization")
- if val != "Bearer secret" {
- return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret")
- }
- return nil
-}
-
-func writeResponse(rw http.ResponseWriter, statusCode int, filename string) error {
- file, err := os.Open(filepath.Join("fixtures", filename))
- if err != nil {
- return err
- }
-
- defer func() { _ = file.Close() }()
-
- rw.WriteHeader(statusCode)
-
- _, err = io.Copy(rw, file)
- if err != nil {
- return err
- }
-
- return nil
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer secret"),
+ )
}
func TestClient_CreateRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("POST /v1/domains/example.com/dns-records", func(rw http.ResponseWriter, req *http.Request) {
- err := checkAuthorizationHeader(req)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- content, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if string(bytes.TrimSpace(content)) != `{"type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","subdomain":"_acme-challenge"}` {
- http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest)
- return
- }
-
- err = writeResponse(rw, http.StatusOK, "createDomainDNSRecord.json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ client := mockBuilder().
+ Route("POST /v1/domains/example.com/dns-records",
+ servermock.ResponseFromFixture("createDomainDNSRecord.json"),
+ servermock.CheckRequestJSONBody(`{"type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","subdomain":"_acme-challenge"}`)).
+ Build(t)
payload := DNSRecord{
Type: "TXT",
@@ -89,7 +37,7 @@ func TestClient_CreateRecord(t *testing.T) {
SubDomain: "_acme-challenge",
}
- response, err := client.CreateRecord(context.Background(), "example.com.", payload)
+ response, err := client.CreateRecord(t.Context(), "example.com.", payload)
require.NoError(t, err)
expected := &DNSRecord{
@@ -101,51 +49,37 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_CreateRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("POST /v1/domains/example.com/dns-records",
+ servermock.ResponseFromFixture("error_bad_request.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("POST /v1/domains/example.com/dns-records", func(rw http.ResponseWriter, _ *http.Request) {
- err := writeResponse(rw, http.StatusBadRequest, "error_bad_request.json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- _, err := client.CreateRecord(context.Background(), "example.com.", DNSRecord{})
+ _, err := client.CreateRecord(t.Context(), "example.com.", DNSRecord{})
require.Error(t, err)
assert.EqualError(t, err, "400: Value must be a number conforming to the specified constraints (bad_request) [15095f25-aac3-4d60-a788-96cb5136f186]")
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /v1/domains/example.com/dns-records/123",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
- mux.HandleFunc("DELETE /v1/domains/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) {
- err := checkAuthorizationHeader(req)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(http.StatusNoContent)
- })
-
- err := client.DeleteRecord(context.Background(), "example.com.", 123)
+ err := client.DeleteRecord(t.Context(), "example.com.", 123)
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /v1/domains/example.com/dns-records/123",
+ servermock.ResponseFromFixture("error_unauthorized.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
- mux.HandleFunc("DELETE /v1/domains/example.com/dns-records/123", func(rw http.ResponseWriter, _ *http.Request) {
- err := writeResponse(rw, http.StatusBadRequest, "error_unauthorized.json")
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
-
- err := client.DeleteRecord(context.Background(), "example.com.", 123)
+ err := client.DeleteRecord(t.Context(), "example.com.", 123)
require.Error(t, err)
assert.EqualError(t, err, "401: Unauthorized (unauthorized) [15095f25-aac3-4d60-a788-96cb5136f186]")
diff --git a/providers/dns/timewebcloud/internal/types.go b/providers/dns/timewebcloud/internal/types.go
index 81da4df5c..80cdb2c70 100644
--- a/providers/dns/timewebcloud/internal/types.go
+++ b/providers/dns/timewebcloud/internal/types.go
@@ -3,9 +3,11 @@ package internal
import "fmt"
type DNSRecord struct {
- ID int `json:"id,omitempty"`
- Type string `json:"type,omitempty"`
- Value string `json:"value,omitempty"`
+ ID int `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Value string `json:"value,omitempty"`
+
+ // SubDomain is the full name of a subdomain (not only the subdomain label).
SubDomain string `json:"subdomain,omitempty"`
}
diff --git a/providers/dns/timewebcloud/timewebcloud.go b/providers/dns/timewebcloud/timewebcloud.go
index a2ab0dd65..a599566e3 100644
--- a/providers/dns/timewebcloud/timewebcloud.go
+++ b/providers/dns/timewebcloud/timewebcloud.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/timewebcloud/internal"
)
@@ -81,7 +82,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("timewebcloud: authentication token is missing")
}
- client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken))
+ client := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken),
+ ),
+ )
return &DNSProvider{
config: config,
@@ -105,15 +110,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("timewebcloud: could not find zone for domain %q: %w", domain, err)
}
- subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
- if err != nil {
- return fmt.Errorf("timewebcloud: %w", err)
- }
-
record := internal.DNSRecord{
Type: "TXT",
Value: info.Value,
- SubDomain: subDomain,
+ SubDomain: dns01.UnFqdn(info.EffectiveFQDN),
}
response, err := d.client.CreateRecord(context.Background(), authZone, record)
@@ -140,6 +140,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("timewebcloud: unknown record ID for '%s'", info.EffectiveFQDN)
}
diff --git a/providers/dns/timewebcloud/timewebcloud.toml b/providers/dns/timewebcloud/timewebcloud.toml
index 4f8d7e860..c8bde636a 100644
--- a/providers/dns/timewebcloud/timewebcloud.toml
+++ b/providers/dns/timewebcloud/timewebcloud.toml
@@ -6,16 +6,16 @@ Since = "v4.20.0"
Example = '''
TIMEWEBCLOUD_AUTH_TOKEN=xxxxxx \
-lego --email you@example.com --dns timewebcloud -d '*.example.com' -d example.com run
+lego --dns timewebcloud -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
TIMEWEBCLOUD_AUTH_TOKEN = "Authentication token"
[Configuration.Additional]
- TIMEWEBCLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- TIMEWEBCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- TIMEWEBCLOUD_HTTP_TIMEOUT = "API request timeout"
+ TIMEWEBCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ TIMEWEBCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ TIMEWEBCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)"
[Links]
API = "https://timeweb.cloud/api-docs"
diff --git a/providers/dns/timewebcloud/timewebcloud_test.go b/providers/dns/timewebcloud/timewebcloud_test.go
index cd3e2e26f..26e107578 100644
--- a/providers/dns/timewebcloud/timewebcloud_test.go
+++ b/providers/dns/timewebcloud/timewebcloud_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -97,6 +98,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -110,6 +112,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/todaynic/internal/client.go b/providers/dns/todaynic/internal/client.go
new file mode 100644
index 000000000..2c537f4a7
--- /dev/null
+++ b/providers/dns/todaynic/internal/client.go
@@ -0,0 +1,141 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+ querystring "github.com/google/go-querystring/query"
+)
+
+const defaultBaseURL = "https://todapi.now.cn:2443"
+
+// Client the TodayNIC API client.
+type Client struct {
+ authUserID string
+ apiKey string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(authUserID, apiKey string) (*Client, error) {
+ if authUserID == "" || apiKey == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ authUserID: authUserID,
+ apiKey: apiKey,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddRecord(ctx context.Context, record Record) (int, error) {
+ endpoint := c.BaseURL.JoinPath("api", "dns", "add-domain-record.json")
+
+ query, err := querystring.Values(record)
+ if err != nil {
+ return 0, err
+ }
+
+ req, err := c.newRequest(ctx, endpoint, query)
+ if err != nil {
+ return 0, err
+ }
+
+ var result APIResponse
+
+ err = c.do(req, &result)
+ if err != nil {
+ return 0, err
+ }
+
+ return result.ID, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, recordID int) error {
+ endpoint := c.BaseURL.JoinPath("api", "dns", "delete-domain-record.json")
+
+ query := endpoint.Query()
+ query.Set("Id", strconv.Itoa(recordID))
+
+ req, err := c.newRequest(ctx, endpoint, query)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func (c *Client) newRequest(ctx context.Context, endpoint *url.URL, query url.Values) (*http.Request, error) {
+ query.Set("auth-userid", c.authUserID)
+ query.Set("api-key", c.apiKey)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/todaynic/internal/client_test.go b/providers/dns/todaynic/internal/client_test.go
new file mode 100644
index 000000000..71ee7f8b7
--- /dev/null
+++ b/providers/dns/todaynic/internal/client_test.go
@@ -0,0 +1,94 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user123", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestClient_AddRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/dns/add-domain-record.json",
+ servermock.ResponseFromFixture("add_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("Domain", "example.com").
+ With("Host", "_acme-challenge").
+ With("Type", "TXT").
+ With("Value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("Ttl", "600").
+ With("auth-userid", "user123").
+ With("api-key", "secret"),
+ ).
+ Build(t)
+
+ record := Record{
+ Domain: "example.com",
+ Host: "_acme-challenge",
+ Type: "TXT",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: "600",
+ }
+
+ recordID, err := client.AddRecord(t.Context(), record)
+ require.NoError(t, err)
+
+ assert.Equal(t, 11554102, recordID)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/dns/add-domain-record.json",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusNotFound),
+ ).
+ Build(t)
+
+ record := Record{
+ Domain: "example.com",
+ Host: "_acme-challenge",
+ Type: "TXT",
+ Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+ TTL: "600",
+ }
+
+ _, err := client.AddRecord(t.Context(), record)
+ require.EqualError(t, err, "host.repeat (2d5876b2-f272-43e9-acc1-4c6a3d3683b1)")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /api/dns/delete-domain-record.json",
+ servermock.ResponseFromFixture("add_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("Id", "123").
+ With("auth-userid", "user123").
+ With("api-key", "secret"),
+ ).
+ Build(t)
+
+ err := client.DeleteRecord(t.Context(), 123)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/todaynic/internal/fixtures/add_record.json b/providers/dns/todaynic/internal/fixtures/add_record.json
new file mode 100644
index 000000000..27f34d71c
--- /dev/null
+++ b/providers/dns/todaynic/internal/fixtures/add_record.json
@@ -0,0 +1,4 @@
+{
+ "RequestId": "f60ea4d9-67ef-49fa-bbae-06178a6e7293",
+ "Id": 11554102
+}
diff --git a/providers/dns/todaynic/internal/fixtures/error.json b/providers/dns/todaynic/internal/fixtures/error.json
new file mode 100644
index 000000000..3ea9c9310
--- /dev/null
+++ b/providers/dns/todaynic/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+ "RequestId": "2d5876b2-f272-43e9-acc1-4c6a3d3683b1",
+ "error": "host.repeat"
+}
diff --git a/providers/dns/todaynic/internal/types.go b/providers/dns/todaynic/internal/types.go
new file mode 100644
index 000000000..0a15c7da8
--- /dev/null
+++ b/providers/dns/todaynic/internal/types.go
@@ -0,0 +1,26 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+ RequestID string `json:"RequestId"`
+ Message string `json:"error"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("%s (%s)", a.Message, a.RequestID)
+}
+
+type Record struct {
+ Domain string `url:"Domain,omitempty"`
+ Host string `url:"Host,omitempty"`
+ Type string `url:"Type,omitempty"`
+ Value string `url:"Value,omitempty"`
+ Mx string `url:"Mx,omitempty"`
+ TTL string `url:"Ttl,omitempty"`
+}
+
+type APIResponse struct {
+ RequestID string `json:"RequestId"`
+ ID int `json:"Id"`
+}
diff --git a/providers/dns/todaynic/todaynic.go b/providers/dns/todaynic/todaynic.go
new file mode 100644
index 000000000..3a3734033
--- /dev/null
+++ b/providers/dns/todaynic/todaynic.go
@@ -0,0 +1,164 @@
+// Package todaynic implements a DNS provider for solving the DNS-01 challenge using TodayNIC.
+package todaynic
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/todaynic/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "TODAYNIC_"
+
+ EnvAuthUserID = envNamespace + "AUTH_USER_ID"
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ AuthUserID string
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, 600),
+ 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
+
+ recordIDs map[string]int
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for TodayNIC.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAuthUserID, EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("todaynic: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.AuthUserID = values[EnvAuthUserID]
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for TodayNIC.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("todaynic: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.AuthUserID, config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("todaynic: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int),
+ }, 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("todaynic: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("todaynic: %w", err)
+ }
+
+ record := internal.Record{
+ Domain: dns01.UnFqdn(authZone),
+ Host: subDomain,
+ Type: "TXT",
+ Value: info.Value,
+ TTL: strconv.Itoa(d.config.TTL),
+ }
+
+ recordID, err := d.client.AddRecord(context.Background(), record)
+ if err != nil {
+ return fmt.Errorf("todaynic: add record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = recordID
+ 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)
+
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("todaynic: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err := d.client.DeleteRecord(context.Background(), recordID)
+ if err != nil {
+ return fmt.Errorf("todaynic: delete 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/dns/todaynic/todaynic.toml b/providers/dns/todaynic/todaynic.toml
new file mode 100644
index 000000000..16d55ccc0
--- /dev/null
+++ b/providers/dns/todaynic/todaynic.toml
@@ -0,0 +1,25 @@
+Name = "TodayNIC/时代互联"
+Description = ''''''
+URL = "https://www.todaynic.com/"
+Code = "todaynic"
+Since = "v4.32.0"
+
+Example = '''
+TODAYNIC_AUTH_USER_ID="xxx" \
+TODAYNIC_API_KEY="yyy" \
+lego --dns todaynic -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ TODAYNIC_AUTH_USER_ID = "account ID"
+ TODAYNIC_API_KEY = "API key"
+ [Configuration.Additional]
+ TODAYNIC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ TODAYNIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ TODAYNIC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ TODAYNIC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.todaynic.com/partner/mode_Http_Api_detail.php"
+ apipost = "https://docs.apipost.net/docs/detail/49dcef10a876000?target_id=0"
diff --git a/providers/dns/todaynic/todaynic_test.go b/providers/dns/todaynic/todaynic_test.go
new file mode 100644
index 000000000..c73bf6cc5
--- /dev/null
+++ b/providers/dns/todaynic/todaynic_test.go
@@ -0,0 +1,207 @@
+package todaynic
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAuthUserID, EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAuthUserID: "user123",
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing user ID",
+ envVars: map[string]string{
+ EnvAuthUserID: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "todaynic: some credentials information are missing: TODAYNIC_AUTH_USER_ID",
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{
+ EnvAuthUserID: "user123",
+ EnvAPIKey: "",
+ },
+ expected: "todaynic: some credentials information are missing: TODAYNIC_API_KEY",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "todaynic: some credentials information are missing: TODAYNIC_AUTH_USER_ID,TODAYNIC_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ authUserID string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ authUserID: "user123",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing user ID",
+ apiKey: "secret",
+ expected: "todaynic: credentials missing",
+ },
+ {
+ desc: "missing API key",
+ authUserID: "user123",
+ expected: "todaynic: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "todaynic: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.AuthUserID = test.authUserID
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.AuthUserID = "user123"
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders(),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /api/dns/add-domain-record.json",
+ servermock.ResponseFromInternal("add_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("Domain", "example.com").
+ With("Host", "_acme-challenge").
+ With("Type", "TXT").
+ With("Value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY").
+ With("Ttl", "600").
+ With("auth-userid", "user123").
+ With("api-key", "secret"),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /api/dns/delete-domain-record.json",
+ servermock.ResponseFromInternal("add_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("Id", "123").
+ With("auth-userid", "user123").
+ With("api-key", "secret"),
+ ).
+ Build(t)
+
+ provider.recordIDs["abc"] = 123
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/transip/transip.go b/providers/dns/transip/transip.go
index 779704a21..bc2913aa4 100644
--- a/providers/dns/transip/transip.go
+++ b/providers/dns/transip/transip.go
@@ -4,6 +4,7 @@ package transip
import (
"errors"
"fmt"
+ "net/http"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -23,6 +24,7 @@ const (
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
@@ -34,6 +36,7 @@ type Config struct {
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int64
+ HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -42,6 +45,9 @@ func NewDefaultConfig() *Config {
TTL: int64(env.GetOrDefaultInt(EnvTTL, 10)),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
}
}
@@ -73,10 +79,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("transip: the configuration of the DNS provider is nil")
}
- client, err := gotransip.NewClient(gotransip.ClientConfiguration{
+ cfg := gotransip.ClientConfiguration{
AccountName: config.AccountName,
PrivateKeyPath: config.PrivateKeyPath,
- })
+ }
+
+ if config.HTTPClient != nil {
+ cfg.HTTPClient = config.HTTPClient
+ } else {
+ // Uses an explicit default HTTP client because the desec.NewDefaultClientOptions uses the http.DefaultClient.
+ cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}
+ }
+
+ client, err := gotransip.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("transip: %w", err)
}
@@ -153,6 +168,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err = d.repository.RemoveDNSEntry(domainName, entry); err != nil {
return fmt.Errorf("transip: couldn't get Record ID in CleanUp: %w", err)
}
+
return nil
}
}
diff --git a/providers/dns/transip/transip.toml b/providers/dns/transip/transip.toml
index 47059c551..bf7d58ee3 100644
--- a/providers/dns/transip/transip.toml
+++ b/providers/dns/transip/transip.toml
@@ -7,7 +7,7 @@ Since = "v2.0.0"
Example = '''
TRANSIP_ACCOUNT_NAME = "Account name" \
TRANSIP_PRIVATE_KEY_PATH = "transip.key" \
-lego --email you@example.com --dns transip -d '*.example.com' -d example.com run
+lego --dns transip -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,9 +15,10 @@ lego --email you@example.com --dns transip -d '*.example.com' -d example.com run
TRANSIP_ACCOUNT_NAME = "Account name"
TRANSIP_PRIVATE_KEY_PATH = "Private key path"
[Configuration.Additional]
- TRANSIP_POLLING_INTERVAL = "Time between DNS propagation check"
- TRANSIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- TRANSIP_TTL = "The TTL of the TXT record used for the DNS challenge"
+ TRANSIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ TRANSIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)"
+ TRANSIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)"
+ TRANSIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.transip.eu/rest/docs.html"
diff --git a/providers/dns/transip/transip_test.go b/providers/dns/transip/transip_test.go
index b42753680..3c6e86657 100644
--- a/providers/dns/transip/transip_test.go
+++ b/providers/dns/transip/transip_test.go
@@ -58,6 +58,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -79,6 +80,7 @@ func TestNewDNSProvider(t *testing.T) {
// 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()
envTest.Apply(map[string]string{
@@ -156,6 +158,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -169,6 +172,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/ultradns/ultradns.go b/providers/dns/ultradns/ultradns.go
index f95cf18e2..da76c56f4 100644
--- a/providers/dns/ultradns/ultradns.go
+++ b/providers/dns/ultradns/ultradns.go
@@ -4,6 +4,7 @@ package ultradns
import (
"errors"
"fmt"
+ "net/http"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -53,7 +54,7 @@ type Config struct {
func NewDefaultConfig() *Config {
return &Config{
Endpoint: env.GetOrDefaultString(EnvEndpoint, defaultEndpoint),
- TTL: env.GetOrDefaultInt(EnvTTL, 120),
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second),
}
@@ -121,7 +122,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
RecordType: "TXT",
}
- res, _, _ := recordService.Read(rrSetKeyData)
+ resp, _, _ := recordService.Read(rrSetKeyData)
rrSetData := &rrset.RRSet{
OwnerName: info.EffectiveFQDN,
@@ -130,11 +131,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
RData: []string{info.Value},
}
- if res != nil && res.StatusCode == 200 {
+ if resp != nil && resp.StatusCode == http.StatusOK {
_, err = recordService.Update(rrSetKeyData, rrSetData)
} else {
_, err = recordService.Create(rrSetKeyData, rrSetData)
}
+
if err != nil {
return fmt.Errorf("ultradns: %w", err)
}
diff --git a/providers/dns/ultradns/ultradns.toml b/providers/dns/ultradns/ultradns.toml
index c6ff72eac..4c3dbbe72 100644
--- a/providers/dns/ultradns/ultradns.toml
+++ b/providers/dns/ultradns/ultradns.toml
@@ -7,7 +7,7 @@ Since = "v4.10.0"
Example = '''
ULTRADNS_USERNAME=username \
ULTRADNS_PASSWORD=password \
-lego --email you@example.com --dns ultradns -d '*.example.com' -d example.com run
+lego --dns ultradns -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,9 +16,9 @@ lego --email you@example.com --dns ultradns -d '*.example.com' -d example.com ru
ULTRADNS_PASSWORD = "API Password"
[Configuration.Additional]
ULTRADNS_ENDPOINT = "API endpoint URL, defaults to https://api.ultradns.com/"
- ULTRADNS_TTL = "The TTL of the TXT record used for the DNS challenge"
- ULTRADNS_POLLING_INTERVAL = "Time between DNS propagation check"
- ULTRADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
+ ULTRADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ ULTRADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)"
+ ULTRADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
[Links]
API = "https://ultra-portalstatic.ultradns.com/static/docs/REST-API_User_Guide.pdf"
diff --git a/providers/dns/ultradns/ultradns_test.go b/providers/dns/ultradns/ultradns_test.go
index eefa63ec3..464bc51cd 100644
--- a/providers/dns/ultradns/ultradns_test.go
+++ b/providers/dns/ultradns/ultradns_test.go
@@ -177,6 +177,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/uniteddomains/uniteddomains.go b/providers/dns/uniteddomains/uniteddomains.go
new file mode 100644
index 000000000..683cab1fe
--- /dev/null
+++ b/providers/dns/uniteddomains/uniteddomains.go
@@ -0,0 +1,105 @@
+// Package uniteddomains implements a DNS provider for solving the DNS-01 challenge using United-Domains.
+package uniteddomains
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ionos"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "UNITEDDOMAINS_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultBaseURL = "https://dnsapi.united-domains.de/dns"
+
+const minTTL = 300
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config = ionos.Config
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*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 {
+ prv challenge.ProviderTimeout
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for United-Domains.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("uniteddomains: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for United-Domains.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("uniteddomains: the configuration of the DNS provider is nil")
+ }
+
+ provider, err := ionos.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("uniteddomains: %w", err)
+ }
+
+ return &DNSProvider{prv: provider}, 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.prv.Timeout()
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("uniteddomains: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("uniteddomains: %w", err)
+ }
+
+ return nil
+}
diff --git a/providers/dns/uniteddomains/uniteddomains.toml b/providers/dns/uniteddomains/uniteddomains.toml
new file mode 100644
index 000000000..fe8b9e574
--- /dev/null
+++ b/providers/dns/uniteddomains/uniteddomains.toml
@@ -0,0 +1,22 @@
+Name = "United-Domains"
+Description = ''''''
+URL = "https://www.united-domains.de/"
+Code = "uniteddomains"
+Since = "v4.29.0"
+
+Example = '''
+UNITEDDOMAINS_API_KEY=xxxxxxxx \
+lego --dns uniteddomains -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ UNITEDDOMAINS_API_KEY = "API key `.` https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/"
+ [Configuration.Additional]
+ UNITEDDOMAINS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ UNITEDDOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)"
+ UNITEDDOMAINS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ UNITEDDOMAINS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.united-domains.de/dns-apidoc/"
diff --git a/providers/dns/uniteddomains/uniteddomains_test.go b/providers/dns/uniteddomains/uniteddomains_test.go
new file mode 100644
index 000000000..93afb01ab
--- /dev/null
+++ b/providers/dns/uniteddomains/uniteddomains_test.go
@@ -0,0 +1,126 @@
+package uniteddomains
+
+import (
+ "testing"
+
+ "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 credentials",
+ envVars: map[string]string{
+ EnvAPIKey: "",
+ },
+ expected: "uniteddomains: some credentials information are missing: UNITEDDOMAINS_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.prv)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ tll int
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "123",
+ tll: minTTL,
+ },
+ {
+ desc: "missing credentials",
+ tll: minTTL,
+ expected: "uniteddomains: credentials missing",
+ },
+ {
+ desc: "invalid TTL",
+ apiKey: "123",
+ tll: 30,
+ expected: "uniteddomains: invalid TTL, TTL (30) must be greater than 300",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+ config.TTL = test.tll
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.prv)
+ } 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/dns/variomedia/internal/client.go b/providers/dns/variomedia/internal/client.go
index 4a671e88e..0e4ef9518 100644
--- a/providers/dns/variomedia/internal/client.go
+++ b/providers/dns/variomedia/internal/client.go
@@ -38,7 +38,7 @@ func NewClient(apiToken string) *Client {
// CreateDNSRecord creates a new DNS entry.
// https://api.variomedia.de/docs/dns-records.html#erstellen
-func (c Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateDNSRecordResponse, error) {
+func (c *Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateDNSRecordResponse, error) {
endpoint := c.baseURL.JoinPath("dns-records")
data := CreateDNSRecordRequest{Data: Data{
@@ -52,6 +52,7 @@ func (c Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateD
}
var result CreateDNSRecordResponse
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -62,7 +63,7 @@ func (c Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateD
// DeleteDNSRecord deletes a DNS record.
// https://api.variomedia.de/docs/dns-records.html#l%C3%B6schen
-func (c Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordResponse, error) {
+func (c *Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordResponse, error) {
endpoint := c.baseURL.JoinPath("dns-records", id)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -71,6 +72,7 @@ func (c Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordRe
}
var result DeleteRecordResponse
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -81,7 +83,7 @@ func (c Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordRe
// GetJob returns a single job based on its ID.
// https://api.variomedia.de/docs/job-queue.html
-func (c Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) {
+func (c *Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) {
endpoint := c.baseURL.JoinPath("queue-jobs", id)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
@@ -90,6 +92,7 @@ func (c Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error)
}
var result GetJobResponse
+
err = c.do(req, &result)
if err != nil {
return nil, err
@@ -98,7 +101,7 @@ func (c Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error)
return &result, nil
}
-func (c Client) do(req *http.Request, data any) error {
+func (c *Client) do(req *http.Request, data any) error {
req.Header.Set(authorizationHeader, "token "+c.apiToken)
resp, err := c.HTTPClient.Do(req)
@@ -153,6 +156,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var errAPI APIError
+
err := json.Unmarshal(raw, &errAPI)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/variomedia/internal/client_test.go b/providers/dns/variomedia/internal/client_test.go
index c0017f24a..24778bdaf 100644
--- a/providers/dns/variomedia/internal/client_test.go
+++ b/providers/dns/variomedia/internal/client_test.go
@@ -1,68 +1,37 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
- "os"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient("secret")
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
-}
-
-func mockHandler(method string, filename string) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != method {
- http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, method), http.StatusBadRequest)
- return
- }
-
- filename = "./fixtures/" + filename
- statusCode := http.StatusOK
-
- if req.Header.Get(authorizationHeader) != "token secret" {
- statusCode = http.StatusUnauthorized
- filename = "./fixtures/error.json"
- }
-
- rw.WriteHeader(statusCode)
-
- file, err := os.Open(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
- }
- }
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithAccept("application/vnd.variomedia.v1+json").
+ WithAuthorization("token secret"))
}
func TestClient_CreateDNSRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/dns-records", mockHandler(http.MethodPost, "POST_dns-records.json"))
+ client := mockBuilder().
+ Route("POST /dns-records",
+ servermock.ResponseFromFixture("POST_dns-records.json"),
+ servermock.CheckHeader().
+ WithContentType("application/vnd.api+json"),
+ servermock.CheckRequestJSONBody(`{"data":{"type":"dns-record","attributes":{"record_type":"TXT","name":"_acme-challenge","domain":"example.com","data":"test","ttl":300}}}`)).
+ Build(t)
record := DNSRecord{
RecordType: "TXT",
@@ -72,7 +41,7 @@ func TestClient_CreateDNSRecord(t *testing.T) {
TTL: 300,
}
- resp, err := client.CreateDNSRecord(context.Background(), record)
+ resp, err := client.CreateDNSRecord(t.Context(), record)
require.NoError(t, err)
expected := &CreateDNSRecordResponse{
@@ -108,11 +77,12 @@ func TestClient_CreateDNSRecord(t *testing.T) {
}
func TestClient_DeleteDNSRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /dns-records/test",
+ servermock.ResponseFromFixture("DELETE_dns-records_pending.json")).
+ Build(t)
- mux.HandleFunc("/dns-records/test", mockHandler(http.MethodDelete, "DELETE_dns-records_pending.json"))
-
- resp, err := client.DeleteDNSRecord(context.Background(), "test")
+ resp, err := client.DeleteDNSRecord(t.Context(), "test")
require.NoError(t, err)
expected := &DeleteRecordResponse{
@@ -143,11 +113,12 @@ func TestClient_DeleteDNSRecord(t *testing.T) {
}
func TestClient_GetJob(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("GET /queue-jobs/test",
+ servermock.ResponseFromFixture("GET_queue-jobs.json")).
+ Build(t)
- mux.HandleFunc("/queue-jobs/test", mockHandler(http.MethodGet, "GET_queue-jobs.json"))
-
- resp, err := client.GetJob(context.Background(), "test")
+ resp, err := client.GetJob(t.Context(), "test")
require.NoError(t, err)
expected := &GetJobResponse{
diff --git a/providers/dns/variomedia/variomedia.go b/providers/dns/variomedia/variomedia.go
index 0f2c73c05..2d12fd975 100644
--- a/providers/dns/variomedia/variomedia.go
+++ b/providers/dns/variomedia/variomedia.go
@@ -10,11 +10,13 @@ import (
"sync"
"time"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/platform/wait"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/variomedia/internal"
)
@@ -91,6 +93,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
config: config,
client: client,
@@ -161,6 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("variomedia: unknown record ID for '%s'", info.EffectiveFQDN)
}
@@ -175,18 +180,30 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("variomedia: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
-func (d *DNSProvider) waitJob(ctx context.Context, domain string, id string) error {
- return wait.For("variomedia: apply change on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) {
- result, err := d.client.GetJob(ctx, id)
- if err != nil {
- return false, err
- }
+func (d *DNSProvider) waitJob(ctx context.Context, domain, id string) error {
+ return wait.Retry(ctx,
+ func() error {
+ result, err := d.client.GetJob(ctx, id)
+ if err != nil {
+ return fmt.Errorf("apply change on %s: %w", domain, err)
+ }
- log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status)
+ log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status)
- return result.Data.Attributes.Status == "done", nil
- })
+ if result.Data.Attributes.Status != "done" {
+ return fmt.Errorf("apply change on %s: status: %s", domain, result.Data.Attributes.Status)
+ }
+
+ return nil
+ },
+ backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
+ backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
+ )
}
diff --git a/providers/dns/variomedia/variomedia.toml b/providers/dns/variomedia/variomedia.toml
index 945a6f9f5..8390d1922 100644
--- a/providers/dns/variomedia/variomedia.toml
+++ b/providers/dns/variomedia/variomedia.toml
@@ -6,18 +6,18 @@ Since = "v4.8.0"
Example = '''
VARIOMEDIA_API_TOKEN=xxxx \
-lego --email you@example.com --dns variomedia -d '*.example.com' -d example.com run
+lego --dns variomedia -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
VARIOMEDIA_API_TOKEN = "API token"
[Configuration.Additional]
- VARIOMEDIA_POLLING_INTERVAL = "Time between DNS propagation check"
- VARIOMEDIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VARIOMEDIA_TTL = "The TTL of the TXT record used for the DNS challenge"
- VARIOMEDIA_SEQUENCE_INTERVAL = "Time between sequential requests"
- VARIOMEDIA_HTTP_TIMEOUT = "API request timeout"
+ VARIOMEDIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ VARIOMEDIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ VARIOMEDIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ VARIOMEDIA_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ VARIOMEDIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.variomedia.de/docs/dns-records.html"
diff --git a/providers/dns/variomedia/variomedia_test.go b/providers/dns/variomedia/variomedia_test.go
index 305646070..552419fd0 100644
--- a/providers/dns/variomedia/variomedia_test.go
+++ b/providers/dns/variomedia/variomedia_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -91,6 +92,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -104,6 +106,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/vegadns/fixtures/create_record.json b/providers/dns/vegadns/fixtures/create_record.json
new file mode 100644
index 000000000..2199130b9
--- /dev/null
+++ b/providers/dns/vegadns/fixtures/create_record.json
@@ -0,0 +1,12 @@
+{
+ "status": "ok",
+ "record": {
+ "name": "_acme-challenge.example.com",
+ "value": "my_challenge",
+ "record_type": "TXT",
+ "ttl": 3600,
+ "record_id": 3,
+ "location_id": null,
+ "domain_id": 1
+ }
+}
diff --git a/providers/dns/vegadns/fixtures/record_delete.json b/providers/dns/vegadns/fixtures/record_delete.json
new file mode 100644
index 000000000..bc4e01029
--- /dev/null
+++ b/providers/dns/vegadns/fixtures/record_delete.json
@@ -0,0 +1,3 @@
+{
+ "status": "ok"
+}
diff --git a/providers/dns/vegadns/fixtures/records.json b/providers/dns/vegadns/fixtures/records.json
new file mode 100644
index 000000000..9fa41ce7a
--- /dev/null
+++ b/providers/dns/vegadns/fixtures/records.json
@@ -0,0 +1,43 @@
+{
+ "status": "ok",
+ "total_records": 2,
+ "domain": {
+ "status": "active",
+ "domain": "example.com",
+ "owner_id": 0,
+ "domain_id": 1
+ },
+ "records": [
+ {
+ "retry": "2048",
+ "minimum": "2560",
+ "refresh": "16384",
+ "email": "hostmaster.example.com",
+ "record_type": "SOA",
+ "expire": "1048576",
+ "ttl": 86400,
+ "record_id": 1,
+ "nameserver": "ns1.example.com",
+ "domain_id": 1,
+ "serial": ""
+ },
+ {
+ "name": "example.com",
+ "value": "ns1.example.com",
+ "record_type": "NS",
+ "ttl": 3600,
+ "record_id": 2,
+ "location_id": null,
+ "domain_id": 1
+ },
+ {
+ "name": "_acme-challenge.example.com",
+ "value": "my_challenge",
+ "record_type": "TXT",
+ "ttl": 3600,
+ "record_id": 3,
+ "location_id": null,
+ "domain_id": 1
+ }
+ ]
+}
diff --git a/providers/dns/vegadns/fixtures/token.json b/providers/dns/vegadns/fixtures/token.json
new file mode 100644
index 000000000..39ab1a4a9
--- /dev/null
+++ b/providers/dns/vegadns/fixtures/token.json
@@ -0,0 +1,5 @@
+{
+ "access_token": "699dd4ff-e381-46b8-8bf8-5de49dd56c1f",
+ "token_type": "bearer",
+ "expires_in": 3600
+}
diff --git a/providers/dns/vegadns/vegadns.go b/providers/dns/vegadns/vegadns.go
index b56bce97b..9f1f189c3 100644
--- a/providers/dns/vegadns/vegadns.go
+++ b/providers/dns/vegadns/vegadns.go
@@ -2,14 +2,17 @@
package vegadns
import (
+ "context"
"errors"
"fmt"
+ "net/http"
"time"
- vegaClient "github.com/OpenDNS/vegadns2client"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/nrdcg/vegadns"
)
// Environment variables names.
@@ -23,18 +26,21 @@ const (
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- BaseURL string
- APIKey string
- APISecret string
+ BaseURL string
+ APIKey string
+ APISecret string
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
+ HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -42,14 +48,17 @@ func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, 10),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 12*time.Minute),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, time.Minute),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
- client vegaClient.VegaDNSClient
+ client *vegadns.Client
}
// NewDNSProvider returns a DNSProvider instance configured for VegaDNS.
@@ -75,11 +84,21 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("vegadns: the configuration of the DNS provider is nil")
}
- vega := vegaClient.NewVegaDNSClient(config.BaseURL)
- vega.APIKey = config.APIKey
- vega.APISecret = config.APISecret
+ if config.HTTPClient == nil {
+ config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
+ }
- return &DNSProvider{client: vega, config: config}, nil
+ config.HTTPClient = clientdebug.Wrap(config.HTTPClient)
+
+ client, err := vegadns.NewClient(config.BaseURL,
+ vegadns.WithOAuth(config.APIKey, config.APISecret),
+ vegadns.WithHTTPClient(config.HTTPClient),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("vegadns: %w", err)
+ }
+
+ return &DNSProvider{client: client, config: config}, nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -90,39 +109,71 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
// 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)
- _, domainID, err := d.client.GetAuthZone(info.EffectiveFQDN)
+ domainID, err := d.findDomainID(ctx, info.EffectiveFQDN)
if err != nil {
- return fmt.Errorf("vegadns: can't find Authoritative Zone for %s in Present: %w", info.EffectiveFQDN, err)
+ return fmt.Errorf("vegadns: find domain ID for %s: %w", info.EffectiveFQDN, err)
}
- err = d.client.CreateTXT(domainID, info.EffectiveFQDN, info.Value, d.config.TTL)
+ err = d.client.CreateTXTRecord(ctx, domainID, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL)
if err != nil {
- return fmt.Errorf("vegadns: %w", err)
+ return fmt.Errorf("vegadns: create TXT 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)
- _, domainID, err := d.client.GetAuthZone(info.EffectiveFQDN)
+ domainID, err := d.findDomainID(ctx, info.EffectiveFQDN)
if err != nil {
- return fmt.Errorf("vegadns: can't find Authoritative Zone for %s in CleanUp: %w", info.EffectiveFQDN, err)
+ return fmt.Errorf("vegadns: find domain ID for %s: %w", info.EffectiveFQDN, err)
}
- txt := dns01.UnFqdn(info.EffectiveFQDN)
-
- recordID, err := d.client.GetRecordID(domainID, txt, "TXT")
+ recordID, err := d.findRecordID(ctx, domainID, dns01.UnFqdn(info.EffectiveFQDN))
if err != nil {
- return fmt.Errorf("vegadns: couldn't get Record ID in CleanUp: %w", err)
+ return fmt.Errorf("vegadns: find record ID for %d: %w", domainID, err)
}
- err = d.client.DeleteRecord(recordID)
+ err = d.client.DeleteRecord(ctx, recordID)
if err != nil {
- return fmt.Errorf("vegadns: %w", err)
+ return fmt.Errorf("vegadns: delete record: %w", err)
}
+
return nil
}
+
+func (d *DNSProvider) findDomainID(ctx context.Context, fqdn string) (int, error) {
+ for host := range dns01.UnFqdnDomainsSeq(fqdn) {
+ id, err := d.client.GetDomainID(ctx, host)
+ if err != nil {
+ continue
+ }
+
+ return id, nil
+ }
+
+ return 0, errors.New("domain not found")
+}
+
+func (d *DNSProvider) findRecordID(ctx context.Context, domainID int, name string) (int, error) {
+ records, err := d.client.GetRecords(ctx, domainID)
+ if err != nil {
+ return 0, fmt.Errorf("get records: %w", err)
+ }
+
+ for _, r := range records {
+ if r.Name == name && r.RecordType == "TXT" {
+ return r.RecordID, nil
+ }
+ }
+
+ return 0, errors.New("record not found")
+}
diff --git a/providers/dns/vegadns/vegadns.toml b/providers/dns/vegadns/vegadns.toml
index e1a7cc713..d01490f55 100644
--- a/providers/dns/vegadns/vegadns.toml
+++ b/providers/dns/vegadns/vegadns.toml
@@ -12,9 +12,9 @@ Example = ''''''
SECRET_VEGADNS_SECRET = "API secret"
VEGADNS_URL = "API endpoint URL"
[Configuration.Additional]
- VEGADNS_POLLING_INTERVAL = "Time between DNS propagation check"
- VEGADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VEGADNS_TTL = "The TTL of the TXT record used for the DNS challenge"
+ VEGADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)"
+ VEGADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 720)"
+ VEGADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)"
[Links]
API = "https://github.com/shupp/VegaDNS-API"
diff --git a/providers/dns/vegadns/vegadns_mock_test.go b/providers/dns/vegadns/vegadns_mock_test.go
deleted file mode 100644
index 5a705e092..000000000
--- a/providers/dns/vegadns/vegadns_mock_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package vegadns
-
-const tokenResponseMock = `
-{
- "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f",
- "token_type":"bearer",
- "expires_in":3600
-}
-`
-
-const domainsResponseMock = `
-{
- "domains":[
- {
- "domain_id":1,
- "domain":"example.com",
- "status":"active",
- "owner_id":0
- }
- ]
-}
-`
-
-const recordsResponseMock = `
-{
- "status":"ok",
- "total_records":2,
- "domain":{
- "status":"active",
- "domain":"example.com",
- "owner_id":0,
- "domain_id":1
- },
- "records":[
- {
- "retry":"2048",
- "minimum":"2560",
- "refresh":"16384",
- "email":"hostmaster.example.com",
- "record_type":"SOA",
- "expire":"1048576",
- "ttl":86400,
- "record_id":1,
- "nameserver":"ns1.example.com",
- "domain_id":1,
- "serial":""
- },
- {
- "name":"example.com",
- "value":"ns1.example.com",
- "record_type":"NS",
- "ttl":3600,
- "record_id":2,
- "location_id":null,
- "domain_id":1
- },
- {
- "name":"_acme-challenge.example.com",
- "value":"my_challenge",
- "record_type":"TXT",
- "ttl":3600,
- "record_id":3,
- "location_id":null,
- "domain_id":1
- }
- ]
-}
-`
-
-const recordCreatedResponseMock = `
-{
- "status":"ok",
- "record":{
- "name":"_acme-challenge.example.com",
- "value":"my_challenge",
- "record_type":"TXT",
- "ttl":3600,
- "record_id":3,
- "location_id":null,
- "domain_id":1
- }
-}
-`
-
-const recordDeletedResponseMock = `{"status": "ok"}`
diff --git a/providers/dns/vegadns/vegadns_test.go b/providers/dns/vegadns/vegadns_test.go
index 60f614c3b..edcd2c60d 100644
--- a/providers/dns/vegadns/vegadns_test.go
+++ b/providers/dns/vegadns/vegadns_test.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,6 +19,7 @@ var envTest = tester.NewEnvTest(EnvKey, EnvSecret, EnvURL)
func TestNewDNSProvider_Fail(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
_, err := NewDNSProvider()
@@ -26,12 +28,10 @@ func TestNewDNSProvider_Fail(t *testing.T) {
func TestDNSProvider_TimeoutSuccess(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
- setupTest(t, muxSuccess())
-
- provider, err := NewDNSProvider()
- require.NoError(t, err)
+ provider := mockBuilder().Build(t)
timeout, interval := provider.Timeout()
assert.Equal(t, 12*time.Minute, timeout)
@@ -42,35 +42,51 @@ func TestDNSProvider_Present(t *testing.T) {
testCases := []struct {
desc string
handler http.Handler
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "Success",
- handler: muxSuccess(),
+ desc: "success",
+ builder: mockBuilder().
+ Route("POST /1.0/token",
+ servermock.ResponseFromFixture("token.json")).
+ Route("GET /1.0/domains", getDomainHandler()).
+ Route("POST /1.0/records",
+ servermock.ResponseFromFixture("create_record.json").
+ WithStatusCode(http.StatusCreated)),
},
{
- desc: "FailToFindZone",
- handler: muxFailToFindZone(),
- expectedError: "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in Present: Unable to find auth zone for fqdn _acme-challenge.example.com",
+ desc: "fail to find the zone",
+ builder: mockBuilder().
+ Route("POST /1.0/token",
+ servermock.ResponseFromFixture("token.json")).
+ Route("GET /1.0/domains",
+ servermock.Noop().
+ WithStatusCode(http.StatusNotFound)),
+ expectedError: "vegadns: find domain ID for _acme-challenge.example.com.: domain not found",
},
{
- desc: "FailToCreateTXT",
- handler: muxFailToCreateTXT(),
- expectedError: "vegadns: Got bad answer from VegaDNS on CreateTXT. Code: 400. Message: ",
+ desc: "fail to create TXT record",
+ builder: mockBuilder().
+ Route("POST /1.0/token",
+ servermock.ResponseFromFixture("token.json")).
+ Route("GET /1.0/domains", getDomainHandler()).
+ Route("POST /1.0/records",
+ servermock.Noop().
+ WithStatusCode(http.StatusBadRequest)),
+ expectedError: "vegadns: create TXT record: bad answer from VegaDNS (code: 400, message: )",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
- setupTest(t, test.handler)
+ provider := test.builder.Build(t)
- provider, err := NewDNSProvider()
- require.NoError(t, err)
-
- err = provider.Present(testDomain, "token", "keyAuth")
+ err := provider.Present(testDomain, "token", "keyAuth")
if test.expectedError == "" {
assert.NoError(t, err)
} else {
@@ -83,36 +99,54 @@ func TestDNSProvider_Present(t *testing.T) {
func TestDNSProvider_CleanUp(t *testing.T) {
testCases := []struct {
desc string
- handler http.Handler
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "Success",
- handler: muxSuccess(),
+ desc: "success",
+ builder: mockBuilder().
+ Route("POST /1.0/token",
+ servermock.ResponseFromFixture("token.json")).
+ Route("GET /1.0/domains", getDomainHandler()).
+ Route("GET /1.0/records",
+ servermock.ResponseFromFixture("records.json"),
+ servermock.CheckQueryParameter().With("domain_id", "1")).
+ Route("DELETE /1.0/records/3",
+ servermock.ResponseFromFixture("record_delete.json")),
},
{
- desc: "FailToFindZone",
- handler: muxFailToFindZone(),
- expectedError: "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in CleanUp: Unable to find auth zone for fqdn _acme-challenge.example.com",
+ desc: "fail to find the zone",
+ builder: mockBuilder().
+ Route("POST /1.0/token",
+ servermock.ResponseFromFixture("token.json")).
+ Route("GET /1.0/domains",
+ servermock.Noop().
+ WithStatusCode(http.StatusNotFound)),
+ expectedError: "vegadns: find domain ID for _acme-challenge.example.com.: domain not found",
},
{
- desc: "FailToGetRecordID",
- handler: muxFailToGetRecordID(),
- expectedError: "vegadns: couldn't get Record ID in CleanUp: Got bad answer from VegaDNS on GetRecordID. Code: 404. Message: ",
+ desc: "fail to get record ID",
+ builder: mockBuilder().
+ Route("POST /1.0/token",
+ servermock.ResponseFromFixture("token.json")).
+ Route("GET /1.0/domains", getDomainHandler()).
+ Route("GET /1.0/records",
+ servermock.Noop().
+ WithStatusCode(http.StatusNotFound),
+ servermock.CheckQueryParameter().With("domain_id", "1")),
+ expectedError: "vegadns: find record ID for 1: get records: bad answer from VegaDNS (code: 404, message: )",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
- setupTest(t, test.handler)
+ provider := test.builder.Build(t)
- provider, err := NewDNSProvider()
- require.NoError(t, err)
-
- err = provider.CleanUp(testDomain, "token", "keyAuth")
+ err := provider.CleanUp(testDomain, "token", "keyAuth")
if test.expectedError == "" {
assert.NoError(t, err)
} else {
@@ -122,163 +156,37 @@ func TestDNSProvider_CleanUp(t *testing.T) {
}
}
-func muxSuccess() *http.ServeMux {
- mux := http.NewServeMux()
+func getDomainHandler() http.HandlerFunc {
+ return func(rw http.ResponseWriter, req *http.Request) {
+ if req.URL.Query().Get("search") == testDomain {
+ fmt.Fprint(rw, `
+{
+ "domains":[
+ {
+ "domain_id":1,
+ "domain":"example.com",
+ "status":"active",
+ "owner_id":0
+ }
+ ]
+}
+`)
- mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, tokenResponseMock)
return
}
- w.WriteHeader(http.StatusBadRequest)
- })
- mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Query().Get("search") == "example.com" {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, domainsResponseMock)
- return
- }
- w.WriteHeader(http.StatusNotFound)
- })
-
- mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- if r.URL.Query().Get("domain_id") == "1" {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, recordsResponseMock)
- return
- }
- w.WriteHeader(http.StatusNotFound)
- return
- case http.MethodPost:
- w.WriteHeader(http.StatusCreated)
- fmt.Fprint(w, recordCreatedResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/1.0/records/3", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodDelete {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, recordDeletedResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- fmt.Printf("Not Found for Request: (%+v)\n\n", r)
- })
-
- return mux
+ rw.WriteHeader(http.StatusNotFound)
+ }
}
-func muxFailToFindZone() *http.ServeMux {
- mux := http.NewServeMux()
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ envTest.Apply(map[string]string{
+ EnvKey: "key",
+ EnvSecret: "secret",
+ EnvURL: server.URL,
+ })
- mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, tokenResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- })
-
- return mux
-}
-
-func muxFailToCreateTXT() *http.ServeMux {
- mux := http.NewServeMux()
-
- mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, tokenResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Query().Get("search") == testDomain {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, domainsResponseMock)
- return
- }
- w.WriteHeader(http.StatusNotFound)
- })
-
- mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- if r.URL.Query().Get("domain_id") == "1" {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, recordsResponseMock)
- return
- }
- w.WriteHeader(http.StatusNotFound)
- return
- case http.MethodPost:
- w.WriteHeader(http.StatusBadRequest)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- return mux
-}
-
-func muxFailToGetRecordID() *http.ServeMux {
- mux := http.NewServeMux()
-
- mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, tokenResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Query().Get("search") == testDomain {
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, domainsResponseMock)
- return
- }
- w.WriteHeader(http.StatusNotFound)
- })
-
- mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- return mux
-}
-
-func setupTest(t *testing.T, mux http.Handler) {
- t.Helper()
-
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- envTest.Apply(map[string]string{
- EnvKey: "key",
- EnvSecret: "secret",
- EnvURL: server.URL,
+ return NewDNSProvider()
})
}
diff --git a/providers/dns/vercel/internal/client.go b/providers/dns/vercel/internal/client.go
index 4bc59ba0c..930f3543e 100644
--- a/providers/dns/vercel/internal/client.go
+++ b/providers/dns/vercel/internal/client.go
@@ -51,6 +51,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) (
}
respData := &CreateRecordResponse{}
+
err = c.do(req, respData)
if err != nil {
return nil, err
@@ -61,7 +62,7 @@ func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) (
// DeleteRecord deletes a DNS record.
// https://vercel.com/docs/rest-api#endpoints/dns/delete-a-dns-record
-func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID string) error {
+func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error {
endpoint := c.baseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", recordID)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
@@ -135,6 +136,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var response APIErrorResponse
+
err := json.Unmarshal(raw, &response)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/vercel/internal/client_test.go b/providers/dns/vercel/internal/client_test.go
index 771349b25..eb5ee501d 100644
--- a/providers/dns/vercel/internal/client_test.go
+++ b/providers/dns/vercel/internal/client_test.go
@@ -1,72 +1,38 @@
package internal
import (
- "bytes"
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123")
+ client.baseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123")
- client.baseURL, _ = url.Parse(server.URL)
-
- return client, mux
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("Bearer secret"))
}
func TestClient_CreateRecord(t *testing.T) {
- client, mux := setupTest(t)
-
- mux.HandleFunc("/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Bearer secret" {
- http.Error(rw, fmt.Sprintf("invalid API token: %s", auth), http.StatusUnauthorized)
- return
- }
-
- teamID := req.URL.Query().Get("teamId")
- if teamID != "123" {
- http.Error(rw, fmt.Sprintf("invalid team ID: %s", teamID), http.StatusUnauthorized)
- return
- }
-
- reqBody, err := io.ReadAll(req.Body)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- expectedReqBody := `{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}`
- assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody)))
-
- rw.WriteHeader(http.StatusOK)
- _, err = fmt.Fprintf(rw, `{
+ client := mockBuilder().
+ Route("POST /v2/domains/example.com/records",
+ servermock.RawStringResponse(`{
"uid": "9e2eab60-0ba5-4dff-b481-2999c9764b84",
"updated": 1
- }`)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ }`),
+ servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}`),
+ servermock.CheckQueryParameter().Strict().
+ With("teamId", "123")).
+ Build(t)
record := Record{
Name: "_acme-challenge.example.com.",
@@ -75,7 +41,7 @@ func TestClient_CreateRecord(t *testing.T) {
TTL: 60,
}
- resp, err := client.CreateRecord(context.Background(), "example.com.", record)
+ resp, err := client.CreateRecord(t.Context(), "example.com.", record)
require.NoError(t, err)
expected := &CreateRecordResponse{
@@ -87,28 +53,12 @@ func TestClient_CreateRecord(t *testing.T) {
}
func TestClient_DeleteRecord(t *testing.T) {
- client, mux := setupTest(t)
+ client := mockBuilder().
+ Route("DELETE /v2/domains/example.com/records/1234567", nil,
+ servermock.CheckQueryParameter().Strict().
+ With("teamId", "123")).
+ Build(t)
- mux.HandleFunc("/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
- return
- }
- auth := req.Header.Get("Authorization")
- if auth != "Bearer secret" {
- http.Error(rw, fmt.Sprintf("invalid API token: %s", auth), http.StatusUnauthorized)
- return
- }
-
- teamID := req.URL.Query().Get("teamId")
- if teamID != "123" {
- http.Error(rw, fmt.Sprintf("invalid team ID: %s", teamID), http.StatusUnauthorized)
- return
- }
-
- rw.WriteHeader(http.StatusOK)
- })
-
- err := client.DeleteRecord(context.Background(), "example.com.", "1234567")
+ err := client.DeleteRecord(t.Context(), "example.com.", "1234567")
require.NoError(t, err)
}
diff --git a/providers/dns/vercel/vercel.go b/providers/dns/vercel/vercel.go
index bf3a0f532..965e3de12 100644
--- a/providers/dns/vercel/vercel.go
+++ b/providers/dns/vercel/vercel.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/vercel/internal"
)
@@ -44,7 +45,7 @@ type Config struct {
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, 60),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
@@ -86,7 +87,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("vercel: credentials missing")
}
- client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), config.TeamID)
+ client := internal.NewClient(
+ clientdebug.Wrap(
+ internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken),
+ ),
+ config.TeamID,
+ )
return &DNSProvider{
config: config,
@@ -142,6 +148,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("vercel: unknown record ID for '%s'", info.EffectiveFQDN)
}
diff --git a/providers/dns/vercel/vercel.toml b/providers/dns/vercel/vercel.toml
index 60df41798..4700d6d78 100644
--- a/providers/dns/vercel/vercel.toml
+++ b/providers/dns/vercel/vercel.toml
@@ -6,7 +6,7 @@ Since = "v4.7.0"
Example = '''
VERCEL_API_TOKEN=xxxxxx \
-lego --email you@example.com --dns vercel -d '*.example.com' -d example.com run
+lego --dns vercel -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns vercel -d '*.example.com' -d example.com run
VERCEL_API_TOKEN = "Authentication token"
[Configuration.Additional]
VERCEL_TEAM_ID = "Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx)"
- VERCEL_POLLING_INTERVAL = "Time between DNS propagation check"
- VERCEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VERCEL_TTL = "The TTL of the TXT record used for the DNS challenge"
- VERCEL_HTTP_TIMEOUT = "API request timeout"
+ VERCEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ VERCEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ VERCEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ VERCEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://vercel.com/docs/rest-api#endpoints/dns"
diff --git a/providers/dns/vercel/vercel_test.go b/providers/dns/vercel/vercel_test.go
index 6c19a4db5..d4cf37904 100644
--- a/providers/dns/vercel/vercel_test.go
+++ b/providers/dns/vercel/vercel_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/versio/fixtures/error_failToCreateTXT.json b/providers/dns/versio/fixtures/error_failToCreateTXT.json
new file mode 100644
index 000000000..1e1784517
--- /dev/null
+++ b/providers/dns/versio/fixtures/error_failToCreateTXT.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 400,
+ "message": "ProcessError|DNS record invalid type _acme-challenge.example.eu. TST"
+ }
+}
diff --git a/providers/dns/versio/fixtures/error_failToFindZone.json b/providers/dns/versio/fixtures/error_failToFindZone.json
new file mode 100644
index 000000000..635b2bda1
--- /dev/null
+++ b/providers/dns/versio/fixtures/error_failToFindZone.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 401,
+ "message": "ObjectDoesNotExist|Domain not found"
+ }
+}
diff --git a/providers/dns/versio/fixtures/token.json b/providers/dns/versio/fixtures/token.json
new file mode 100644
index 000000000..0dc0dda25
--- /dev/null
+++ b/providers/dns/versio/fixtures/token.json
@@ -0,0 +1,5 @@
+{
+ "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f",
+ "token_type":"bearer",
+ "expires_in":3600
+}
diff --git a/providers/dns/versio/internal/client.go b/providers/dns/versio/internal/client.go
index 6f70aacd2..6a92cc958 100644
--- a/providers/dns/versio/internal/client.go
+++ b/providers/dns/versio/internal/client.go
@@ -26,7 +26,7 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(username string, password string) *Client {
+func NewClient(username, password string) *Client {
baseURL, _ := url.Parse(DefaultBaseURL)
return &Client{
@@ -48,6 +48,7 @@ func (c *Client) UpdateDomain(ctx context.Context, domain string, msg *DomainInf
}
respData := &DomainInfoResponse{}
+
err = c.do(req, respData)
if err != nil {
return nil, err
@@ -71,6 +72,7 @@ func (c *Client) GetDomain(ctx context.Context, domain string) (*DomainInfoRespo
}
respData := &DomainInfoResponse{}
+
err = c.do(req, respData)
if err != nil {
return nil, err
@@ -88,6 +90,7 @@ func (c *Client) do(req *http.Request, result any) error {
if resp != nil {
defer func() { _ = resp.Body.Close() }()
}
+
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
@@ -140,6 +143,7 @@ func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
response := &ErrorResponse{}
+
err := json.Unmarshal(raw, response)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
diff --git a/providers/dns/versio/internal/client_test.go b/providers/dns/versio/internal/client_test.go
index f1015d28a..8dfcb4ff8 100644
--- a/providers/dns/versio/internal/client_test.go
+++ b/providers/dns/versio/internal/client_test.go
@@ -1,64 +1,38 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, pattern string, h http.HandlerFunc) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc(pattern, h)
-
- client := NewClient("user", "secret")
- client.HTTPClient = server.Client()
- client.BaseURL, _ = url.Parse(server.URL)
-
- return client
-}
-
-func writeFixture(rw http.ResponseWriter, filename string) {
- 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)
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("user", "secret"))
}
func TestClient_GetDomain(t *testing.T) {
- client := setupTest(t, "/domains/example.com", 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
- }
+ client := mockBuilder().
+ Route("GET /domains/example.com",
+ servermock.ResponseFromFixture("get-domain.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("show_dns_records", "true")).
+ Build(t)
- auth := req.Header.Get("Authorization")
- if auth != "Basic dXNlcjpzZWNyZXQ=" {
- http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
- return
- }
-
- writeFixture(rw, "get-domain.json")
- })
-
- records, err := client.GetDomain(context.Background(), "example.com")
+ records, err := client.GetDomain(t.Context(), "example.com")
require.NoError(t, err)
expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{
@@ -80,36 +54,22 @@ func TestClient_GetDomain(t *testing.T) {
}
func TestClient_GetDomain_error(t *testing.T) {
- client := setupTest(t, "/domains/example.com", 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
- }
+ client := mockBuilder().
+ Route("GET /domains/example.com",
+ servermock.ResponseFromFixture("get-domain-error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- rw.WriteHeader(http.StatusUnauthorized)
-
- writeFixture(rw, "get-domain-error.json")
- })
-
- _, err := client.GetDomain(context.Background(), "example.com")
+ _, err := client.GetDomain(t.Context(), "example.com")
require.ErrorAs(t, err, &ErrorMessage{})
}
func TestClient_UpdateDomain(t *testing.T) {
- client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- auth := req.Header.Get("Authorization")
- if auth != "Basic dXNlcjpzZWNyZXQ=" {
- http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
- return
- }
-
- writeFixture(rw, "update-domain.json")
- })
+ client := mockBuilder().
+ Route("POST /domains/example.com/update",
+ servermock.ResponseFromFixture("update-domain.json"),
+ servermock.CheckRequestJSONBodyFromFixture("update-domain-request.json")).
+ Build(t)
msg := &DomainInfo{DNSRecords: []Record{
{Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600},
@@ -126,7 +86,7 @@ func TestClient_UpdateDomain(t *testing.T) {
{Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400},
}}
- records, err := client.UpdateDomain(context.Background(), "example.com", msg)
+ records, err := client.UpdateDomain(t.Context(), "example.com", msg)
require.NoError(t, err)
expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{
@@ -148,16 +108,11 @@ func TestClient_UpdateDomain(t *testing.T) {
}
func TestClient_UpdateDomain_error(t *testing.T) {
- client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
- return
- }
-
- rw.WriteHeader(http.StatusUnauthorized)
-
- writeFixture(rw, "update-domain.json")
- })
+ client := mockBuilder().
+ Route("POST /domains/example.com/update",
+ servermock.ResponseFromFixture("update-domain-error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
msg := &DomainInfo{DNSRecords: []Record{
{Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600},
@@ -174,6 +129,6 @@ func TestClient_UpdateDomain_error(t *testing.T) {
{Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400},
}}
- _, err := client.UpdateDomain(context.Background(), "example.com", msg)
+ _, err := client.UpdateDomain(t.Context(), "example.com", msg)
require.ErrorAs(t, err, &ErrorMessage{})
}
diff --git a/providers/dns/versio/internal/fixtures/update-domain-request.json b/providers/dns/versio/internal/fixtures/update-domain-request.json
new file mode 100644
index 000000000..f351678fc
--- /dev/null
+++ b/providers/dns/versio/internal/fixtures/update-domain-request.json
@@ -0,0 +1,78 @@
+{
+ "dns_records": [
+ {
+ "type": "MX",
+ "name": "example.com",
+ "value": "fallback.axc.eu",
+ "prio": 20,
+ "ttl": 3600
+ },
+ {
+ "type": "TXT",
+ "name": "example.com",
+ "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"",
+ "ttl": 3600
+ },
+ {
+ "type": "A",
+ "name": "example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "A",
+ "name": "ftp.example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "A",
+ "name": "localhost.example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "A",
+ "name": "pop.example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "A",
+ "name": "smtp.example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "A",
+ "name": "www.example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "A",
+ "name": "dev.example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "A",
+ "name": "_domainkey.domain.com.example.com",
+ "value": "185.13.227.159",
+ "ttl": 14400
+ },
+ {
+ "type": "MX",
+ "name": "example.com",
+ "value": "spamfilter2.axc.eu",
+ "ttl": 3600
+ },
+ {
+ "type": "A",
+ "name": "redirect.example.com",
+ "value": "localhost",
+ "prio": 10,
+ "ttl": 14400
+ }
+ ]
+}
diff --git a/providers/dns/versio/versio.go b/providers/dns/versio/versio.go
index 08a2d4639..05a7263c4 100644
--- a/providers/dns/versio/versio.go
+++ b/providers/dns/versio/versio.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/versio/internal"
)
@@ -55,7 +56,7 @@ func NewDefaultConfig() *Config {
return &Config{
BaseURL: baseURL,
TTL: env.GetOrDefaultInt(EnvTTL, 300),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
HTTPClient: &http.Client{
@@ -91,9 +92,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("versio: the configuration of the DNS provider is nil")
}
+
if config.Username == "" {
return nil, errors.New("versio: the versio username is missing")
}
+
if config.Password == "" {
return nil, errors.New("versio: the versio password is missing")
}
@@ -108,6 +111,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -155,6 +160,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("versio: %w", err)
}
+
return nil
}
@@ -182,6 +188,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
// loop through the existing entries and remove the specific record
msg := &internal.DomainInfo{}
+
for _, e := range domains.DomainInfo.DNSRecords {
if e.Name != info.EffectiveFQDN {
msg.DNSRecords = append(msg.DNSRecords, e)
@@ -192,5 +199,6 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("versio: %w", err)
}
+
return nil
}
diff --git a/providers/dns/versio/versio.toml b/providers/dns/versio/versio.toml
index 7fc27ebcd..733947095 100644
--- a/providers/dns/versio/versio.toml
+++ b/providers/dns/versio/versio.toml
@@ -7,7 +7,7 @@ Since = "v2.7.0"
Example = '''
VERSIO_USERNAME= \
VERSIO_PASSWORD= \
-lego --email you@example.com --dns versio -d '*.example.com' -d example.com run
+lego --dns versio -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -20,11 +20,11 @@ To test with the sandbox environment set ```VERSIO_ENDPOINT=https://www.versio.n
VERSIO_PASSWORD = "Basic authentication password"
[Configuration.Additional]
VERSIO_ENDPOINT = "The endpoint URL of the API Server"
- VERSIO_POLLING_INTERVAL = "Time between DNS propagation check"
- VERSIO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VERSIO_HTTP_TIMEOUT = "API request timeout"
- VERSIO_SEQUENCE_INTERVAL = "Time between sequential requests, default 60s"
- VERSIO_TTL = "The TTL of the TXT record used for the DNS challenge"
+ VERSIO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ VERSIO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ VERSIO_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ VERSIO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ VERSIO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.versio.nl/RESTapidoc/"
diff --git a/providers/dns/versio/versio_mock_test.go b/providers/dns/versio/versio_mock_test.go
deleted file mode 100644
index 07dc74e83..000000000
--- a/providers/dns/versio/versio_mock_test.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package versio
-
-const tokenResponseMock = `
-{
- "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f",
- "token_type":"bearer",
- "expires_in":3600
-}
-`
-
-const tokenFailToFindZoneMock = `{"error":{"code":401,"message":"ObjectDoesNotExist|Domain not found"}}`
-
-const tokenFailToCreateTXTMock = `{"error":{"code":400,"message":"ProcessError|DNS record invalid type _acme-challenge.example.eu. TST"}}`
diff --git a/providers/dns/versio/versio_test.go b/providers/dns/versio/versio_test.go
index 09040ab4c..563e70d05 100644
--- a/providers/dns/versio/versio_test.go
+++ b/providers/dns/versio/versio_test.go
@@ -1,14 +1,12 @@
package versio
import (
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"testing"
- "github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -56,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -125,21 +124,37 @@ func TestNewDNSProviderConfig(t *testing.T) {
func TestDNSProvider_Present(t *testing.T) {
testCases := []struct {
desc string
- handler http.Handler
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "Success",
- handler: muxSuccess(),
+ desc: "Success",
+ builder: mockBuilder().
+ Route("GET /domains/example.com",
+ servermock.ResponseFromFixture("token.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("show_dns_records", "true")).
+ Route("POST /domains/example.com/update",
+ servermock.ResponseFromFixture("token.json")),
},
{
- desc: "FailToFindZone",
- handler: muxFailToFindZone(),
+ desc: "FailToFindZone",
+ builder: mockBuilder().
+ Route("GET /domains/example.com",
+ servermock.ResponseFromFixture("error_failToFindZone.json").
+ WithStatusCode(http.StatusUnauthorized)),
expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`,
},
{
- desc: "FailToCreateTXT",
- handler: muxFailToCreateTXT(),
+ desc: "FailToCreateTXT",
+ builder: mockBuilder().
+ Route("GET /domains/example.com",
+ servermock.ResponseFromFixture("token.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("show_dns_records", "true")).
+ Route("POST /domains/example.com/update",
+ servermock.ResponseFromFixture("error_failToCreateTXT.json").
+ WithStatusCode(http.StatusBadRequest)),
expectedError: `versio: [status code: 400] 400: ProcessError|DNS record invalid type _acme-challenge.example.eu. TST`,
},
}
@@ -147,19 +162,12 @@ func TestDNSProvider_Present(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
- baseURL := setupTest(t, test.handler)
+ provider := test.builder.Build(t)
- envTest.Apply(map[string]string{
- EnvUsername: "me@example.com",
- EnvPassword: "secret",
- EnvEndpoint: baseURL,
- })
- provider, err := NewDNSProvider()
- require.NoError(t, err)
-
- err = provider.Present(testDomain, "token", "keyAuth")
+ err := provider.Present(testDomain, "token", "keyAuth")
if test.expectedError == "" {
require.NoError(t, err)
} else {
@@ -172,16 +180,25 @@ func TestDNSProvider_Present(t *testing.T) {
func TestDNSProvider_CleanUp(t *testing.T) {
testCases := []struct {
desc string
- handler http.Handler
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "Success",
- handler: muxSuccess(),
+ desc: "Success",
+ builder: mockBuilder().
+ Route("GET /domains/example.com",
+ servermock.ResponseFromFixture("token.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("show_dns_records", "true")).
+ Route("POST /domains/example.com/update",
+ servermock.ResponseFromFixture("token.json")),
},
{
- desc: "FailToFindZone",
- handler: muxFailToFindZone(),
+ desc: "FailToFindZone",
+ builder: mockBuilder().
+ Route("GET /domains/example.com",
+ servermock.ResponseFromFixture("error_failToFindZone.json").
+ WithStatusCode(http.StatusUnauthorized)),
expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`,
},
}
@@ -189,20 +206,12 @@ func TestDNSProvider_CleanUp(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
- baseURL := setupTest(t, test.handler)
+ provider := test.builder.Build(t)
- envTest.Apply(map[string]string{
- EnvUsername: "me@example.com",
- EnvPassword: "secret",
- EnvEndpoint: baseURL,
- })
-
- provider, err := NewDNSProvider()
- require.NoError(t, err)
-
- err = provider.CleanUp(testDomain, "token", "keyAuth")
+ err := provider.CleanUp(testDomain, "token", "keyAuth")
if test.expectedError == "" {
require.NoError(t, err)
} else {
@@ -212,91 +221,13 @@ func TestDNSProvider_CleanUp(t *testing.T) {
}
}
-func muxSuccess() *http.ServeMux {
- mux := http.NewServeMux()
-
- mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet && r.URL.Query().Get("show_dns_records") == "true" {
- fmt.Fprint(w, tokenResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/domains/example.com/update", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- fmt.Fprint(w, tokenResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- log.Printf("unexpected request: %+v\n\n", r)
- data, _ := io.ReadAll(r.Body)
- defer func() { _ = r.Body.Close() }()
- log.Println(string(data))
- http.NotFound(w, r)
- })
-
- return mux
-}
-
-func muxFailToFindZone() *http.ServeMux {
- mux := http.NewServeMux()
-
- mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, _ *http.Request) {
- http.Error(w, tokenFailToFindZoneMock, http.StatusUnauthorized)
- })
-
- return mux
-}
-
-func muxFailToCreateTXT() *http.ServeMux {
- mux := http.NewServeMux()
-
- mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet && r.URL.Query().Get("show_dns_records") == "true" {
- fmt.Fprint(w, tokenResponseMock)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/domains/example.com/update", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- http.Error(w, tokenFailToCreateTXTMock, http.StatusBadRequest)
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- })
-
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- log.Printf("unexpected request: %+v\n\n", r)
- data, _ := io.ReadAll(r.Body)
- defer func() { _ = r.Body.Close() }()
- log.Println(string(data))
- http.NotFound(w, r)
- })
-
- return mux
-}
-
-func setupTest(t *testing.T, handler http.Handler) string {
- t.Helper()
-
- server := httptest.NewServer(handler)
- t.Cleanup(server.Close)
-
- return server.URL
-}
-
func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -310,9 +241,29 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ envTest.Apply(map[string]string{
+ EnvUsername: "me@example.com",
+ EnvPassword: "secret",
+ EnvEndpoint: server.URL,
+ })
+
+ provider, err := NewDNSProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ provider.client.HTTPClient = server.Client()
+
+ return provider, nil
+ })
+}
diff --git a/providers/dns/vinyldns/mock_test.go b/providers/dns/vinyldns/mock_test.go
deleted file mode 100644
index 54fd8e214..000000000
--- a/providers/dns/vinyldns/mock_test.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package vinyldns
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "os"
- "sync"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func setupTest(t *testing.T) (*http.ServeMux, *DNSProvider) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- config := NewDefaultConfig()
- config.AccessKey = "foo"
- config.SecretKey = "bar"
- config.Host = server.URL
-
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- return mux, p
-}
-
-type mockRouter struct {
- debug bool
-
- mu sync.Mutex
- routes map[string]map[string]http.HandlerFunc
-}
-
-func newMockRouter() *mockRouter {
- routes := map[string]map[string]http.HandlerFunc{
- http.MethodGet: {},
- http.MethodPost: {},
- http.MethodPut: {},
- http.MethodDelete: {},
- }
-
- return &mockRouter{
- routes: routes,
- }
-}
-
-func (h *mockRouter) Debug() *mockRouter {
- h.debug = true
-
- return h
-}
-
-func (h *mockRouter) Get(path string, statusCode int, filename string) *mockRouter {
- h.add(http.MethodGet, path, statusCode, filename)
- return h
-}
-
-func (h *mockRouter) Post(path string, statusCode int, filename string) *mockRouter {
- h.add(http.MethodPost, path, statusCode, filename)
- return h
-}
-
-func (h *mockRouter) Put(path string, statusCode int, filename string) *mockRouter {
- h.add(http.MethodPut, path, statusCode, filename)
- return h
-}
-
-func (h *mockRouter) Delete(path string, statusCode int, filename string) *mockRouter {
- h.add(http.MethodDelete, path, statusCode, filename)
- return h
-}
-
-func (h *mockRouter) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- if h.debug {
- fmt.Println(req)
- }
-
- rt := h.routes[req.Method]
- if rt == nil {
- http.NotFound(rw, req)
- return
- }
-
- hdl := rt[req.URL.Path]
- if hdl == nil {
- http.NotFound(rw, req)
- return
- }
-
- hdl(rw, req)
-}
-
-func (h *mockRouter) add(method, path string, statusCode int, filename string) {
- h.routes[method][path] = func(rw http.ResponseWriter, req *http.Request) {
- rw.WriteHeader(statusCode)
-
- data, err := os.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- rw.Header().Set("Content-Type", "application/json")
- _, _ = rw.Write(data)
- }
-}
diff --git a/providers/dns/vinyldns/vinyldns.go b/providers/dns/vinyldns/vinyldns.go
index a206602da..65a024513 100644
--- a/providers/dns/vinyldns/vinyldns.go
+++ b/providers/dns/vinyldns/vinyldns.go
@@ -2,13 +2,17 @@
package vinyldns
import (
+ "context"
"errors"
"fmt"
+ "net/http"
+ "strconv"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
"github.com/vinyldns/go-vinyldns/vinyldns"
)
@@ -17,25 +21,30 @@ import (
const (
envNamespace = "VINYLDNS_"
- EnvAccessKey = envNamespace + "ACCESS_KEY"
- EnvSecretKey = envNamespace + "SECRET_KEY"
- EnvHost = envNamespace + "HOST"
+ EnvAccessKey = envNamespace + "ACCESS_KEY"
+ EnvSecretKey = envNamespace + "SECRET_KEY"
+ EnvHost = envNamespace + "HOST"
+ EnvQuoteValue = envNamespace + "QUOTE_VALUE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- AccessKey string
- SecretKey string
- Host string
+ AccessKey string
+ SecretKey string
+ Host string
+ QuoteValue bool
+
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
+ HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -44,6 +53,9 @@ func NewDefaultConfig() *Config {
TTL: env.GetOrDefaultInt(EnvTTL, 30),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
}
}
@@ -66,6 +78,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config.AccessKey = values[EnvAccessKey]
config.SecretKey = values[EnvSecretKey]
config.Host = values[EnvHost]
+ config.QuoteValue = env.GetOrDefaultBool(EnvQuoteValue, false)
return NewDNSProviderConfig(config)
}
@@ -91,13 +104,22 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
UserAgent: useragent.Get(),
})
- client.HTTPClient.Timeout = 30 * time.Second
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ } else {
+ // For compatibility, it should be removed in v5.
+ client.HTTPClient.Timeout = 30 * time.Second
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
return &DNSProvider{client: client, config: config}, nil
}
// 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)
existingRecord, err := d.getRecordSet(info.EffectiveFQDN)
@@ -105,10 +127,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("vinyldns: %w", err)
}
- record := vinyldns.Record{Text: info.Value}
+ value := d.formatValue(info.Value)
+
+ record := vinyldns.Record{Text: value}
if existingRecord == nil || existingRecord.ID == "" {
- err = d.createRecordSet(info.EffectiveFQDN, []vinyldns.Record{record})
+ err = d.createRecordSet(ctx, info.EffectiveFQDN, []vinyldns.Record{record})
if err != nil {
return fmt.Errorf("vinyldns: %w", err)
}
@@ -117,7 +141,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
}
for _, i := range existingRecord.Records {
- if i.Text == info.Value {
+ if i.Text == value {
return nil
}
}
@@ -125,7 +149,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
records := existingRecord.Records
records = append(records, record)
- err = d.updateRecordSet(existingRecord, records)
+ err = d.updateRecordSet(ctx, existingRecord, records)
if err != nil {
return fmt.Errorf("vinyldns: %w", err)
}
@@ -135,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)
existingRecord, err := d.getRecordSet(info.EffectiveFQDN)
@@ -146,15 +172,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
+ value := d.formatValue(info.Value)
+
var records []vinyldns.Record
+
for _, i := range existingRecord.Records {
- if i.Text != info.Value {
+ if i.Text != value {
records = append(records, i)
}
}
if len(records) == 0 {
- err = d.deleteRecordSet(existingRecord)
+ err = d.deleteRecordSet(ctx, existingRecord)
if err != nil {
return fmt.Errorf("vinyldns: %w", err)
}
@@ -162,7 +191,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
- err = d.updateRecordSet(existingRecord, records)
+ err = d.updateRecordSet(ctx, existingRecord, records)
if err != nil {
return fmt.Errorf("vinyldns: %w", err)
}
@@ -175,3 +204,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
+
+func (d *DNSProvider) formatValue(v string) string {
+ if d.config.QuoteValue {
+ return strconv.Quote(v)
+ }
+
+ return v
+}
diff --git a/providers/dns/vinyldns/vinyldns.toml b/providers/dns/vinyldns/vinyldns.toml
index bdd07bae8..d6dd5810e 100644
--- a/providers/dns/vinyldns/vinyldns.toml
+++ b/providers/dns/vinyldns/vinyldns.toml
@@ -8,7 +8,7 @@ Example = '''
VINYLDNS_ACCESS_KEY=xxxxxx \
VINYLDNS_SECRET_KEY=yyyyy \
VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \
-lego --email you@example.com --dns vinyldns -d '*.example.com' -d example.com run
+lego --dns vinyldns -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -22,9 +22,11 @@ Users are required to have DELETE ACL level or zone admin permissions on the Vin
VINYLDNS_SECRET_KEY = "The VinylDNS API Secret key"
VINYLDNS_HOST = "The VinylDNS API URL"
[Configuration.Additional]
- VINYLDNS_POLLING_INTERVAL = "Time between DNS propagation check"
- VINYLDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VINYLDNS_TTL = "The TTL of the TXT record used for the DNS challenge"
+ VINYLDNS_QUOTE_VALUE = "Adds quotes around the TXT record value (Default: false)"
+ VINYLDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)"
+ VINYLDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ VINYLDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)"
+ VINYLDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.vinyldns.io/api/"
diff --git a/providers/dns/vinyldns/vinyldns_test.go b/providers/dns/vinyldns/vinyldns_test.go
index 8bfb192c8..7dfe2c13f 100644
--- a/providers/dns/vinyldns/vinyldns_test.go
+++ b/providers/dns/vinyldns/vinyldns_test.go
@@ -2,10 +2,12 @@ package vinyldns
import (
"net/http"
+ "net/http/httptest"
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
@@ -76,6 +78,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -154,63 +157,87 @@ func TestNewDNSProviderConfig(t *testing.T) {
}
}
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.AccessKey = "foo"
+ config.SecretKey = "bar"
+ config.Host = server.URL
+ config.HTTPClient = server.Client()
+
+ return NewDNSProviderConfig(config)
+ })
+}
+
func TestDNSProvider_Present(t *testing.T) {
testCases := []struct {
desc string
keyAuth string
- handler http.Handler
+ builder *servermock.Builder[*DNSProvider]
}{
{
desc: "new record",
keyAuth: "123456d==",
- handler: newMockRouter().
- Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName").
- Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll-empty").
- Post("/zones/"+zoneID+"/recordsets", http.StatusAccepted, "recordSetUpdate-create").
- Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"),
+ builder: mockBuilder().
+ Route("GET /zones/name/"+targetRootDomain+".",
+ servermock.ResponseFromFixture("zoneByName.json")).
+ Route("GET /zones/"+zoneID+"/recordsets",
+ servermock.ResponseFromFixture("recordSetsListAll-empty.json")).
+ Route("POST /zones/"+zoneID+"/recordsets",
+ servermock.ResponseFromFixture("recordSetUpdate-create.json").
+ WithStatusCode(http.StatusAccepted)).
+ Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID,
+ servermock.ResponseFromFixture("recordSetChange-create.json")),
},
{
desc: "existing record",
keyAuth: "123456d==",
- handler: newMockRouter().
- Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName").
- Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"),
+ builder: mockBuilder().
+ Route("GET /zones/name/"+targetRootDomain+".",
+ servermock.ResponseFromFixture("zoneByName.json")).
+ Route("GET /zones/"+zoneID+"/recordsets",
+ servermock.ResponseFromFixture("recordSetsListAll.json")),
},
{
desc: "duplicate key",
keyAuth: "abc123!!",
- handler: newMockRouter().
- Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName").
- Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll").
- Put("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetUpdate-create").
- Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"),
+ builder: mockBuilder().
+ Route("GET /zones/name/"+targetRootDomain+".",
+ servermock.ResponseFromFixture("zoneByName.json")).
+ Route("GET /zones/"+zoneID+"/recordsets",
+ servermock.ResponseFromFixture("recordSetsListAll.json")).
+ Route("PUT /zones/"+zoneID+"/recordsets/"+recordID,
+ servermock.ResponseFromFixture("recordSetUpdate-create.json").
+ WithStatusCode(http.StatusAccepted)).
+ Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID,
+ servermock.ResponseFromFixture("recordSetChange-create.json")),
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
+ provider := test.builder.Build(t)
- mux, p := setupTest(t)
- mux.Handle("/", test.handler)
-
- err := p.Present(targetDomain, "token"+test.keyAuth, test.keyAuth)
+ err := provider.Present(targetDomain, "token"+test.keyAuth, test.keyAuth)
require.NoError(t, err)
})
}
}
func TestDNSProvider_CleanUp(t *testing.T) {
- mux, p := setupTest(t)
+ provider := mockBuilder().
+ Route("GET /zones/name/"+targetRootDomain+".",
+ servermock.ResponseFromFixture("zoneByName.json")).
+ Route("GET /zones/"+zoneID+"/recordsets",
+ servermock.ResponseFromFixture("recordSetsListAll.json")).
+ Route("DELETE /zones/"+zoneID+"/recordsets/"+recordID,
+ servermock.ResponseFromFixture("recordSetDelete.json").
+ WithStatusCode(http.StatusAccepted)).
+ Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID,
+ servermock.ResponseFromFixture("recordSetChange-delete.json")).
+ Build(t)
- mux.Handle("/", newMockRouter().
- Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName").
- Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll").
- Delete("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetDelete").
- Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-delete"),
- )
-
- err := p.CleanUp(targetDomain, "123456d==", "123456d==")
+ err := provider.CleanUp(targetDomain, "123456d==", "123456d==")
require.NoError(t, err)
}
@@ -220,6 +247,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -233,6 +261,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/vinyldns/wrapper.go b/providers/dns/vinyldns/wrapper.go
index f17b3de31..e7b59a82b 100644
--- a/providers/dns/vinyldns/wrapper.go
+++ b/providers/dns/vinyldns/wrapper.go
@@ -1,8 +1,10 @@
package vinyldns
import (
+ "context"
"fmt"
+ "github.com/cenkalti/backoff/v5"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/wait"
"github.com/vinyldns/go-vinyldns/vinyldns"
@@ -25,6 +27,7 @@ func (d *DNSProvider) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) {
}
var recordSets []vinyldns.RecordSet
+
for _, i := range allRecordSets {
if i.Type == "TXT" {
recordSets = append(recordSets, i)
@@ -41,7 +44,7 @@ func (d *DNSProvider) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) {
}
}
-func (d *DNSProvider) createRecordSet(fqdn string, records []vinyldns.Record) error {
+func (d *DNSProvider) createRecordSet(ctx context.Context, fqdn string, records []vinyldns.Record) error {
zoneName, hostName, err := splitDomain(fqdn)
if err != nil {
return err
@@ -65,10 +68,10 @@ func (d *DNSProvider) createRecordSet(fqdn string, records []vinyldns.Record) er
return err
}
- return d.waitForChanges("CreateRS", resp)
+ return d.waitForChanges(ctx, "CreateRS", resp)
}
-func (d *DNSProvider) updateRecordSet(recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error {
+func (d *DNSProvider) updateRecordSet(ctx context.Context, recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error {
operation := "delete"
if len(recordSet.Records) < len(newRecords) {
operation = "add"
@@ -82,33 +85,35 @@ func (d *DNSProvider) updateRecordSet(recordSet *vinyldns.RecordSet, newRecords
return err
}
- return d.waitForChanges("UpdateRS - "+operation, resp)
+ return d.waitForChanges(ctx, "UpdateRS - "+operation, resp)
}
-func (d *DNSProvider) deleteRecordSet(existingRecord *vinyldns.RecordSet) error {
+func (d *DNSProvider) deleteRecordSet(ctx context.Context, existingRecord *vinyldns.RecordSet) error {
resp, err := d.client.RecordSetDelete(existingRecord.ZoneID, existingRecord.ID)
if err != nil {
return err
}
- return d.waitForChanges("DeleteRS", resp)
+ return d.waitForChanges(ctx, "DeleteRS", resp)
}
-func (d *DNSProvider) waitForChanges(operation string, resp *vinyldns.RecordSetUpdateResponse) error {
- return wait.For("vinyldns", d.config.PropagationTimeout, d.config.PollingInterval,
- func() (bool, error) {
+func (d *DNSProvider) waitForChanges(ctx context.Context, operation string, resp *vinyldns.RecordSetUpdateResponse) error {
+ return wait.Retry(ctx,
+ func() error {
change, err := d.client.RecordSetChange(resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID)
if err != nil {
- return false, fmt.Errorf("failed to query change status: %w", err)
+ return fmt.Errorf("failed to query change status: %w", err)
}
- if change.Status == "Complete" {
- return true, nil
+ if change.Status != "Complete" {
+ return fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s",
+ operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID)
}
- return false, fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s",
- operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID)
+ return nil
},
+ backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
+ backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
)
}
diff --git a/providers/dns/virtualname/virtualname.go b/providers/dns/virtualname/virtualname.go
new file mode 100644
index 000000000..34637d280
--- /dev/null
+++ b/providers/dns/virtualname/virtualname.go
@@ -0,0 +1,103 @@
+// Package virtualname implements a DNS provider for solving the DNS-01 challenge using Virtualname DNS.
+package virtualname
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "VIRTUALNAME_"
+
+ EnvToken = envNamespace + "TOKEN"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultBaseURL = "https://api.virtualname.net/v1"
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config = tecnocratica.Config
+
+// 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, 10*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ prv challenge.ProviderTimeout
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Virtualname.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvToken)
+ if err != nil {
+ return nil, fmt.Errorf("virtualname: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Token = values[EnvToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Virtualname.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("virtualname: the configuration of the DNS provider is nil")
+ }
+
+ provider, err := tecnocratica.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("virtualname: %w", err)
+ }
+
+ return &DNSProvider{prv: provider}, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("virtualname: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("virtualname: %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.prv.Timeout()
+}
diff --git a/providers/dns/virtualname/virtualname.toml b/providers/dns/virtualname/virtualname.toml
new file mode 100644
index 000000000..881f09797
--- /dev/null
+++ b/providers/dns/virtualname/virtualname.toml
@@ -0,0 +1,22 @@
+Name = "Virtualname"
+Description = ''''''
+URL = "https://www.virtualname.es/"
+Code = "virtualname"
+Since = "v4.30.0"
+
+Example = '''
+VIRTUALNAME_TOKEN=xxxxxx \
+lego --dns virtualname -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ VIRTUALNAME_TOKEN = "API token"
+ [Configuration.Additional]
+ VIRTUALNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ VIRTUALNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ VIRTUALNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ VIRTUALNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://developers.virtualname.net/#dns"
diff --git a/providers/dns/virtualname/virtualname_test.go b/providers/dns/virtualname/virtualname_test.go
new file mode 100644
index 000000000..da5867e86
--- /dev/null
+++ b/providers/dns/virtualname/virtualname_test.go
@@ -0,0 +1,116 @@
+package virtualname
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvToken: "secret",
+ },
+ },
+ {
+ desc: "missing credentials: token",
+ envVars: map[string]string{
+ EnvToken: "",
+ },
+ expected: "virtualname: some credentials information are missing: VIRTUALNAME_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.prv)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ token string
+ expected string
+ }{
+ {
+ desc: "success",
+ token: "secret",
+ },
+ {
+ desc: "missing token",
+ expected: "virtualname: missing credentials",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Token = test.token
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.prv)
+ } 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/dns/vkcloud/internal/client.go b/providers/dns/vkcloud/internal/client.go
index 5ced88d2d..2b03518db 100644
--- a/providers/dns/vkcloud/internal/client.go
+++ b/providers/dns/vkcloud/internal/client.go
@@ -46,6 +46,7 @@ func (c *Client) ListZones() ([]DNSZone, error) {
endpoint := c.baseURL.JoinPath("/")
var zones []DNSZone
+
opts := &gophercloud.RequestOpts{JSONResponse: &zones}
err := c.request(http.MethodGet, endpoint, opts)
@@ -60,6 +61,7 @@ func (c *Client) ListTXTRecords(zoneUUID string) ([]DNSTXTRecord, error) {
endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/")
var records []DNSTXTRecord
+
opts := &gophercloud.RequestOpts{JSONResponse: &records}
err := c.request(http.MethodGet, endpoint, opts)
diff --git a/providers/dns/vkcloud/vkcloud.go b/providers/dns/vkcloud/vkcloud.go
index e76e87137..ffacdbe52 100644
--- a/providers/dns/vkcloud/vkcloud.go
+++ b/providers/dns/vkcloud/vkcloud.go
@@ -119,7 +119,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
// Present creates a TXT record to fulfill the dns-01 challenge.
-func (r *DNSProvider) Present(domain, _, keyAuth string) error {
+func (d *DNSProvider) Present(domain, _, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -129,12 +129,13 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
authZone = dns01.UnFqdn(authZone)
- zones, err := r.client.ListZones()
+ zones, err := d.client.ListZones()
if err != nil {
return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err)
}
var zoneUUID string
+
for _, zone := range zones {
if zone.Zone == authZone {
zoneUUID = zone.UUID
@@ -150,7 +151,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
return fmt.Errorf("vkcloud: %w", err)
}
- err = r.upsertTXTRecord(zoneUUID, subDomain, info.Value)
+ err = d.upsertTXTRecord(zoneUUID, subDomain, info.Value)
if err != nil {
return fmt.Errorf("vkcloud: %w", err)
}
@@ -159,7 +160,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
}
// CleanUp removes the TXT record matching the specified parameters.
-func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
+func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -169,7 +170,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
authZone = dns01.UnFqdn(authZone)
- zones, err := r.client.ListZones()
+ zones, err := d.client.ListZones()
if err != nil {
return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err)
}
@@ -191,7 +192,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
return fmt.Errorf("vkcloud: %w", err)
}
- err = r.removeTXTRecord(zoneUUID, subDomain, info.Value)
+ err = d.removeTXTRecord(zoneUUID, subDomain, info.Value)
if err != nil {
return fmt.Errorf("vkcloud: %w", err)
}
@@ -201,12 +202,12 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
// Timeout returns the timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
-func (r *DNSProvider) Timeout() (timeout, interval time.Duration) {
- return r.config.PropagationTimeout, r.config.PollingInterval
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
}
-func (r *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error {
- records, err := r.client.ListTXTRecords(zoneUUID)
+func (d *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error {
+ records, err := d.client.ListTXTRecords(zoneUUID)
if err != nil {
return err
}
@@ -218,15 +219,15 @@ func (r *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error {
}
}
- return r.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{
+ return d.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{
Name: name,
Content: value,
- TTL: r.config.TTL,
+ TTL: d.config.TTL,
})
}
-func (r *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error {
- records, err := r.client.ListTXTRecords(zoneUUID)
+func (d *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error {
+ records, err := d.client.ListTXTRecords(zoneUUID)
if err != nil {
return err
}
@@ -234,7 +235,7 @@ func (r *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error {
name = dns01.UnFqdn(name)
for _, record := range records {
if record.Name == name && record.Content == value {
- return r.client.DeleteTXTRecord(zoneUUID, record.UUID)
+ return d.client.DeleteTXTRecord(zoneUUID, record.UUID)
}
}
diff --git a/providers/dns/vkcloud/vkcloud.toml b/providers/dns/vkcloud/vkcloud.toml
index 8e67e2670..04f57fea3 100644
--- a/providers/dns/vkcloud/vkcloud.toml
+++ b/providers/dns/vkcloud/vkcloud.toml
@@ -8,7 +8,7 @@ Example = '''
VK_CLOUD_PROJECT_ID="" \
VK_CLOUD_USERNAME="" \
VK_CLOUD_PASSWORD="" \
-lego --email you@example.com --dns vkcloud -d '*.example.com' -d example.com run
+lego --dns vkcloud -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -33,9 +33,9 @@ You can find all required and additional information on ["Project/Keys" page](ht
VK_CLOUD_DNS_ENDPOINT="URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds"
VK_CLOUD_IDENTITY_ENDPOINT="URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds"
VK_CLOUD_DOMAIN_NAME="Openstack users domain name. Defaults to `users` but can be changed for usage with private clouds"
- VK_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check"
- VK_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VK_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
+ VK_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ VK_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ VK_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
[Links]
API = "https://mcs.mail.ru/docs/networks/vnet/networks/publicdns/api"
diff --git a/providers/dns/vkcloud/vkcloud_test.go b/providers/dns/vkcloud/vkcloud_test.go
index edc32363a..e7883b486 100644
--- a/providers/dns/vkcloud/vkcloud_test.go
+++ b/providers/dns/vkcloud/vkcloud_test.go
@@ -60,6 +60,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -188,6 +189,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -201,6 +203,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/volcengine/volcengine.go b/providers/dns/volcengine/volcengine.go
index 2fcba1b05..765d38adb 100644
--- a/providers/dns/volcengine/volcengine.go
+++ b/providers/dns/volcengine/volcengine.go
@@ -12,7 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/miekg/dns"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
"github.com/volcengine/volc-sdk-golang/base"
volc "github.com/volcengine/volc-sdk-golang/service/dns"
)
@@ -62,7 +62,7 @@ func NewDefaultConfig() *Config {
Region: env.GetOrDefaultString(EnvRegion, volc.DefaultRegion),
TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
- PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 240*time.Second),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 4*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, volc.Timeout*time.Second),
}
@@ -126,16 +126,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("volcengine: get zone ID: %w", err)
}
- subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, deref(zone.ZoneName))
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.ZoneName))
if err != nil {
return fmt.Errorf("volcengine: %w", err)
}
crr := &volc.CreateRecordRequest{
- Host: pointer(subDomain),
- TTL: pointer(int64(d.config.TTL)),
- Type: pointer("TXT"),
- Value: pointer(info.Value),
+ Host: ptr.Pointer(subDomain),
+ TTL: ptr.Pointer(int64(d.config.TTL)),
+ Type: ptr.Pointer("TXT"),
+ Value: ptr.Pointer(info.Value),
ZID: zone.ZID,
}
@@ -159,6 +159,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
+
if !ok {
return fmt.Errorf("volcengine: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
}
@@ -170,16 +171,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("volcengine: delete record: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
func (d *DNSProvider) getZone(ctx context.Context, fqdn string) (volc.TopZoneResponse, error) {
- for _, index := range dns.Split(fqdn) {
- domain := fqdn[index:]
-
+ for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
lzr := &volc.ListZonesRequest{
- Key: pointer(dns01.UnFqdn(domain)),
- SearchMode: pointer("exact"),
+ Key: ptr.Pointer(dns01.UnFqdn(domain)),
+ SearchMode: ptr.Pointer("exact"),
}
zones, err := d.client.ListZones(ctx, lzr)
@@ -187,7 +190,7 @@ func (d *DNSProvider) getZone(ctx context.Context, fqdn string) (volc.TopZoneRes
return volc.TopZoneResponse{}, fmt.Errorf("list zones: %w", err)
}
- total := deref(zones.Total)
+ total := ptr.Deref(zones.Total)
if total == 0 || len(zones.Zones) == 0 {
continue
@@ -233,14 +236,3 @@ func newClient(config *Config) *volc.Client {
return volc.NewClient(caller)
}
-
-func pointer[T any](v T) *T { return &v }
-
-func deref[T any](v *T) T {
- if v == nil {
- var zero T
- return zero
- }
-
- return *v
-}
diff --git a/providers/dns/volcengine/volcengine.toml b/providers/dns/volcengine/volcengine.toml
index 85431714f..ceedcb18a 100644
--- a/providers/dns/volcengine/volcengine.toml
+++ b/providers/dns/volcengine/volcengine.toml
@@ -7,7 +7,7 @@ Since = "v4.19.0"
Example = '''
VOLC_ACCESSKEY=xxx \
VOLC_SECRETKEY=yyy \
-lego --email you@example.com --dns volcengine -d '*.example.com' -d example.com run
+lego --dns volcengine -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -18,10 +18,10 @@ lego --email you@example.com --dns volcengine -d '*.example.com' -d example.com
VOLC_REGION = "Region"
VOLC_HOST = "API host"
VOLC_SCHEME = "API scheme"
- VOLC_POLLING_INTERVAL = "Time between DNS propagation check"
- VOLC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VOLC_TTL = "The TTL of the TXT record used for the DNS challenge"
- VOLC_HTTP_TIMEOUT = "API request timeout"
+ VOLC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ VOLC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)"
+ VOLC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ VOLC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 15)"
[Links]
API = "https://www.volcengine.com/docs/6758/155086"
diff --git a/providers/dns/volcengine/volcengine_test.go b/providers/dns/volcengine/volcengine_test.go
index 5e9167612..0f79ed83a 100644
--- a/providers/dns/volcengine/volcengine_test.go
+++ b/providers/dns/volcengine/volcengine_test.go
@@ -55,6 +55,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -125,6 +126,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -138,6 +140,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/vscale/vscale.go b/providers/dns/vscale/vscale.go
index a500837bc..a159db307 100644
--- a/providers/dns/vscale/vscale.go
+++ b/providers/dns/vscale/vscale.go
@@ -4,11 +4,9 @@
package vscale
import (
- "context"
"errors"
"fmt"
"net/http"
- "net/url"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -30,27 +28,20 @@ const (
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
-const minTTL = 60
+const defaultBaseURL = "https://api.vscale.io/v1/domains"
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
-type Config struct {
- BaseURL string
- Token string
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = selectel.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- BaseURL: env.GetOrDefaultString(EnvBaseURL, selectel.DefaultVScaleBaseURL),
- TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
+ BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL),
+ TTL: env.GetOrDefaultInt(EnvTTL, selectel.MinTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
- PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -59,8 +50,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *selectel.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for Vscale Domains API.
@@ -83,53 +73,21 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("vscale: the configuration of the DNS provider is nil")
}
- if config.Token == "" {
- return nil, errors.New("vscale: credentials missing")
+ if config.BaseURL == "" {
+ config.BaseURL = defaultBaseURL
}
- if config.TTL < minTTL {
- return nil, fmt.Errorf("vscale: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
- }
-
- client := selectel.NewClient(config.Token)
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- var err error
- client.BaseURL, err = url.Parse(config.BaseURL)
+ provider, err := selectel.NewDNSProviderConfig(config)
if err != nil {
return nil, fmt.Errorf("vscale: %w", err)
}
- return &DNSProvider{config: config, client: client}, nil
+ return &DNSProvider{prv: provider}, 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 DNS-01 challenge.
+// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- // TODO(ldez) replace domain by FQDN to follow CNAME.
- domainObj, err := d.client.GetDomainByName(ctx, domain)
- if err != nil {
- return fmt.Errorf("vscale: %w", err)
- }
-
- txtRecord := selectel.Record{
- Type: "TXT",
- TTL: d.config.TTL,
- Name: info.EffectiveFQDN,
- Content: info.Value,
- }
- _, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord)
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("vscale: %w", err)
}
@@ -137,35 +95,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return nil
}
-// CleanUp removes a TXT record used for DNS-01 challenge.
+// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- recordName := dns01.UnFqdn(info.EffectiveFQDN)
-
- ctx := context.Background()
-
- // TODO(ldez) replace domain by FQDN to follow CNAME.
- domainObj, err := d.client.GetDomainByName(ctx, domain)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("vscale: %w", err)
}
- records, err := d.client.ListRecords(ctx, domainObj.ID)
- if err != nil {
- return fmt.Errorf("vscale: %w", err)
- }
-
- // Delete records with specific FQDN
- var lastErr error
- for _, record := range records {
- if record.Name == recordName {
- err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID)
- if err != nil {
- lastErr = fmt.Errorf("vscale: %w", err)
- }
- }
- }
-
- return lastErr
+ 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.prv.Timeout()
}
diff --git a/providers/dns/vscale/vscale.toml b/providers/dns/vscale/vscale.toml
index 83aa6a513..f7dc0d943 100644
--- a/providers/dns/vscale/vscale.toml
+++ b/providers/dns/vscale/vscale.toml
@@ -6,7 +6,7 @@ Since = "v2.0.0"
Example = '''
VSCALE_API_TOKEN=xxxxx \
-lego --email you@example.com --dns vscale -d '*.example.com' -d example.com run
+lego --dns vscale -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -14,10 +14,10 @@ lego --email you@example.com --dns vscale -d '*.example.com' -d example.com run
VSCALE_API_TOKEN = "API token"
[Configuration.Additional]
VSCALE_BASE_URL = "API endpoint URL"
- VSCALE_POLLING_INTERVAL = "Time between DNS propagation check"
- VSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VSCALE_TTL = "The TTL of the TXT record used for the DNS challenge"
- VSCALE_HTTP_TIMEOUT = "API request timeout"
+ VSCALE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ VSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ VSCALE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ VSCALE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://developers.vscale.io/documentation/api/v1/#api-Domains_Records"
diff --git a/providers/dns/vscale/vscale_test.go b/providers/dns/vscale/vscale_test.go
index 6a9b25583..9012c7563 100644
--- a/providers/dns/vscale/vscale_test.go
+++ b/providers/dns/vscale/vscale_test.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/providers/dns/internal/selectel"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -36,6 +37,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -45,8 +47,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- assert.NotNil(t, p.config)
- assert.NotNil(t, p.client)
+ assert.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -76,7 +77,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
desc: "bad TTL value",
token: "123",
ttl: 59,
- expected: fmt.Sprintf("vscale: invalid TTL, TTL (59) must be greater than %d", minTTL),
+ expected: fmt.Sprintf("vscale: invalid TTL, TTL (59) must be greater than %d", selectel.MinTTL),
},
}
@@ -91,8 +92,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- assert.NotNil(t, p.config)
- assert.NotNil(t, p.client)
+ assert.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -106,6 +106,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -119,6 +120,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/vultr/vultr.go b/providers/dns/vultr/vultr.go
index 7672d2054..f97a321c1 100644
--- a/providers/dns/vultr/vultr.go
+++ b/providers/dns/vultr/vultr.go
@@ -13,6 +13,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/vultr/govultr/v3"
"golang.org/x/oauth2"
)
@@ -38,7 +39,7 @@ type Config struct {
PollingInterval time.Duration
TTL int
HTTPClient *http.Client
- HTTPTimeout time.Duration
+ HTTPTimeout time.Duration // TODO(ldez): remove in v5
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -84,7 +85,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
authClient := OAuthStaticAccessToken(config.HTTPClient, config.APIKey)
authClient.Timeout = config.HTTPTimeout
- client := govultr.NewClient(authClient)
+ client := govultr.NewClient(clientdebug.Wrap(authClient))
return &DNSProvider{client: client, config: config}, nil
}
@@ -106,7 +107,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("vultr: %w", err)
}
- req := govultr.DomainRecordReq{
+ req := govultr.DomainRecordCreateReq{
Name: subDomain,
Type: "TXT",
Data: `"` + info.Value + `"`,
@@ -135,6 +136,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
var allErr []string
+
for _, rec := range records {
err := d.client.DomainRecord.Delete(ctx, zoneDomain, rec.ID)
if err != nil {
@@ -204,6 +206,7 @@ func (d *DNSProvider) findTxtRecords(ctx context.Context, domain, fqdn string) (
listOptions := &govultr.ListOptions{PerPage: 25}
var records []govultr.DomainRecord
+
for {
result, meta, resp, err := d.client.DomainRecord.List(ctx, zoneDomain, listOptions)
if err != nil {
diff --git a/providers/dns/vultr/vultr.toml b/providers/dns/vultr/vultr.toml
index 83b896f77..78e878bea 100644
--- a/providers/dns/vultr/vultr.toml
+++ b/providers/dns/vultr/vultr.toml
@@ -6,17 +6,17 @@ Since = "v0.3.1"
Example = '''
VULTR_API_KEY=xxxxx \
-lego --email you@example.com --dns vultr -d '*.example.com' -d example.com run
+lego --dns vultr -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
VULTR_API_KEY = "API key"
[Configuration.Additional]
- VULTR_POLLING_INTERVAL = "Time between DNS propagation check"
- VULTR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- VULTR_TTL = "The TTL of the TXT record used for the DNS challenge"
- VULTR_HTTP_TIMEOUT = "API request timeout"
+ VULTR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ VULTR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ VULTR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ VULTR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://www.vultr.com/api/#dns"
diff --git a/providers/dns/vultr/vultr_test.go b/providers/dns/vultr/vultr_test.go
index 71d8ad414..17d962b2a 100644
--- a/providers/dns/vultr/vultr_test.go
+++ b/providers/dns/vultr/vultr_test.go
@@ -1,7 +1,6 @@
package vultr
import (
- "context"
"encoding/json"
"fmt"
"net/http"
@@ -11,6 +10,7 @@ import (
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vultr/govultr/v3"
@@ -45,6 +45,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -160,56 +161,53 @@ func TestDNSProvider_getHostedZone(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- client := govultr.NewClient(nil)
- err := client.SetBaseURL(server.URL)
- require.NoError(t, err)
-
- p := &DNSProvider{client: client}
-
var pageCount int
- mux.HandleFunc("/v2/domains", func(rw http.ResponseWriter, req *http.Request) {
- pageCount++
+ provider := servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ client := govultr.NewClient(server.Client())
+ err := client.SetBaseURL(server.URL)
+ require.NoError(t, err)
- query := req.URL.Query()
- cursor, _ := strconv.Atoi(query.Get("cursor"))
- perPage, _ := strconv.Atoi(query.Get("per_page"))
+ return &DNSProvider{client: client}, nil
+ },
+ ).
+ Route("GET /v2/domains", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ pageCount++
- var next string
- if len(domains)/perPage > cursor {
- next = strconv.Itoa(cursor + 1)
- }
+ query := req.URL.Query()
+ cursor, _ := strconv.Atoi(query.Get("cursor"))
+ perPage, _ := strconv.Atoi(query.Get("per_page"))
- start := cursor * perPage
- if len(domains) < start {
- start = cursor * len(domains)
- }
+ var next string
+ if len(domains)/perPage > cursor {
+ next = strconv.Itoa(cursor + 1)
+ }
- end := (cursor + 1) * perPage
- if len(domains) < end {
- end = len(domains)
- }
+ start := cursor * perPage
+ if len(domains) < start {
+ start = cursor * len(domains)
+ }
- db := domainsBase{
- Domains: domains[start:end],
- Meta: &govultr.Meta{
- Total: len(domains),
- Links: &govultr.Links{Next: next},
- },
- }
+ end := min(len(domains), (cursor+1)*perPage)
- err = json.NewEncoder(rw).Encode(db)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ db := domainsBase{
+ Domains: domains[start:end],
+ Meta: &govultr.Meta{
+ Total: len(domains),
+ Links: &govultr.Links{Next: next},
+ },
+ }
- zone, err := p.getHostedZone(context.Background(), test.domain)
+ err := json.NewEncoder(rw).Encode(db)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ })).
+ Build(t)
+
+ zone, err := provider.getHostedZone(t.Context(), test.domain)
require.NoError(t, err)
assert.Equal(t, test.expected, zone)
@@ -224,6 +222,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -237,6 +236,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/webnames/internal/client.go b/providers/dns/webnames/internal/client.go
index 5b1a8b357..985503d2a 100644
--- a/providers/dns/webnames/internal/client.go
+++ b/providers/dns/webnames/internal/client.go
@@ -83,6 +83,7 @@ func (c *Client) doRequest(ctx context.Context, data url.Values) error {
}
var r APIResponse
+
err = json.Unmarshal(raw, &r)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
diff --git a/providers/dns/webnames/internal/client_test.go b/providers/dns/webnames/internal/client_test.go
index 8885c50d6..9507b6f98 100644
--- a/providers/dns/webnames/internal/client_test.go
+++ b/providers/dns/webnames/internal/client_test.go
@@ -1,75 +1,25 @@
package internal
import (
- "context"
- "fmt"
- "io"
- "net/http"
"net/http/httptest"
- "net/url"
- "os"
- "path"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T, filename string, expectedParams url.Values) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("secret")
+ client.baseURL = server.URL
+ client.HTTPClient = server.Client()
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ )
}
func TestClient_AddTXTRecord(t *testing.T) {
@@ -94,19 +44,23 @@ func TestClient_AddTXTRecord(t *testing.T) {
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)
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture(test.filename),
+ servermock.CheckForm().Strict().
+ With("domain", "example.com").
+ With("type", "TXT").
+ With("record", "foo:txtTXTtxt").
+ With("action", "add").
+ With("apikey", "secret"),
+ ).
+ Build(t)
domain := "example.com"
subDomain := "foo"
content := "txtTXTtxt"
- err := client.AddTXTRecord(context.Background(), domain, subDomain, content)
+ err := client.AddTXTRecord(t.Context(), domain, subDomain, content)
test.require(t, err)
})
}
@@ -134,19 +88,23 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
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)
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture(test.filename),
+ servermock.CheckForm().Strict().
+ With("domain", "example.com").
+ With("type", "TXT").
+ With("record", "foo:txtTXTtxt").
+ With("action", "delete").
+ With("apikey", "secret"),
+ ).
+ Build(t)
domain := "example.com"
subDomain := "foo"
content := "txtTXTtxt"
- err := client.RemoveTXTRecord(context.Background(), domain, subDomain, content)
+ err := client.RemoveTXTRecord(t.Context(), domain, subDomain, content)
test.require(t, err)
})
}
diff --git a/providers/dns/webnames/webnames.go b/providers/dns/webnames/webnames.go
index 78905e22c..9c27164e3 100644
--- a/providers/dns/webnames/webnames.go
+++ b/providers/dns/webnames/webnames.go
@@ -6,17 +6,20 @@ import (
"errors"
"fmt"
"net/http"
+ "strings"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/webnames/internal"
)
// Environment variables names.
const (
- envNamespace = "WEBNAMES_"
+ envNamespace = "WEBNAMESRU_"
+ altEnvNamespace = "WEBNAMES_"
EnvAPIKey = envNamespace + "API_KEY"
@@ -39,10 +42,10 @@ type Config struct {
// 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),
+ PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, dns01.DefaultPropagationTimeout, env.ParseSecond, altEnvName(EnvPropagationTimeout)),
+ PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)),
HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ Timeout: env.GetOneWithFallback(EnvHTTPTimeout, 20*time.Second, env.ParseSecond, altEnvName(EnvHTTPTimeout)),
},
}
}
@@ -54,11 +57,11 @@ type DNSProvider struct {
}
// NewDNSProvider returns a new DNS provider using
-// environment variable WEBNAMES_API_KEY for adding and removing the DNS record.
+// environment variable WEBNAMESRU_API_KEY for adding and removing the DNS record.
func NewDNSProvider() (*DNSProvider, error) {
- values, err := env.Get(EnvAPIKey)
+ values, err := env.GetWithFallback([]string{EnvAPIKey, altEnvName(EnvAPIKey)})
if err != nil {
- return nil, fmt.Errorf("webnames: %w", err)
+ return nil, fmt.Errorf("webnamesru: %w", err)
}
config := NewDefaultConfig()
@@ -70,11 +73,11 @@ func NewDNSProvider() (*DNSProvider, error) {
// 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")
+ return nil, errors.New("webnamesru: the configuration of the DNS provider is nil")
}
if config.APIKey == "" {
- return nil, errors.New("webnames: credentials missing")
+ return nil, errors.New("webnamesru: credentials missing")
}
client := internal.NewClient(config.APIKey)
@@ -83,6 +86,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
@@ -92,17 +97,17 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
- return fmt.Errorf("webnames: could not find zone for domain %q: %w", domain, err)
+ return fmt.Errorf("webnamesru: 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)
+ return fmt.Errorf("webnamesru: %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",
+ return fmt.Errorf("webnamesru: failed to create TXT records [domain: %s, sub domain: %s]: %w",
dns01.UnFqdn(authZone), subDomain, err)
}
@@ -115,17 +120,17 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
- return fmt.Errorf("webnames: could not find zone for domain %q: %w", domain, err)
+ return fmt.Errorf("webnamesru: 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)
+ return fmt.Errorf("webnamesru: %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",
+ return fmt.Errorf("webnamesru: failed to remove TXT records [domain: %s, sub domain: %s]: %w",
dns01.UnFqdn(authZone), subDomain, err)
}
@@ -137,3 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
+
+func altEnvName(v string) string {
+ return strings.ReplaceAll(v, envNamespace, altEnvNamespace)
+}
diff --git a/providers/dns/webnames/webnames.toml b/providers/dns/webnames/webnames.toml
index 030d385c9..b038deaf5 100644
--- a/providers/dns/webnames/webnames.toml
+++ b/providers/dns/webnames/webnames.toml
@@ -1,12 +1,13 @@
-Name = "Webnames"
+Name = "webnames.ru"
Description = ''''''
URL = "https://www.webnames.ru/"
Code = "webnames"
+Aliases = ["webnamesru"]
Since = "v4.15.0"
Example = '''
-WEBNAMES_API_KEY=xxxxxx \
-lego --email you@example.com --dns webnames -d '*.example.com' -d example.com run
+WEBNAMESRU_API_KEY=xxxxxx \
+lego --dns webnamesru -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -19,12 +20,11 @@ The API key can be found: Personal account / My domains and services / Select th
[Configuration]
[Configuration.Credentials]
- WEBNAMES_API_KEY = "Domain API key"
+ WEBNAMESRU_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"
+ WEBNAMESRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ WEBNAMESRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ WEBNAMESRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://github.com/regtime-ltd/certbot-dns-webnames"
diff --git a/providers/dns/webnames/webnames_test.go b/providers/dns/webnames/webnames_test.go
index 3ec69501f..072591c68 100644
--- a/providers/dns/webnames/webnames_test.go
+++ b/providers/dns/webnames/webnames_test.go
@@ -29,13 +29,14 @@ func TestNewDNSProvider(t *testing.T) {
envVars: map[string]string{
EnvAPIKey: "",
},
- expected: "webnames: some credentials information are missing: WEBNAMES_API_KEY",
+ expected: "webnamesru: some credentials information are missing: WEBNAMESRU_API_KEY",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -65,7 +66,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
},
{
desc: "missing credentials",
- expected: "webnames: credentials missing",
+ expected: "webnamesru: credentials missing",
},
}
@@ -93,6 +94,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -106,6 +108,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/webnamesca/internal/client.go b/providers/dns/webnamesca/internal/client.go
new file mode 100644
index 000000000..203ff9eac
--- /dev/null
+++ b/providers/dns/webnamesca/internal/client.go
@@ -0,0 +1,162 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+ "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+)
+
+const defaultBaseURL = "https://www.webnames.ca/_/APICore"
+
+// Client the webnames.ca API client.
+type Client struct {
+ user string
+ key string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(user, key string) (*Client, error) {
+ if user == "" || key == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ user: user,
+ key: key,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, domainName, hostName, value string) ([]DNSRecordSet, error) {
+ endpoint := c.BaseURL.JoinPath("domains", domainName, "add-txt-record")
+
+ query := endpoint.Query()
+ query.Set("hostName", hostName)
+ query.Set("txt", value)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[*DNSInfo]
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Result.DNSRecordSets, nil
+}
+
+func (c *Client) DeleteTXTRecord(ctx context.Context, domainName, hostName, value string) ([]DNSRecordSet, error) {
+ endpoint := c.BaseURL.JoinPath("domains", domainName, "delete-txt-record")
+
+ query := endpoint.Query()
+ query.Set("hostName", hostName)
+ query.Set("txt", value)
+
+ endpoint.RawQuery = query.Encode()
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse[*DNSInfo]
+
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Result.DNSRecordSets, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ useragent.SetHeader(req.Header)
+
+ req.Header.Set("API-User", c.user)
+ req.Header.Set("API-Key", c.key)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
diff --git a/providers/dns/webnamesca/internal/client_test.go b/providers/dns/webnamesca/internal/client_test.go
new file mode 100644
index 000000000..ad8571ed0
--- /dev/null
+++ b/providers/dns/webnamesca/internal/client_test.go
@@ -0,0 +1,96 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.BaseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ },
+ servermock.CheckHeader().
+ With("API-User", "user").
+ With("API-Key", "secret").
+ WithJSONHeaders(),
+ )
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/example.com/add-txt-record",
+ servermock.ResponseFromFixture("add_txt_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("hostName", "foo.example.com").
+ With("txt", "value")).
+ Build(t)
+
+ result, err := client.AddTXTRecord(t.Context(), "example.com", "foo.example.com", "value")
+ require.NoError(t, err)
+
+ expected := []DNSRecordSet{{
+ Hostname: "_acme-challenge.example.com",
+ Type: "TXT",
+ Records: []string{"value"},
+ }}
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_AddTXTRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("POST /domains/example.com/add-txt-record",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.AddTXTRecord(t.Context(), "example.com", "foo.example.com", "value")
+ require.EqualError(t, err, "message: User does not exist., details: string, logiD: 35579, result: {}")
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/delete-txt-record",
+ servermock.ResponseFromFixture("delete_txt_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("hostName", "foo.example.com").
+ With("txt", "value")).
+ Build(t)
+
+ result, err := client.DeleteTXTRecord(t.Context(), "example.com", "foo.example.com", "value")
+ require.NoError(t, err)
+
+ expected := []DNSRecordSet{{
+ Hostname: "_acme-challenge.example.com",
+ Type: "TXT",
+ Records: []string{"value"},
+ }}
+
+ assert.Equal(t, expected, result)
+}
+
+func TestClient_DeleteTXTRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("DELETE /domains/example.com/delete-txt-record",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusBadRequest)).
+ Build(t)
+
+ _, err := client.DeleteTXTRecord(t.Context(), "example.com", "foo.example.com", "value")
+ require.EqualError(t, err, "message: User does not exist., details: string, logiD: 35579, result: {}")
+}
diff --git a/providers/dns/webnamesca/internal/fixtures/add_txt_record.json b/providers/dns/webnamesca/internal/fixtures/add_txt_record.json
new file mode 100644
index 000000000..9754689a7
--- /dev/null
+++ b/providers/dns/webnamesca/internal/fixtures/add_txt_record.json
@@ -0,0 +1,34 @@
+{
+ "result": {
+ "domainAdvancedDNSConfigID": 3258480,
+ "domainID": 1333334,
+ "dtCreated": "2025-10-30T11:55:23.243",
+ "dtModified": "2025-10-30T11:55:23.177",
+ "timeToLive": 21600,
+ "soAorigin": "hosting.webnames.ca",
+ "soArefresh": 21600,
+ "soAretry": 180,
+ "soAexpire": 1209600,
+ "soAnegcache": 3600,
+ "forwardingURL": null,
+ "gripping": false,
+ "name": null,
+ "dtSubmitted": "2025-10-30T11:55:24.927",
+ "dtRequestedDNSChange": null,
+ "type": "REAL_DOMAIN",
+ "userManaged": false,
+ "effectiveMgmtOption": "AD",
+ "urlForwardRootOnly": false,
+ "enableDNSSEC": false,
+ "dnsRecordSets": [
+ {
+ "hostname": "_acme-challenge.example.com",
+ "type": "TXT",
+ "records": [
+ "value"
+ ]
+ }
+ ]
+ },
+ "logID": 36014
+}
diff --git a/providers/dns/webnamesca/internal/fixtures/delete_txt_record.json b/providers/dns/webnamesca/internal/fixtures/delete_txt_record.json
new file mode 100644
index 000000000..be2279ef6
--- /dev/null
+++ b/providers/dns/webnamesca/internal/fixtures/delete_txt_record.json
@@ -0,0 +1,36 @@
+{
+ "errorMessage": "string",
+ "errorDetails": "string",
+ "logID": 0,
+ "result": {
+ "domainAdvancedDNSConfigID": 0,
+ "domainID": 0,
+ "dtCreated": "2025-10-29T21:22:31.478",
+ "dtModified": "2025-10-29T21:22:31.478",
+ "timeToLive": 0,
+ "soAorigin": "string",
+ "soArefresh": 0,
+ "soAretry": 0,
+ "soAexpire": 0,
+ "soAnegcache": 0,
+ "forwardingURL": "string",
+ "gripping": true,
+ "name": "string",
+ "dtSubmitted": "2025-10-29T21:22:31.478",
+ "dtRequestedDNSChange": "2025-10-29T21:22:31.478",
+ "type": "string",
+ "userManaged": true,
+ "effectiveMgmtOption": "string",
+ "urlForwardRootOnly": true,
+ "enableDNSSEC": true,
+ "dnsRecordSets": [
+ {
+ "hostname": "_acme-challenge.example.com",
+ "type": "TXT",
+ "records": [
+ "value"
+ ]
+ }
+ ]
+ }
+}
diff --git a/providers/dns/webnamesca/internal/fixtures/error.json b/providers/dns/webnamesca/internal/fixtures/error.json
new file mode 100644
index 000000000..3e7548abb
--- /dev/null
+++ b/providers/dns/webnamesca/internal/fixtures/error.json
@@ -0,0 +1,6 @@
+{
+ "errorMessage": "User does not exist.",
+ "errorDetails": "string",
+ "logID": 35579,
+ "result": {}
+}
diff --git a/providers/dns/webnamesca/internal/types.go b/providers/dns/webnamesca/internal/types.go
new file mode 100644
index 000000000..8dc56c33a
--- /dev/null
+++ b/providers/dns/webnamesca/internal/types.go
@@ -0,0 +1,33 @@
+package internal
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+type APIError struct {
+ ErrorMessage string `json:"errorMessage,omitempty"`
+ ErrorDetails string `json:"errorDetails,omitempty"`
+ LogID int `json:"logID,omitempty"`
+ Result json.RawMessage `json:"result,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ return fmt.Sprintf("message: %s, details: %s, logiD: %d, result: %s", a.ErrorMessage, a.ErrorDetails, a.LogID, a.Result)
+}
+
+type APIResponse[T any] struct {
+ Result T `json:"result,omitempty"`
+ LogID int `json:"logID,omitempty"`
+}
+
+type DNSInfo struct {
+ DomainID int `json:"domainID,omitempty"`
+ DNSRecordSets []DNSRecordSet `json:"dnsRecordSets,omitempty"`
+}
+
+type DNSRecordSet struct {
+ Hostname string `json:"hostname"`
+ Type string `json:"type"`
+ Records []string `json:"records"`
+}
diff --git a/providers/dns/webnamesca/webnamesca.go b/providers/dns/webnamesca/webnamesca.go
new file mode 100644
index 000000000..874c1c48e
--- /dev/null
+++ b/providers/dns/webnamesca/webnamesca.go
@@ -0,0 +1,134 @@
+// Package webnamesca implements a DNS provider for solving the DNS-01 challenge using webnames.ca.
+package webnamesca
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/webnamesca/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "WEBNAMESCA_"
+
+ EnvAPIUser = envNamespace + "API_USER"
+ EnvAPIKey = envNamespace + "API_KEY"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIUser string
+ APIKey string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for webnames.ca.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIUser, EnvAPIKey)
+ if err != nil {
+ return nil, fmt.Errorf("webnamesca: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIUser = values[EnvAPIUser]
+ config.APIKey = values[EnvAPIKey]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for webnames.ca.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("webnamesca: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIUser, config.APIKey)
+ if err != nil {
+ return nil, fmt.Errorf("webnamesca: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("webnamesca: could not find zone for domain %q: %w", domain, err)
+ }
+
+ _, err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+ if err != nil {
+ return fmt.Errorf("webnamesca: add TXT record: %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)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("webnamesca: could not find zone for domain %q: %w", domain, err)
+ }
+
+ _, err = d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+ if err != nil {
+ return fmt.Errorf("webnamesca: delete TXT 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/dns/webnamesca/webnamesca.toml b/providers/dns/webnamesca/webnamesca.toml
new file mode 100644
index 000000000..ab68a04a0
--- /dev/null
+++ b/providers/dns/webnamesca/webnamesca.toml
@@ -0,0 +1,24 @@
+Name = "webnames.ca"
+Description = ''''''
+URL = "https://www.webnames.ca/"
+Code = "webnamesca"
+Since = "v4.28.0"
+
+Example = '''
+WEBNAMESCA_API_USER="xxx" \
+WEBNAMESCA_API_KEY="yyy" \
+lego --dns webnamesca -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ WEBNAMESCA_API_USER = "API username"
+ WEBNAMESCA_API_KEY = "API key"
+ [Configuration.Additional]
+ WEBNAMESCA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ WEBNAMESCA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ WEBNAMESCA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ WEBNAMESCA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.webnames.ca/_/swagger/index.html"
diff --git a/providers/dns/webnamesca/webnamesca_test.go b/providers/dns/webnamesca/webnamesca_test.go
new file mode 100644
index 000000000..0459ef44e
--- /dev/null
+++ b/providers/dns/webnamesca/webnamesca_test.go
@@ -0,0 +1,199 @@
+package webnamesca
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIUser, EnvAPIKey).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIUser: "user",
+ EnvAPIKey: "secret",
+ },
+ },
+ {
+ desc: "missing EnvAPIUser",
+ envVars: map[string]string{
+ EnvAPIUser: "",
+ EnvAPIKey: "secret",
+ },
+ expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_USER",
+ },
+ {
+ desc: "missing EnvAPIKey",
+ envVars: map[string]string{
+ EnvAPIUser: "user",
+ EnvAPIKey: "",
+ },
+ expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_KEY",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_USER,WEBNAMESCA_API_KEY",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiUser string
+ apiKey string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiUser: "user",
+ apiKey: "secret",
+ },
+ {
+ desc: "missing apiUser",
+ apiKey: "secret",
+ expected: "webnamesca: credentials missing",
+ },
+ {
+ desc: "missing apiKey",
+ apiUser: "user",
+ expected: "webnamesca: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "webnamesca: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIUser = test.apiUser
+ config.APIKey = test.apiKey
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(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)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.APIUser = "user"
+ config.APIKey = "secret"
+ config.HTTPClient = server.Client()
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckHeader().
+ WithJSONHeaders().
+ With("API-User", "user").
+ With("API-Key", "secret"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("POST /domains/example.com/add-txt-record",
+ servermock.ResponseFromInternal("add_txt_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("hostName", "_acme-challenge.example.com").
+ With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")).
+ Build(t)
+
+ err := provider.Present("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("DELETE /domains/example.com/delete-txt-record",
+ servermock.ResponseFromInternal("delete_txt_record.json"),
+ servermock.CheckQueryParameter().Strict().
+ With("hostName", "_acme-challenge.example.com").
+ With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "abc", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/websupport/internal/client_test.go b/providers/dns/websupport/internal/client_test.go
deleted file mode 100644
index 9612f6096..000000000
--- a/providers/dns/websupport/internal/client_test.go
+++ /dev/null
@@ -1,234 +0,0 @@
-package internal
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "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
- }
-
- open, err := os.Open(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, err := NewClient("apiKey", "secretKey")
- require.NoError(t, err)
-
- client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
-
- return client
-}
-
-func TestClient_GetUser(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/v1/user/self", http.StatusOK, "./fixtures/get-user.json")
-
- user, err := client.GetUser(context.Background(), "self")
- require.NoError(t, err)
-
- expected := &User{
- ID: 987654321,
- Login: "lego@example.com",
- Active: true,
- CreateTime: 1675237889,
- Group: "users",
- Email: "lego@example.com",
- Phone: "+123456789",
- ContactPerson: "",
- AwaitingTosConfirmation: "1",
- UserLanguage: "sk-SK",
- Credit: 0,
- VerifyURL: "https://rest.websupport.sk/v1/user/verify/key/xxx",
- Billing: []Billing{{
- ID: 1099970,
- Profile: "default",
- IsDefault: true,
- Name: "asdsdfs",
- City: "Žilina",
- Street: "asddfsdfsdf",
- Zip: "01234",
- Country: "sk",
- }},
- Market: Market{Name: "Slovakia", Identifier: "sk", Currency: "EUR"},
- }
-
- assert.Equal(t, expected, user)
-}
-
-func TestClient_ListRecords(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/v1/user/self/zone/example.com/record", http.StatusOK, "./fixtures/list-records.json")
-
- resp, err := client.ListRecords(context.Background(), "example.com")
- require.NoError(t, err)
-
- expected := &ListResponse{
- Items: []Record{
- {
- ID: 1,
- Type: "A",
- Name: "@",
- Content: "37.9.169.99",
- TTL: 600,
- }, {
- ID: 2,
- Type: "NS",
- Name: "@",
- Content: "ns1.scaledo.com",
- TTL: 600,
- },
- },
- Pager: Pager{Page: 1, PageSize: 0, Items: 2},
- }
-
- assert.Equal(t, expected, resp)
-}
-
-func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/v1/user/self/zone/example.com/record", http.StatusCreated, "./fixtures/add-record.json")
-
- record := Record{
- Type: "TXT",
- Name: "_acme-challenge",
- Content: "txttxttxt",
- TTL: 600,
- }
-
- resp, err := client.AddRecord(context.Background(), "example.com", record)
- require.NoError(t, err)
-
- expected := &Response{
- Status: "success",
- Item: &Record{
- ID: 4,
- Type: "A",
- Name: "@",
- Content: "1.2.3.4",
- TTL: 600,
- Zone: &Zone{
- ID: 1,
- Name: "example.com",
- UpdateTime: 1381169608,
- },
- },
- Errors: json.RawMessage("[]"),
- }
-
- assert.Equal(t, expected, resp)
-}
-
-func TestClient_AddRecord_error_400(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/v1/user/self/zone/example.com/record", http.StatusBadRequest, "./fixtures/add-record-error-400.json")
-
- record := Record{
- Type: "TXT",
- Name: "_acme-challenge",
- Content: "txttxttxt",
- TTL: 600,
- }
-
- resp, err := client.AddRecord(context.Background(), "example.com", record)
- require.NoError(t, err)
-
- assert.Equal(t, "error", resp.Status)
-
- expectedRecord := &Record{
- ID: 0,
- Type: "A",
- Name: "something bad !@#$%^&*(",
- Content: "123.456.789.123",
- TTL: 600,
- Zone: &Zone{
- ID: 1,
- Name: "scaledo.com",
- UpdateTime: 1381169608,
- },
- }
- assert.Equal(t, expectedRecord, resp.Item)
-
- expected := &Errors{Name: []string{"Invalid input."}, Content: []string{"Wrong IP address format"}}
- assert.Equal(t, expected, ParseError(resp))
-}
-
-func TestClient_AddRecord_error_404(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/v1/user/self/zone/example.com/record", http.StatusNotFound, "./fixtures/add-record-error-404.json")
-
- record := Record{
- Type: "TXT",
- Name: "_acme-challenge",
- Content: "txttxttxt",
- TTL: 600,
- }
-
- resp, err := client.AddRecord(context.Background(), "example.com", record)
- require.Error(t, err)
-
- assert.Nil(t, resp)
-}
-
-func TestClient_DeleteRecord(t *testing.T) {
- client := setupTest(t, http.MethodDelete, "/v1/user/self/zone/example.com/record/123", http.StatusOK, "./fixtures/delete-record.json")
-
- resp, err := client.DeleteRecord(context.Background(), "example.com", 123)
- require.NoError(t, err)
-
- expected := &Response{
- Status: "success",
- Item: &Record{
- ID: 1,
- Type: "A",
- Name: "@",
- Content: "1.2.3.4",
- TTL: 600,
- Zone: &Zone{
- ID: 1,
- Name: "scaledo.com",
- UpdateTime: 1381316081,
- },
- },
- Errors: json.RawMessage("[]"),
- }
-
- assert.Equal(t, expected, resp)
-}
-
-func TestClient_DeleteRecord_error(t *testing.T) {
- client := setupTest(t, http.MethodDelete, "/v1/user/self/zone/example.com/record/123", http.StatusNotFound, "./fixtures/delete-record-error-404.json")
-
- resp, err := client.DeleteRecord(context.Background(), "example.com", 123)
- require.Error(t, err)
-
- assert.Nil(t, resp)
-}
diff --git a/providers/dns/websupport/internal/fixtures/add-record-error-400.json b/providers/dns/websupport/internal/fixtures/add-record-error-400.json
deleted file mode 100644
index b60b7989a..000000000
--- a/providers/dns/websupport/internal/fixtures/add-record-error-400.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "status": "error",
- "item": {
- "id": null,
- "type": "A",
- "name": "something bad !@#$%^&*(",
- "content": "123.456.789.123",
- "ttl": 600,
- "prio": null,
- "weight": null,
- "port": null,
- "zone": {
- "id": 1,
- "name": "scaledo.com",
- "updateTime": 1381169608
- }
- },
- "errors": {
- "content": [
- "Wrong IP address format"
- ],
- "name": [
- "Invalid input."
- ]
- }
-}
diff --git a/providers/dns/websupport/internal/fixtures/add-record-error-404.json b/providers/dns/websupport/internal/fixtures/add-record-error-404.json
deleted file mode 100644
index 837b5392a..000000000
--- a/providers/dns/websupport/internal/fixtures/add-record-error-404.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "code": 404,
- "message": "Zone not found"
-}
diff --git a/providers/dns/websupport/internal/fixtures/add-record.json b/providers/dns/websupport/internal/fixtures/add-record.json
deleted file mode 100644
index 5990cf3d3..000000000
--- a/providers/dns/websupport/internal/fixtures/add-record.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "status": "success",
- "item": {
- "id": 4,
- "type": "A",
- "name": "@",
- "content": "1.2.3.4",
- "ttl": 600,
- "prio": null,
- "weight": null,
- "port": null,
- "zone": {
- "id": 1,
- "name": "example.com",
- "updateTime": 1381169608
- }
- },
- "errors": []
-}
diff --git a/providers/dns/websupport/internal/fixtures/delete-record-error-404.json b/providers/dns/websupport/internal/fixtures/delete-record-error-404.json
deleted file mode 100644
index e66fa5dc6..000000000
--- a/providers/dns/websupport/internal/fixtures/delete-record-error-404.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "code": 404,
- "message": "Record not found"
-}
diff --git a/providers/dns/websupport/internal/fixtures/delete-record.json b/providers/dns/websupport/internal/fixtures/delete-record.json
deleted file mode 100644
index 8fdff82cb..000000000
--- a/providers/dns/websupport/internal/fixtures/delete-record.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "status": "success",
- "item": {
- "id": 1,
- "type": "A",
- "name": "@",
- "content": "1.2.3.4",
- "ttl": 600,
- "prio": null,
- "weight": null,
- "port": null,
- "zone": {
- "id": 1,
- "name": "scaledo.com",
- "updateTime": 1381316081
- }
- },
- "errors": []
-}
diff --git a/providers/dns/websupport/internal/fixtures/get-record.json b/providers/dns/websupport/internal/fixtures/get-record.json
deleted file mode 100644
index d1bd2f137..000000000
--- a/providers/dns/websupport/internal/fixtures/get-record.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "id": 69966832,
- "type": "TXT",
- "name": "_acme-challenge",
- "content": "txttxttxt",
- "ttl": 600,
- "zone": {
- "id": 0,
- "name": "example.com",
- "updateTime": 1675240207
- }
-}
diff --git a/providers/dns/websupport/internal/fixtures/get-user.json b/providers/dns/websupport/internal/fixtures/get-user.json
deleted file mode 100644
index ad4978755..000000000
--- a/providers/dns/websupport/internal/fixtures/get-user.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "id": 987654321,
- "login": "lego@example.com",
- "parentId": null,
- "active": true,
- "createTime": 1675237889,
- "group": "users",
- "email": "lego@example.com",
- "phone": "+123456789",
- "contactPerson": "",
- "awaitingTosConfirmation": "1",
- "userLanguage": "sk-SK",
- "credit": 0,
- "verifyUrl": "https:\/\/rest.websupport.sk\/v1\/user\/verify\/key\/xxx",
- "billing": [
- {
- "id": 1099970,
- "profile": "default",
- "isDefault": true,
- "name": "asdsdfs",
- "city": "\u017dilina",
- "street": "asddfsdfsdf",
- "companyRegId": null,
- "taxId": null,
- "vatId": null,
- "zip": "01234",
- "country": "sk",
- "isic": ""
- }
- ],
- "market": {
- "name": "Slovakia",
- "identifier": "sk",
- "currency": "EUR"
- }
-}
diff --git a/providers/dns/websupport/internal/fixtures/list-records.json b/providers/dns/websupport/internal/fixtures/list-records.json
deleted file mode 100644
index d0ad57dc9..000000000
--- a/providers/dns/websupport/internal/fixtures/list-records.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "items": [
- {
- "id": 1,
- "type": "A",
- "name": "@",
- "content": "37.9.169.99",
- "ttl": 600,
- "prio": null,
- "weight": null,
- "port": null
- },
- {
- "id": 2,
- "type": "NS",
- "name": "@",
- "content": "ns1.scaledo.com",
- "ttl": 600,
- "prio": null,
- "weight": null,
- "port": null
- }
- ],
- "pager": {
- "page": 1,
- "pagesize": 0,
- "items": 2
- }
-}
diff --git a/providers/dns/websupport/internal/types.go b/providers/dns/websupport/internal/types.go
deleted file mode 100644
index 0923282aa..000000000
--- a/providers/dns/websupport/internal/types.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package internal
-
-import (
- "encoding/json"
- "fmt"
-)
-
-type APIError struct {
- Code int `json:"code"`
- Message string `json:"message"`
-}
-
-func (a *APIError) Error() string {
- return fmt.Sprintf("%d: %s", a.Code, a.Message)
-}
-
-type Record struct {
- ID int `json:"id,omitempty"`
- Type string `json:"type,omitempty"`
- Name string `json:"name,omitempty"` // subdomain name or @ if you don't want subdomain
- Content string `json:"content,omitempty"`
- TTL int `json:"ttl,omitempty"` // default 600
- Zone *Zone `json:"zone"`
-}
-
-type Zone struct {
- ID int `json:"id"`
- Name string `json:"name"`
- UpdateTime int `json:"updateTime"`
-}
-
-type Response struct {
- Item *Record `json:"item"`
- Status string `json:"status"`
- Errors json.RawMessage `json:"errors"`
-}
-
-type ListResponse struct {
- Items []Record `json:"items"`
- Pager Pager `json:"pager"`
-}
-
-type Pager struct {
- Page int `json:"page"`
- PageSize int `json:"pagesize"`
- Items int `json:"items"`
-}
-
-type Errors struct {
- Name []string `json:"name"`
- Content []string `json:"content"`
-}
-
-func (e *Errors) Error() string {
- var msg string
- for i, s := range e.Name {
- msg += s
- if i != len(e.Name)-1 {
- msg += ": "
- }
- }
-
- for i, s := range e.Content {
- msg += s
- if i != len(e.Content)-1 {
- msg += ": "
- }
- }
-
- return msg
-}
-
-// ParseError extract error from Response.
-func ParseError(resp *Response) error {
- var errAPI Errors
- err := json.Unmarshal(resp.Errors, &errAPI)
- if err != nil {
- return err
- }
-
- return &errAPI
-}
-
-type User struct {
- ID int `json:"id"`
- Login string `json:"login"`
- ParentID int `json:"parentId"`
- Active bool `json:"active"`
- CreateTime int `json:"createTime"`
- Group string `json:"group"`
- Email string `json:"email"`
- Phone string `json:"phone"`
- ContactPerson string `json:"contactPerson"`
- AwaitingTosConfirmation string `json:"awaitingTosConfirmation"`
- UserLanguage string `json:"userLanguage"`
- Credit int `json:"credit"`
- VerifyURL string `json:"verifyUrl"`
- Billing []Billing `json:"billing"`
- Market Market `json:"market"`
-}
-
-type Billing struct {
- ID int `json:"id"`
- Profile string `json:"profile"`
- IsDefault bool `json:"isDefault"`
- Name string `json:"name"`
- City string `json:"city"`
- Street string `json:"street"`
- CompanyRegID int `json:"companyRegId"`
- TaxID int `json:"taxId"`
- VatID int `json:"vatId"`
- Zip string `json:"zip"`
- Country string `json:"country"`
- ISIC string `json:"isic"`
-}
-
-type Market struct {
- Name string `json:"name"`
- Identifier string `json:"identifier"`
- Currency string `json:"currency"`
-}
diff --git a/providers/dns/websupport/websupport.go b/providers/dns/websupport/websupport.go
index db31315d8..4187ba32b 100644
--- a/providers/dns/websupport/websupport.go
+++ b/providers/dns/websupport/websupport.go
@@ -2,19 +2,19 @@
package websupport
import (
- "context"
"errors"
"fmt"
"net/http"
- "sync"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/websupport/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/active24"
)
+const baseAPIDomain = "websupport.sk"
+
// Environment variables names.
const (
envNamespace = "WEBSUPPORT_"
@@ -26,30 +26,17 @@ const (
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
- EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL"
)
-var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
-
// Config is used to configure the creation of the DNSProvider.
-type Config struct {
- APIKey string
- Secret string
-
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- SequenceInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = active24.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, 600),
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
- SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
@@ -58,11 +45,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *internal.Client
-
- recordIDs map[string]int
- recordIDsMu sync.Mutex
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for Websupport.
@@ -86,101 +69,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("websupport: the configuration of the DNS provider is nil")
}
- client, err := internal.NewClient(config.APIKey, config.Secret)
+ provider, err := active24.NewDNSProviderConfig(config, baseAPIDomain)
if err != nil {
return nil, fmt.Errorf("websupport: %w", err)
}
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- return &DNSProvider{
- config: config,
- client: client,
- recordIDs: make(map[string]int),
- }, nil
+ return &DNSProvider{prv: provider}, 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("websupport: could not find zone for domain %q: %w", domain, err)
- }
-
- subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("websupport: %w", err)
}
- record := internal.Record{
- Type: "TXT",
- Name: subDomain,
- Content: info.Value,
- TTL: d.config.TTL,
- }
-
- resp, err := d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record)
- if err != nil {
- return fmt.Errorf("websupport: add record: %w", err)
- }
-
- if resp.Status == internal.StatusSuccess {
- d.recordIDsMu.Lock()
- d.recordIDs[token] = resp.Item.ID
- d.recordIDsMu.Unlock()
-
- return nil
- }
-
- return fmt.Errorf("websupport: %w", internal.ParseError(resp))
+ 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(info.EffectiveFQDN)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("websupport: could not find zone for domain %q: %w", domain, err)
+ return fmt.Errorf("websupport: %w", err)
}
- // gets the record's unique ID
- d.recordIDsMu.Lock()
- recordID, ok := d.recordIDs[token]
- d.recordIDsMu.Unlock()
- if !ok {
- return fmt.Errorf("websupport: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
- }
-
- resp, err := d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
- if err != nil {
- return fmt.Errorf("websupport: delete record: %w", err)
- }
-
- // deletes record ID from map
- d.recordIDsMu.Lock()
- delete(d.recordIDs, token)
- d.recordIDsMu.Unlock()
-
- if resp.Status == internal.StatusSuccess {
- return nil
- }
-
- return fmt.Errorf("websupport: %w", internal.ParseError(resp))
+ 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
-}
-
-// 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
+ return d.prv.Timeout()
}
diff --git a/providers/dns/websupport/websupport.toml b/providers/dns/websupport/websupport.toml
index d1a0af7dc..4908f0235 100644
--- a/providers/dns/websupport/websupport.toml
+++ b/providers/dns/websupport/websupport.toml
@@ -7,7 +7,7 @@ Since = "v4.10.0"
Example = '''
WEBSUPPORT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
WEBSUPPORT_SECRET="yyyyyyyyyyyyyyyyyyyyy" \
-lego --email you@example.com --dns websupport -d '*.example.com' -d example.com run
+lego --dns websupport -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,11 +15,12 @@ lego --email you@example.com --dns websupport -d '*.example.com' -d example.com
WEBSUPPORT_API_KEY = "API key"
WEBSUPPORT_SECRET = "API secret"
[Configuration.Additional]
- WEBSUPPORT_POLLING_INTERVAL = "Time between DNS propagation check"
- WEBSUPPORT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- WEBSUPPORT_SEQUENCE_INTERVAL = "Time between sequential requests"
- WEBSUPPORT_TTL = "The TTL of the TXT record used for the DNS challenge"
- WEBSUPPORT_HTTP_TIMEOUT = "API request timeout"
+ WEBSUPPORT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ WEBSUPPORT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ WEBSUPPORT_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)"
+ WEBSUPPORT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
+ WEBSUPPORT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
- API = "https://rest.websupport.sk/docs/v1.zone"
+ API = "https://rest.websupport.sk/v2/docs"
+ APIv1 = "https://rest.websupport.sk/docs/v1.service#services"
diff --git a/providers/dns/websupport/websupport_test.go b/providers/dns/websupport/websupport_test.go
index e79dd7130..196c9bab8 100644
--- a/providers/dns/websupport/websupport_test.go
+++ b/providers/dns/websupport/websupport_test.go
@@ -20,13 +20,14 @@ func TestNewDNSProvider(t *testing.T) {
{
desc: "success",
envVars: map[string]string{
- EnvAPIKey: "key",
+ EnvAPIKey: "user",
EnvSecret: "secret",
},
},
{
desc: "missing API key",
envVars: map[string]string{
+ EnvAPIKey: "",
EnvSecret: "secret",
},
expected: "websupport: some credentials information are missing: WEBSUPPORT_API_KEY",
@@ -34,7 +35,8 @@ func TestNewDNSProvider(t *testing.T) {
{
desc: "missing secret",
envVars: map[string]string{
- EnvAPIKey: "key",
+ EnvAPIKey: "user",
+ EnvSecret: "",
},
expected: "websupport: some credentials information are missing: WEBSUPPORT_SECRET",
},
@@ -48,6 +50,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -57,8 +60,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.client)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -75,17 +77,19 @@ func TestNewDNSProviderConfig(t *testing.T) {
}{
{
desc: "success",
- apiKey: "key",
+ apiKey: "user",
secret: "secret",
},
{
desc: "missing API key",
+ apiKey: "",
secret: "secret",
expected: "websupport: credentials missing",
},
{
desc: "missing secret",
- apiKey: "key",
+ apiKey: "user",
+ secret: "",
expected: "websupport: credentials missing",
},
{
@@ -105,8 +109,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
- require.NotNil(t, p.client)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -120,6 +123,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -133,6 +137,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/wedos/internal/client.go b/providers/dns/wedos/internal/client.go
index defcabf6c..48c89d189 100644
--- a/providers/dns/wedos/internal/client.go
+++ b/providers/dns/wedos/internal/client.go
@@ -26,7 +26,7 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(username string, password string) *Client {
+func NewClient(username, password string) *Client {
return &Client{
username: username,
password: password,
@@ -69,6 +69,7 @@ func (c *Client) AddRecord(ctx context.Context, zone string, record DNSRow) erro
}
cmd := commandDNSRowAdd
+
if record.ID == "" {
payload.Name = record.Name
} else {
@@ -87,7 +88,7 @@ func (c *Client) AddRecord(ctx context.Context, zone string, record DNSRow) erro
// DeleteRecord deletes a record from the zone.
// If a record does not have an ID, it will be looked up.
// https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-row-delete/
-func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID string) error {
+func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error {
payload := DNSRowRequest{
Domain: dns01.UnFqdn(zone),
ID: recordID,
diff --git a/providers/dns/wedos/internal/client_test.go b/providers/dns/wedos/internal/client_test.go
index 30c7d4863..f2515618a 100644
--- a/providers/dns/wedos/internal/client_test.go
+++ b/providers/dns/wedos/internal/client_test.go
@@ -1,64 +1,38 @@
package internal
import (
- "context"
"fmt"
"net/http"
"net/http/httptest"
- "os"
"regexp"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupNew(t *testing.T, expectedForm string, filename string) *Client {
- t.Helper()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.baseURL = server.URL
+ client.HTTPClient = server.Client()
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
- err := req.ParseForm()
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
-
- exp := regexp.MustCompile(`"auth":"\w+",`)
-
- form := req.PostForm.Get("request")
- form = exp.ReplaceAllString(form, `"auth":"xxx",`)
-
- if form != expectedForm {
- t.Logf("invalid form data: %s", req.PostForm.Get("request"))
- http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest)
- return
- }
-
- data, err := os.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename))
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- rw.Header().Set("Content-Type", "application/json")
- _, _ = rw.Write(data)
- })
-
- client := NewClient("user", "secret")
- client.baseURL = server.URL
-
- return client
+ return client, nil
+ },
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded())
}
func TestClient_GetRecords(t *testing.T) {
- expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}`
- client := setupNew(t, expectedForm, commandDNSRowsList)
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture(commandDNSRowsList+".json"),
+ checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}`)).
+ Build(t)
- records, err := client.GetRecords(context.Background(), "example.com.")
+ records, err := client.GetRecords(t.Context(), "example.com.")
require.NoError(t, err)
assert.Len(t, records, 4)
@@ -95,9 +69,11 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_AddRecord(t *testing.T) {
- expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"type":"TXT","rdata":"foobar"}}}`
-
- client := setupNew(t, expectedForm, commandDNSRowAdd)
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture(commandDNSRowAdd+".json"),
+ checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)).
+ Build(t)
record := DNSRow{
ID: "",
@@ -107,14 +83,16 @@ func TestClient_AddRecord(t *testing.T) {
Data: "foobar",
}
- err := client.AddRecord(context.Background(), "example.com.", record)
+ err := client.AddRecord(t.Context(), "example.com.", record)
require.NoError(t, err)
}
func TestClient_AddRecord_update(t *testing.T) {
- expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"row_id":"1","domain":"example.com","ttl":1800,"type":"TXT","rdata":"foobar"}}}`
-
- client := setupNew(t, expectedForm, commandDNSRowUpdate)
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture(commandDNSRowUpdate+".json"),
+ checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"row_id":"1","domain":"example.com","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)).
+ Build(t)
record := DNSRow{
ID: "1",
@@ -124,24 +102,50 @@ func TestClient_AddRecord_update(t *testing.T) {
Data: "foobar",
}
- err := client.AddRecord(context.Background(), "example.com.", record)
+ err := client.AddRecord(t.Context(), "example.com.", record)
require.NoError(t, err)
}
func TestClient_DeleteRecord(t *testing.T) {
- expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"row_id":"1","domain":"example.com","rdata":""}}}`
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture(commandDNSRowDelete+".json"),
+ checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"row_id":"1","domain":"example.com","rdata":""}}}`)).
+ Build(t)
- client := setupNew(t, expectedForm, commandDNSRowDelete)
-
- err := client.DeleteRecord(context.Background(), "example.com.", "1")
+ err := client.DeleteRecord(t.Context(), "example.com.", "1")
require.NoError(t, err)
}
func TestClient_Commit(t *testing.T) {
- expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}`
+ client := mockBuilder().
+ Route("POST /",
+ servermock.ResponseFromFixture(commandDNSDomainCommit+".json"),
+ checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}`)).
+ Build(t)
- client := setupNew(t, expectedForm, commandDNSDomainCommit)
-
- err := client.Commit(context.Background(), "example.com.")
+ err := client.Commit(t.Context(), "example.com.")
require.NoError(t, err)
}
+
+func checkFormRequest(data string) servermock.LinkFunc {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ err := req.ParseForm()
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ form := regexp.MustCompile(`"auth":"\w+",`).
+ ReplaceAllString(req.PostForm.Get("request"), `"auth":"xxx",`)
+
+ if form != data {
+ http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest)
+ return
+ }
+
+ next.ServeHTTP(rw, req)
+ })
+ }
+}
diff --git a/providers/dns/wedos/internal/token.go b/providers/dns/wedos/internal/token.go
index b83b107c1..11e680cb8 100644
--- a/providers/dns/wedos/internal/token.go
+++ b/providers/dns/wedos/internal/token.go
@@ -8,13 +8,14 @@ import (
"time"
)
-func authToken(userName string, wapiPass string) string {
+func authToken(userName, wapiPass string) string {
return sha1string(userName + sha1string(wapiPass) + czechHourString())
}
func sha1string(txt string) string {
h := sha1.New()
_, _ = io.WriteString(h, txt)
+
return hex.EncodeToString(h.Sum(nil))
}
@@ -46,18 +47,19 @@ func utcToCet(utc time.Time) time.Time {
if utcMonth < time.March || utcMonth > time.October {
return utc.Add(time.Hour)
}
+
if utcMonth > time.March && utcMonth < time.October {
return utc.Add(time.Hour * 2)
}
dayOff := 0
+
breaking := time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC)
- for {
- if breaking.Weekday() == time.Sunday {
- break
- }
+ for breaking.Weekday() != time.Sunday {
dayOff--
+
breaking = time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC)
+
if dayOff < -7 {
panic("safety exit to avoid infinite loop")
}
@@ -66,6 +68,7 @@ func utcToCet(utc time.Time) time.Time {
if (utcMonth == time.March && utc.Before(breaking)) || (utcMonth == time.October && utc.After(breaking)) {
return utc.Add(time.Hour)
}
+
return utc.Add(time.Hour * 2)
}
diff --git a/providers/dns/wedos/wedos.go b/providers/dns/wedos/wedos.go
index 85187ec46..164fb5f10 100644
--- a/providers/dns/wedos/wedos.go
+++ b/providers/dns/wedos/wedos.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/wedos/internal"
)
@@ -94,6 +95,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{config: config, client: client}, nil
}
diff --git a/providers/dns/wedos/wedos.toml b/providers/dns/wedos/wedos.toml
index 64845536e..89abfc16c 100644
--- a/providers/dns/wedos/wedos.toml
+++ b/providers/dns/wedos/wedos.toml
@@ -7,7 +7,7 @@ Since = "v4.4.0"
Example = '''
WEDOS_USERNAME=xxxxxxxx \
WEDOS_WAPI_PASSWORD=xxxxxxxx \
-lego --email you@example.com --dns wedos -d '*.example.com' -d example.com run
+lego --dns wedos -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -15,10 +15,10 @@ lego --email you@example.com --dns wedos -d '*.example.com' -d example.com run
WEDOS_USERNAME = "Username is the same as for the admin account"
WEDOS_WAPI_PASSWORD = "Password needs to be generated and IP allowed in the admin interface"
[Configuration.Additional]
- WEDOS_POLLING_INTERVAL = "Time between DNS propagation check"
- WEDOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- WEDOS_HTTP_TIMEOUT = "API request timeout"
- WEDOS_TTL = "The TTL of the TXT record used for the DNS challenge"
+ WEDOS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ WEDOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)"
+ WEDOS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)"
+ WEDOS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://kb.wedos.com/en/kategorie/wapi-api-interface/wdns-en/"
diff --git a/providers/dns/wedos/wedos_test.go b/providers/dns/wedos/wedos_test.go
index 9363002b5..25f70d0fc 100644
--- a/providers/dns/wedos/wedos_test.go
+++ b/providers/dns/wedos/wedos_test.go
@@ -54,6 +54,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -120,6 +121,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -133,6 +135,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/westcn/westcn.go b/providers/dns/westcn/westcn.go
new file mode 100644
index 000000000..1906f9737
--- /dev/null
+++ b/providers/dns/westcn/westcn.go
@@ -0,0 +1,104 @@
+// Package westcn implements a DNS provider for solving the DNS-01 challenge using West.cn/西部数码.
+package westcn
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/westcn"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "WESTCN_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+const defaultBaseURL = "https://api.west.cn/api/v2"
+
+var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config = westcn.Config
+
+// 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, 10*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ prv challenge.ProviderTimeout
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for West.cn/西部数码.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("westcn: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for West.cn/西部数码.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("westcn: the configuration of the DNS provider is nil")
+ }
+
+ provider, err := westcn.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("westcn: %w", err)
+ }
+
+ return &DNSProvider{prv: provider}, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ err := d.prv.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("westcn: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ err := d.prv.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("westcn: %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.prv.Timeout()
+}
diff --git a/providers/dns/westcn/westcn.toml b/providers/dns/westcn/westcn.toml
new file mode 100644
index 000000000..1b0cb0a7a
--- /dev/null
+++ b/providers/dns/westcn/westcn.toml
@@ -0,0 +1,24 @@
+Name = "West.cn/西部数码"
+Description = ''''''
+URL = "https://www.west.cn"
+Code = "westcn"
+Since = "v4.21.0"
+
+Example = '''
+WESTCN_USERNAME="xxx" \
+WESTCN_PASSWORD="yyy" \
+lego --dns westcn -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ WESTCN_USERNAME = "Username"
+ WESTCN_PASSWORD = "API password"
+ [Configuration.Additional]
+ WESTCN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)"
+ WESTCN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)"
+ WESTCN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
+ WESTCN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://www.west.cn/CustomerCenter/doc/domain_v2.html"
diff --git a/providers/dns/westcn/westcn_test.go b/providers/dns/westcn/westcn_test.go
new file mode 100644
index 000000000..a546d518e
--- /dev/null
+++ b/providers/dns/westcn/westcn_test.go
@@ -0,0 +1,144 @@
+package westcn
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "secret",
+ },
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "secret",
+ },
+ expected: "westcn: some credentials information are missing: WESTCN_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "",
+ },
+ expected: "westcn: some credentials information are missing: WESTCN_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "westcn: some credentials information are missing: WESTCN_USERNAME,WESTCN_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.prv)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing username",
+ password: "secret",
+ expected: "westcn: credentials missing",
+ },
+ {
+ desc: "missing password",
+ username: "user",
+ expected: "westcn: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "westcn: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.Username = test.username
+ config.Password = test.password
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.prv)
+ } 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/dns/yandex/internal/client.go b/providers/dns/yandex/internal/client.go
index 5d7e6bff3..4b0421f49 100644
--- a/providers/dns/yandex/internal/client.go
+++ b/providers/dns/yandex/internal/client.go
@@ -12,7 +12,7 @@ import (
"time"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
- "github.com/google/go-querystring/query"
+ querystring "github.com/google/go-querystring/query"
)
const defaultBaseURL = "https://pddimp.yandex.ru/api2/admin/dns"
@@ -51,6 +51,7 @@ func (c *Client) AddRecord(ctx context.Context, payload Record) (*Record, error)
}
r := AddResponse{}
+
err = c.do(req, &r)
if err != nil {
return nil, err
@@ -68,6 +69,7 @@ func (c *Client) RemoveRecord(ctx context.Context, payload Record) (int, error)
}
r := RemoveResponse{}
+
err = c.do(req, &r)
if err != nil {
return 0, err
@@ -89,6 +91,7 @@ func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error
}
r := ListResponse{}
+
err = c.do(req, &r)
if err != nil {
return nil, err
@@ -130,7 +133,7 @@ func newRequest(ctx context.Context, method string, endpoint *url.URL, payload a
if payload != nil {
switch method {
case http.MethodPost:
- values, err := query.Values(payload)
+ values, err := querystring.Values(payload)
if err != nil {
return nil, err
}
@@ -138,7 +141,7 @@ func newRequest(ctx context.Context, method string, endpoint *url.URL, payload a
buf.WriteString(values.Encode())
case http.MethodGet:
- values, err := query.Values(payload)
+ values, err := querystring.Values(payload)
if err != nil {
return nil, err
}
diff --git a/providers/dns/yandex/internal/client_test.go b/providers/dns/yandex/internal/client_test.go
index 67166ee85..4bb3357a6 100644
--- a/providers/dns/yandex/internal/client_test.go
+++ b/providers/dns/yandex/internal/client_test.go
@@ -1,328 +1,133 @@
package internal
import (
- "context"
- "encoding/json"
- "net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func setupTest(t *testing.T) (*Client, *http.ServeMux) {
- t.Helper()
-
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
+func setupClient(server *httptest.Server) (*Client, error) {
client, err := NewClient("lego")
- require.NoError(t, err)
+ if err != nil {
+ return nil, err
+ }
client.HTTPClient = server.Client()
client.baseURL, _ = url.Parse(server.URL)
- return client, mux
+ return client, nil
}
func TestAddRecord(t *testing.T) {
- testCases := []struct {
- desc string
- handler http.HandlerFunc
- data Record
- expectError bool
- }{
- {
- desc: "success",
- handler: func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
- assert.Equal(t, "lego", r.Header.Get(pddTokenHeader))
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /add",
+ servermock.ResponseFromFixture("add_record.json"),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ servermock.CheckForm().Strict().
+ With("domain", "example.com").
+ With("subdomain", "foo").
+ With("ttl", "300").
+ With("content", "txtTXTtxtTXTtxtTXT").
+ With("type", "TXT")).
+ Build(t)
- err := r.ParseForm()
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- assert.Equal(t, `content=txtTXTtxtTXTtxtTXT&domain=example.com&subdomain=foo&ttl=300&type=TXT`, r.PostForm.Encode())
-
- response := AddResponse{
- Domain: "example.com",
- Record: &Record{
- ID: 1,
- Type: "TXT",
- Domain: "example.com",
- SubDomain: "foo",
- FQDN: "foo.example.com.",
- Content: "txtTXTtxtTXTtxtTXT",
- TTL: 300,
- },
- BaseResponse: BaseResponse{
- Success: "ok",
- },
- }
-
- err = json.NewEncoder(w).Encode(response)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- },
- data: Record{
- Domain: "example.com",
- Type: "TXT",
- Content: "txtTXTtxtTXTtxtTXT",
- SubDomain: "foo",
- TTL: 300,
- },
- },
- {
- desc: "error",
- handler: func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
- assert.Equal(t, "lego", r.Header.Get(pddTokenHeader))
-
- err := r.ParseForm()
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- assert.Equal(t, `content=txtTXTtxtTXTtxtTXT&domain=example.com&subdomain=foo&ttl=300&type=TXT`, r.PostForm.Encode())
-
- response := AddResponse{
- Domain: "example.com",
- BaseResponse: BaseResponse{
- Success: "error",
- Error: "bad things",
- },
- }
-
- err = json.NewEncoder(w).Encode(response)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- },
- data: Record{
- Domain: "example.com",
- Type: "TXT",
- Content: "txtTXTtxtTXTtxtTXT",
- SubDomain: "foo",
- TTL: 300,
- },
- expectError: true,
- },
+ data := Record{
+ Domain: "example.com",
+ Type: "TXT",
+ Content: "txtTXTtxtTXTtxtTXT",
+ SubDomain: "foo",
+ TTL: 300,
}
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- client, mux := setupTest(t)
+ record, err := client.AddRecord(t.Context(), data)
+ require.NoError(t, err)
+ require.NotNil(t, record)
+}
- mux.HandleFunc("/add", test.handler)
+func TestAddRecord_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /add",
+ servermock.ResponseFromFixture("add_record_error.json"),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded()).
+ Build(t)
- record, err := client.AddRecord(context.Background(), test.data)
- if test.expectError {
- require.Error(t, err)
- require.Nil(t, record)
- } else {
- require.NoError(t, err)
- require.NotNil(t, record)
- }
- })
+ data := Record{
+ Domain: "example.com",
+ Type: "TXT",
+ Content: "txtTXTtxtTXTtxtTXT",
+ SubDomain: "foo",
+ TTL: 300,
}
+
+ _, err := client.AddRecord(t.Context(), data)
+ require.EqualError(t, err, "error during operation: error bad things")
}
func TestRemoveRecord(t *testing.T) {
- testCases := []struct {
- desc string
- handler http.HandlerFunc
- data Record
- expectError bool
- }{
- {
- desc: "success",
- handler: func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
- assert.Equal(t, "lego", r.Header.Get(pddTokenHeader))
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /del",
+ servermock.ResponseFromFixture("remove_record.json"),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded(),
+ servermock.CheckForm().Strict().
+ With("domain", "example.com").
+ With("record_id", "6")).
+ Build(t)
- err := r.ParseForm()
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- assert.Equal(t, `domain=example.com&record_id=6`, r.PostForm.Encode())
-
- response := RemoveResponse{
- Domain: "example.com",
- RecordID: 6,
- BaseResponse: BaseResponse{
- Success: "ok",
- },
- }
-
- err = json.NewEncoder(w).Encode(response)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- },
- data: Record{
- ID: 6,
- Domain: "example.com",
- },
- },
- {
- desc: "error",
- handler: func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodPost, r.Method)
- assert.Equal(t, "lego", r.Header.Get(pddTokenHeader))
-
- err := r.ParseForm()
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- assert.Equal(t, `domain=example.com&record_id=6`, r.PostForm.Encode())
-
- response := RemoveResponse{
- Domain: "example.com",
- RecordID: 6,
- BaseResponse: BaseResponse{
- Success: "error",
- Error: "bad things",
- },
- }
-
- err = json.NewEncoder(w).Encode(response)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- },
- data: Record{
- ID: 6,
- Domain: "example.com",
- },
- expectError: true,
- },
+ data := Record{
+ ID: 6,
+ Domain: "example.com",
}
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- client, mux := setupTest(t)
+ id, err := client.RemoveRecord(t.Context(), data)
+ require.NoError(t, err)
- mux.HandleFunc("/del", test.handler)
+ assert.Equal(t, 6, id)
+}
- id, err := client.RemoveRecord(context.Background(), test.data)
- if test.expectError {
- require.Error(t, err)
- require.Equal(t, 0, id)
- } else {
- require.NoError(t, err)
- require.Equal(t, 6, id)
- }
- })
+func TestRemoveRecord_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /del",
+ servermock.ResponseFromFixture("remove_record_error.json"),
+ servermock.CheckHeader().
+ WithContentTypeFromURLEncoded()).
+ Build(t)
+
+ data := Record{
+ ID: 6,
+ Domain: "example.com",
}
+
+ _, err := client.RemoveRecord(t.Context(), data)
+ require.EqualError(t, err, "error during operation: error bad things")
}
func TestGetRecords(t *testing.T) {
- testCases := []struct {
- desc string
- handler http.HandlerFunc
- domain string
- expectError bool
- }{
- {
- desc: "success",
- handler: func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodGet, r.Method)
- assert.Equal(t, "lego", r.Header.Get(pddTokenHeader))
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /list",
+ servermock.ResponseFromFixture("get_records.json"),
+ servermock.CheckForm().Strict().
+ With("domain", "example.com")).
+ Build(t)
- assert.Equal(t, "domain=example.com", r.URL.RawQuery)
+ records, err := client.GetRecords(t.Context(), "example.com")
+ require.NoError(t, err)
- response := ListResponse{
- Domain: "example.com",
- Records: []Record{
- {
- ID: 1,
- Type: "TXT",
- Domain: "example.com",
- SubDomain: "foo",
- FQDN: "foo.example.com.",
- Content: "txtTXTtxtTXTtxtTXT",
- TTL: 300,
- },
- {
- ID: 2,
- Type: "NS",
- Domain: "example.com",
- SubDomain: "foo",
- FQDN: "foo.example.com.",
- Content: "bar",
- TTL: 300,
- },
- },
- BaseResponse: BaseResponse{
- Success: "ok",
- },
- }
-
- err := json.NewEncoder(w).Encode(response)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- },
- domain: "example.com",
- },
- {
- desc: "error",
- handler: func(w http.ResponseWriter, r *http.Request) {
- require.Equal(t, http.MethodGet, r.Method)
- assert.Equal(t, "lego", r.Header.Get(pddTokenHeader))
-
- assert.Equal(t, "domain=example.com", r.URL.RawQuery)
-
- response := ListResponse{
- Domain: "example.com",
- BaseResponse: BaseResponse{
- Success: "error",
- Error: "bad things",
- },
- }
-
- err := json.NewEncoder(w).Encode(response)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- },
- domain: "example.com",
- expectError: true,
- },
- }
-
- for _, test := range testCases {
- t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
- client, mux := setupTest(t)
-
- mux.HandleFunc("/list", test.handler)
-
- records, err := client.GetRecords(context.Background(), test.domain)
- if test.expectError {
- require.Error(t, err)
- require.Empty(t, records)
- } else {
- require.NoError(t, err)
- require.Len(t, records, 2)
- }
- })
- }
+ require.Len(t, records, 2)
+}
+
+func TestGetRecords_error(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("GET /list",
+ servermock.ResponseFromFixture("get_records_error.json")).
+ Build(t)
+
+ _, err := client.GetRecords(t.Context(), "example.com")
+ require.EqualError(t, err, "error during operation: error bad things")
}
diff --git a/providers/dns/yandex/internal/fixtures/add_record.json b/providers/dns/yandex/internal/fixtures/add_record.json
new file mode 100644
index 000000000..1e4452d1d
--- /dev/null
+++ b/providers/dns/yandex/internal/fixtures/add_record.json
@@ -0,0 +1,13 @@
+{
+ "success": "ok",
+ "domain": "example.com",
+ "record": {
+ "record_id": 1,
+ "domain": "example.com",
+ "subdomain": "foo",
+ "fqdn": "foo.example.com.",
+ "ttl": 300,
+ "type": "TXT",
+ "content": "txtTXTtxtTXTtxtTXT"
+ }
+}
diff --git a/providers/dns/yandex/internal/fixtures/add_record_error.json b/providers/dns/yandex/internal/fixtures/add_record_error.json
new file mode 100644
index 000000000..932ccd674
--- /dev/null
+++ b/providers/dns/yandex/internal/fixtures/add_record_error.json
@@ -0,0 +1,5 @@
+{
+ "success": "error",
+ "error": "bad things",
+ "domain": "example.com"
+}
diff --git a/providers/dns/yandex/internal/fixtures/get_records.json b/providers/dns/yandex/internal/fixtures/get_records.json
new file mode 100644
index 000000000..e538834b4
--- /dev/null
+++ b/providers/dns/yandex/internal/fixtures/get_records.json
@@ -0,0 +1,24 @@
+{
+ "success": "ok",
+ "domain": "example.com",
+ "records": [
+ {
+ "record_id": 1,
+ "domain": "example.com",
+ "subdomain": "foo",
+ "fqdn": "foo.example.com.",
+ "ttl": 300,
+ "type": "TXT",
+ "content": "txtTXTtxtTXTtxtTXT"
+ },
+ {
+ "record_id": 2,
+ "domain": "example.com",
+ "subdomain": "foo",
+ "fqdn": "foo.example.com.",
+ "ttl": 300,
+ "type": "NS",
+ "content": "bar"
+ }
+ ]
+}
diff --git a/providers/dns/yandex/internal/fixtures/get_records_error.json b/providers/dns/yandex/internal/fixtures/get_records_error.json
new file mode 100644
index 000000000..932ccd674
--- /dev/null
+++ b/providers/dns/yandex/internal/fixtures/get_records_error.json
@@ -0,0 +1,5 @@
+{
+ "success": "error",
+ "error": "bad things",
+ "domain": "example.com"
+}
diff --git a/providers/dns/yandex/internal/fixtures/remove_record.json b/providers/dns/yandex/internal/fixtures/remove_record.json
new file mode 100644
index 000000000..3241ba9dc
--- /dev/null
+++ b/providers/dns/yandex/internal/fixtures/remove_record.json
@@ -0,0 +1,5 @@
+{
+ "success": "ok",
+ "domain": "example.com",
+ "record_id": 6
+}
diff --git a/providers/dns/yandex/internal/fixtures/remove_record_error.json b/providers/dns/yandex/internal/fixtures/remove_record_error.json
new file mode 100644
index 000000000..cd1471c9d
--- /dev/null
+++ b/providers/dns/yandex/internal/fixtures/remove_record_error.json
@@ -0,0 +1,6 @@
+{
+ "success": "error",
+ "error": "bad things",
+ "domain": "example.com",
+ "record_id": 6
+}
diff --git a/providers/dns/yandex/internal/types.go b/providers/dns/yandex/internal/types.go
index ed1873cef..48a85042c 100644
--- a/providers/dns/yandex/internal/types.go
+++ b/providers/dns/yandex/internal/types.go
@@ -30,18 +30,21 @@ func (r BaseResponse) GetError() string {
type AddResponse struct {
BaseResponse
+
Domain string `json:"domain,omitempty"`
Record *Record `json:"record,omitempty"`
}
type RemoveResponse struct {
BaseResponse
+
Domain string `json:"domain,omitempty"`
RecordID int `json:"record_id,omitempty"`
}
type ListResponse struct {
BaseResponse
+
Domain string `json:"domain,omitempty"`
Records []Record `json:"records,omitempty"`
}
diff --git a/providers/dns/yandex/yandex.go b/providers/dns/yandex/yandex.go
index c51602f67..7ae505ec0 100644
--- a/providers/dns/yandex/yandex.go
+++ b/providers/dns/yandex/yandex.go
@@ -11,6 +11,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/yandex/internal"
"github.com/miekg/dns"
)
@@ -88,6 +89,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{client: client, config: config}, nil
}
@@ -133,6 +136,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
var record *internal.Record
+
for _, rcd := range records {
if rcd.Type == "TXT" && rcd.SubDomain == subDomain && rcd.Content == info.Value {
record = &rcd
@@ -153,6 +157,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("yandex: %w", err)
}
+
return nil
}
diff --git a/providers/dns/yandex/yandex.toml b/providers/dns/yandex/yandex.toml
index 91adf4658..a36df069e 100644
--- a/providers/dns/yandex/yandex.toml
+++ b/providers/dns/yandex/yandex.toml
@@ -7,17 +7,17 @@ Since = "v3.7.0"
Example = '''
YANDEX_PDD_TOKEN= \
-lego --email you@example.com --dns yandex -d '*.example.com' -d example.com run
+lego --dns yandex -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
YANDEX_PDD_TOKEN = "Basic authentication username"
[Configuration.Additional]
- YANDEX_POLLING_INTERVAL = "Time between DNS propagation check"
- YANDEX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- YANDEX_HTTP_TIMEOUT = "API request timeout"
- YANDEX_TTL = "The TTL of the TXT record used for the DNS challenge"
+ YANDEX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ YANDEX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ YANDEX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)"
+ YANDEX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://yandex.com/dev/domain/doc/concepts/api-dns.html"
diff --git a/providers/dns/yandex/yandex_test.go b/providers/dns/yandex/yandex_test.go
index 144a24126..8a0a7534a 100644
--- a/providers/dns/yandex/yandex_test.go
+++ b/providers/dns/yandex/yandex_test.go
@@ -33,6 +33,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -95,6 +96,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -108,6 +110,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/yandex360/internal/client.go b/providers/dns/yandex360/internal/client.go
index 2bebc6c20..33aeb0daa 100644
--- a/providers/dns/yandex360/internal/client.go
+++ b/providers/dns/yandex360/internal/client.go
@@ -47,7 +47,7 @@ func NewClient(oauthToken string, orgID int64) (*Client, error) {
// 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) {
+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)
@@ -68,7 +68,7 @@ func (c Client) AddRecord(ctx context.Context, domain string, record Record) (*R
// 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 {
+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)
@@ -79,7 +79,7 @@ func (c Client) DeleteRecord(ctx context.Context, domain string, recordID int64)
return c.do(req, nil)
}
-func (c Client) do(req *http.Request, result any) error {
+func (c *Client) do(req *http.Request, result any) error {
req.Header.Set("Authorization", "OAuth "+c.oauthToken)
resp, err := c.HTTPClient.Do(req)
@@ -138,6 +138,7 @@ 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)
diff --git a/providers/dns/yandex360/internal/client_test.go b/providers/dns/yandex360/internal/client_test.go
index d0ddac0c3..aa21672e4 100644
--- a/providers/dns/yandex360/internal/client_test.go
+++ b/providers/dns/yandex360/internal/client_test.go
@@ -1,60 +1,39 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("secret", 123456)
+ if err != nil {
+ return nil, err
+ }
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithAuthorization("OAuth secret"))
}
func TestClient_AddRecord(t *testing.T) {
- client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodPost, http.StatusOK, "add-record.json")
+ client := mockBuilder().
+ Route("POST /directory/v1/org/123456/domains/example.com/dns",
+ servermock.ResponseFromFixture("add-record.json"),
+ servermock.CheckRequestJSONBody(`{"name":"_acme-challenge","text":"txtxtxt","ttl":60,"type":"TXT"}`)).
+ Build(t)
record := Record{
Name: "_acme-challenge",
@@ -63,7 +42,7 @@ func TestClient_AddRecord(t *testing.T) {
Type: "TXT",
}
- newRecord, err := client.AddRecord(context.Background(), "example.com", record)
+ newRecord, err := client.AddRecord(t.Context(), "example.com", record)
require.NoError(t, err)
expected := &Record{
@@ -78,7 +57,11 @@ func TestClient_AddRecord(t *testing.T) {
}
func TestClient_AddRecord_error(t *testing.T) {
- client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodGet, http.StatusUnauthorized, "error.json")
+ client := mockBuilder().
+ Route("POST /directory/v1/org/123456/domains/example.com/dns",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
record := Record{
Name: "_acme-challenge",
@@ -87,22 +70,29 @@ func TestClient_AddRecord_error(t *testing.T) {
Type: "TXT",
}
- newRecord, err := client.AddRecord(context.Background(), "example.com", record)
+ newRecord, err := client.AddRecord(t.Context(), "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")
+ client := mockBuilder().
+ Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456",
+ servermock.ResponseFromFixture("delete-record.json")).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "example.com", 789456)
+ err := client.DeleteRecord(t.Context(), "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")
+ client := mockBuilder().
+ Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456",
+ servermock.ResponseFromFixture("error.json").
+ WithStatusCode(http.StatusUnauthorized)).
+ Build(t)
- err := client.DeleteRecord(context.Background(), "example.com", 789456)
+ err := client.DeleteRecord(t.Context(), "example.com", 789456)
require.Error(t, err)
}
diff --git a/providers/dns/yandex360/yandex360.go b/providers/dns/yandex360/yandex360.go
index e2ee7beb2..0f4571750 100644
--- a/providers/dns/yandex360/yandex360.go
+++ b/providers/dns/yandex360/yandex360.go
@@ -13,7 +13,9 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/yandex360/internal"
+ "github.com/miekg/dns"
)
// Environment variables names.
@@ -97,6 +99,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
}
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
return &DNSProvider{
client: client,
config: config,
@@ -108,7 +112,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN))
+ authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN))
if err != nil {
return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err)
}
@@ -143,7 +147,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
- authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN))
+ authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN))
if err != nil {
return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err)
}
diff --git a/providers/dns/yandex360/yandex360.toml b/providers/dns/yandex360/yandex360.toml
index 88e4036ab..444b1cc38 100644
--- a/providers/dns/yandex360/yandex360.toml
+++ b/providers/dns/yandex360/yandex360.toml
@@ -8,7 +8,7 @@ Since = "v4.14.0"
Example = '''
YANDEX360_OAUTH_TOKEN= \
YANDEX360_ORG_ID= \
-lego --email you@example.com --dns yandex360 -d '*.example.com' -d example.com run
+lego --dns yandex360 -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,10 +16,10 @@ lego --email you@example.com --dns yandex360 -d '*.example.com' -d example.com r
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"
+ YANDEX360_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ YANDEX360_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ YANDEX360_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)"
+ YANDEX360_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://yandex.ru/dev/api360/doc/ref/DomainDNSService.html"
diff --git a/providers/dns/yandex360/yandex360_test.go b/providers/dns/yandex360/yandex360_test.go
index 545c90985..c1d37ad12 100644
--- a/providers/dns/yandex360/yandex360_test.go
+++ b/providers/dns/yandex360/yandex360_test.go
@@ -43,6 +43,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -109,6 +110,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -122,6 +124,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/yandexcloud/yandexcloud.go b/providers/dns/yandexcloud/yandexcloud.go
index 22da14404..f9c64def1 100644
--- a/providers/dns/yandexcloud/yandexcloud.go
+++ b/providers/dns/yandexcloud/yandexcloud.go
@@ -14,9 +14,12 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
- ycdns "github.com/yandex-cloud/go-genproto/yandex/cloud/dns/v1"
- ycsdk "github.com/yandex-cloud/go-sdk"
- "github.com/yandex-cloud/go-sdk/iamkey"
+ ycdnsproto "github.com/yandex-cloud/go-genproto/yandex/cloud/dns/v1"
+ ycdns "github.com/yandex-cloud/go-sdk/services/dns/v1"
+ ycsdk "github.com/yandex-cloud/go-sdk/v2"
+ "github.com/yandex-cloud/go-sdk/v2/credentials"
+ "github.com/yandex-cloud/go-sdk/v2/pkg/iamkey"
+ "github.com/yandex-cloud/go-sdk/v2/pkg/options"
)
// Environment variables names.
@@ -54,7 +57,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- client *ycsdk.SDK
+ client ycdns.DnsZoneClient
config *Config
}
@@ -91,19 +94,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("yandexcloud: iam token is malformed: %w", err)
}
- client, err := ycsdk.Build(context.Background(), ycsdk.Config{Credentials: creds})
+ sdk, err := ycsdk.Build(context.Background(), options.WithCredentials(creds))
if err != nil {
return nil, errors.New("yandexcloud: unable to build yandex cloud sdk")
}
return &DNSProvider{
- client: client,
+ client: ycdns.NewDnsZoneClient(sdk),
config: config,
}, nil
}
// Present creates a TXT record to fulfill the dns-01 challenge.
-func (r *DNSProvider) Present(domain, _, keyAuth string) error {
+func (d *DNSProvider) Present(domain, _, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -113,7 +116,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
ctx := context.Background()
- zones, err := r.getZones(ctx)
+ zones, err := d.getZones(ctx)
if err != nil {
return fmt.Errorf("yandexcloud: %w", err)
}
@@ -135,7 +138,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
return fmt.Errorf("yandexcloud: %w", err)
}
- err = r.upsertRecordSetData(ctx, zoneID, subDomain, info.Value)
+ err = d.upsertRecordSetData(ctx, zoneID, subDomain, info.Value)
if err != nil {
return fmt.Errorf("yandexcloud: %w", err)
}
@@ -144,7 +147,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
}
// CleanUp removes the TXT record matching the specified parameters.
-func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
+func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
@@ -154,7 +157,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
ctx := context.Background()
- zones, err := r.getZones(ctx)
+ zones, err := d.getZones(ctx)
if err != nil {
return fmt.Errorf("yandexcloud: %w", err)
}
@@ -176,7 +179,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
return fmt.Errorf("yandexcloud: %w", err)
}
- err = r.removeRecordSetData(ctx, zoneID, subDomain, info.Value)
+ err = d.removeRecordSetData(ctx, zoneID, subDomain, info.Value)
if err != nil {
return fmt.Errorf("yandexcloud: %w", err)
}
@@ -186,17 +189,17 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
// Timeout returns the timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
-func (r *DNSProvider) Timeout() (timeout, interval time.Duration) {
- return r.config.PropagationTimeout, r.config.PollingInterval
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
}
// getZones retrieves available zones from yandex cloud.
-func (r *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) {
- list := &ycdns.ListDnsZonesRequest{
- FolderId: r.config.FolderID,
+func (d *DNSProvider) getZones(ctx context.Context) ([]*ycdnsproto.DnsZone, error) {
+ list := &ycdnsproto.ListDnsZonesRequest{
+ FolderId: d.config.FolderID,
}
- response, err := r.client.DNS().DnsZone().List(ctx, list)
+ response, err := d.client.List(ctx, list)
if err != nil {
return nil, errors.New("unable to fetch dns zones")
}
@@ -204,28 +207,29 @@ func (r *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) {
return response.GetDnsZones(), nil
}
-func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error {
- get := &ycdns.GetDnsZoneRecordSetRequest{
+func (d *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error {
+ get := &ycdnsproto.GetDnsZoneRecordSetRequest{
DnsZoneId: zoneID,
Name: name,
Type: "TXT",
}
- exist, err := r.client.DNS().DnsZone().GetRecordSet(ctx, get)
+ exist, err := d.client.GetRecordSet(ctx, get)
if err != nil {
if !strings.Contains(err.Error(), "RecordSet not found") {
return err
}
}
- record := &ycdns.RecordSet{
+ record := &ycdnsproto.RecordSet{
Name: name,
Type: "TXT",
- Ttl: int64(r.config.TTL),
+ Ttl: int64(d.config.TTL),
Data: []string{},
}
- var deletions []*ycdns.RecordSet
+ var deletions []*ycdnsproto.RecordSet
+
if exist != nil {
record.SetData(append(record.GetData(), exist.GetData()...))
deletions = append(deletions, exist)
@@ -237,25 +241,25 @@ func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, val
return nil
}
- update := &ycdns.UpdateRecordSetsRequest{
+ update := &ycdnsproto.UpdateRecordSetsRequest{
DnsZoneId: zoneID,
Deletions: deletions,
- Additions: []*ycdns.RecordSet{record},
+ Additions: []*ycdnsproto.RecordSet{record},
}
- _, err = r.client.DNS().DnsZone().UpdateRecordSets(ctx, update)
+ _, err = d.client.UpdateRecordSets(ctx, update)
return err
}
-func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error {
- get := &ycdns.GetDnsZoneRecordSetRequest{
+func (d *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error {
+ get := &ycdnsproto.GetDnsZoneRecordSetRequest{
DnsZoneId: zoneID,
Name: name,
Type: "TXT",
}
- previousRecord, err := r.client.DNS().DnsZone().GetRecordSet(ctx, get)
+ previousRecord, err := d.client.GetRecordSet(ctx, get)
if err != nil {
if strings.Contains(err.Error(), "RecordSet not found") {
// RecordSet is not present, nothing to do
@@ -265,14 +269,14 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val
return err
}
- var additions []*ycdns.RecordSet
+ var additions []*ycdnsproto.RecordSet
if len(previousRecord.GetData()) > 1 {
// RecordSet is not empty we should update it
- record := &ycdns.RecordSet{
+ record := &ycdnsproto.RecordSet{
Name: name,
Type: "TXT",
- Ttl: int64(r.config.TTL),
+ Ttl: int64(d.config.TTL),
Data: []string{},
}
@@ -285,34 +289,35 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val
additions = append(additions, record)
}
- update := &ycdns.UpdateRecordSetsRequest{
+ update := &ycdnsproto.UpdateRecordSetsRequest{
DnsZoneId: zoneID,
- Deletions: []*ycdns.RecordSet{previousRecord},
+ Deletions: []*ycdnsproto.RecordSet{previousRecord},
Additions: additions,
}
- _, err = r.client.DNS().DnsZone().UpdateRecordSets(ctx, update)
+ _, err = d.client.UpdateRecordSets(ctx, update)
return err
}
// decodeCredentials converts base64 encoded json of iam token to struct.
-func decodeCredentials(accountB64 string) (ycsdk.Credentials, error) {
+func decodeCredentials(accountB64 string) (credentials.Credentials, error) {
account, err := base64.StdEncoding.DecodeString(accountB64)
if err != nil {
return nil, err
}
key := &iamkey.Key{}
+
err = json.Unmarshal(account, key)
if err != nil {
return nil, err
}
- return ycsdk.ServiceAccountKey(key)
+ return credentials.ServiceAccountKey(key)
}
-func appendRecordSetData(record *ycdns.RecordSet, value string) bool {
+func appendRecordSetData(record *ycdnsproto.RecordSet, value string) bool {
if slices.Contains(record.GetData(), value) {
return false
}
diff --git a/providers/dns/yandexcloud/yandexcloud.toml b/providers/dns/yandexcloud/yandexcloud.toml
index c19b9c1cc..d4b40bb1d 100644
--- a/providers/dns/yandexcloud/yandexcloud.toml
+++ b/providers/dns/yandexcloud/yandexcloud.toml
@@ -7,7 +7,7 @@ Since = "v4.9.0"
Example = '''
YANDEX_CLOUD_IAM_TOKEN= \
YANDEX_CLOUD_FOLDER_ID= \
-lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run
+lego --dns yandexcloud -d '*.example.com' -d example.com run
# ---
@@ -20,7 +20,7 @@ YANDEX_CLOUD_IAM_TOKEN=$(echo '{ \
"private_key": "-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----" \
}' | base64) \
YANDEX_CLOUD_FOLDER_ID= \
-lego --email you@example.com --dns yandexcloud -d '*.example.com' -d example.com run
+lego --dns yandexcloud -d '*.example.com' -d example.com run
'''
Additional = '''
@@ -40,9 +40,9 @@ cat key.json | base64
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"
- YANDEX_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- YANDEX_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge"
+ YANDEX_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ YANDEX_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ YANDEX_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)"
[Links]
API = "https://cloud.yandex.com/en/docs/dns/quickstart"
diff --git a/providers/dns/yandexcloud/yandexcloud_test.go b/providers/dns/yandexcloud/yandexcloud_test.go
index 48f75d134..52dad574d 100644
--- a/providers/dns/yandexcloud/yandexcloud_test.go
+++ b/providers/dns/yandexcloud/yandexcloud_test.go
@@ -71,6 +71,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -143,6 +144,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -156,6 +158,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/zoneedit/internal/client.go b/providers/dns/zoneedit/internal/client.go
new file mode 100644
index 000000000..c8b99e173
--- /dev/null
+++ b/providers/dns/zoneedit/internal/client.go
@@ -0,0 +1,108 @@
+package internal
+
+import (
+ "bytes"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "slices"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://dynamic.zoneedit.com"
+
+// Client the ZoneEdit API client.
+type Client struct {
+ user string
+ authToken string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(user, authToken string) (*Client, error) {
+ if user == "" || authToken == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ user: user,
+ authToken: authToken,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+func (c *Client) CreateTXTRecord(domain, rdata string) error {
+ return c.perform("txt-create.php", domain, rdata)
+}
+
+func (c *Client) DeleteTXTRecord(domain, rdata string) error {
+ return c.perform("txt-delete.php", domain, rdata)
+}
+
+func (c *Client) perform(actionPath, domain, rdata string) error {
+ endpoint := c.baseURL.JoinPath(actionPath)
+
+ query := endpoint.Query()
+ query.Set("host", domain)
+ query.Set("rdata", rdata)
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequest(http.MethodGet, endpoint.String(), http.NoBody)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req)
+}
+
+func (c *Client) do(req *http.Request) error {
+ req.SetBasicAuth(c.user, c.authToken)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ raw, _ := io.ReadAll(resp.Body)
+
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ if bytes.Contains(raw, []byte("SUCCESS CODE")) {
+ return nil
+ }
+
+ raw = bytes.TrimSpace(raw)
+
+ // The answer is not an XML valid (missing closing), so I fix it to parse it.
+ if bytes.HasSuffix(raw, []byte(">")) {
+ raw = slices.Concat(raw[:len(raw)-1], []byte("/>"))
+ }
+
+ var apiErr APIError
+
+ err = xml.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/dns/zoneedit/internal/client_test.go b/providers/dns/zoneedit/internal/client_test.go
new file mode 100644
index 000000000..1d9f9be79
--- /dev/null
+++ b/providers/dns/zoneedit/internal/client_test.go
@@ -0,0 +1,64 @@
+package internal
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
+ "github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder(func(server *httptest.Server) (*Client, error) {
+ client, err := NewClient("user", "secret")
+ if err != nil {
+ return nil, err
+ }
+
+ client.baseURL, _ = url.Parse(server.URL)
+ client.HTTPClient = server.Client()
+
+ return client, nil
+ })
+}
+
+func TestClient_CreateTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /txt-create.php",
+ servermock.ResponseFromFixture("success.xml")).
+ Build(t)
+
+ err := client.CreateTXTRecord("_acme-challenge.example.com", "value")
+ require.NoError(t, err)
+}
+
+func TestClient_CreateTXTRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /txt-create.php",
+ servermock.ResponseFromFixture("error.xml")).
+ Build(t)
+
+ err := client.CreateTXTRecord("_acme-challenge.example.com", "value")
+ require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)")
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /txt-delete.php",
+ servermock.ResponseFromFixture("success.xml")).
+ Build(t)
+
+ err := client.DeleteTXTRecord("_acme-challenge.example.com", "value")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteTXTRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /txt-delete.php",
+ servermock.ResponseFromFixture("error.xml")).
+ Build(t)
+
+ err := client.DeleteTXTRecord("_acme-challenge.example.com", "value")
+ require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)")
+}
diff --git a/providers/dns/zoneedit/internal/fixtures/error.xml b/providers/dns/zoneedit/internal/fixtures/error.xml
new file mode 100644
index 000000000..6c0f1de60
--- /dev/null
+++ b/providers/dns/zoneedit/internal/fixtures/error.xml
@@ -0,0 +1 @@
+
diff --git a/providers/dns/zoneedit/internal/fixtures/success.xml b/providers/dns/zoneedit/internal/fixtures/success.xml
new file mode 100644
index 000000000..80d75169d
--- /dev/null
+++ b/providers/dns/zoneedit/internal/fixtures/success.xml
@@ -0,0 +1 @@
+
diff --git a/providers/dns/zoneedit/internal/types.go b/providers/dns/zoneedit/internal/types.go
new file mode 100644
index 000000000..96fa41c36
--- /dev/null
+++ b/providers/dns/zoneedit/internal/types.go
@@ -0,0 +1,18 @@
+package internal
+
+import (
+ "encoding/xml"
+ "fmt"
+)
+
+type APIError struct {
+ XMLName xml.Name `xml:"ERROR"`
+ Text string `xml:",chardata"`
+ Code string `xml:"CODE,attr"`
+ Message string `xml:"TEXT,attr"`
+ Zone string `xml:"ZONE,attr"`
+}
+
+func (a APIError) Error() string {
+ return fmt.Sprintf("%s: %s (%s)", a.Code, a.Message, a.Zone)
+}
diff --git a/providers/dns/zoneedit/zoneedit.go b/providers/dns/zoneedit/zoneedit.go
new file mode 100644
index 000000000..c815f975a
--- /dev/null
+++ b/providers/dns/zoneedit/zoneedit.go
@@ -0,0 +1,126 @@
+// Package zoneedit implements a DNS provider for solving the DNS-01 challenge using ZoneEdit.
+package zoneedit
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/zoneedit/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ZONEEDIT_"
+
+ EnvUser = envNamespace + "USER"
+ EnAuthToken = envNamespace + "AUTH_TOKEN"
+
+ 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 {
+ User string
+ AuthToken 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 DNSProvider instance configured for ZoneEdit.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUser, EnAuthToken)
+ if err != nil {
+ return nil, fmt.Errorf("zoneedit: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.User = values[EnvUser]
+ config.AuthToken = values[EnAuthToken]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ZoneEdit.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("zoneedit: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.User, config.AuthToken)
+ if err != nil {
+ return nil, fmt.Errorf("zoneedit: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ err := d.client.CreateTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+ if err != nil {
+ return fmt.Errorf("zoneedit: create TXT record: %w", err)
+ }
+
+ // ERROR CODE="702" TEXT="Minimum 10 seconds between requests"
+ time.Sleep(11 * time.Second)
+
+ 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)
+
+ err := d.client.DeleteTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+ if err != nil {
+ return fmt.Errorf("zoneedit: delete TXT record: %w", err)
+ }
+
+ // ERROR CODE="702" TEXT="Minimum 10 seconds between requests"
+ time.Sleep(11 * time.Second)
+
+ 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/dns/zoneedit/zoneedit.toml b/providers/dns/zoneedit/zoneedit.toml
new file mode 100644
index 000000000..cdc53b33a
--- /dev/null
+++ b/providers/dns/zoneedit/zoneedit.toml
@@ -0,0 +1,23 @@
+Name = "ZoneEdit"
+Description = ''''''
+URL = "https://www.zoneedit.com"
+Code = "zoneedit"
+Since = "v4.25.0"
+
+Example = '''
+ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \
+ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
+lego --dns zoneedit -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ZONEEDIT_USER = "User ID"
+ ZONEEDIT_AUTH_TOKEN = "Authentication token"
+ [Configuration.Additional]
+ ZONEEDIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ZONEEDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ZONEEDIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns"
diff --git a/providers/dns/zoneedit/zoneedit_test.go b/providers/dns/zoneedit/zoneedit_test.go
new file mode 100644
index 000000000..0b251fddf
--- /dev/null
+++ b/providers/dns/zoneedit/zoneedit_test.go
@@ -0,0 +1,146 @@
+package zoneedit
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvUser, EnAuthToken).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvUser: "user",
+ EnAuthToken: "secret",
+ },
+ },
+ {
+ desc: "missing user ID",
+ envVars: map[string]string{
+ EnvUser: "",
+ EnAuthToken: "secret",
+ },
+ expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER",
+ },
+ {
+ desc: "missing auth token",
+ envVars: map[string]string{
+ EnvUser: "user",
+ EnAuthToken: "",
+ },
+ expected: "zoneedit: some credentials information are missing: ZONEEDIT_AUTH_TOKEN",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER,ZONEEDIT_AUTH_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
+ user string
+ authToken string
+ expected string
+ }{
+ {
+ desc: "success",
+ user: "user",
+ authToken: "secret",
+ },
+ {
+ desc: "missing user ID",
+ authToken: "secret",
+ expected: "zoneedit: credentials missing",
+ },
+ {
+ desc: "missing auth token",
+ user: "user",
+ expected: "zoneedit: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "zoneedit: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.User = test.user
+ config.AuthToken = test.authToken
+
+ 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/dns/zoneee/internal/client.go b/providers/dns/zoneee/internal/client.go
index e4463b83e..9446cd771 100644
--- a/providers/dns/zoneee/internal/client.go
+++ b/providers/dns/zoneee/internal/client.go
@@ -26,7 +26,7 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(username string, apiKey string) *Client {
+func NewClient(username, apiKey string) *Client {
baseURL, _ := url.Parse(DefaultEndpoint)
return &Client{
diff --git a/providers/dns/zoneee/internal/client_test.go b/providers/dns/zoneee/internal/client_test.go
index 9e53117ac..c2f0e781e 100644
--- a/providers/dns/zoneee/internal/client_test.go
+++ b/providers/dns/zoneee/internal/client_test.go
@@ -1,65 +1,36 @@
package internal
import (
- "context"
- "fmt"
- "io"
"net/http"
"net/http/httptest"
"net/url"
- "os"
- "path/filepath"
"testing"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"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()
+func mockBuilder() *servermock.Builder[*Client] {
+ return servermock.NewBuilder[*Client](
+ func(server *httptest.Server) (*Client, error) {
+ client := NewClient("user", "secret")
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
- 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
- }
-
- 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
+ return client, nil
+ },
+ servermock.CheckHeader().WithJSONHeaders().
+ WithBasicAuth("user", "secret"),
+ )
}
func TestClient_GetTxtRecords(t *testing.T) {
- client := setupTest(t, http.MethodGet, "/dns/example.com/txt", http.StatusOK, "get-txt-records.json")
+ client := mockBuilder().
+ Route("GET /dns/example.com/txt", servermock.ResponseFromFixture("get-txt-records.json")).
+ Build(t)
- records, err := client.GetTxtRecords(context.Background(), "example.com")
+ records, err := client.GetTxtRecords(t.Context(), "example.com")
require.NoError(t, err)
expected := []TXTRecord{
@@ -70,9 +41,14 @@ func TestClient_GetTxtRecords(t *testing.T) {
}
func TestClient_AddTxtRecord(t *testing.T) {
- client := setupTest(t, http.MethodPost, "/dns/example.com/txt", http.StatusCreated, "create-txt-record.json")
+ client := mockBuilder().
+ Route("POST /dns/example.com/txt",
+ servermock.ResponseFromFixture("create-txt-record.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"name":"prefix.example.com","destination":"server.example.com"}`)).
+ Build(t)
- records, err := client.AddTxtRecord(context.Background(), "example.com", TXTRecord{Name: "prefix.example.com", Destination: "server.example.com"})
+ records, err := client.AddTxtRecord(t.Context(), "example.com", TXTRecord{Name: "prefix.example.com", Destination: "server.example.com"})
require.NoError(t, err)
expected := []TXTRecord{
@@ -83,8 +59,12 @@ func TestClient_AddTxtRecord(t *testing.T) {
}
func TestClient_RemoveTxtRecord(t *testing.T) {
- client := setupTest(t, http.MethodDelete, "/dns/example.com/txt/123", http.StatusNoContent, "")
+ client := mockBuilder().
+ Route("DELETE /dns/example.com/txt/123",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)).
+ Build(t)
- err := client.RemoveTxtRecord(context.Background(), "example.com", "123")
+ err := client.RemoveTxtRecord(t.Context(), "example.com", "123")
require.NoError(t, err)
}
diff --git a/providers/dns/zoneee/zoneee.go b/providers/dns/zoneee/zoneee.go
index 7dbbc4314..5c34ea1c9 100644
--- a/providers/dns/zoneee/zoneee.go
+++ b/providers/dns/zoneee/zoneee.go
@@ -12,6 +12,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/zoneee/internal"
)
@@ -69,6 +70,7 @@ func NewDNSProvider() (*DNSProvider, error) {
}
rawEndpoint := env.GetOrDefaultString(EnvEndpoint, internal.DefaultEndpoint)
+
endpoint, err := url.Parse(rawEndpoint)
if err != nil {
return nil, fmt.Errorf("zoneee: %w", err)
@@ -105,6 +107,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
+
+ client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
+
if config.Endpoint != nil {
client.BaseURL = config.Endpoint
}
@@ -138,6 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("zoneee: %w", err)
}
+
return nil
}
@@ -160,6 +166,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
var id string
+
for _, record := range records {
if record.Destination == info.Value {
id = record.ID
diff --git a/providers/dns/zoneee/zoneee.toml b/providers/dns/zoneee/zoneee.toml
index 5d95095e8..ab7133180 100644
--- a/providers/dns/zoneee/zoneee.toml
+++ b/providers/dns/zoneee/zoneee.toml
@@ -7,7 +7,7 @@ Since = "v2.1.0"
Example = '''
ZONEEE_API_USER=xxxxx \
ZONEEE_API_KEY=yyyyy \
-lego --email you@example.com --dns zoneee -d '*.example.com' -d example.com run
+lego --dns zoneee -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -16,10 +16,9 @@ lego --email you@example.com --dns zoneee -d '*.example.com' -d example.com run
ZONEEE_API_KEY = "API key"
[Configuration.Additional]
ZONEEE_ENDPOINT = "API endpoint URL"
- ZONEEE_POLLING_INTERVAL = "Time between DNS propagation check"
- ZONEEE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- ZONEEE_TTL = "The TTL of the TXT record used for the DNS challenge"
- ZONEEE_HTTP_TIMEOUT = "API request timeout"
+ ZONEEE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)"
+ ZONEEE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ ZONEEE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://api.zone.eu/v2"
diff --git a/providers/dns/zoneee/zoneee_test.go b/providers/dns/zoneee/zoneee_test.go
index 1f2909fa7..9ad87c02a 100644
--- a/providers/dns/zoneee/zoneee_test.go
+++ b/providers/dns/zoneee/zoneee_test.go
@@ -6,17 +6,22 @@ import (
"net/http"
"net/http/httptest"
"net/url"
- "path"
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/go-acme/lego/v4/providers/dns/zoneee/internal"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
+const (
+ fakeUsername = "user"
+ fakeAPIKey = "secret"
+)
+
var envTest = tester.NewEnvTest(EnvEndpoint, EnvAPIUser, EnvAPIKey).
WithLiveTestRequirements(EnvAPIUser, EnvAPIKey).
WithDomain(envDomain)
@@ -72,6 +77,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -94,7 +100,6 @@ func TestNewDNSProviderConfig(t *testing.T) {
desc string
apiUser string
apiKey string
- endpoint string
expected string
}{
{
@@ -124,10 +129,6 @@ func TestNewDNSProviderConfig(t *testing.T) {
config.APIKey = test.apiKey
config.Username = test.apiUser
- if test.endpoint != "" {
- config.Endpoint = mustParse(test.endpoint)
- }
-
p, err := NewDNSProviderConfig(config)
if test.expected == "" {
@@ -147,57 +148,33 @@ func TestDNSProvider_Present(t *testing.T) {
testCases := []struct {
desc string
- username string
- apiKey string
- handlers map[string]http.HandlerFunc
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "success",
- username: "bar",
- apiKey: "foo",
- handlers: map[string]http.HandlerFunc{
- path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord,
- },
+ desc: "success",
+ builder: mockBuilder(fakeUsername, fakeAPIKey).
+ Route("POST /dns/"+hostedZone+"/txt",
+ mockHandlerCreateRecord()),
},
{
- desc: "invalid auth",
- username: "nope",
- apiKey: "foo",
- handlers: map[string]http.HandlerFunc{
- path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord,
- },
+ desc: "invalid auth",
+ builder: mockBuilder("nope", "nope").
+ Route("POST /dns/"+hostedZone+"/txt", nil),
expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized",
},
{
desc: "error",
- username: "bar",
- apiKey: "foo",
+ builder: mockBuilder(fakeUsername, fakeAPIKey),
expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
+ provider := test.builder.Build(t)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- for uri, handler := range test.handlers {
- mux.HandleFunc(uri, handler)
- }
-
- config := NewDefaultConfig()
- config.Endpoint = mustParse(server.URL)
- config.Username = test.username
- config.APIKey = test.apiKey
-
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- err = p.Present(domain, "token", "key")
+ err := provider.Present(domain, "token", "key")
if test.expectedError == "" {
require.NoError(t, err)
} else {
@@ -213,81 +190,49 @@ func TestDNSProvider_Cleanup(t *testing.T) {
testCases := []struct {
desc string
- username string
- apiKey string
- handlers map[string]http.HandlerFunc
+ builder *servermock.Builder[*DNSProvider]
expectedError string
}{
{
- desc: "success",
- username: "bar",
- apiKey: "foo",
- handlers: map[string]http.HandlerFunc{
- path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{
- ID: "1234",
- Name: domain,
- Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM",
- Delete: true,
- Modify: true,
- }}),
- path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord,
- },
+ desc: "success",
+ builder: mockBuilder(fakeUsername, fakeAPIKey).
+ Route("GET /dns/"+hostedZone+"/txt",
+ mockHandlerGetRecords([]internal.TXTRecord{{
+ ID: "1234",
+ Name: domain,
+ Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM",
+ Delete: true,
+ Modify: true,
+ }})).
+ Route("DELETE /dns/"+hostedZone+"/txt/1234",
+ servermock.Noop().
+ WithStatusCode(http.StatusNoContent)),
},
{
- desc: "no txt records",
- username: "bar",
- apiKey: "foo",
- handlers: map[string]http.HandlerFunc{
- path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{}),
- path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord,
- },
+ desc: "no txt records",
+ builder: mockBuilder(fakeUsername, fakeAPIKey).
+ Route("GET /dns/"+hostedZone+"/txt",
+ mockHandlerGetRecords([]internal.TXTRecord{})),
expectedError: "zoneee: txt record does not exist for LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM",
},
{
- desc: "invalid auth",
- username: "nope",
- apiKey: "foo",
- handlers: map[string]http.HandlerFunc{
- path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{
- ID: "1234",
- Name: domain,
- Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM",
- Delete: true,
- Modify: true,
- }}),
- path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord,
- },
+ desc: "invalid auth",
+ builder: mockBuilder("nope", "nope").
+ Route("GET /dns/"+hostedZone+"/txt", nil),
expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized",
},
{
desc: "error",
- username: "bar",
- apiKey: "foo",
+ builder: mockBuilder(fakeUsername, fakeAPIKey),
expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
- t.Parallel()
+ provider := test.builder.Build(t)
- mux := http.NewServeMux()
- server := httptest.NewServer(mux)
- t.Cleanup(server.Close)
-
- for uri, handler := range test.handlers {
- mux.HandleFunc(uri, handler)
- }
-
- config := NewDefaultConfig()
- config.Endpoint = mustParse(server.URL)
- config.Username = test.username
- config.APIKey = test.apiKey
-
- p, err := NewDNSProviderConfig(config)
- require.NoError(t, err)
-
- err = p.CleanUp(domain, "token", "key")
+ err := provider.CleanUp(domain, "token", "key")
if test.expectedError == "" {
require.NoError(t, err)
} else {
@@ -303,6 +248,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -316,6 +262,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -325,72 +272,59 @@ func TestLiveCleanUp(t *testing.T) {
require.NoError(t, err)
}
-func mustParse(rawURL string) *url.URL {
- uri, err := url.Parse(rawURL)
- if err != nil {
- panic(err)
- }
- return uri
+func mockBuilder(username, apiKey string) *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.HTTPClient = server.Client()
+ config.Endpoint, _ = url.Parse(server.URL)
+ config.Username = username
+ config.APIKey = apiKey
+
+ return NewDNSProviderConfig(config)
+ },
+ checkBasicAuth())
}
-func mockHandlerCreateRecord(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+func mockHandlerCreateRecord() http.HandlerFunc {
+ return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) {
+ record := internal.TXTRecord{}
- username, apiKey, ok := req.BasicAuth()
- if username != "bar" || apiKey != "foo" || !ok {
- rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key."))
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
+ err := json.NewDecoder(req.Body).Decode(&record)
+ if err != nil {
+ return nil, err
+ }
- record := internal.TXTRecord{}
- err := json.NewDecoder(req.Body).Decode(&record)
- if err != nil {
- http.Error(rw, err.Error(), http.StatusBadRequest)
- return
- }
+ record.ID = "1234"
+ record.Delete = true
+ record.Modify = true
+ record.ResourceURL = req.URL.String() + "/1234"
- record.ID = "1234"
- record.Delete = true
- record.Modify = true
- record.ResourceURL = req.URL.String() + "/1234"
-
- bytes, err := json.Marshal([]internal.TXTRecord{record})
- if err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if _, err = rw.Write(bytes); err != nil {
- http.Error(rw, err.Error(), http.StatusInternalServerError)
- return
- }
+ return []internal.TXTRecord{record}, nil
+ })
}
func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc {
- return func(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- username, apiKey, ok := req.BasicAuth()
- if username != "bar" || apiKey != "foo" || !ok {
- rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key."))
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- for _, value := range records {
- if value.ResourceURL == "" {
- value.ResourceURL = req.URL.String() + "/" + value.ID
+ return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) {
+ for _, record := range records {
+ if record.ResourceURL == "" {
+ record.ResourceURL = req.URL.String() + "/" + record.ID
}
}
- bytes, err := json.Marshal(records)
+ return records, nil
+ })
+}
+
+func encodeJSONHandler(build func(req *http.Request, rw http.ResponseWriter) (any, error)) http.HandlerFunc {
+ return func(rw http.ResponseWriter, req *http.Request) {
+ data, err := build(req, rw)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ bytes, err := json.Marshal(data)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
@@ -403,18 +337,18 @@ func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc {
}
}
-func mockHandlerDeleteRecord(rw http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodDelete {
- http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
+func checkBasicAuth() servermock.LinkFunc {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ username, apiKey, ok := req.BasicAuth()
+ if username != fakeUsername || apiKey != fakeAPIKey || !ok {
+ rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key."))
+ http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- username, apiKey, ok := req.BasicAuth()
- if username != "bar" || apiKey != "foo" || !ok {
- rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key."))
- http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
+ return
+ }
- rw.WriteHeader(http.StatusNoContent)
+ next.ServeHTTP(rw, req)
+ })
+ }
}
diff --git a/providers/dns/zonomi/zonomi.go b/providers/dns/zonomi/zonomi.go
index 8c7a2943f..fe54b80fc 100644
--- a/providers/dns/zonomi/zonomi.go
+++ b/providers/dns/zonomi/zonomi.go
@@ -2,7 +2,6 @@
package zonomi
import (
- "context"
"errors"
"fmt"
"net/http"
@@ -26,22 +25,17 @@ const (
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
+const defaultBaseURL = "https://zonomi.com/app/dns/dyndns.jsp"
+
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
-type Config struct {
- APIKey string
-
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = rimuhosting.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, 3600),
+ TTL: env.GetOrDefaultInt(EnvTTL, rimuhosting.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
@@ -52,8 +46,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *rimuhosting.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for Zonomi.
@@ -76,48 +69,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("zonomi: the configuration of the DNS provider is nil")
}
- if config.APIKey == "" {
- return nil, errors.New("zonomi: incomplete credentials, missing API key")
+ provider, err := rimuhosting.NewDNSProviderConfig(config, defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("zonomi: %w", err)
}
- client := rimuhosting.NewClient(config.APIKey)
- client.BaseURL = rimuhosting.DefaultZonomiBaseURL
-
- 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
+ return &DNSProvider{prv: provider}, nil
}
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- ctx := context.Background()
-
- records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN))
+ err := d.prv.Present(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("zonomi: failed to find record(s) for %s: %w", domain, err)
- }
-
- actions := []rimuhosting.ActionParameter{
- rimuhosting.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL),
- }
-
- for _, record := range records {
- actions = append(actions, rimuhosting.NewAddRecordAction(record.Name, record.Content, d.config.TTL))
- }
-
- _, err = d.client.DoActions(ctx, actions...)
- if err != nil {
- return fmt.Errorf("zonomi: failed to add record(s) for %s: %w", domain, err)
+ return fmt.Errorf("zonomi: %w", err)
}
return nil
@@ -125,14 +89,16 @@ 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 {
- info := dns01.GetChallengeInfo(domain, keyAuth)
-
- action := rimuhosting.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
-
- _, err := d.client.DoActions(context.Background(), action)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("zonomi: failed to delete record for %s: %w", domain, err)
+ return fmt.Errorf("zonomi: %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.prv.Timeout()
+}
diff --git a/providers/dns/zonomi/zonomi.toml b/providers/dns/zonomi/zonomi.toml
index 9780323a7..b91bcaac6 100644
--- a/providers/dns/zonomi/zonomi.toml
+++ b/providers/dns/zonomi/zonomi.toml
@@ -6,17 +6,17 @@ Since = "v3.5.0"
Example = '''
ZONOMI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns zonomi -d '*.example.com' -d example.com run
+lego --dns zonomi -d '*.example.com' -d example.com run
'''
[Configuration]
[Configuration.Credentials]
ZONOMI_API_KEY = "User API key"
[Configuration.Additional]
- ZONOMI_POLLING_INTERVAL = "Time between DNS propagation check"
- ZONOMI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
- ZONOMI_TTL = "The TTL of the TXT record used for the DNS challenge"
- ZONOMI_HTTP_TIMEOUT = "API request timeout"
+ ZONOMI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ZONOMI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ZONOMI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"
+ ZONOMI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
[Links]
API = "https://zonomi.com/app/dns/dyndns.jsp"
diff --git a/providers/dns/zonomi/zonomi_test.go b/providers/dns/zonomi/zonomi_test.go
index fb1b68773..2e13e937e 100644
--- a/providers/dns/zonomi/zonomi_test.go
+++ b/providers/dns/zonomi/zonomi_test.go
@@ -36,6 +36,7 @@ func TestNewDNSProvider(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
+
envTest.ClearEnv()
envTest.Apply(test.envVars)
@@ -45,7 +46,7 @@ func TestNewDNSProvider(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -83,7 +84,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
- require.NotNil(t, p.config)
+ require.NotNil(t, p.prv)
} else {
require.EqualError(t, err, test.expected)
}
@@ -97,6 +98,7 @@ func TestLivePresent(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
@@ -110,6 +112,7 @@ func TestLiveCleanUp(t *testing.T) {
}
envTest.RestoreEnv()
+
provider, err := NewDNSProvider()
require.NoError(t, err)
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index 3d9f4965d..9c4bc9e61 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -6,17 +6,28 @@ import (
"fmt"
"github.com/go-acme/lego/v4/challenge"
- "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/providers/dns/acmedns"
+ "github.com/go-acme/lego/v4/providers/dns/active24"
"github.com/go-acme/lego/v4/providers/dns/alidns"
+ "github.com/go-acme/lego/v4/providers/dns/aliesa"
"github.com/go-acme/lego/v4/providers/dns/allinkl"
+ "github.com/go-acme/lego/v4/providers/dns/alwaysdata"
+ "github.com/go-acme/lego/v4/providers/dns/anexia"
+ "github.com/go-acme/lego/v4/providers/dns/artfiles"
"github.com/go-acme/lego/v4/providers/dns/arvancloud"
"github.com/go-acme/lego/v4/providers/dns/auroradns"
"github.com/go-acme/lego/v4/providers/dns/autodns"
+ "github.com/go-acme/lego/v4/providers/dns/axelname"
+ "github.com/go-acme/lego/v4/providers/dns/azion"
"github.com/go-acme/lego/v4/providers/dns/azure"
"github.com/go-acme/lego/v4/providers/dns/azuredns"
+ "github.com/go-acme/lego/v4/providers/dns/baiducloud"
+ "github.com/go-acme/lego/v4/providers/dns/beget"
+ "github.com/go-acme/lego/v4/providers/dns/binarylane"
"github.com/go-acme/lego/v4/providers/dns/bindman"
"github.com/go-acme/lego/v4/providers/dns/bluecat"
+ "github.com/go-acme/lego/v4/providers/dns/bluecatv2"
+ "github.com/go-acme/lego/v4/providers/dns/bookmyname"
"github.com/go-acme/lego/v4/providers/dns/brandit"
"github.com/go-acme/lego/v4/providers/dns/bunny"
"github.com/go-acme/lego/v4/providers/dns/checkdomain"
@@ -26,15 +37,20 @@ import (
"github.com/go-acme/lego/v4/providers/dns/cloudns"
"github.com/go-acme/lego/v4/providers/dns/cloudru"
"github.com/go-acme/lego/v4/providers/dns/cloudxns"
+ "github.com/go-acme/lego/v4/providers/dns/com35"
"github.com/go-acme/lego/v4/providers/dns/conoha"
+ "github.com/go-acme/lego/v4/providers/dns/conohav3"
"github.com/go-acme/lego/v4/providers/dns/constellix"
"github.com/go-acme/lego/v4/providers/dns/corenetworks"
"github.com/go-acme/lego/v4/providers/dns/cpanel"
+ "github.com/go-acme/lego/v4/providers/dns/czechia"
+ "github.com/go-acme/lego/v4/providers/dns/ddnss"
"github.com/go-acme/lego/v4/providers/dns/derak"
"github.com/go-acme/lego/v4/providers/dns/desec"
"github.com/go-acme/lego/v4/providers/dns/designate"
"github.com/go-acme/lego/v4/providers/dns/digitalocean"
"github.com/go-acme/lego/v4/providers/dns/directadmin"
+ "github.com/go-acme/lego/v4/providers/dns/dnsexit"
"github.com/go-acme/lego/v4/providers/dns/dnshomede"
"github.com/go-acme/lego/v4/providers/dns/dnsimple"
"github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy"
@@ -44,23 +60,33 @@ import (
"github.com/go-acme/lego/v4/providers/dns/dreamhost"
"github.com/go-acme/lego/v4/providers/dns/duckdns"
"github.com/go-acme/lego/v4/providers/dns/dyn"
+ "github.com/go-acme/lego/v4/providers/dns/dyndnsfree"
"github.com/go-acme/lego/v4/providers/dns/dynu"
"github.com/go-acme/lego/v4/providers/dns/easydns"
+ "github.com/go-acme/lego/v4/providers/dns/edgecenter"
"github.com/go-acme/lego/v4/providers/dns/edgedns"
+ "github.com/go-acme/lego/v4/providers/dns/edgeone"
"github.com/go-acme/lego/v4/providers/dns/efficientip"
"github.com/go-acme/lego/v4/providers/dns/epik"
+ "github.com/go-acme/lego/v4/providers/dns/eurodns"
+ "github.com/go-acme/lego/v4/providers/dns/excedo"
"github.com/go-acme/lego/v4/providers/dns/exec"
"github.com/go-acme/lego/v4/providers/dns/exoscale"
+ "github.com/go-acme/lego/v4/providers/dns/f5xc"
"github.com/go-acme/lego/v4/providers/dns/freemyip"
"github.com/go-acme/lego/v4/providers/dns/gandi"
"github.com/go-acme/lego/v4/providers/dns/gandiv5"
"github.com/go-acme/lego/v4/providers/dns/gcloud"
"github.com/go-acme/lego/v4/providers/dns/gcore"
+ "github.com/go-acme/lego/v4/providers/dns/gigahostno"
"github.com/go-acme/lego/v4/providers/dns/glesys"
"github.com/go-acme/lego/v4/providers/dns/godaddy"
"github.com/go-acme/lego/v4/providers/dns/googledomains"
+ "github.com/go-acme/lego/v4/providers/dns/gravity"
"github.com/go-acme/lego/v4/providers/dns/hetzner"
"github.com/go-acme/lego/v4/providers/dns/hostingde"
+ "github.com/go-acme/lego/v4/providers/dns/hostinger"
+ "github.com/go-acme/lego/v4/providers/dns/hostingnl"
"github.com/go-acme/lego/v4/providers/dns/hosttech"
"github.com/go-acme/lego/v4/providers/dns/httpnet"
"github.com/go-acme/lego/v4/providers/dns/httpreq"
@@ -75,9 +101,15 @@ import (
"github.com/go-acme/lego/v4/providers/dns/internetbs"
"github.com/go-acme/lego/v4/providers/dns/inwx"
"github.com/go-acme/lego/v4/providers/dns/ionos"
+ "github.com/go-acme/lego/v4/providers/dns/ionoscloud"
"github.com/go-acme/lego/v4/providers/dns/ipv64"
+ "github.com/go-acme/lego/v4/providers/dns/ispconfig"
+ "github.com/go-acme/lego/v4/providers/dns/ispconfigddns"
"github.com/go-acme/lego/v4/providers/dns/iwantmyname"
+ "github.com/go-acme/lego/v4/providers/dns/jdcloud"
"github.com/go-acme/lego/v4/providers/dns/joker"
+ "github.com/go-acme/lego/v4/providers/dns/keyhelp"
+ "github.com/go-acme/lego/v4/providers/dns/leaseweb"
"github.com/go-acme/lego/v4/providers/dns/liara"
"github.com/go-acme/lego/v4/providers/dns/lightsail"
"github.com/go-acme/lego/v4/providers/dns/limacity"
@@ -86,22 +118,30 @@ import (
"github.com/go-acme/lego/v4/providers/dns/loopia"
"github.com/go-acme/lego/v4/providers/dns/luadns"
"github.com/go-acme/lego/v4/providers/dns/mailinabox"
+ "github.com/go-acme/lego/v4/providers/dns/manageengine"
+ "github.com/go-acme/lego/v4/providers/dns/manual"
"github.com/go-acme/lego/v4/providers/dns/metaname"
+ "github.com/go-acme/lego/v4/providers/dns/metaregistrar"
"github.com/go-acme/lego/v4/providers/dns/mijnhost"
"github.com/go-acme/lego/v4/providers/dns/mittwald"
+ "github.com/go-acme/lego/v4/providers/dns/myaddr"
"github.com/go-acme/lego/v4/providers/dns/mydnsjp"
"github.com/go-acme/lego/v4/providers/dns/mythicbeasts"
"github.com/go-acme/lego/v4/providers/dns/namecheap"
"github.com/go-acme/lego/v4/providers/dns/namedotcom"
"github.com/go-acme/lego/v4/providers/dns/namesilo"
+ "github.com/go-acme/lego/v4/providers/dns/namesurfer"
"github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech"
+ "github.com/go-acme/lego/v4/providers/dns/neodigit"
"github.com/go-acme/lego/v4/providers/dns/netcup"
"github.com/go-acme/lego/v4/providers/dns/netlify"
"github.com/go-acme/lego/v4/providers/dns/nicmanager"
+ "github.com/go-acme/lego/v4/providers/dns/nicru"
"github.com/go-acme/lego/v4/providers/dns/nifcloud"
"github.com/go-acme/lego/v4/providers/dns/njalla"
"github.com/go-acme/lego/v4/providers/dns/nodion"
"github.com/go-acme/lego/v4/providers/dns/ns1"
+ "github.com/go-acme/lego/v4/providers/dns/octenium"
"github.com/go-acme/lego/v4/providers/dns/oraclecloud"
"github.com/go-acme/lego/v4/providers/dns/otc"
"github.com/go-acme/lego/v4/providers/dns/ovh"
@@ -109,6 +149,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/plesk"
"github.com/go-acme/lego/v4/providers/dns/porkbun"
"github.com/go-acme/lego/v4/providers/dns/rackspace"
+ "github.com/go-acme/lego/v4/providers/dns/rainyun"
"github.com/go-acme/lego/v4/providers/dns/rcodezero"
"github.com/go-acme/lego/v4/providers/dns/regfish"
"github.com/go-acme/lego/v4/providers/dns/regru"
@@ -125,27 +166,35 @@ import (
"github.com/go-acme/lego/v4/providers/dns/shellrent"
"github.com/go-acme/lego/v4/providers/dns/simply"
"github.com/go-acme/lego/v4/providers/dns/sonic"
+ "github.com/go-acme/lego/v4/providers/dns/spaceship"
"github.com/go-acme/lego/v4/providers/dns/stackpath"
+ "github.com/go-acme/lego/v4/providers/dns/syse"
"github.com/go-acme/lego/v4/providers/dns/technitium"
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
"github.com/go-acme/lego/v4/providers/dns/timewebcloud"
+ "github.com/go-acme/lego/v4/providers/dns/todaynic"
"github.com/go-acme/lego/v4/providers/dns/transip"
"github.com/go-acme/lego/v4/providers/dns/ultradns"
+ "github.com/go-acme/lego/v4/providers/dns/uniteddomains"
"github.com/go-acme/lego/v4/providers/dns/variomedia"
"github.com/go-acme/lego/v4/providers/dns/vegadns"
"github.com/go-acme/lego/v4/providers/dns/vercel"
"github.com/go-acme/lego/v4/providers/dns/versio"
"github.com/go-acme/lego/v4/providers/dns/vinyldns"
+ "github.com/go-acme/lego/v4/providers/dns/virtualname"
"github.com/go-acme/lego/v4/providers/dns/vkcloud"
"github.com/go-acme/lego/v4/providers/dns/volcengine"
"github.com/go-acme/lego/v4/providers/dns/vscale"
"github.com/go-acme/lego/v4/providers/dns/vultr"
"github.com/go-acme/lego/v4/providers/dns/webnames"
+ "github.com/go-acme/lego/v4/providers/dns/webnamesca"
"github.com/go-acme/lego/v4/providers/dns/websupport"
"github.com/go-acme/lego/v4/providers/dns/wedos"
+ "github.com/go-acme/lego/v4/providers/dns/westcn"
"github.com/go-acme/lego/v4/providers/dns/yandex"
"github.com/go-acme/lego/v4/providers/dns/yandex360"
"github.com/go-acme/lego/v4/providers/dns/yandexcloud"
+ "github.com/go-acme/lego/v4/providers/dns/zoneedit"
"github.com/go-acme/lego/v4/providers/dns/zoneee"
"github.com/go-acme/lego/v4/providers/dns/zonomi"
)
@@ -153,28 +202,50 @@ import (
// NewDNSChallengeProviderByName Factory for DNS providers.
func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
switch name {
- case "manual":
- return dns01.NewDNSProviderManual()
case "acme-dns", "acmedns":
return acmedns.NewDNSProvider()
+ case "active24":
+ return active24.NewDNSProvider()
case "alidns":
return alidns.NewDNSProvider()
+ case "aliesa":
+ return aliesa.NewDNSProvider()
case "allinkl":
return allinkl.NewDNSProvider()
+ case "alwaysdata":
+ return alwaysdata.NewDNSProvider()
+ case "anexia":
+ return anexia.NewDNSProvider()
+ case "artfiles":
+ return artfiles.NewDNSProvider()
case "arvancloud":
return arvancloud.NewDNSProvider()
case "auroradns":
return auroradns.NewDNSProvider()
case "autodns":
return autodns.NewDNSProvider()
+ case "axelname":
+ return axelname.NewDNSProvider()
+ case "azion":
+ return azion.NewDNSProvider()
case "azure":
return azure.NewDNSProvider()
case "azuredns":
return azuredns.NewDNSProvider()
+ case "baiducloud":
+ return baiducloud.NewDNSProvider()
+ case "beget":
+ return beget.NewDNSProvider()
+ case "binarylane":
+ return binarylane.NewDNSProvider()
case "bindman":
return bindman.NewDNSProvider()
case "bluecat":
return bluecat.NewDNSProvider()
+ case "bluecatv2":
+ return bluecatv2.NewDNSProvider()
+ case "bookmyname":
+ return bookmyname.NewDNSProvider()
case "brandit":
return brandit.NewDNSProvider()
case "bunny":
@@ -193,14 +264,22 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return cloudru.NewDNSProvider()
case "cloudxns":
return cloudxns.NewDNSProvider()
+ case "com35":
+ return com35.NewDNSProvider()
case "conoha":
return conoha.NewDNSProvider()
+ case "conohav3":
+ return conohav3.NewDNSProvider()
case "constellix":
return constellix.NewDNSProvider()
case "corenetworks":
return corenetworks.NewDNSProvider()
case "cpanel":
return cpanel.NewDNSProvider()
+ case "czechia":
+ return czechia.NewDNSProvider()
+ case "ddnss":
+ return ddnss.NewDNSProvider()
case "derak":
return derak.NewDNSProvider()
case "desec":
@@ -211,6 +290,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return digitalocean.NewDNSProvider()
case "directadmin":
return directadmin.NewDNSProvider()
+ case "dnsexit":
+ return dnsexit.NewDNSProvider()
case "dnshomede":
return dnshomede.NewDNSProvider()
case "dnsimple":
@@ -229,20 +310,32 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return duckdns.NewDNSProvider()
case "dyn":
return dyn.NewDNSProvider()
+ case "dyndnsfree":
+ return dyndnsfree.NewDNSProvider()
case "dynu":
return dynu.NewDNSProvider()
case "easydns":
return easydns.NewDNSProvider()
+ case "edgecenter":
+ return edgecenter.NewDNSProvider()
case "edgedns", "fastdns":
return edgedns.NewDNSProvider()
+ case "edgeone":
+ return edgeone.NewDNSProvider()
case "efficientip":
return efficientip.NewDNSProvider()
case "epik":
return epik.NewDNSProvider()
+ case "eurodns":
+ return eurodns.NewDNSProvider()
+ case "excedo":
+ return excedo.NewDNSProvider()
case "exec":
return exec.NewDNSProvider()
case "exoscale":
return exoscale.NewDNSProvider()
+ case "f5xc":
+ return f5xc.NewDNSProvider()
case "freemyip":
return freemyip.NewDNSProvider()
case "gandi":
@@ -253,16 +346,24 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return gcloud.NewDNSProvider()
case "gcore":
return gcore.NewDNSProvider()
+ case "gigahostno":
+ return gigahostno.NewDNSProvider()
case "glesys":
return glesys.NewDNSProvider()
case "godaddy":
return godaddy.NewDNSProvider()
case "googledomains":
return googledomains.NewDNSProvider()
+ case "gravity":
+ return gravity.NewDNSProvider()
case "hetzner":
return hetzner.NewDNSProvider()
case "hostingde":
return hostingde.NewDNSProvider()
+ case "hostinger":
+ return hostinger.NewDNSProvider()
+ case "hostingnl":
+ return hostingnl.NewDNSProvider()
case "hosttech":
return hosttech.NewDNSProvider()
case "httpnet":
@@ -291,12 +392,24 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return inwx.NewDNSProvider()
case "ionos":
return ionos.NewDNSProvider()
+ case "ionoscloud":
+ return ionoscloud.NewDNSProvider()
case "ipv64":
return ipv64.NewDNSProvider()
+ case "ispconfig":
+ return ispconfig.NewDNSProvider()
+ case "ispconfigddns":
+ return ispconfigddns.NewDNSProvider()
case "iwantmyname":
return iwantmyname.NewDNSProvider()
+ case "jdcloud":
+ return jdcloud.NewDNSProvider()
case "joker":
return joker.NewDNSProvider()
+ case "keyhelp":
+ return keyhelp.NewDNSProvider()
+ case "leaseweb":
+ return leaseweb.NewDNSProvider()
case "liara":
return liara.NewDNSProvider()
case "lightsail":
@@ -313,12 +426,20 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return luadns.NewDNSProvider()
case "mailinabox":
return mailinabox.NewDNSProvider()
+ case "manageengine":
+ return manageengine.NewDNSProvider()
+ case "manual":
+ return manual.NewDNSProvider()
case "metaname":
return metaname.NewDNSProvider()
+ case "metaregistrar":
+ return metaregistrar.NewDNSProvider()
case "mijnhost":
return mijnhost.NewDNSProvider()
case "mittwald":
return mittwald.NewDNSProvider()
+ case "myaddr":
+ return myaddr.NewDNSProvider()
case "mydnsjp":
return mydnsjp.NewDNSProvider()
case "mythicbeasts":
@@ -329,14 +450,20 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return namedotcom.NewDNSProvider()
case "namesilo":
return namesilo.NewDNSProvider()
+ case "namesurfer":
+ return namesurfer.NewDNSProvider()
case "nearlyfreespeech":
return nearlyfreespeech.NewDNSProvider()
+ case "neodigit":
+ return neodigit.NewDNSProvider()
case "netcup":
return netcup.NewDNSProvider()
case "netlify":
return netlify.NewDNSProvider()
case "nicmanager":
return nicmanager.NewDNSProvider()
+ case "nicru":
+ return nicru.NewDNSProvider()
case "nifcloud":
return nifcloud.NewDNSProvider()
case "njalla":
@@ -345,6 +472,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return nodion.NewDNSProvider()
case "ns1":
return ns1.NewDNSProvider()
+ case "octenium":
+ return octenium.NewDNSProvider()
case "oraclecloud":
return oraclecloud.NewDNSProvider()
case "otc":
@@ -359,6 +488,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return porkbun.NewDNSProvider()
case "rackspace":
return rackspace.NewDNSProvider()
+ case "rainyun":
+ return rainyun.NewDNSProvider()
case "rcodezero":
return rcodezero.NewDNSProvider()
case "regfish":
@@ -391,18 +522,26 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return simply.NewDNSProvider()
case "sonic":
return sonic.NewDNSProvider()
+ case "spaceship":
+ return spaceship.NewDNSProvider()
case "stackpath":
return stackpath.NewDNSProvider()
+ case "syse":
+ return syse.NewDNSProvider()
case "technitium":
return technitium.NewDNSProvider()
case "tencentcloud":
return tencentcloud.NewDNSProvider()
case "timewebcloud":
return timewebcloud.NewDNSProvider()
+ case "todaynic":
+ return todaynic.NewDNSProvider()
case "transip":
return transip.NewDNSProvider()
case "ultradns":
return ultradns.NewDNSProvider()
+ case "uniteddomains":
+ return uniteddomains.NewDNSProvider()
case "variomedia":
return variomedia.NewDNSProvider()
case "vegadns":
@@ -413,6 +552,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return versio.NewDNSProvider()
case "vinyldns":
return vinyldns.NewDNSProvider()
+ case "virtualname":
+ return virtualname.NewDNSProvider()
case "vkcloud":
return vkcloud.NewDNSProvider()
case "volcengine":
@@ -421,18 +562,24 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return vscale.NewDNSProvider()
case "vultr":
return vultr.NewDNSProvider()
- case "webnames":
+ case "webnames", "webnamesru":
return webnames.NewDNSProvider()
+ case "webnamesca":
+ return webnamesca.NewDNSProvider()
case "websupport":
return websupport.NewDNSProvider()
case "wedos":
return wedos.NewDNSProvider()
+ case "westcn":
+ return westcn.NewDNSProvider()
case "yandex":
return yandex.NewDNSProvider()
case "yandex360":
return yandex360.NewDNSProvider()
case "yandexcloud":
return yandexcloud.NewDNSProvider()
+ case "zoneedit":
+ return zoneedit.NewDNSProvider()
case "zoneee":
return zoneee.NewDNSProvider()
case "zonomi":
diff --git a/providers/http/memcached/memcached.go b/providers/http/memcached/memcached.go
index b26def2c4..376ae8c16 100644
--- a/providers/http/memcached/memcached.go
+++ b/providers/http/memcached/memcached.go
@@ -33,12 +33,14 @@ func (w *HTTPProvider) Present(domain, token, keyAuth string) error {
var errs []error
challengePath := path.Join("/", http01.ChallengePath(token))
+
for _, host := range w.hosts {
mc, err := memcache.New(host)
if err != nil {
errs = append(errs, err)
continue
}
+
_ = mc.Add(&memcache.Item{
Key: challengePath,
Value: []byte(keyAuth),
diff --git a/providers/http/memcached/memcached_test.go b/providers/http/memcached/memcached_test.go
index fb450f988..5862efbc6 100644
--- a/providers/http/memcached/memcached_test.go
+++ b/providers/http/memcached/memcached_test.go
@@ -25,6 +25,7 @@ func loadMemcachedHosts() []string {
if memcachedHostsStr != "" {
return strings.Split(memcachedHostsStr, ",")
}
+
return nil
}
@@ -38,6 +39,7 @@ func TestNewMemcachedProviderValid(t *testing.T) {
if len(memcachedHosts) == 0 {
t.Skip("Skipping memcached tests")
}
+
_, err := NewMemcachedProvider(memcachedHosts)
require.NoError(t, err)
}
@@ -46,6 +48,7 @@ func TestMemcachedPresentSingleHost(t *testing.T) {
if len(memcachedHosts) == 0 {
t.Skip("Skipping memcached tests")
}
+
p, err := NewMemcachedProvider(memcachedHosts[0:1])
require.NoError(t, err)
@@ -64,6 +67,7 @@ func TestMemcachedPresentMultiHost(t *testing.T) {
if len(memcachedHosts) <= 1 {
t.Skip("Skipping memcached multi-host tests")
}
+
p, err := NewMemcachedProvider(memcachedHosts)
require.NoError(t, err)
@@ -71,6 +75,7 @@ func TestMemcachedPresentMultiHost(t *testing.T) {
err = p.Present(domain, token, keyAuth)
require.NoError(t, err)
+
for _, host := range memcachedHosts {
mc, err := memcache.New(host)
require.NoError(t, err)
@@ -84,6 +89,7 @@ func TestMemcachedPresentPartialFailureMultiHost(t *testing.T) {
if len(memcachedHosts) == 0 {
t.Skip("Skipping memcached tests")
}
+
hosts := append(memcachedHosts, "5.5.5.5:11211")
p, err := NewMemcachedProvider(hosts)
require.NoError(t, err)
@@ -92,6 +98,7 @@ func TestMemcachedPresentPartialFailureMultiHost(t *testing.T) {
err = p.Present(domain, token, keyAuth)
require.NoError(t, err)
+
for _, host := range memcachedHosts {
mc, err := memcache.New(host)
require.NoError(t, err)
@@ -105,6 +112,7 @@ func TestMemcachedCleanup(t *testing.T) {
if len(memcachedHosts) == 0 {
t.Skip("Skipping memcached tests")
}
+
p, err := NewMemcachedProvider(memcachedHosts)
require.NoError(t, err)
require.NoError(t, p.CleanUp(domain, token, keyAuth))
diff --git a/providers/http/s3/s3.go b/providers/http/s3/s3.go
index 07e1eed63..e277deeea 100644
--- a/providers/http/s3/s3.go
+++ b/providers/http/s3/s3.go
@@ -57,6 +57,7 @@ func (s *HTTPProvider) Present(domain, token, keyAuth string) error {
if err != nil {
return fmt.Errorf("s3: failed to upload token to s3: %w", err)
}
+
return nil
}
diff --git a/providers/http/webroot/webroot.go b/providers/http/webroot/webroot.go
index c5b49caee..c94c4579c 100644
--- a/providers/http/webroot/webroot.go
+++ b/providers/http/webroot/webroot.go
@@ -29,6 +29,7 @@ func (w *HTTPProvider) Present(domain, token, keyAuth string) error {
var err error
challengeFilePath := filepath.Join(w.path, http01.ChallengePath(token))
+
err = os.MkdirAll(filepath.Dir(challengeFilePath), 0o755)
if err != nil {
return fmt.Errorf("could not create required directories in webroot for HTTP challenge: %w", err)
diff --git a/providers/http/webroot/webroot_test.go b/providers/http/webroot/webroot_test.go
index 124b324a3..4c55e2b90 100644
--- a/providers/http/webroot/webroot_test.go
+++ b/providers/http/webroot/webroot_test.go
@@ -29,6 +29,7 @@ func TestHTTPProvider(t *testing.T) {
}
var data []byte
+
data, err = os.ReadFile(challengeFilePath)
require.NoError(t, err)
diff --git a/registration/registar.go b/registration/registar.go
index 78e0ce7d8..5d3ea250b 100644
--- a/registration/registar.go
+++ b/registration/registar.go
@@ -15,7 +15,7 @@ const mailTo = "mailto:"
// of which the client needs to keep track itself.
// WARNING: will be removed in the future (acme.ExtendedAccount), https://github.com/go-acme/lego/issues/855.
type Resource struct {
- Body acme.Account `json:"body,omitempty"`
+ Body acme.Account `json:"body"`
URI string `json:"uri,omitempty"`
}
@@ -60,7 +60,7 @@ func (r *Registrar) Register(options RegisterOptions) (*Resource, error) {
account, err := r.core.Accounts.New(accMsg)
if err != nil {
// seems impossible
- var errorDetails acme.ProblemDetails
+ errorDetails := &acme.ProblemDetails{}
if !errors.As(err, &errorDetails) || errorDetails.HTTPStatus != http.StatusConflict {
return nil, err
}
@@ -84,7 +84,7 @@ func (r *Registrar) RegisterWithExternalAccountBinding(options RegisterEABOption
account, err := r.core.Accounts.NewEAB(accMsg, options.Kid, options.HmacEncoded)
if err != nil {
// seems impossible
- var errorDetails acme.ProblemDetails
+ errorDetails := &acme.ProblemDetails{}
if !errors.As(err, &errorDetails) || errorDetails.HTTPStatus != http.StatusConflict {
return nil, err
}
@@ -160,6 +160,7 @@ func (r *Registrar) ResolveAccountByKey() (*Resource, error) {
log.Infof("acme: Trying to resolve account by key")
accMsg := acme.Account{OnlyReturnExisting: true}
+
account, err := r.core.Accounts.New(accMsg)
if err != nil {
return nil, err
diff --git a/registration/registar_test.go b/registration/registar_test.go
index efbc4f6f7..43df1d648 100644
--- a/registration/registar_test.go
+++ b/registration/registar_test.go
@@ -3,31 +3,30 @@ package registration
import (
"crypto/rand"
"crypto/rsa"
+ "fmt"
"net/http"
"testing"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/acme/api"
"github.com/go-acme/lego/v4/platform/tester"
+ "github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRegistrar_ResolveAccountByKey(t *testing.T) {
- mux, apiURL := tester.SetupFakeAPI(t)
+ server := tester.MockACMEServer().
+ Route("/account",
+ http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ rw.Header().Set("Location",
+ fmt.Sprintf("http://%s/account", req.Context().Value(http.LocalAddrContextKey)))
- mux.HandleFunc("/account", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Location", apiURL+"/account")
- err := tester.WriteJSONResponse(w, acme.Account{
- Status: "valid",
- })
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- })
+ servermock.JSONEncode(acme.Account{Status: "valid"}).ServeHTTP(rw, req)
+ })).
+ BuildHTTPS(t)
- key, err := rsa.GenerateKey(rand.Reader, 512)
+ key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err, "Could not generate test key")
user := mockUser{
@@ -36,7 +35,7 @@ func TestRegistrar_ResolveAccountByKey(t *testing.T) {
privatekey: key,
}
- core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
registrar := NewRegistrar(core, user)