diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index ecd9cb6a5..ea3fd9a3a 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -45,6 +45,7 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
+ - Through Certimate
- go install
- Other
validations:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 33f7be155..7f6793167 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -24,6 +24,7 @@ body:
- Through Bitnami
- Through 1Panel
- Through Zoraxy
+ - Through Certimate
- go install
- Other
validations:
diff --git a/.github/ISSUE_TEMPLATE/new_dns_provider.yml b/.github/ISSUE_TEMPLATE/new_dns_provider.yml
index 9e9fe3c03..b319bc287 100644
--- a/.github/ISSUE_TEMPLATE/new_dns_provider.yml
+++ b/.github/ISSUE_TEMPLATE/new_dns_provider.yml
@@ -14,9 +14,15 @@ body:
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
@@ -34,11 +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 c0bbbfbdc..4f9d444fc 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -17,15 +17,11 @@ jobs:
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 91977bc28..33ca106cc 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
env:
GO_VERSION: stable
- GOLANGCI_LINT_VERSION: v2.6.0
+ GOLANGCI_LINT_VERSION: v2.10
HUGO_VERSION: 0.148.2
CGO_ENABLED: 0
LEGO_E2E_TESTS: CI
@@ -21,43 +21,36 @@ jobs:
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
- name: Generate and Check generated elements
run: |
make generate-dns
git diff --exit-code
- # https://golangci-lint.run/usage/install#other-ci
- - name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }}
- 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
+ - 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@v2.7.0
+ 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@v2.7.0
+ 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
+ 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 a83c85909..6a0d3b703 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -42,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 }}
@@ -69,9 +67,10 @@ jobs:
# https://goreleaser.com/ci/actions/
- name: Run GoReleaser
+ id: goreleaser
uses: goreleaser/goreleaser-action@v6
with:
- version: v2.12.3
+ version: v2.13.0
args: release -p 1 --clean --timeout=90m
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN_REPO }}
@@ -80,7 +79,9 @@ jobs:
- uses: actions/attest-build-provenance@v3
with:
- subject-checksums: ./dist/lego_*_checksums.txt
+ 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 a6f0c4bfa..b6ab51ccc 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -180,6 +180,12 @@ linters:
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:
@@ -216,11 +222,7 @@ linters:
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
+ - path: providers/(dns|http)/([\d\w]+/)*[\d\w]+_test.go
text: envTest is a global variable
linters:
- gochecknoglobals
@@ -228,6 +230,10 @@ linters:
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:
@@ -263,6 +269,10 @@ linters:
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'
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 073997209..c358f8a38 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -90,8 +90,9 @@ dockers_v2:
- linux/arm/v7
tags:
- 'latest'
- - '{{ .Tag }}'
+ - 'v{{ .Major }}'
- 'v{{ .Major }}.{{ .Minor }}'
+ - '{{ .Tag }}'
labels:
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
'org.opencontainers.image.title': '{{.ProjectName}}'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccfd912d5..ae73f70f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,129 @@
# Changelog
-lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️
+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
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/README.md b/README.md
index 1a8480b24..e90e94962 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
# Lego
-Let's Encrypt client and ACME library written in Go.
+[ACME](https://www.rfc-editor.org/rfc/rfc8555.html) client and library for Let's Encrypt and other ACME CAs written in Go.
[](https://pkg.go.dev/github.com/go-acme/lego/v4)
[](https://github.com//go-acme/lego/actions)
@@ -24,7 +24,7 @@ So if you think that lego is worth it, please consider [donating](https://donate
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses
- 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 [170 DNS providers](https://go-acme.github.io/lego/dns)
+- 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
@@ -56,51 +56,63 @@ 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).
+
diff --git a/acme/api/identifier.go b/acme/api/identifier.go
index 42a8fd391..245ed8515 100644
--- a/acme/api/identifier.go
+++ b/acme/api/identifier.go
@@ -2,7 +2,6 @@ package api
import (
"cmp"
- "maps"
"net"
"slices"
@@ -10,7 +9,9 @@ import (
)
func createIdentifiers(domains []string) []acme.Identifier {
- uniqIdentifiers := make(map[string]acme.Identifier)
+ uniqIdentifiers := make(map[string]struct{})
+
+ var identifiers []acme.Identifier
for _, domain := range domains {
if _, ok := uniqIdentifiers[domain]; ok {
@@ -23,10 +24,12 @@ func createIdentifiers(domains []string) []acme.Identifier {
ident.Type = "ip"
}
- uniqIdentifiers[domain] = ident
+ identifiers = append(identifiers, ident)
+
+ uniqIdentifiers[domain] = struct{}{}
}
- return slices.AppendSeq(make([]acme.Identifier, 0, len(uniqIdentifiers)), maps.Values(uniqIdentifiers))
+ return identifiers
}
// compareIdentifiers compares 2 slices of [acme.Identifier].
diff --git a/acme/api/internal/sender/sender.go b/acme/api/internal/sender/sender.go
index d5db5d410..d8859edf4 100644
--- a/acme/api/internal/sender/sender.go
+++ b/acme/api/internal/sender/sender.go
@@ -120,39 +120,46 @@ 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 errorDetails.HTTPStatus == http.StatusConflict && errorDetails.Type == acme.AlreadyReplacedErr {
- return &acme.AlreadyReplacedError{ProblemDetails: errorDetails}
- }
-
- return errorDetails
+ if resp.StatusCode < http.StatusBadRequest {
+ return nil
}
- 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
+ }
}
type httpsOnly struct {
diff --git a/acme/api/internal/sender/sender_test.go b/acme/api/internal/sender/sender_test.go
index 1f25c6d26..73701ab11 100644
--- a/acme/api/internal/sender/sender_test.go
+++ b/acme/api/internal/sender/sender_test.go
@@ -1,11 +1,14 @@
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"
)
@@ -78,3 +81,70 @@ func TestDo_failWithHTTP(t *testing.T) {
_, 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 11f6edf99..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.28.0"
+ ourUserAgent = "xenolf-acme/4.32.0"
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release
// NOTE: Update this with each tagged release.
- ourUserAgentComment = "release"
+ ourUserAgentComment = "detach"
)
diff --git a/acme/api/service.go b/acme/api/service.go
index 65518e1d9..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 {
@@ -56,3 +59,29 @@ func getRetryAfter(resp *http.Response) string {
return resp.Header.Get("Retry-After")
}
+
+// ParseRetryAfter parses the Retry-After header value according to RFC 7231.
+// The header can be either delay-seconds (numeric) or HTTP-date (RFC 1123 format).
+// https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3
+// Returns the duration until the retry time.
+// TODO(ldez): unexposed this function in v5.
+func ParseRetryAfter(value string) (time.Duration, error) {
+ if value == "" {
+ return 0, nil
+ }
+
+ if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
+ return time.Duration(seconds) * time.Second, nil
+ }
+
+ if retryTime, err := time.Parse(time.RFC1123, value); err == nil {
+ duration := time.Until(retryTime)
+ if duration < 0 {
+ return 0, nil
+ }
+
+ return duration, nil
+ }
+
+ return 0, fmt.Errorf("invalid Retry-After value: %q", value)
+}
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/errors.go b/acme/errors.go
index 161a47c38..cd447d7b4 100644
--- a/acme/errors.go
+++ b/acme/errors.go
@@ -10,6 +10,7 @@ const (
errNS = "urn:ietf:params:acme:error:"
BadNonceErr = errNS + "badNonce"
AlreadyReplacedErr = errNS + "alreadyReplaced"
+ RateLimitedErr = errNS + "rateLimited"
)
// ProblemDetails the problem details object.
@@ -28,18 +29,18 @@ type ProblemDetails struct {
}
func (p *ProblemDetails) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
- msg.WriteString(fmt.Sprintf("acme: error: %d", p.HTTPStatus))
+ _, _ = fmt.Fprintf(msg, "acme: error: %d", p.HTTPStatus)
if p.Method != "" || p.URL != "" {
- msg.WriteString(fmt.Sprintf(" :: %s :: %s", p.Method, p.URL))
+ _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Method, p.URL)
}
- msg.WriteString(fmt.Sprintf(" :: %s :: %s", p.Type, p.Detail))
+ _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Type, p.Detail)
for _, sub := range p.SubProblems {
- msg.WriteString(fmt.Sprintf(", problem: %q :: %s", sub.Type, sub.Detail))
+ _, _ = fmt.Fprintf(msg, ", problem: %q :: %s", sub.Type, sub.Detail)
}
if p.Instance != "" {
@@ -63,9 +64,30 @@ 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.
+// 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/certcrypto/crypto.go b/certcrypto/crypto.go
index 00f0654b9..800bb3f5b 100644
--- a/certcrypto/crypto.go
+++ b/certcrypto/crypto.go
@@ -242,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")
}
@@ -258,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 {
diff --git a/certificate/renewal.go b/certificate/renewal.go
index 15e804745..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.
@@ -92,9 +93,9 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse
}
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)
}
}
diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go
index 6ce43e0aa..23209638a 100644
--- a/certificate/renewal_test.go
+++ b/certificate/renewal_test.go
@@ -74,6 +74,42 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {
assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter)
}
+func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) {
+ leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
+ require.NoError(t, err)
+
+ server := tester.MockACMEServer().
+ Route("GET /renewalInfo/"+ariLeafCertID,
+ servermock.RawStringResponse(`{
+ "suggestedWindow": {
+ "start": "2020-03-17T17:51:09Z",
+ "end": "2020-03-17T18:21:09Z"
+ },
+ "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
+ }
+ }`).
+ WithHeader("Content-Type", "application/json").
+ WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))).
+ BuildHTTPS(t)
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err, "Could not generate test key")
+
+ core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
+ require.NoError(t, err)
+
+ certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
+
+ ri, err := certifier.GetRenewalInfo(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)
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/resolver/errors.go b/challenge/resolver/errors.go
index 6a859922c..65a6ccdb7 100644
--- a/challenge/resolver/errors.go
+++ b/challenge/resolver/errors.go
@@ -3,6 +3,8 @@ package resolver
import (
"bytes"
"fmt"
+ "maps"
+ "slices"
"sort"
)
@@ -25,3 +27,7 @@ func (e obtainError) Error() string {
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 aac1016d8..66b12c7a7 100644
--- a/challenge/resolver/prober.go
+++ b/challenge/resolver/prober.go
@@ -98,11 +98,24 @@ func (p *Prober) Solve(authorizations []acme.Authorization) error {
}
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
@@ -111,6 +124,8 @@ func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
continue
}
+
+ uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{}
}
// Solve challenge
@@ -123,22 +138,43 @@ func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
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 {
@@ -150,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)
}
}()
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 06ff07d2c..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",
@@ -30,6 +33,30 @@ func TestProber_Solve(t *testing.T) {
createStubAuthorizationHTTP01("example.org", acme.StatusProcessing),
createStubAuthorizationHTTP01("example.net", acme.StatusProcessing),
},
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 3, Solve: 3, CleanUp: 3",
+ },
+ },
+ {
+ desc: "DNS-01 deduplicate",
+ solvers: map[challenge.Type]solver{
+ challenge.DNS01: &preSolverMock{
+ preSolve: map[string]error{},
+ solve: map[string]error{},
+ cleanUp: map[string]error{},
+ },
+ },
+ authz: []acme.Authorization{
+ createStubAuthorizationDNS01("a.example", false),
+ createStubAuthorizationDNS01("a.example", true),
+ createStubAuthorizationDNS01("b.example", false),
+ createStubAuthorizationDNS01("b.example", true),
+ createStubAuthorizationDNS01("c.example", true),
+ createStubAuthorizationDNS01("d.example", false),
+ },
+ expectedCounters: map[challenge.Type]string{
+ challenge.DNS01: "PreSolve: 4, Solve: 6, CleanUp: 4",
+ },
},
{
desc: "already valid",
@@ -45,6 +72,9 @@ func TestProber_Solve(t *testing.T) {
createStubAuthorizationHTTP01("example.org", acme.StatusValid),
createStubAuthorizationHTTP01("example.net", acme.StatusValid),
},
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 0, Solve: 0, CleanUp: 0",
+ },
},
{
desc: "when preSolve fail, auth is flagged as error and skipped",
@@ -69,6 +99,9 @@ func TestProber_Solve(t *testing.T) {
expectedError: `error: one or more domains had a problem:
[example.com] preSolve error example.com
`,
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
+ },
},
{
desc: "errors at different stages",
@@ -95,6 +128,9 @@ func TestProber_Solve(t *testing.T) {
[example.com] preSolve error example.com
[example.org] solve error example.org
`,
+ expectedCounters: map[challenge.Type]string{
+ challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3",
+ },
},
}
@@ -112,6 +148,10 @@ func TestProber_Solve(t *testing.T) {
} else {
require.NoError(t, err)
}
+
+ for n, s := range test.solvers {
+ assert.Equal(t, test.expectedCounters[n], fmt.Sprintf("%s", s))
+ }
})
}
}
diff --git a/challenge/resolver/solver_manager.go b/challenge/resolver/solver_manager.go
index 48d9194b9..87cf6e2d8 100644
--- a/challenge/resolver/solver_manager.go
+++ b/challenge/resolver/solver_manager.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"sort"
- "strconv"
"time"
"github.com/cenkalti/backoff/v5"
@@ -94,22 +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.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.
@@ -134,7 +131,7 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error {
return wait.Retry(ctx, operation,
backoff.WithBackOff(bo),
- backoff.WithMaxElapsedTime(100*initialInterval))
+ backoff.WithMaxElapsedTime(100*retryAfter))
}
func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) {
diff --git a/cmd/accounts_storage.go b/cmd/accounts_storage.go
index 1dbdfb84b..01db2faf8 100644
--- a/cmd/accounts_storage.go
+++ b/cmd/accounts_storage.go
@@ -16,6 +16,8 @@ import (
"github.com/urfave/cli/v2"
)
+const userIDPlaceholder = "noemail@example.com"
+
const (
baseAccountsRootFolderName = "accounts"
baseKeysFolderName = "keys"
@@ -32,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
@@ -40,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)
@@ -49,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)
@@ -57,6 +59,7 @@ const (
// └── "path" option
type AccountsStorage struct {
userID string
+ email string
rootPath string
rootUserPath string
keysPath string
@@ -66,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 {
@@ -77,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),
@@ -112,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 {
@@ -124,14 +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,14 +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)
}
}
@@ -154,15 +167,15 @@ 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)
@@ -180,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)
}
}
diff --git a/cmd/cmd_list.go b/cmd/cmd_list.go
index 864b85977..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,
},
},
@@ -100,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()
@@ -150,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 99bc5ebbd..4b41ebc78 100644
--- a/cmd/cmd_renew.go
+++ b/cmd/cmd_renew.go
@@ -144,7 +144,9 @@ func renew(ctx *cli.Context) error {
bundle := !ctx.Bool(flgNoBundle)
- meta := map[string]string{hookEnvAccountEmail: account.Email}
+ meta := map[string]string{
+ hookEnvAccountEmail: account.Email,
+ }
// CSR
if ctx.IsSet(flgCSR) {
diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go
index 16814b4de..5924c4b66 100644
--- a/cmd/cmd_run.go
+++ b/cmd/cmd_run.go
@@ -104,9 +104,9 @@ 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 the ACME server 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 {
diff --git a/cmd/lego/zz_gen_version.go b/cmd/lego/zz_gen_version.go
index 8eed28947..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.28.0+dev-release"
+const defaultVersion = "v4.32.0+dev-detach"
var version = ""
diff --git a/cmd/setup.go b/cmd/setup.go
index 4d17f2e27..6d15adad3 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -40,7 +40,7 @@ func setupAccount(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account,
if accountsStorage.ExistsAccountFilePath() {
account = accountsStorage.LoadAccount(privateKey)
} else {
- account = &Account{Email: accountsStorage.GetUserID(), key: privateKey}
+ account = &Account{Email: accountsStorage.GetEmail(), key: privateKey}
}
return account, keyType
@@ -118,15 +118,6 @@ func getKeyType(ctx *cli.Context) certcrypto.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
-}
-
func getUserAgent(ctx *cli.Context) string {
return strings.TrimSpace(fmt.Sprintf("%s lego-cli/%s", ctx.String(flgUserAgent), ctx.App.Version))
}
@@ -180,6 +171,10 @@ func checkRetry(ctx context.Context, resp *http.Response, err error) (bool, erro
return rt, err
}
+ if resp == nil {
+ return rt, nil
+ }
+
if resp.StatusCode/100 == 2 {
return rt, nil
}
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index e21f37e63..f73f3920b 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -12,12 +12,14 @@ import (
func allDNSCodes() string {
providers := []string{
- "manual",
"acme-dns",
"active24",
"alidns",
+ "aliesa",
"allinkl",
+ "alwaysdata",
"anexia",
+ "artfiles",
"arvancloud",
"auroradns",
"autodns",
@@ -30,6 +32,7 @@ func allDNSCodes() string {
"binarylane",
"bindman",
"bluecat",
+ "bluecatv2",
"bookmyname",
"brandit",
"bunny",
@@ -40,16 +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",
@@ -62,10 +69,13 @@ func allDNSCodes() string {
"dyndnsfree",
"dynu",
"easydns",
+ "edgecenter",
"edgedns",
"edgeone",
"efficientip",
"epik",
+ "eurodns",
+ "excedo",
"exec",
"exoscale",
"f5xc",
@@ -74,12 +84,15 @@ func allDNSCodes() string {
"gandiv5",
"gcloud",
"gcore",
+ "gigahostno",
"glesys",
"godaddy",
"googledomains",
+ "gravity",
"hetzner",
"hostingde",
"hostinger",
+ "hostingnl",
"hosttech",
"httpnet",
"httpreq",
@@ -94,10 +107,15 @@ func allDNSCodes() string {
"internetbs",
"inwx",
"ionos",
+ "ionoscloud",
"ipv64",
+ "ispconfig",
+ "ispconfigddns",
"iwantmyname",
+ "jdcloud",
"joker",
"keyhelp",
+ "leaseweb",
"liara",
"lightsail",
"limacity",
@@ -107,6 +125,7 @@ func allDNSCodes() string {
"luadns",
"mailinabox",
"manageengine",
+ "manual",
"metaname",
"metaregistrar",
"mijnhost",
@@ -117,7 +136,9 @@ func allDNSCodes() string {
"namecheap",
"namedotcom",
"namesilo",
+ "namesurfer",
"nearlyfreespeech",
+ "neodigit",
"netcup",
"netlify",
"nicmanager",
@@ -153,16 +174,20 @@ func allDNSCodes() string {
"sonic",
"spaceship",
"stackpath",
+ "syse",
"technitium",
"tencentcloud",
"timewebcloud",
+ "todaynic",
"transip",
"ultradns",
+ "uniteddomains",
"variomedia",
"vegadns",
"vercel",
"versio",
"vinyldns",
+ "virtualname",
"vkcloud",
"volcengine",
"vscale",
@@ -244,13 +269,38 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`)
+ ew.writeln(` - "ALICLOUD_LINE": Line (Default: default)`)
ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ALICLOUD_REGION_ID": Region ID (Default: cn-hangzhou)`)
ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`)
ew.writeln()
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.`)
@@ -271,6 +321,27 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -292,6 +363,27 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/anexia`)
+ case "artfiles":
+ // generated from: providers/dns/artfiles/artfiles.toml
+ ew.writeln(`Configuration for ArtFiles.`)
+ ew.writeln(`Code: 'artfiles'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ARTFILES_PASSWORD": API password`)
+ ew.writeln(` - "ARTFILES_USERNAME": API username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ARTFILES_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ARTFILES_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ARTFILES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`)
+ ew.writeln(` - "ARTFILES_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/artfiles`)
+
case "arvancloud":
// generated from: providers/dns/arvancloud/arvancloud.toml
ew.writeln(`Configuration for ArvanCloud.`)
@@ -471,7 +563,7 @@ func displayDNSHelp(w io.Writer, name string) error {
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: 120)`)
+ 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`)
@@ -561,6 +653,31 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecat`)
+ case "bluecatv2":
+ // generated from: providers/dns/bluecatv2/bluecatv2.toml
+ ew.writeln(`Configuration for Bluecat v2.`)
+ ew.writeln(`Code: 'bluecatv2'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BLUECATV2_CONFIG_NAME": Configuration name`)
+ ew.writeln(` - "BLUECATV2_PASSWORD": API password`)
+ ew.writeln(` - "BLUECATV2_USERNAME": API username`)
+ ew.writeln(` - "BLUECATV2_VIEW_NAME": DNS View Name`)
+ ew.writeln(` - "BLUECAT_SERVER_URL": The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BLUECATV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BLUECATV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "BLUECATV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "BLUECATV2_SKIP_DEPLOY": Skip quick deployements`)
+ ew.writeln(` - "BLUECATV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecatv2`)
+
case "bookmyname":
// generated from: providers/dns/bookmyname/bookmyname.toml
ew.writeln(`Configuration for BookMyName.`)
@@ -779,6 +896,27 @@ func displayDNSHelp(w io.Writer, name string) error {
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 v2.`)
@@ -891,6 +1029,47 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1006,6 +1185,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1252,6 +1451,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1296,6 +1515,7 @@ func displayDNSHelp(w io.Writer, name string) error {
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`)
@@ -1344,6 +1564,48 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1393,6 +1655,7 @@ func displayDNSHelp(w io.Writer, name string) error {
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()
@@ -1505,6 +1768,28 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1566,6 +1851,28 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1627,6 +1934,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1926,6 +2253,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -1945,6 +2292,50 @@ func displayDNSHelp(w io.Writer, name string) error {
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 (Deprecated).`)
@@ -1966,6 +2357,28 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -2011,6 +2424,26 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/keyhelp`)
+ case "leaseweb":
+ // generated from: providers/dns/leaseweb/leaseweb.toml
+ ew.writeln(`Configuration for Leaseweb.`)
+ ew.writeln(`Code: 'leaseweb'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "LEASEWEB_API_KEY": API key`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "LEASEWEB_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "LEASEWEB_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "LEASEWEB_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "LEASEWEB_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/leaseweb`)
+
case "liara":
// generated from: providers/dns/liara/liara.toml
ew.writeln(`Configuration for Liara.`)
@@ -2026,6 +2459,7 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "LIARA_TEAM_ID": The team ID to access services in a team`)
ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`)
ew.writeln()
@@ -2200,6 +2634,16 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -2408,6 +2852,30 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesilo`)
+ case "namesurfer":
+ // generated from: providers/dns/namesurfer/namesurfer.toml
+ ew.writeln(`Configuration for FusionLayer NameSurfer.`)
+ ew.writeln(`Code: 'namesurfer'`)
+ ew.writeln(`Since: 'v4.32.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "NAMESURFER_API_KEY": API key name`)
+ ew.writeln(` - "NAMESURFER_API_SECRET": API secret`)
+ ew.writeln(` - "NAMESURFER_BASE_URL": The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "NAMESURFER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "NAMESURFER_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`)
+ ew.writeln(` - "NAMESURFER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "NAMESURFER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`)
+ ew.writeln(` - "NAMESURFER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`)
+ ew.writeln(` - "NAMESURFER_VIEW": DNS view name (optional, default: empty string)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesurfer`)
+
case "nearlyfreespeech":
// generated from: providers/dns/nearlyfreespeech/nearlyfreespeech.toml
ew.writeln(`Configuration for NearlyFreeSpeech.NET.`)
@@ -2430,6 +2898,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -2951,7 +3439,7 @@ func displayDNSHelp(w io.Writer, name string) error {
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()
@@ -3209,6 +3697,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -3272,6 +3780,27 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -3314,6 +3843,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -3423,6 +3972,26 @@ func displayDNSHelp(w io.Writer, name string) error {
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.`)
@@ -3738,8 +4307,6 @@ func displayDNSHelp(w io.Writer, name string) error {
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/content/_index.md b/docs/content/_index.md
index 229435e7d..95e411afc 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -7,6 +7,16 @@ 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)
@@ -14,7 +24,7 @@ Let's Encrypt client and ACME library written in Go.
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): issues certificates for IP addresses
- 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 [150 DNS providers]({{% ref "dns" %}})
+- Comes with about [180 DNS providers]({{% ref "dns" %}})
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Renew certificates
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 cb3d24016..5564dba1b 100644
--- a/docs/content/dns/zz_gen_acme-dns.md
+++ b/docs/content/dns/zz_gen_acme-dns.md
@@ -28,13 +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 --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
```
diff --git a/docs/content/dns/zz_gen_active24.md b/docs/content/dns/zz_gen_active24.md
index cadc6660c..6ec5c467a 100644
--- a/docs/content/dns/zz_gen_active24.md
+++ b/docs/content/dns/zz_gen_active24.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Active24 provider:
```bash
ACTIVE24_API_KEY="xxx" \
ACTIVE24_SECRET="yyy" \
-lego --email you@example.com --dns active24 -d '*.example.com' -d example.com run
+lego --dns active24 -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_alidns.md b/docs/content/dns/zz_gen_alidns.md
index 7a7a36e8a..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
```
@@ -58,8 +58,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `ALICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) |
+| `ALICLOUD_LINE` | Line (Default: default) |
| `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ALICLOUD_REGION_ID` | Region ID (Default: cn-hangzhou) |
| `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
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 2415c812f..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
```
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
index 4256d957c..e12ec7cfd 100644
--- a/docs/content/dns/zz_gen_anexia.md
+++ b/docs/content/dns/zz_gen_anexia.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Anexia CloudDNS provider:
```bash
ANEXIA_TOKEN=xxx \
-lego --email you@example.com --dns anexia -d '*.example.com' -d example.com run
+lego --dns anexia -d '*.example.com' -d example.com run
```
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 b9fa1af8d..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
```
diff --git a/docs/content/dns/zz_gen_auroradns.md b/docs/content/dns/zz_gen_auroradns.md
index 9fffe34bc..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
```
diff --git a/docs/content/dns/zz_gen_autodns.md b/docs/content/dns/zz_gen_autodns.md
index 73f41b980..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
```
diff --git a/docs/content/dns/zz_gen_axelname.md b/docs/content/dns/zz_gen_axelname.md
index b1bb3e166..91476e521 100644
--- a/docs/content/dns/zz_gen_axelname.md
+++ b/docs/content/dns/zz_gen_axelname.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Axelname provider:
```bash
AXELNAME_NICKNAME="yyy" \
AXELNAME_TOKEN="xxx" \
-lego --email you@example.com --dns axelname -d '*.example.com' -d example.com run
+lego --dns axelname -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_azion.md b/docs/content/dns/zz_gen_azion.md
index af2a281b0..c5ca33552 100644
--- a/docs/content/dns/zz_gen_azion.md
+++ b/docs/content/dns/zz_gen_azion.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Azion provider:
```bash
AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns azion -d '*.example.com' -d example.com run
+lego --dns azion -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_azuredns.md b/docs/content/dns/zz_gen_azuredns.md
index 85feaae88..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
```
diff --git a/docs/content/dns/zz_gen_baiducloud.md b/docs/content/dns/zz_gen_baiducloud.md
index 11a71c1ab..59a2f9a2d 100644
--- a/docs/content/dns/zz_gen_baiducloud.md
+++ b/docs/content/dns/zz_gen_baiducloud.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Baidu Cloud provider:
```bash
BAIDUCLOUD_ACCESS_KEY_ID="xxx" \
BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \
-lego --email you@example.com --dns baiducloud -d '*.example.com' -d example.com run
+lego --dns baiducloud -d '*.example.com' -d example.com run
```
@@ -51,7 +51,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
|--------------------------------|-------------|
| `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: 120) |
+| `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" %}}).
diff --git a/docs/content/dns/zz_gen_beget.md b/docs/content/dns/zz_gen_beget.md
index ae1d16a7c..3f03a2ac5 100644
--- a/docs/content/dns/zz_gen_beget.md
+++ b/docs/content/dns/zz_gen_beget.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Beget.com provider:
```bash
BEGET_USERNAME=xxxxxx \
BEGET_PASSWORD=yyyyyy \
-lego --email you@example.com --dns beget -d '*.example.com' -d example.com run
+lego --dns beget -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_binarylane.md b/docs/content/dns/zz_gen_binarylane.md
index 4d65bb0bc..eebf3c54e 100644
--- a/docs/content/dns/zz_gen_binarylane.md
+++ b/docs/content/dns/zz_gen_binarylane.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Binary Lane provider:
```bash
BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns binarylane -d '*.example.com' -d example.com run
+lego --dns binarylane -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_bindman.md b/docs/content/dns/zz_gen_bindman.md
index e12f25b7a..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
```
diff --git a/docs/content/dns/zz_gen_bluecat.md b/docs/content/dns/zz_gen_bluecat.md
index ee45c7f8b..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
```
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
index 3f5d1f2c3..cb7e1d3a1 100644
--- a/docs/content/dns/zz_gen_bookmyname.md
+++ b/docs/content/dns/zz_gen_bookmyname.md
@@ -28,7 +28,7 @@ Here is an example bash command using the BookMyName provider:
```bash
BOOKMYNAME_USERNAME="xxx" \
BOOKMYNAME_PASSWORD="yyy" \
-lego --email you@example.com --dns bookmyname -d '*.example.com' -d example.com run
+lego --dns bookmyname -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_brandit.md b/docs/content/dns/zz_gen_brandit.md
index 5d1f91214..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
```
diff --git a/docs/content/dns/zz_gen_bunny.md b/docs/content/dns/zz_gen_bunny.md
index 884c61aea..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
```
diff --git a/docs/content/dns/zz_gen_checkdomain.md b/docs/content/dns/zz_gen_checkdomain.md
index 516d87880..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
```
diff --git a/docs/content/dns/zz_gen_civo.md b/docs/content/dns/zz_gen_civo.md
index a2cffe27c..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
```
diff --git a/docs/content/dns/zz_gen_clouddns.md b/docs/content/dns/zz_gen_clouddns.md
index 27a254873..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
```
diff --git a/docs/content/dns/zz_gen_cloudflare.md b/docs/content/dns/zz_gen_cloudflare.md
index 0fd1d440e..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
```
diff --git a/docs/content/dns/zz_gen_cloudns.md b/docs/content/dns/zz_gen_cloudns.md
index 01d4b7815..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
```
diff --git a/docs/content/dns/zz_gen_cloudru.md b/docs/content/dns/zz_gen_cloudru.md
index 52190b031..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
```
diff --git a/docs/content/dns/zz_gen_cloudxns.md b/docs/content/dns/zz_gen_cloudxns.md
index 0b290b693..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
```
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 4d5f84660..08a979b31 100644
--- a/docs/content/dns/zz_gen_conoha.md
+++ b/docs/content/dns/zz_gen_conoha.md
@@ -29,7 +29,7 @@ Here is an example bash command using the ConoHa v2 provider:
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
```
diff --git a/docs/content/dns/zz_gen_conohav3.md b/docs/content/dns/zz_gen_conohav3.md
index 208f2f91b..e473f9434 100644
--- a/docs/content/dns/zz_gen_conohav3.md
+++ b/docs/content/dns/zz_gen_conohav3.md
@@ -29,7 +29,7 @@ Here is an example bash command using the ConoHa v3 provider:
CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \
CONOHAV3_API_USER_ID=xxxx \
CONOHAV3_API_PASSWORD=yyyy \
-lego --email you@example.com --dns conohav3 -d '*.example.com' -d example.com run
+lego --dns conohav3 -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_constellix.md b/docs/content/dns/zz_gen_constellix.md
index 23628e001..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
```
diff --git a/docs/content/dns/zz_gen_corenetworks.md b/docs/content/dns/zz_gen_corenetworks.md
index dc756647e..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
```
diff --git a/docs/content/dns/zz_gen_cpanel.md b/docs/content/dns/zz_gen_cpanel.md
index 48cb229e7..e5c0cc047 100644
--- a/docs/content/dns/zz_gen_cpanel.md
+++ b/docs/content/dns/zz_gen_cpanel.md
@@ -31,7 +31,7 @@ Here is an example bash command using the CPanel/WHM provider:
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
+lego --dns cpanel -d '*.example.com' -d example.com run
## WHM
@@ -39,7 +39,7 @@ 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
+lego --dns cpanel -d '*.example.com' -d example.com run
```
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 fedbf4683..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
```
diff --git a/docs/content/dns/zz_gen_desec.md b/docs/content/dns/zz_gen_desec.md
index 977a00e06..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
```
diff --git a/docs/content/dns/zz_gen_designate.md b/docs/content/dns/zz_gen_designate.md
index 74cd04920..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
```
diff --git a/docs/content/dns/zz_gen_digitalocean.md b/docs/content/dns/zz_gen_digitalocean.md
index 24307cfb0..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
```
diff --git a/docs/content/dns/zz_gen_directadmin.md b/docs/content/dns/zz_gen_directadmin.md
index 006cb87d6..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
```
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 b865578e6..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
```
diff --git a/docs/content/dns/zz_gen_dnsimple.md b/docs/content/dns/zz_gen_dnsimple.md
index d73122273..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
```
diff --git a/docs/content/dns/zz_gen_dnsmadeeasy.md b/docs/content/dns/zz_gen_dnsmadeeasy.md
index 572676fbd..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
```
diff --git a/docs/content/dns/zz_gen_dnspod.md b/docs/content/dns/zz_gen_dnspod.md
index b9e906052..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
```
diff --git a/docs/content/dns/zz_gen_dode.md b/docs/content/dns/zz_gen_dode.md
index 153650406..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
```
diff --git a/docs/content/dns/zz_gen_domeneshop.md b/docs/content/dns/zz_gen_domeneshop.md
index a519cfbef..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
```
diff --git a/docs/content/dns/zz_gen_dreamhost.md b/docs/content/dns/zz_gen_dreamhost.md
index e713b8ad2..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
```
diff --git a/docs/content/dns/zz_gen_duckdns.md b/docs/content/dns/zz_gen_duckdns.md
index 1290b82fd..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
```
diff --git a/docs/content/dns/zz_gen_dyn.md b/docs/content/dns/zz_gen_dyn.md
index f241ea930..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
```
diff --git a/docs/content/dns/zz_gen_dyndnsfree.md b/docs/content/dns/zz_gen_dyndnsfree.md
index 6f4cf46ff..ea549b4e2 100644
--- a/docs/content/dns/zz_gen_dyndnsfree.md
+++ b/docs/content/dns/zz_gen_dyndnsfree.md
@@ -28,7 +28,7 @@ Here is an example bash command using the DynDnsFree.de provider:
```bash
DYNDNSFREE_USERNAME="xxx" \
DYNDNSFREE_PASSWORD="yyy" \
-lego --email you@example.com --dns dyndnsfree -d '*.example.com' -d example.com run
+lego --dns dyndnsfree -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_dynu.md b/docs/content/dns/zz_gen_dynu.md
index 4db76456f..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
```
diff --git a/docs/content/dns/zz_gen_easydns.md b/docs/content/dns/zz_gen_easydns.md
index 196e6ab7c..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
```
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 21d819d2c..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
```
diff --git a/docs/content/dns/zz_gen_edgeone.md b/docs/content/dns/zz_gen_edgeone.md
index b7b5b1eec..ba5de5ba2 100644
--- a/docs/content/dns/zz_gen_edgeone.md
+++ b/docs/content/dns/zz_gen_edgeone.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Tencent EdgeOne provider:
```bash
EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \
EDGEONE_SECRET_KEY=your-secret-key \
-lego --email you@example.com --dns edgeone -d '*.example.com' -d example.com run
+lego --dns edgeone -d '*.example.com' -d example.com run
```
@@ -55,6 +55,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| `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" %}}).
diff --git a/docs/content/dns/zz_gen_efficientip.md b/docs/content/dns/zz_gen_efficientip.md
index 7c151e67a..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
```
diff --git a/docs/content/dns/zz_gen_epik.md b/docs/content/dns/zz_gen_epik.md
index 50f66e8da..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
```
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 fb2b17e3d..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
```
@@ -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 5392ff573..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
```
diff --git a/docs/content/dns/zz_gen_f5xc.md b/docs/content/dns/zz_gen_f5xc.md
index c8a664a00..0fd8fe58a 100644
--- a/docs/content/dns/zz_gen_f5xc.md
+++ b/docs/content/dns/zz_gen_f5xc.md
@@ -29,7 +29,7 @@ Here is an example bash command using the F5 XC provider:
F5XC_API_TOKEN="xxx" \
F5XC_TENANT_NAME="yyy" \
F5XC_GROUP_NAME="zzz" \
-lego --email you@example.com --dns f5xc -d '*.example.com' -d example.com run
+lego --dns f5xc -d '*.example.com' -d example.com run
```
@@ -54,6 +54,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| `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.
diff --git a/docs/content/dns/zz_gen_freemyip.md b/docs/content/dns/zz_gen_freemyip.md
index d89e17c27..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
```
diff --git a/docs/content/dns/zz_gen_gandi.md b/docs/content/dns/zz_gen_gandi.md
index 961ed6873..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
```
diff --git a/docs/content/dns/zz_gen_gandiv5.md b/docs/content/dns/zz_gen_gandiv5.md
index 773bd3b08..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
```
diff --git a/docs/content/dns/zz_gen_gcloud.md b/docs/content/dns/zz_gen_gcloud.md
index ff228a1c8..64acc1d1e 100644
--- a/docs/content/dns/zz_gen_gcloud.md
+++ b/docs/content/dns/zz_gen_gcloud.md
@@ -29,18 +29,18 @@ Here is an example bash command using the Google Cloud provider:
# Using a service account file
GCE_PROJECT="gc-project-id" \
GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \
-lego --email you@example.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 --email you@example.com --dns gcloud -d '*.example.com' -d example.com run
+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 --email you@example.com --dns gcloud -d '*.example.com' -d example.com run
+lego --dns gcloud -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_gcore.md b/docs/content/dns/zz_gen_gcore.md
index f2a17c3fb..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
```
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 ff43cfe9a..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
```
diff --git a/docs/content/dns/zz_gen_godaddy.md b/docs/content/dns/zz_gen_godaddy.md
index c5392a878..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
```
diff --git a/docs/content/dns/zz_gen_googledomains.md b/docs/content/dns/zz_gen_googledomains.md
index c6f6d0577..2421184c0 100644
--- a/docs/content/dns/zz_gen_googledomains.md
+++ b/docs/content/dns/zz_gen_googledomains.md
@@ -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
```
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 5778a64ce..4e81bd4d9 100644
--- a/docs/content/dns/zz_gen_hetzner.md
+++ b/docs/content/dns/zz_gen_hetzner.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Hetzner provider:
```bash
HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run
+lego --dns hetzner -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_hostingde.md b/docs/content/dns/zz_gen_hostingde.md
index cc86116e1..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
```
diff --git a/docs/content/dns/zz_gen_hostinger.md b/docs/content/dns/zz_gen_hostinger.md
index 193455f63..c05b3f003 100644
--- a/docs/content/dns/zz_gen_hostinger.md
+++ b/docs/content/dns/zz_gen_hostinger.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Hostinger provider:
```bash
HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns hostinger -d '*.example.com' -d example.com run
+lego --dns hostinger -d '*.example.com' -d example.com run
```
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 4f9f117ba..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
```
diff --git a/docs/content/dns/zz_gen_httpnet.md b/docs/content/dns/zz_gen_httpnet.md
index 06883b3f8..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
```
diff --git a/docs/content/dns/zz_gen_httpreq.md b/docs/content/dns/zz_gen_httpreq.md
index 9c6476802..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
```
diff --git a/docs/content/dns/zz_gen_huaweicloud.md b/docs/content/dns/zz_gen_huaweicloud.md
index 9a37a8878..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
```
diff --git a/docs/content/dns/zz_gen_hurricane.md b/docs/content/dns/zz_gen_hurricane.md
index da78630d4..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
```
diff --git a/docs/content/dns/zz_gen_hyperone.md b/docs/content/dns/zz_gen_hyperone.md
index 83dfdb111..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
```
diff --git a/docs/content/dns/zz_gen_ibmcloud.md b/docs/content/dns/zz_gen_ibmcloud.md
index 94997b703..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
```
diff --git a/docs/content/dns/zz_gen_iij.md b/docs/content/dns/zz_gen_iij.md
index 8c73f58a5..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
```
diff --git a/docs/content/dns/zz_gen_iijdpf.md b/docs/content/dns/zz_gen_iijdpf.md
index 7c694fc32..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
```
diff --git a/docs/content/dns/zz_gen_infoblox.md b/docs/content/dns/zz_gen_infoblox.md
index 2d07628f3..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
```
diff --git a/docs/content/dns/zz_gen_infomaniak.md b/docs/content/dns/zz_gen_infomaniak.md
index be02d8ee8..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
```
diff --git a/docs/content/dns/zz_gen_internetbs.md b/docs/content/dns/zz_gen_internetbs.md
index e98fbf4b9..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
```
diff --git a/docs/content/dns/zz_gen_inwx.md b/docs/content/dns/zz_gen_inwx.md
index a46ff061e..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
```
diff --git a/docs/content/dns/zz_gen_ionos.md b/docs/content/dns/zz_gen_ionos.md
index 60a2ede03..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
```
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 21327caaf..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
```
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 cbdb29cb3..4638e1379 100644
--- a/docs/content/dns/zz_gen_iwantmyname.md
+++ b/docs/content/dns/zz_gen_iwantmyname.md
@@ -30,7 +30,7 @@ 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
```
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 c8d55b2f7..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
```
diff --git a/docs/content/dns/zz_gen_keyhelp.md b/docs/content/dns/zz_gen_keyhelp.md
index 2886a0a8e..e39d3ce82 100644
--- a/docs/content/dns/zz_gen_keyhelp.md
+++ b/docs/content/dns/zz_gen_keyhelp.md
@@ -28,7 +28,7 @@ Here is an example bash command using the KeyHelp provider:
```bash
KEYHELP_BASE_URL="https://keyhelp.example.com" \
KEYHELP_API_KEY="xxx" \
-lego --email you@example.com --dns keyhelp -d '*.example.com' -d example.com run
+lego --dns keyhelp -d '*.example.com' -d example.com run
```
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 2c3d59ae0..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
```
@@ -50,6 +50,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}).
| `LIARA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
| `LIARA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
| `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `LIARA_TEAM_ID` | The team ID to access services in a team |
| `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
diff --git a/docs/content/dns/zz_gen_limacity.md b/docs/content/dns/zz_gen_limacity.md
index 2a01814e5..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
```
diff --git a/docs/content/dns/zz_gen_linode.md b/docs/content/dns/zz_gen_linode.md
index 8c8487541..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
```
diff --git a/docs/content/dns/zz_gen_liquidweb.md b/docs/content/dns/zz_gen_liquidweb.md
index 9d8fe8c9c..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
```
diff --git a/docs/content/dns/zz_gen_loopia.md b/docs/content/dns/zz_gen_loopia.md
index 3951de8e1..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
```
diff --git a/docs/content/dns/zz_gen_luadns.md b/docs/content/dns/zz_gen_luadns.md
index c987cc9bf..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
```
diff --git a/docs/content/dns/zz_gen_mailinabox.md b/docs/content/dns/zz_gen_mailinabox.md
index 3ffed1cc7..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
```
diff --git a/docs/content/dns/zz_gen_manageengine.md b/docs/content/dns/zz_gen_manageengine.md
index 32b3a3aeb..a39db8208 100644
--- a/docs/content/dns/zz_gen_manageengine.md
+++ b/docs/content/dns/zz_gen_manageengine.md
@@ -28,7 +28,7 @@ Here is an example bash command using the ManageEngine CloudDNS provider:
```bash
MANAGEENGINE_CLIENT_ID="xxx" \
MANAGEENGINE_CLIENT_SECRET="yyy" \
-lego --email you@example.com --dns manageengine -d '*.example.com' -d example.com run
+lego --dns manageengine -d '*.example.com' -d example.com run
```
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 a90d0170b..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
```
diff --git a/docs/content/dns/zz_gen_metaregistrar.md b/docs/content/dns/zz_gen_metaregistrar.md
index 63cc2bebc..22de046e2 100644
--- a/docs/content/dns/zz_gen_metaregistrar.md
+++ b/docs/content/dns/zz_gen_metaregistrar.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Metaregistrar provider:
```bash
METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns metaregistrar -d '*.example.com' -d example.com run
+lego --dns metaregistrar -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_mijnhost.md b/docs/content/dns/zz_gen_mijnhost.md
index 42abc6558..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
```
diff --git a/docs/content/dns/zz_gen_mittwald.md b/docs/content/dns/zz_gen_mittwald.md
index 943397ee9..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
```
diff --git a/docs/content/dns/zz_gen_myaddr.md b/docs/content/dns/zz_gen_myaddr.md
index 277a0bf06..4a52a058b 100644
--- a/docs/content/dns/zz_gen_myaddr.md
+++ b/docs/content/dns/zz_gen_myaddr.md
@@ -27,7 +27,7 @@ Here is an example bash command using the myaddr.{tools,dev,io} provider:
```bash
MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \
-lego --email you@example.com --dns myaddr -d '*.example.com' -d example.com run
+lego --dns myaddr -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_mydnsjp.md b/docs/content/dns/zz_gen_mydnsjp.md
index 5b29266db..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
```
diff --git a/docs/content/dns/zz_gen_mythicbeasts.md b/docs/content/dns/zz_gen_mythicbeasts.md
index 37feebf8c..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
```
diff --git a/docs/content/dns/zz_gen_namecheap.md b/docs/content/dns/zz_gen_namecheap.md
index 706651660..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
```
diff --git a/docs/content/dns/zz_gen_namedotcom.md b/docs/content/dns/zz_gen_namedotcom.md
index 36a423faa..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
```
diff --git a/docs/content/dns/zz_gen_namesilo.md b/docs/content/dns/zz_gen_namesilo.md
index 397a1a3ca..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
```
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 86f6152f9..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
```
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 337baf59d..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
```
diff --git a/docs/content/dns/zz_gen_netlify.md b/docs/content/dns/zz_gen_netlify.md
index b08f650f0..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
```
diff --git a/docs/content/dns/zz_gen_nicmanager.md b/docs/content/dns/zz_gen_nicmanager.md
index 0b6e1b2cb..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
```
diff --git a/docs/content/dns/zz_gen_nicru.md b/docs/content/dns/zz_gen_nicru.md
index d55477a32..3ac8d99cf 100644
--- a/docs/content/dns/zz_gen_nicru.md
+++ b/docs/content/dns/zz_gen_nicru.md
@@ -30,7 +30,7 @@ NICRU_USER="" \
NICRU_PASSWORD="" \
NICRU_SERVICE_ID="" \
NICRU_SECRET="" \
-lego --dns nicru --domains "*.example.com" --email you@example.com run
+lego --dns nicru -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_nifcloud.md b/docs/content/dns/zz_gen_nifcloud.md
index 9b9929ce2..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
```
diff --git a/docs/content/dns/zz_gen_njalla.md b/docs/content/dns/zz_gen_njalla.md
index cf268041c..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
```
diff --git a/docs/content/dns/zz_gen_nodion.md b/docs/content/dns/zz_gen_nodion.md
index c11759e8e..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
```
diff --git a/docs/content/dns/zz_gen_ns1.md b/docs/content/dns/zz_gen_ns1.md
index 547a51c1c..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
```
diff --git a/docs/content/dns/zz_gen_octenium.md b/docs/content/dns/zz_gen_octenium.md
index 874c4e780..f25da4f44 100644
--- a/docs/content/dns/zz_gen_octenium.md
+++ b/docs/content/dns/zz_gen_octenium.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Octenium provider:
```bash
OCTENIUM_API_KEY="xxx" \
-lego --email you@example.com --dns octenium -d '*.example.com' -d example.com run
+lego --dns octenium -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_oraclecloud.md b/docs/content/dns/zz_gen_oraclecloud.md
index c43c24b21..b7192f380 100644
--- a/docs/content/dns/zz_gen_oraclecloud.md
+++ b/docs/content/dns/zz_gen_oraclecloud.md
@@ -34,13 +34,13 @@ OCI_USER_OCID="ocid1.user.oc1..secret" \
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 --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com run
+lego --dns oraclecloud -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_otc.md b/docs/content/dns/zz_gen_otc.md
index 4f3679fa2..9da69c694 100644
--- a/docs/content/dns/zz_gen_otc.md
+++ b/docs/content/dns/zz_gen_otc.md
@@ -30,7 +30,7 @@ OTC_DOMAIN_NAME=domain_name \
OTC_USER_NAME=user_name \
OTC_PASSWORD=password \
OTC_PROJECT_NAME=project_name \
-lego --email you@example.com --dns otc -d '*.example.com' -d example.com run
+lego --dns otc -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_ovh.md b/docs/content/dns/zz_gen_ovh.md
index 7abc01b92..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
```
diff --git a/docs/content/dns/zz_gen_pdns.md b/docs/content/dns/zz_gen_pdns.md
index 34a22cf84..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
```
diff --git a/docs/content/dns/zz_gen_plesk.md b/docs/content/dns/zz_gen_plesk.md
index b18b2656a..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
```
diff --git a/docs/content/dns/zz_gen_porkbun.md b/docs/content/dns/zz_gen_porkbun.md
index 9fd230d0d..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
```
diff --git a/docs/content/dns/zz_gen_rackspace.md b/docs/content/dns/zz_gen_rackspace.md
index 6dcf6b2b2..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
```
diff --git a/docs/content/dns/zz_gen_rainyun.md b/docs/content/dns/zz_gen_rainyun.md
index 74ced9f54..680eb845a 100644
--- a/docs/content/dns/zz_gen_rainyun.md
+++ b/docs/content/dns/zz_gen_rainyun.md
@@ -27,7 +27,7 @@ Here is an example bash command using the Rain Yun/雨云 provider:
```bash
RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns rainyun -d '*.example.com' -d example.com run
+lego --dns rainyun -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_rcodezero.md b/docs/content/dns/zz_gen_rcodezero.md
index 98eaea9ca..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
```
diff --git a/docs/content/dns/zz_gen_regfish.md b/docs/content/dns/zz_gen_regfish.md
index 149338e5e..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
```
diff --git a/docs/content/dns/zz_gen_regru.md b/docs/content/dns/zz_gen_regru.md
index 1d0e0053d..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
```
diff --git a/docs/content/dns/zz_gen_rfc2136.md b/docs/content/dns/zz_gen_rfc2136.md
index ffdbc4b54..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
```
diff --git a/docs/content/dns/zz_gen_rimuhosting.md b/docs/content/dns/zz_gen_rimuhosting.md
index 2a703dec7..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
```
diff --git a/docs/content/dns/zz_gen_route53.md b/docs/content/dns/zz_gen_route53.md
index a0967a57e..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
```
diff --git a/docs/content/dns/zz_gen_safedns.md b/docs/content/dns/zz_gen_safedns.md
index 2a9e179f5..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
```
diff --git a/docs/content/dns/zz_gen_sakuracloud.md b/docs/content/dns/zz_gen_sakuracloud.md
index e08e73e70..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
```
diff --git a/docs/content/dns/zz_gen_scaleway.md b/docs/content/dns/zz_gen_scaleway.md
index 2f6af9d8a..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
```
diff --git a/docs/content/dns/zz_gen_selectel.md b/docs/content/dns/zz_gen_selectel.md
index 33dc859bb..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
```
diff --git a/docs/content/dns/zz_gen_selectelv2.md b/docs/content/dns/zz_gen_selectelv2.md
index 933ca201f..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
```
diff --git a/docs/content/dns/zz_gen_selfhostde.md b/docs/content/dns/zz_gen_selfhostde.md
index 12df0c10d..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
```
diff --git a/docs/content/dns/zz_gen_servercow.md b/docs/content/dns/zz_gen_servercow.md
index 3851325d1..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
```
diff --git a/docs/content/dns/zz_gen_shellrent.md b/docs/content/dns/zz_gen_shellrent.md
index 6c1365b7e..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
```
diff --git a/docs/content/dns/zz_gen_simply.md b/docs/content/dns/zz_gen_simply.md
index 32df66f05..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
```
diff --git a/docs/content/dns/zz_gen_sonic.md b/docs/content/dns/zz_gen_sonic.md
index f56a23151..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
```
diff --git a/docs/content/dns/zz_gen_spaceship.md b/docs/content/dns/zz_gen_spaceship.md
index 4594fe217..9f3b51e43 100644
--- a/docs/content/dns/zz_gen_spaceship.md
+++ b/docs/content/dns/zz_gen_spaceship.md
@@ -28,7 +28,7 @@ Here is an example bash command using the Spaceship provider:
```bash
SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns spaceship -d '*.example.com' -d example.com run
+lego --dns spaceship -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_stackpath.md b/docs/content/dns/zz_gen_stackpath.md
index ce0a02eac..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
```
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 80f7c6a1f..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
```
diff --git a/docs/content/dns/zz_gen_tencentcloud.md b/docs/content/dns/zz_gen_tencentcloud.md
index ef1e6cdf8..178ffcf43 100644
--- a/docs/content/dns/zz_gen_tencentcloud.md
+++ b/docs/content/dns/zz_gen_tencentcloud.md
@@ -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
```
diff --git a/docs/content/dns/zz_gen_timewebcloud.md b/docs/content/dns/zz_gen_timewebcloud.md
index af218ddce..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
```
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 769fbc734..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
```
diff --git a/docs/content/dns/zz_gen_ultradns.md b/docs/content/dns/zz_gen_ultradns.md
index 8e0fa9b20..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
```
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 282ec9da3..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
```
diff --git a/docs/content/dns/zz_gen_vercel.md b/docs/content/dns/zz_gen_vercel.md
index d9e24eee3..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
```
diff --git a/docs/content/dns/zz_gen_versio.md b/docs/content/dns/zz_gen_versio.md
index 0e2edfa1e..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
```
diff --git a/docs/content/dns/zz_gen_vinyldns.md b/docs/content/dns/zz_gen_vinyldns.md
index 666bc39c4..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
```
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 eede62cf5..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
```
diff --git a/docs/content/dns/zz_gen_volcengine.md b/docs/content/dns/zz_gen_volcengine.md
index 9d3c92d0d..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
```
diff --git a/docs/content/dns/zz_gen_vscale.md b/docs/content/dns/zz_gen_vscale.md
index 660542d61..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
```
diff --git a/docs/content/dns/zz_gen_vultr.md b/docs/content/dns/zz_gen_vultr.md
index a3807c1a1..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
```
diff --git a/docs/content/dns/zz_gen_webnames.md b/docs/content/dns/zz_gen_webnames.md
index 4945775a5..cad02c287 100644
--- a/docs/content/dns/zz_gen_webnames.md
+++ b/docs/content/dns/zz_gen_webnames.md
@@ -27,7 +27,7 @@ Here is an example bash command using the webnames.ru provider:
```bash
WEBNAMESRU_API_KEY=xxxxxx \
-lego --email you@example.com --dns webnamesru -d '*.example.com' -d example.com run
+lego --dns webnamesru -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_webnamesca.md b/docs/content/dns/zz_gen_webnamesca.md
index 41a33cb82..4a7d3794f 100644
--- a/docs/content/dns/zz_gen_webnamesca.md
+++ b/docs/content/dns/zz_gen_webnamesca.md
@@ -28,7 +28,7 @@ Here is an example bash command using the webnames.ca provider:
```bash
WEBNAMESCA_API_USER="xxx" \
WEBNAMESCA_API_KEY="yyy" \
-lego --email you@example.com --dns webnamesca -d '*.example.com' -d example.com run
+lego --dns webnamesca -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_websupport.md b/docs/content/dns/zz_gen_websupport.md
index 5fe44a860..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
```
diff --git a/docs/content/dns/zz_gen_wedos.md b/docs/content/dns/zz_gen_wedos.md
index 8fe6ba00d..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
```
diff --git a/docs/content/dns/zz_gen_westcn.md b/docs/content/dns/zz_gen_westcn.md
index 434e5b601..a5523b955 100644
--- a/docs/content/dns/zz_gen_westcn.md
+++ b/docs/content/dns/zz_gen_westcn.md
@@ -28,7 +28,7 @@ Here is an example bash command using the West.cn/西部数码 provider:
```bash
WESTCN_USERNAME="xxx" \
WESTCN_PASSWORD="yyy" \
-lego --email you@example.com --dns westcn -d '*.example.com' -d example.com run
+lego --dns westcn -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_yandex.md b/docs/content/dns/zz_gen_yandex.md
index 6100c02fe..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
```
diff --git a/docs/content/dns/zz_gen_yandex360.md b/docs/content/dns/zz_gen_yandex360.md
index 66b90e049..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
```
diff --git a/docs/content/dns/zz_gen_yandexcloud.md b/docs/content/dns/zz_gen_yandexcloud.md
index f5aeba09d..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
```
diff --git a/docs/content/dns/zz_gen_zoneedit.md b/docs/content/dns/zz_gen_zoneedit.md
index e259a2a04..c7f88b3fe 100644
--- a/docs/content/dns/zz_gen_zoneedit.md
+++ b/docs/content/dns/zz_gen_zoneedit.md
@@ -28,7 +28,7 @@ Here is an example bash command using the ZoneEdit provider:
```bash
ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \
ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns zoneedit -d '*.example.com' -d example.com run
+lego --dns zoneedit -d '*.example.com' -d example.com run
```
diff --git a/docs/content/dns/zz_gen_zoneee.md b/docs/content/dns/zz_gen_zoneee.md
index cfc6be692..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
```
diff --git a/docs/content/dns/zz_gen_zonomi.md b/docs/content/dns/zz_gen_zonomi.md
index 1e90a7285..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
```
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index 80cbcaac0..139143b17 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run:
$ lego dnshelp -c code
Supported DNS providers:
- acme-dns, active24, alidns, allinkl, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, conohav3, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hostinger, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, 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, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, 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/hugo.toml b/docs/hugo.toml
index b17206d43..fe076a306 100644
--- a/docs/hugo.toml
+++ b/docs/hugo.toml
@@ -15,6 +15,7 @@ title = "Lego"
custom_css = ["css/theme-custom.css"]
disableLandingPageButton = true
hideAuthorEmail = true
+ hideAuthorName = true
# Author of the site, will be used in meta information
[params.author]
diff --git a/docs/static/.nojekyll b/docs/static/.nojekyll
new file mode 100644
index 000000000..e69de29bb
diff --git a/e2e/dnschallenge/dns_challenges_test.go b/e2e/dnschallenge/dns_challenges_test.go
index 509b57bb1..9dd9ab0d6 100644
--- a/e2e/dnschallenge/dns_challenges_test.go
+++ b/e2e/dnschallenge/dns_challenges_test.go
@@ -58,7 +58,6 @@ func TestChallengeDNS_Run(t *testing.T) {
loader.CleanLegoFiles()
err := load.RunLego(
- "-m", "hubert@hubert.com",
"--accept-tos",
"--dns", "exec",
"--dns.resolvers", ":8053",
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/readme.md b/e2e/readme.md
index 7a2367c9b..171170507 100644
--- a/e2e/readme.md
+++ b/e2e/readme.md
@@ -2,8 +2,8 @@
- 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 3dc72c1ff..b8e88428e 100644
--- a/go.mod
+++ b/go.mod
@@ -5,65 +5,68 @@ go 1.24.0
require (
cloud.google.com/go/compute/metadata v0.9.0
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible
- github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1
- github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.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.30
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13
github.com/Azure/go-autorest/autorest/to v0.4.1
- github.com/BurntSushi/toml v1.5.0
+ github.com/BurntSushi/toml v1.6.0
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0
- github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
- github.com/alibabacloud-go/tea v1.3.13
+ github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15
+ github.com/alibabacloud-go/tea v1.4.0
github.com/aliyun/credentials-go v1.4.7
- github.com/aws/aws-sdk-go-v2 v1.39.4
- github.com/aws/aws-sdk-go-v2/config v1.31.15
- github.com/aws/aws-sdk-go-v2/credentials v1.18.19
- github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.2
- github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1
- github.com/aws/aws-sdk-go-v2/service/s3 v1.89.0
- github.com/aws/aws-sdk-go-v2/service/sts v1.38.9
- github.com/aziontech/azionapi-go-sdk v0.143.0
- github.com/baidubce/bce-sdk-go v0.9.250
+ 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.27
- github.com/go-acme/alidns-20150109/v4 v4.6.1
- github.com/go-acme/tencentclouddnspod v1.1.10
- github.com/go-acme/tencentedgdeone v1.1.48
+ 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.4.0
+ github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/go-cmp v0.7.0
- github.com/google/go-querystring v1.1.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.8
- github.com/hashicorp/go-version v1.7.0
- github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.173
+ 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/v2 v2.10.0
github.com/labbsr0x/bindman-dns-webhook v1.0.2
github.com/ldez/grignotin v0.10.1
- github.com/linode/linodego v1.60.0
+ github.com/linode/linodego v1.65.0
github.com/liquidweb/liquidweb-go v1.6.4
github.com/mattn/go-isatty v0.0.20
- github.com/miekg/dns v1.1.68
+ github.com/miekg/dns v1.1.72
github.com/mimuret/golang-iij-dpf v0.9.1
github.com/namedotcom/go/v4 v4.0.2
- github.com/nrdcg/auroradns v1.1.0
+ github.com/nrdcg/auroradns v1.2.0
github.com/nrdcg/bunny-go v0.1.0
github.com/nrdcg/desec v0.11.1
github.com/nrdcg/dnspod-go v0.4.0
github.com/nrdcg/freemyip v0.3.0
github.com/nrdcg/goacmedns v0.2.0
- github.com/nrdcg/goinwx v0.11.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.103.0
- github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.103.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
@@ -72,35 +75,35 @@ require (
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.3.3
- github.com/sacloud/iaas-api-go v1.20.0
- github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35
+ 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/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.1.48
+ 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.16
- github.com/volcengine/volc-sdk-golang v1.0.224
- github.com/vultr/govultr/v3 v3.24.0
- github.com/yandex-cloud/go-genproto v0.34.0
- github.com/yandex-cloud/go-sdk/services/dns v0.0.16
- github.com/yandex-cloud/go-sdk/v2 v2.24.0
- golang.org/x/crypto v0.43.0
- golang.org/x/net v0.46.0
- golang.org/x/oauth2 v0.32.0
- golang.org/x/text v0.30.0
+ 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.254.0
- gopkg.in/ns1/ns1-go.v2 v2.15.1
+ 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.6.0
+ software.sslmate.com/src/go-pkcs12 v0.7.0
)
require (
- cloud.google.com/go/auth v0.17.0 // indirect
+ cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
@@ -110,27 +113,29 @@ require (
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.5.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.2 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.11 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.11 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.11 // 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.11 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.2 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.11 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.11 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.29.8 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.3 // indirect
- github.com/aws/smithy-go v1.23.1 // 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/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
@@ -148,16 +153,16 @@ require (
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.23.0 // indirect
- github.com/go-resty/resty/v2 v2.16.5 // 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/google/uuid v1.6.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
- github.com/googleapis/gax-go/v2 v2.15.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
+ github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
@@ -181,12 +186,11 @@ require (
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.9 // indirect
- github.com/sacloud/packages-go v0.0.11 // 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.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // 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 v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
@@ -200,24 +204,26 @@ require (
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // 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.1.0 // 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.37.0 // indirect
- go.opentelemetry.io/otel/metric v1.37.0 // indirect
- go.opentelemetry.io/otel/trace v1.37.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.28.0 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/sys v0.37.0 // indirect
- golang.org/x/tools v0.37.0 // 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-20250818200422-3122310a409c // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
- google.golang.org/grpc v1.76.0 // indirect
- google.golang.org/protobuf v1.36.10 // indirect
- gopkg.in/ini.v1 v1.67.0 // 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 a8d61029d..f5b87c9fe 100644
--- a/go.sum
+++ b/go.sum
@@ -13,8 +13,8 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
-cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
+cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
+cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -42,10 +42,10 @@ 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.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I=
+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=
@@ -85,11 +85,11 @@ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUM
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.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
+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.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
-github.com/BurntSushi/toml v1.5.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=
@@ -121,9 +121,10 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
-github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.12/go.mod h1:f2wDpbM7hK9SvLIH09zSKVU1TsyemUNOqErMscMMl7c=
-github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
+github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
+github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 h1:Mubp9hXZMTPWZK+WxrR+kKOVFp4Q/PDZrIIM7ByXI9Y=
+github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
@@ -144,9 +145,9 @@ github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/Ke
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.12/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
-github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94=
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=
@@ -170,52 +171,54 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
-github.com/aws/aws-sdk-go-v2 v1.39.4 h1:qTsQKcdQPHnfGYBBs+Btl8QwxJeoWcOcPcixK90mRhg=
-github.com/aws/aws-sdk-go-v2 v1.39.4/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko=
-github.com/aws/aws-sdk-go-v2/config v1.31.15 h1:gE3M4xuNXfC/9bG4hyowGm/35uQTi7bUKeYs5e/6uvU=
-github.com/aws/aws-sdk-go-v2/config v1.31.15/go.mod h1:HvnvGJoE2I95KAIW8kkWVPJ4XhdrlvwJpV6pEzFQa8o=
-github.com/aws/aws-sdk-go-v2/credentials v1.18.19 h1:Jc1zzwkSY1QbkEcLujwqRTXOdvW8ppND3jRBb/VhBQc=
-github.com/aws/aws-sdk-go-v2/credentials v1.18.19/go.mod h1:DIfQ9fAk5H0pGtnqfqkbSIzky82qYnGvh06ASQXXg6A=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.11 h1:X7X4YKb+c0rkI6d4uJ5tEMxXgCZ+jZ/D6mvkno8c8Uw=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.11/go.mod h1:EqM6vPZQsZHYvC4Cai35UDg/f5NCEU+vp0WfbVqVcZc=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.11 h1:7AANQZkF3ihM8fbdftpjhken0TP9sBzFbV/Ze/Y4HXA=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.11/go.mod h1:NTF4QCGkm6fzVwncpkFQqoquQyOolcyXfbpC98urj+c=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.11 h1:ShdtWUZT37LCAA4Mw2kJAJtzaszfSHFb5n25sdcv4YE=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.11/go.mod h1:7bUb2sSr2MZ3M/N+VyETLTQtInemHXb/Fl3s8CLzm0Y=
+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.11 h1:bKgSxk1TW//00PGQqYmrq83c+2myGidEclp+t9pPqVI=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.11/go.mod h1:vrPYCQ6rFHL8jzQA8ppu3gWX18zxjLIDGTeqDxkBmSI=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.2 h1:DGFpGybmutVsCuF6vSuLZ25Vh55E3VmsnJmFfjeBx4M=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.2/go.mod h1:hm/wU1HDvXCFEDzOLorQnZZ/CVvPXvWEmHMSmqgQRuA=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.11 h1:GpMf3z2KJa4RnJ0ew3Hac+hRFYLZ9DDjfgXjuW+pB54=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.11/go.mod h1:6MZP3ZI4QQsgUCFTwMZA2V0sEriNQ8k2hmoHF3qjimQ=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.11 h1:weapBOuuFIBEQ9OX/NVW3tFQCvSutyjZYk/ga5jDLPo=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.11/go.mod h1:3C1gN4FmIVLwYSh8etngUS+f1viY6nLCDVtZmrFbDy0=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.2 h1:pr1dQ9vamhAf2mYOgiRRC/w9Ht4POFhy6+xXw7hOqwY=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.2/go.mod h1:A4Ch93K7Wam4Qe0Wl0XbPgcgoL5KIJtFIe7wHw6OPWE=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1 h1:KuoA/cmy/yK8n9v/d6WH36cZwGxFOrn0TmZ4lNN3MKQ=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1/go.mod h1:BymbICXBfXQHO6i+yTBhocA9a6DM0uMDQqYelqa9wzs=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.89.0 h1:JbCUlVDEjmhpvpIgXP9QN+/jW61WWWj99cGmxMC49hM=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.89.0/go.mod h1:UHKgcRSx8PVtvsc1Poxb/Co3PD3wL7P+f49P0+cWtuY=
-github.com/aws/aws-sdk-go-v2/service/sso v1.29.8 h1:M5nimZmugcZUO9wG7iVtROxPhiqyZX6ejS1lxlDPbTU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.29.8/go.mod h1:mbef/pgKhtKRwrigPPs7SSSKZgytzP8PQ6P6JAAdqyM=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.3 h1:S5GuJZpYxE0lKeMHKn+BRTz6PTFpgThyJ+5mYfux7BM=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.3/go.mod h1:X4OF+BTd7HIb3L+tc4UlWHVrpgwZZIVENU15pRDVTI0=
-github.com/aws/aws-sdk-go-v2/service/sts v1.38.9 h1:Ekml5vGg6sHSZLZJQJagefnVe6PmqC2oiRkBq4F7fU0=
-github.com/aws/aws-sdk-go-v2/service/sts v1.38.9/go.mod h1:/e15V+o1zFHWdH3u7lpI3rVBcxszktIKuHKCY2/py+k=
+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.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
-github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
-github.com/aziontech/azionapi-go-sdk v0.143.0 h1:4eEBlYT10prgeCVTNR9FIc7f59Crbl2zrH1a4D1BUqU=
-github.com/aziontech/azionapi-go-sdk v0.143.0/go.mod h1:cA5DY/VP4X5Eu11LpQNzNn83ziKjja7QVMIl4J45feA=
-github.com/baidubce/bce-sdk-go v0.9.250 h1:fnvV5clsNCAP6pCauj0eNaUnoLVmjQGnco7rcMqp984=
-github.com/baidubce/bce-sdk-go v0.9.250/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg=
+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.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -238,6 +241,8 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -284,8 +289,8 @@ 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.27 h1:vKdWZG8QFDc7rY7lCfcuudO+ovyp5psYjFwKVqmkhCE=
-github.com/exoscale/egoscale/v3 v3.1.27/go.mod h1:0iY8OxgHJCS5TKqDNhwOW95JBKCnBZl3YGU4Yt+NqkU=
+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=
@@ -312,12 +317,16 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
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.6.1 h1:Dch3aWRcw4U62+jKPjPQN3iW3TPvgIywATbvHzojXeo=
-github.com/go-acme/alidns-20150109/v4 v4.6.1/go.mod h1:RBcqBA5IvUWtlpjx6dC6EkPVyBNLQ+mR18XoaP38BFY=
-github.com/go-acme/tencentclouddnspod v1.1.10 h1:ERVJ4mc3cT4Nb3+n6H/c1AwZnChGBqLoymE0NVYscKI=
-github.com/go-acme/tencentclouddnspod v1.1.10/go.mod h1:Bo/0YQJ/99FM+44HmCQkByuptX1tJsJ9V14MGV/2Qco=
-github.com/go-acme/tencentedgdeone v1.1.48 h1:WLyLBsRVhSLFmtbEFXk0naLODSQn7X6J0Fc/qR8xVUk=
-github.com/go-acme/tencentedgdeone v1.1.48/go.mod h1:mu6tA+bPhlSd+CKUfzRikE0mfxmTlBI6dVTn9LY9dRI=
+github.com/go-acme/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=
@@ -354,15 +363,15 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
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.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
-github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
+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/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
-github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
-github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
@@ -371,6 +380,8 @@ github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXK
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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=
@@ -437,8 +448,9 @@ 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=
@@ -459,12 +471,12 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
-github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
+github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
+github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
-github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
+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=
@@ -513,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=
@@ -529,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.173 h1:Y4ixGadyrK9xHw6Z+cyiiME3SBXepEcUoiT+B8C5FoQ=
-github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.173/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -604,8 +616,8 @@ github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufp
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/linode/linodego v1.60.0 h1:SgsebJFRCi+lSmYy+C40wmKZeJllGGm+W12Qw4+yVdI=
-github.com/linode/linodego v1.60.0/go.mod h1:1+Bt0oTz5rBnDOJbGhccxn7LYVytXTIIfAy7QYmijDs=
+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=
@@ -641,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.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
-github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
+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=
@@ -683,8 +695,8 @@ github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1t
github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/nrdcg/auroradns v1.1.0 h1:KekGh8kmf2MNwqZVVYo/fw/ZONt8QMEmbMFOeljteWo=
-github.com/nrdcg/auroradns v1.1.0/go.mod h1:O7tViUZbAcnykVnrGkXzIJTHoQCHcgalgAe6X1mzHfk=
+github.com/nrdcg/auroradns v1.2.0 h1:Jg407vTdXZvZKsART9CNWMp8rQOyhBk04q0MsOf0YR4=
+github.com/nrdcg/auroradns v1.2.0/go.mod h1:hnByA4Z7MOmV4EPRw5eOmEaNRFavcCIz6kONpNxp9LI=
github.com/nrdcg/bunny-go v0.1.0 h1:GAHTRpHaG/TxfLZlqoJ8OJFzw8rI74+jOTkzxWh0uHA=
github.com/nrdcg/bunny-go v0.1.0/go.mod h1:u+C9dgsspgtWVaAz6QkyV17s9fxD8viwwKoxb9XMz1A=
github.com/nrdcg/desec v0.11.1 h1:ilpKmCr4gGsLcyq3RHfHNmlRzm9fzT2XbWxoVaUCS0s=
@@ -695,18 +707,18 @@ 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.11.0 h1:GER0SE3POub7rxARt3Y3jRy1OON1hwF1LRxHz5xsFBw=
-github.com/nrdcg/goinwx v0.11.0/go.mod h1:0BXSC0FxVtU4aTjX0Zw3x0DK32tjugLzeNIAGtwXvPQ=
+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.103.0 h1:GPwwX9GFIBjV4u1M3Cr8eKCP6drW01IsfQSDIz6SUk8=
-github.com/nrdcg/oci-go-sdk/common/v1065 v1065.103.0/go.mod h1:SfDIKzNQ5AGNMMOA3LGqSPnn63F6Gc4E4bsKArqymvg=
-github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.103.0 h1:MjHla6lf1jpjGXORLpzMeo/tSmx0ejmjMjdjTByaDGY=
-github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.103.0/go.mod h1:o1/kMADX0SlB4hJjWtcs3M6VIUOGR78yhPyiBv6oBkk=
+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=
@@ -814,16 +826,16 @@ github.com/sacloud/api-client-go v0.3.3 h1:ZpSAyGpITA8UFO3Hq4qMHZLGuNI1FgxAxo4sq
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.20.0 h1:L4TfAzoFSwxrD3QXX8UxJa2o+GZrP9b863K+voTy3tQ=
-github.com/sacloud/iaas-api-go v1.20.0/go.mod h1:XV995RM1I7k5AHb7UZrCVyDF/8bZXDxa+uk1EXoj/Zs=
-github.com/sacloud/packages-go v0.0.11 h1:hrRWLmfPM9w7GBs6xb5/ue6pEMl8t1UuDKyR/KfteHo=
-github.com/sacloud/packages-go v0.0.11/go.mod h1:XNF5MCTWcHo9NiqWnYctVbASSSZR3ZOmmQORIzcurJ8=
+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.35 h1:8xfn1RzeI9yoCUuEwDy08F+No6PcKZGEDOQ6hrRyLts=
-github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35/go.mod h1:47B1d/YXmSAxlJxUJxClzHR6b3T4M1WyCvwENPQNBWc=
+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=
@@ -839,13 +851,8 @@ 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.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
-github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 h1:hp2CYQUINdZMHdvTdXtPOY2ainKl4IoMcpAXEf2xj3Q=
-github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/smartystreets/gunit v1.0.4 h1:tpTjnuH7MLlqhoD21vRoMZbMIi5GmBsAJDFyF67GhZA=
-github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ=
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=
@@ -901,9 +908,10 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.10/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48 h1:aoRUrz2ag27jQWcOKHgeE+toSti6/xPqHKMLruOtJuM=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.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=
@@ -916,12 +924,12 @@ github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419/go.mod h1:QN0/
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
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.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.224 h1:k9Vtg64tQAgFTOGWzhyL0b0axuTuExXbLNVlslWlBZI=
-github.com/volcengine/volc-sdk-golang v1.0.224/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM=
-github.com/vultr/govultr/v3 v3.24.0 h1:fTTTj0VBve+Miy+wGhlb90M2NMDfpGFi6Frlj3HVy6M=
-github.com/vultr/govultr/v3 v3.24.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
+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=
@@ -930,12 +938,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
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.34.0 h1:qhTJpPxOTKQbV44rIqoZSdzxDtZW27fkFjAcipEy8Zs=
-github.com/yandex-cloud/go-genproto v0.34.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
-github.com/yandex-cloud/go-sdk/services/dns v0.0.16 h1:0UYrBlQjTO2ct5xcSx6rqkQB95wRBPMVwxfqLQD1sUE=
-github.com/yandex-cloud/go-sdk/services/dns v0.0.16/go.mod h1:HlS3aIAdYEmJu2Ska/nzpcuv9LLVSMMXKGhzyLQwf5s=
-github.com/yandex-cloud/go-sdk/v2 v2.24.0 h1:G53N/RB5g/jw2xNN0egspnwd2ByHA1OVH6wbTx/tIlo=
-github.com/yandex-cloud/go-sdk/v2 v2.24.0/go.mod h1:ZRdpyOig8c/W3bNhwvkeXWWPeDScd9nmXv4AJzKvOsk=
+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=
@@ -959,22 +967,22 @@ 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.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
-go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
-go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
-go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
-go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
-go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
-go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
-go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+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=
@@ -1027,8 +1035,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
-golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+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=
@@ -1072,8 +1080,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
-golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
+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=
@@ -1131,17 +1139,16 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
-golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
-golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+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-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
-golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1158,8 +1165,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+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=
@@ -1245,8 +1252,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1261,8 +1268,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
-golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
-golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
+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=
@@ -1281,8 +1288,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+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=
@@ -1348,8 +1355,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
-golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
+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=
@@ -1378,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.254.0 h1:jl3XrGj7lRjnlUvZAbAdhINTLbsg5dbjmR90+pTQvt4=
-google.golang.org/api v0.254.0/go.mod h1:5BkSURm3D9kAqjGvBNgf0EcbX6Rnrf6UArKkwBzAyqQ=
+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=
@@ -1418,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-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
-google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
-google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
-google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
+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=
@@ -1441,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.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
-google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
+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=
@@ -1457,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.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+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=
@@ -1472,11 +1479,12 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
+gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
-gopkg.in/ns1/ns1-go.v2 v2.15.1 h1:8rri2TzAPYcVbBGXn48+dz1Xg30PzHfZ4k8A9JOS0Z0=
-gopkg.in/ns1/ns1-go.v2 v2.15.1/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=
@@ -1507,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.6.0 h1:f3sQittAeF+pao32Vb+mkli+ZyT+VwKaD014qFGq6oU=
-software.sslmate.com/src/go-pkcs12 v0.6.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/dns/docs/generator.go b/internal/dns/docs/generator.go
index c7f9ef8c7..9355d0d1b 100644
--- a/internal/dns/docs/generator.go
+++ b/internal/dns/docs/generator.go
@@ -190,14 +190,9 @@ 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))
})
@@ -206,13 +201,13 @@ func orderProviders(models *descriptors.Providers) [][]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{})
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/providers/dns/acmedns/acmedns.toml b/providers/dns/acmedns/acmedns.toml
index 6d68a013d..e491569b0 100644
--- a/providers/dns/acmedns/acmedns.toml
+++ b/providers/dns/acmedns/acmedns.toml
@@ -8,13 +8,13 @@ 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 --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
'''
[Configuration]
diff --git a/providers/dns/active24/active24.go b/providers/dns/active24/active24.go
index c8107cab6..0b925de6a 100644
--- a/providers/dns/active24/active24.go
+++ b/providers/dns/active24/active24.go
@@ -2,17 +2,15 @@
package active24
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/active24"
- "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
const baseAPIDomain = "active24.cz"
@@ -31,15 +29,7 @@ const (
)
// 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
-}
+type Config = active24.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
@@ -55,8 +45,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *active24.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for Active24.
@@ -79,83 +68,29 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("active24: the configuration of the DNS provider is nil")
}
- client, err := active24.NewClient(baseAPIDomain, config.APIKey, config.Secret)
+ provider, err := active24.NewDNSProviderConfig(config, baseAPIDomain)
if err != nil {
return nil, fmt.Errorf("active24: %w", err)
}
- if config.HTTPClient != nil {
- client.HTTPClient = config.HTTPClient
- }
-
- client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
-
- return &DNSProvider{
- config: config,
- client: client,
- }, nil
+ return &DNSProvider{prv: provider}, 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("active24: 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("active24: %w", err)
}
- serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
- if err != nil {
- return fmt.Errorf("active24: find service ID: %w", err)
- }
-
- record := active24.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("active24: 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)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("active24: could not find zone for domain %q: %w", domain, err)
- }
-
- serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
- if err != nil {
- return fmt.Errorf("active24: find service ID: %w", err)
- }
-
- recordID, err := d.findRecordID(ctx, strconv.Itoa(serviceID), info)
- if err != nil {
- return fmt.Errorf("active24: find record ID: %w", err)
- }
-
- err = d.client.DeleteRecord(ctx, strconv.Itoa(serviceID), strconv.Itoa(recordID))
- if err != nil {
- return fmt.Errorf("active24: delete record %w", err)
+ return fmt.Errorf("active24: %w", err)
}
return nil
@@ -164,58 +99,5 @@ func (d *DNSProvider) CleanUp(domain, token, 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 (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 := active24.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")
+ return d.prv.Timeout()
}
diff --git a/providers/dns/active24/active24.toml b/providers/dns/active24/active24.toml
index 6a54d4695..b0eaabab8 100644
--- a/providers/dns/active24/active24.toml
+++ b/providers/dns/active24/active24.toml
@@ -7,7 +7,7 @@ Since = "v4.23.0"
Example = '''
ACTIVE24_API_KEY="xxx" \
ACTIVE24_SECRET="yyy" \
-lego --email you@example.com --dns active24 -d '*.example.com' -d example.com run
+lego --dns active24 -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/active24/active24_test.go b/providers/dns/active24/active24_test.go
index 363e0229a..2987fb27b 100644
--- a/providers/dns/active24/active24_test.go
+++ b/providers/dns/active24/active24_test.go
@@ -60,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)
}
@@ -110,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)
}
diff --git a/providers/dns/alidns/alidns.go b/providers/dns/alidns/alidns.go
index a5c883fcb..cdd8e75e0 100644
--- a/providers/dns/alidns/alidns.go
+++ b/providers/dns/alidns/alidns.go
@@ -27,6 +27,7 @@ const (
EnvSecretKey = envNamespace + "SECRET_KEY"
EnvSecurityToken = envNamespace + "SECURITY_TOKEN"
EnvRegionID = envNamespace + "REGION_ID"
+ EnvLine = envNamespace + "LINE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -45,6 +46,7 @@ type Config struct {
SecretKey string
SecurityToken string
RegionID string
+ Line string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@@ -74,6 +76,7 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.RegionID = env.GetOrFile(EnvRegionID)
+ config.Line = env.GetOrFile(EnvLine)
values, err := env.Get(EnvRAMRole)
if err == nil {
@@ -254,12 +257,18 @@ func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) (*alidns.AddDomainR
return nil, err
}
- return new(alidns.AddDomainRecordRequest).
+ adrr := new(alidns.AddDomainRecordRequest).
SetType("TXT").
SetDomainName(zone).
SetRR(rr).
SetValue(value).
- SetTTL(int64(d.config.TTL)), nil
+ SetTTL(int64(d.config.TTL))
+
+ if d.config.Line != "" {
+ adrr.SetLine(d.config.Line)
+ }
+
+ return adrr, nil
}
func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord, error) {
diff --git a/providers/dns/alidns/alidns.toml b/providers/dns/alidns/alidns.toml
index 49a9aeeab..b78e1859d 100644
--- a/providers/dns/alidns/alidns.toml
+++ b/providers/dns/alidns/alidns.toml
@@ -7,13 +7,13 @@ 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]
@@ -23,6 +23,8 @@ lego --email you@example.com --dns alidns - -d '*.example.com' -d example.com ru
ALICLOUD_SECRET_KEY = "Access Key secret"
ALICLOUD_SECURITY_TOKEN = "STS Security Token (optional)"
[Configuration.Additional]
+ ALICLOUD_REGION_ID = "Region ID (Default: cn-hangzhou)"
+ ALICLOUD_LINE = "Line (Default: default)"
ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)"
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 a5b27ff59..376b0903c 100644
--- a/providers/dns/allinkl/allinkl.go
+++ b/providers/dns/allinkl/allinkl.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/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"
@@ -121,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)
@@ -149,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()
@@ -167,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)
@@ -183,8 +184,26 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
_, 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 d9c937ee1..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]
diff --git a/providers/dns/allinkl/allinkl_test.go b/providers/dns/allinkl/allinkl_test.go
index b42adce5d..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"
)
@@ -143,3 +152,108 @@ 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.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 d747e9b36..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,14 +61,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R
requestParams["record_id"] = recordID
}
- req, err := c.newRequest(ctx, "get_dns_settings", requestParams)
- if err != nil {
- return nil, err
- }
+ var g APIResponse[GetDNSSettingsResponse]
- var g GetDNSSettingsAPIResponse
-
- err = c.do(req, &g)
+ err := c.doRequest(ctx, "get_dns_settings", requestParams, &g)
if err != nil {
return nil, err
}
@@ -70,14 +75,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R
// AddDNSSettings Creation of a DNS resource record.
func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) {
- req, err := c.newRequest(ctx, "add_dns_settings", record)
- if err != nil {
- return "", err
- }
+ var g APIResponse[AddDNSSettingsResponse]
- var g AddDNSSettingsAPIResponse
-
- err = c.do(req, &g)
+ err := c.doRequest(ctx, "add_dns_settings", record, &g)
if err != nil {
return "", err
}
@@ -91,14 +91,9 @@ func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string,
func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (string, error) {
requestParams := map[string]string{"record_id": recordID}
- req, err := c.newRequest(ctx, "delete_dns_settings", requestParams)
- if err != nil {
- return "", err
- }
+ var g APIResponse[DeleteDNSSettingsResponse]
- var g DeleteDNSSettingsAPIResponse
-
- err = c.do(req, &g)
+ err := c.doRequest(ctx, "delete_dns_settings", requestParams, &g)
if err != nil {
return "", err
}
@@ -124,7 +119,9 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an
payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body)))
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload))
+ endpoint := c.BaseURL.JoinPath(apiPath)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
@@ -132,6 +129,21 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an
return req, nil
}
+func (c *Client) doRequest(ctx context.Context, action string, requestParams, result any) error {
+ return wait.Retry(ctx,
+ func() error {
+ req, err := c.newRequest(ctx, action, requestParams)
+ if err != nil {
+ return backoff.Permanent(err)
+ }
+
+ return c.do(req, result)
+ },
+ backoff.WithBackOff(&backoff.ZeroBackOff{}),
+ backoff.WithMaxElapsedTime(c.maxElapsedTime),
+ )
+}
+
func (c *Client) do(req *http.Request, result any) error {
c.muFloodTime.Lock()
time.Sleep(time.Until(c.floodTime))
@@ -139,29 +151,40 @@ func (c *Client) do(req *http.Request, result any) error {
resp, err := c.HTTPClient.Do(req)
if err != nil {
- return errutils.NewHTTPDoError(req, err)
+ return backoff.Permanent(errutils.NewHTTPDoError(req, err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
- return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+ return backoff.Permanent(errutils.NewUnexpectedResponseStatusCodeError(req, resp))
}
envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body)
if err != nil {
- return err
+ return backoff.Permanent(err)
}
if envlp.Body.Fault != nil {
- return envlp.Body.Fault
+ if envlp.Body.Fault.Message == "flood_protection" {
+ ft, errP := strconv.ParseFloat(envlp.Body.Fault.Detail, 64)
+ if errP != nil {
+ return fmt.Errorf("parse flood protection delay: %w", envlp.Body.Fault)
+ }
+
+ c.updateFloodTime(ft)
+
+ return envlp.Body.Fault
+ }
+
+ return backoff.Permanent(envlp.Body.Fault)
}
raw := getValue(envlp.Body.KasAPIResponse.Return)
err = mapstructure.Decode(raw, result)
if err != nil {
- return fmt.Errorf("response struct decode: %w", err)
+ return backoff.Permanent(fmt.Errorf("response struct decode: %w", err))
}
return nil
diff --git a/providers/dns/allinkl/internal/client_test.go b/providers/dns/allinkl/internal/client_test.go
index 4b111e31c..949f45bf9 100644
--- a/providers/dns/allinkl/internal/client_test.go
+++ b/providers/dns/allinkl/internal/client_test.go
@@ -2,7 +2,9 @@ package internal
import (
"net/http/httptest"
+ "net/url"
"testing"
+ "time"
"github.com/go-acme/lego/v4/platform/tester/servermock"
"github.com/stretchr/testify/assert"
@@ -11,15 +13,17 @@ import (
func setupClient(server *httptest.Server) (*Client, error) {
client := NewClient("user")
- client.baseURL = server.URL
+ client.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
+ client.maxElapsedTime = 1 * time.Second
+
return client, nil
}
func TestClient_GetDNSSettings(t *testing.T) {
client := servermock.NewBuilder[*Client](setupClient).
- Route("POST /", servermock.ResponseFromFixture("get_dns_settings.xml"),
+ Route("POST /KasApi.php", servermock.ResponseFromFixture("get_dns_settings.xml"),
servermock.CheckRequestBodyFromFixture("get_dns_settings-request.xml").
IgnoreWhitespace()).
Build(t)
@@ -96,9 +100,24 @@ func TestClient_GetDNSSettings(t *testing.T) {
assert.Equal(t, expected, records)
}
+func TestClient_GetDNSSettings_error_flood_protection(t *testing.T) {
+ client := servermock.NewBuilder[*Client](setupClient).
+ Route("POST /KasApi.php",
+ servermock.ResponseFromFixture("flood_protection.xml"),
+ ).
+ Build(t)
+
+ assert.Zero(t, client.floodTime)
+
+ _, err := client.GetDNSSettings(mockContext(t), "example.com", "")
+ require.EqualError(t, err, "KasApi: SOAP-ENV:Server: flood_protection: 0.0688529014587")
+
+ assert.NotZero(t, client.floodTime)
+}
+
func TestClient_AddDNSSettings(t *testing.T) {
client := servermock.NewBuilder[*Client](setupClient).
- Route("POST /", servermock.ResponseFromFixture("add_dns_settings.xml"),
+ Route("POST /KasApi.php", servermock.ResponseFromFixture("add_dns_settings.xml"),
servermock.CheckRequestBodyFromFixture("add_dns_settings-request.xml").
IgnoreWhitespace()).
Build(t)
@@ -118,7 +137,7 @@ func TestClient_AddDNSSettings(t *testing.T) {
func TestClient_DeleteDNSSettings(t *testing.T) {
client := servermock.NewBuilder[*Client](setupClient).
- Route("POST /", servermock.ResponseFromFixture("delete_dns_settings.xml"),
+ Route("POST /KasApi.php", servermock.ResponseFromFixture("delete_dns_settings.xml"),
servermock.CheckRequestBodyFromFixture("delete_dns_settings-request.xml").
IgnoreWhitespace()).
Build(t)
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/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-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 ba8d4d90e..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, 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 7b93b7688..41d092b13 100644
--- a/providers/dns/allinkl/internal/identity_test.go
+++ b/providers/dns/allinkl/internal/identity_test.go
@@ -3,6 +3,7 @@ package internal
import (
"context"
"net/http/httptest"
+ "net/url"
"testing"
"github.com/go-acme/lego/v4/platform/tester/servermock"
@@ -12,7 +13,7 @@ import (
func setupIdentifierClient(server *httptest.Server) (*Identifier, error) {
client := NewIdentifier("user", "secret")
- client.authEndpoint = server.URL
+ client.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
@@ -26,10 +27,13 @@ func mockContext(t *testing.T) context.Context {
func TestIdentifier_Authentication(t *testing.T) {
client := servermock.NewBuilder[*Identifier](setupIdentifierClient).
- Route("POST /", servermock.ResponseFromFixture("auth.xml")).
+ Route("POST /KasAuth.php",
+ servermock.ResponseFromFixture("auth.xml"),
+ servermock.CheckRequestBodyFromFixture("auth-request.xml").
+ IgnoreWhitespace()).
Build(t)
- credentialToken, err := client.Authentication(t.Context(), 60, false)
+ credentialToken, err := client.Authentication(t.Context(), 60, true)
require.NoError(t, err)
assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken)
@@ -37,7 +41,7 @@ func TestIdentifier_Authentication(t *testing.T) {
func TestIdentifier_Authentication_error(t *testing.T) {
client := servermock.NewBuilder[*Identifier](setupIdentifierClient).
- Route("POST /", servermock.ResponseFromFixture("auth_fault.xml")).
+ Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth_fault.xml")).
Build(t)
_, err := client.Authentication(t.Context(), 60, false)
diff --git a/providers/dns/allinkl/internal/types.go b/providers/dns/allinkl/internal/types.go
index b0aa9b4ff..51f7065b5 100644
--- a/providers/dns/allinkl/internal/types.go
+++ b/providers/dns/allinkl/internal/types.go
@@ -26,10 +26,11 @@ type Fault struct {
Code string `xml:"faultcode"`
Message string `xml:"faultstring"`
Actor string `xml:"faultactor"`
+ Detail string `xml:"detail"`
}
-func (f Fault) Error() string {
- return fmt.Sprintf("%s: %s: %s", f.Actor, f.Code, f.Message)
+func (f *Fault) Error() string {
+ return fmt.Sprintf("%s: %s: %s: %s", f.Actor, f.Code, f.Message, f.Detail)
}
// KasResponse a KAS SOAP response.
diff --git a/providers/dns/allinkl/internal/types_api.go b/providers/dns/allinkl/internal/types_api.go
index 22f2c32ed..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,20 +73,12 @@ type ReturnInfo struct {
Aux int `json:"record_aux,omitempty" mapstructure:"record_aux"`
}
-type AddDNSSettingsAPIResponse struct {
- Response AddDNSSettingsResponse `json:"Response" mapstructure:"Response"`
-}
-
type AddDNSSettingsResponse struct {
KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"`
ReturnInfo string `json:"ReturnInfo" mapstructure:"ReturnInfo"`
ReturnString string `json:"ReturnString" mapstructure:"ReturnString"`
}
-type DeleteDNSSettingsAPIResponse struct {
- Response DeleteDNSSettingsResponse `json:"Response"`
-}
-
type DeleteDNSSettingsResponse struct {
KasFloodDelay float64 `json:"KasFloodDelay"`
ReturnString string `json:"ReturnString"`
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.toml b/providers/dns/anexia/anexia.toml
index 4fad8ea48..332f0b8b1 100644
--- a/providers/dns/anexia/anexia.toml
+++ b/providers/dns/anexia/anexia.toml
@@ -6,7 +6,7 @@ Since = "v4.28.0"
Example = '''
ANEXIA_TOKEN=xxx \
-lego --email you@example.com --dns anexia -d '*.example.com' -d example.com run
+lego --dns anexia -d '*.example.com' -d example.com run
'''
Additional = '''
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.toml b/providers/dns/arvancloud/arvancloud.toml
index e94452a8b..aa5cafb51 100644
--- a/providers/dns/arvancloud/arvancloud.toml
+++ b/providers/dns/arvancloud/arvancloud.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/auroradns/auroradns.go b/providers/dns/auroradns/auroradns.go
index 95d6ab759..50d2fbc25 100644
--- a/providers/dns/auroradns/auroradns.go
+++ b/providers/dns/auroradns/auroradns.go
@@ -53,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.
diff --git a/providers/dns/auroradns/auroradns.toml b/providers/dns/auroradns/auroradns.toml
index e000e015e..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]
diff --git a/providers/dns/autodns/autodns.go b/providers/dns/autodns/autodns.go
index 770bac99b..8a9361bc0 100644
--- a/providers/dns/autodns/autodns.go
+++ b/providers/dns/autodns/autodns.go
@@ -128,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
@@ -147,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 78015e431..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]
diff --git a/providers/dns/autodns/internal/client.go b/providers/dns/autodns/internal/client.go
index 547596f81..d92490a60 100644
--- a/providers/dns/autodns/internal/client.go
+++ b/providers/dns/autodns/internal/client.go
@@ -43,24 +43,22 @@ func NewClient(username, 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)
@@ -68,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 {
@@ -88,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 {
@@ -131,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 6fc31ca34..9b0968fdc 100644
--- a/providers/dns/autodns/internal/client_test.go
+++ b/providers/dns/autodns/internal/client_test.go
@@ -1,6 +1,7 @@
package internal
import (
+ "net/http"
"net/http/httptest"
"net/url"
"testing"
@@ -24,7 +25,7 @@ func mockBuilder() *servermock.Builder[*Client] {
WithJSONHeaders())
}
-func TestClient_AddTxtRecords(t *testing.T) {
+func TestClient_AddRecords(t *testing.T) {
client := mockBuilder().
Route("POST /zone/example.com/_stream",
servermock.ResponseFromFixture("add_record.json"),
@@ -33,28 +34,81 @@ func TestClient_AddTxtRecords(t *testing.T) {
With("X-Domainrobot-Context", "123")).
Build(t)
- records := []*ResourceRecord{{}}
+ records := []*ResourceRecord{{
+ Name: "example.com",
+ TTL: 600,
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ }}
- zone, err := client.AddTxtRecords(t.Context(), "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) {
+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{{
+ Name: "example.com",
+ TTL: 600,
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ }}
+
+ _, 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"),
@@ -63,8 +117,58 @@ func TestClient_RemoveTXTRecords(t *testing.T) {
With("X-Domainrobot-Context", "123")).
Build(t)
- records := []*ResourceRecord{{}}
+ records := []*ResourceRecord{{
+ Name: "example.com",
+ TTL: 600,
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ }}
- err := client.RemoveTXTRecords(t.Context(), "example.com", records)
+ 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-request.json b/providers/dns/autodns/internal/fixtures/add_record-request.json
index b798b4fbd..6105c77ac 100644
--- a/providers/dns/autodns/internal/fixtures/add_record-request.json
+++ b/providers/dns/autodns/internal/fixtures/add_record-request.json
@@ -1,10 +1,10 @@
{
"adds": [
{
- "name": "",
- "ttl": 0,
- "type": "",
- "value": ""
+ "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
index 4a95f0784..a0ce66ba6 100644
--- a/providers/dns/autodns/internal/fixtures/add_record.json
+++ b/providers/dns/autodns/internal/fixtures/add_record.json
@@ -1,14 +1,41 @@
{
- "origin": "example.com",
- "resourceRecords": [
+ "stid": "20251121-appf4923-126284",
+ "messages": [
{
- "name": "example.com",
- "ttl": 120,
- "type": "TXT",
- "value": "txt",
- "pref": 1
+ "text": "string",
+ "notice": "string",
+ "messages": [
+ "string"
+ ],
+ "objects": [
+ {
+ "type": "string",
+ "value": "string"
+ }
+ ],
+ "code": "string",
+ "status": "SUCCESS"
}
],
- "action": "xxx",
- "virtualNameServer": "yyy"
+ "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-request.json b/providers/dns/autodns/internal/fixtures/remove_record-request.json
index 0702c7367..92361403e 100644
--- a/providers/dns/autodns/internal/fixtures/remove_record-request.json
+++ b/providers/dns/autodns/internal/fixtures/remove_record-request.json
@@ -2,10 +2,10 @@
"adds": null,
"rems": [
{
- "name": "",
- "ttl": 0,
- "type": "",
- "value": ""
+ "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
index 4a95f0784..a0ce66ba6 100644
--- a/providers/dns/autodns/internal/fixtures/remove_record.json
+++ b/providers/dns/autodns/internal/fixtures/remove_record.json
@@ -1,14 +1,41 @@
{
- "origin": "example.com",
- "resourceRecords": [
+ "stid": "20251121-appf4923-126284",
+ "messages": [
{
- "name": "example.com",
- "ttl": 120,
- "type": "TXT",
- "value": "txt",
- "pref": 1
+ "text": "string",
+ "notice": "string",
+ "messages": [
+ "string"
+ ],
+ "objects": [
+ {
+ "type": "string",
+ "value": "string"
+ }
+ ],
+ "code": "string",
+ "status": "SUCCESS"
}
],
- "action": "xxx",
- "virtualNameServer": "yyy"
+ "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.toml b/providers/dns/axelname/axelname.toml
index ee348d5d8..1e2ad6e72 100644
--- a/providers/dns/axelname/axelname.toml
+++ b/providers/dns/axelname/axelname.toml
@@ -7,7 +7,7 @@ Since = "v4.23.0"
Example = '''
AXELNAME_NICKNAME="yyy" \
AXELNAME_TOKEN="xxx" \
-lego --email you@example.com --dns axelname -d '*.example.com' -d example.com run
+lego --dns axelname -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/azion/azion.go b/providers/dns/azion/azion.go
index 8150d90d5..5584ece0b 100644
--- a/providers/dns/azion/azion.go
+++ b/providers/dns/azion/azion.go
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"net/http"
- "sync"
"time"
"github.com/aziontech/azionapi-go-sdk/idns"
@@ -56,9 +55,6 @@ func NewDefaultConfig() *Config {
type DNSProvider struct {
config *Config
client *idns.APIClient
-
- recordIDs map[string]int32
- recordIDsMu sync.Mutex
}
// NewDNSProvider returns a DNSProvider instance configured for Azion.
@@ -98,9 +94,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := idns.NewAPIClient(clientConfig)
return &DNSProvider{
- config: config,
- client: client,
- recordIDs: make(map[string]int32),
+ config: config,
+ client: client,
}, nil
}
@@ -161,12 +156,6 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return errors.New("azion: create zone record error")
}
- results := resp.GetResults()
-
- d.recordIDsMu.Lock()
- d.recordIDs[token] = results.GetId()
- d.recordIDsMu.Unlock()
-
return nil
}
@@ -186,13 +175,6 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("azion: %w", err)
}
- defer func() {
- // Cleans the record ID.
- d.recordIDsMu.Lock()
- delete(d.recordIDs, token)
- d.recordIDsMu.Unlock()
- }()
-
existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain)
if err != nil {
return fmt.Errorf("azion: find existing record: %w", err)
diff --git a/providers/dns/azion/azion.toml b/providers/dns/azion/azion.toml
index eacfe74a6..52df20ab5 100644
--- a/providers/dns/azion/azion.toml
+++ b/providers/dns/azion/azion.toml
@@ -6,7 +6,7 @@ URL = "https://www.azion.com/en/products/edge-dns/"
Example = '''
AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \
-lego --email you@example.com --dns azion -d '*.example.com' -d example.com run
+lego --dns azion -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/azure/azure.go b/providers/dns/azure/azure.go
index fd00bcbe2..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)
@@ -133,6 +136,18 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
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}
}
diff --git a/providers/dns/azure/azure_test.go b/providers/dns/azure/azure_test.go
index 44fb81eef..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,
@@ -57,6 +58,8 @@ func TestNewDNSProvider(t *testing.T) {
envTest.ClearEnv()
+ test.envVars[EnvLegoAzureBypassDeprecation] = "true"
+
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
@@ -140,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()
diff --git a/providers/dns/azuredns/azuredns.toml b/providers/dns/azuredns/azuredns.toml
index 6c1e1ccff..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
'''
diff --git a/providers/dns/baiducloud/baiducloud.go b/providers/dns/baiducloud/baiducloud.go
index fc317904a..1dc8d90ed 100644
--- a/providers/dns/baiducloud/baiducloud.go
+++ b/providers/dns/baiducloud/baiducloud.go
@@ -24,6 +24,9 @@ const (
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
@@ -37,7 +40,7 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
- TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
}
@@ -103,6 +106,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Rr: subDomain,
Type: "TXT",
Value: info.Value,
+ Ttl: ptr.Pointer(int32(d.config.TTL)),
}
err = d.client.CreateRecord(dns01.UnFqdn(authZone), crr, "")
@@ -122,14 +126,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err)
}
- lrr := &baidudns.ListRecordRequest{}
-
- recordResponse, err := d.client.ListRecord(dns01.UnFqdn(authZone), lrr)
- if err != nil {
- return fmt.Errorf("baiducloud: list record: %w", err)
- }
-
- recordID, err := findRecordID(recordResponse, info)
+ recordID, err := d.findRecordID(dns01.UnFqdn(authZone), info.Value)
if err != nil {
return fmt.Errorf("baiducloud: find record: %w", err)
}
@@ -142,11 +139,26 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
-func findRecordID(recordResponse *baidudns.ListRecordResponse, info dns01.ChallengeInfo) (string, error) {
- for _, record := range recordResponse.Records {
- if record.Type == "TXT" && record.Value == info.Value {
- return record.Id, 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")
diff --git a/providers/dns/baiducloud/baiducloud.toml b/providers/dns/baiducloud/baiducloud.toml
index 941d90b2c..54f1f6312 100644
--- a/providers/dns/baiducloud/baiducloud.toml
+++ b/providers/dns/baiducloud/baiducloud.toml
@@ -7,7 +7,7 @@ Since = "v4.23.0"
Example = '''
BAIDUCLOUD_ACCESS_KEY_ID="xxx" \
BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \
-lego --email you@example.com --dns baiducloud -d '*.example.com' -d example.com run
+lego --dns baiducloud -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,7 +17,7 @@ lego --email you@example.com --dns baiducloud -d '*.example.com' -d example.com
[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: 120)"
+ 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"
diff --git a/providers/dns/beget/beget.toml b/providers/dns/beget/beget.toml
index 3cef2f38c..4ed26d850 100644
--- a/providers/dns/beget/beget.toml
+++ b/providers/dns/beget/beget.toml
@@ -7,7 +7,7 @@ Since = "v4.27.0"
Example = '''
BEGET_USERNAME=xxxxxx \
BEGET_PASSWORD=yyyyyy \
-lego --email you@example.com --dns beget -d '*.example.com' -d example.com run
+lego --dns beget -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/binarylane/binarylane.go b/providers/dns/binarylane/binarylane.go
index 9ff80d698..5bbb7a16a 100644
--- a/providers/dns/binarylane/binarylane.go
+++ b/providers/dns/binarylane/binarylane.go
@@ -151,6 +151,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("binarylane: delete record: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/binarylane/binarylane.toml b/providers/dns/binarylane/binarylane.toml
index 5038fc3e6..8b382f3b2 100644
--- a/providers/dns/binarylane/binarylane.toml
+++ b/providers/dns/binarylane/binarylane.toml
@@ -6,7 +6,7 @@ Since = "v4.26.0"
Example = '''
BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns binarylane -d '*.example.com' -d example.com run
+lego --dns binarylane -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/binarylane/internal/types.go b/providers/dns/binarylane/internal/types.go
index 987e5c356..06d4be5c0 100644
--- a/providers/dns/binarylane/internal/types.go
+++ b/providers/dns/binarylane/internal/types.go
@@ -15,12 +15,12 @@ type APIError struct {
}
func (a *APIError) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
- msg.WriteString(fmt.Sprintf("%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance))
+ _, _ = fmt.Fprintf(msg, "%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance)
for s, values := range a.Errors {
- msg.WriteString(fmt.Sprintf(": %s: %s", s, strings.Join(values, ", ")))
+ _, _ = fmt.Fprintf(msg, ": %s: %s", s, strings.Join(values, ", "))
}
return msg.String()
diff --git a/providers/dns/bindman/bindman.toml b/providers/dns/bindman/bindman.toml
index 5c69e18ff..768601588 100644
--- a/providers/dns/bindman/bindman.toml
+++ b/providers/dns/bindman/bindman.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/bluecat/bluecat.toml b/providers/dns/bluecat/bluecat.toml
index a01a5918d..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]
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.toml b/providers/dns/bookmyname/bookmyname.toml
index 5111c4fbd..76fcb85e7 100644
--- a/providers/dns/bookmyname/bookmyname.toml
+++ b/providers/dns/bookmyname/bookmyname.toml
@@ -7,7 +7,7 @@ Since = "v4.23.0"
Example = '''
BOOKMYNAME_USERNAME="xxx" \
BOOKMYNAME_PASSWORD="yyy" \
-lego --email you@example.com --dns bookmyname -d '*.example.com' -d example.com run
+lego --dns bookmyname -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/brandit/brandit.toml b/providers/dns/brandit/brandit.toml
index 32d15c15c..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]
diff --git a/providers/dns/bunny/bunny.toml b/providers/dns/bunny/bunny.toml
index cbe22d6db..758c4f202 100644
--- a/providers/dns/bunny/bunny.toml
+++ b/providers/dns/bunny/bunny.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/checkdomain/checkdomain.toml b/providers/dns/checkdomain/checkdomain.toml
index c3ac14e36..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]
diff --git a/providers/dns/checkdomain/internal/client.go b/providers/dns/checkdomain/internal/client.go
index d626275ab..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.
diff --git a/providers/dns/civo/civo.toml b/providers/dns/civo/civo.toml
index 9458f01c3..b525712c8 100644
--- a/providers/dns/civo/civo.toml
+++ b/providers/dns/civo/civo.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/clouddns/clouddns.toml b/providers/dns/clouddns/clouddns.toml
index 154d4da67..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]
diff --git a/providers/dns/cloudflare/cloudflare.toml b/providers/dns/cloudflare/cloudflare.toml
index caf132bb4..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 = '''
diff --git a/providers/dns/cloudflare/internal/types.go b/providers/dns/cloudflare/internal/types.go
index 4a7f9e031..50a7bbbf9 100644
--- a/providers/dns/cloudflare/internal/types.go
+++ b/providers/dns/cloudflare/internal/types.go
@@ -42,13 +42,13 @@ type ErrorChain struct {
type Errors []Message
func (e Errors) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
for _, item := range e {
- msg.WriteString(fmt.Sprintf("%d: %s", item.Code, item.Message))
+ _, _ = fmt.Fprintf(msg, "%d: %s", item.Code, item.Message)
for _, link := range item.ErrorChain {
- msg.WriteString(fmt.Sprintf("; %d: %s", link.Code, link.Message))
+ _, _ = fmt.Fprintf(msg, "; %d: %s", link.Code, link.Message)
}
}
diff --git a/providers/dns/cloudns/cloudns.toml b/providers/dns/cloudns/cloudns.toml
index dd191f06a..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]
diff --git a/providers/dns/cloudru/cloudru.go b/providers/dns/cloudru/cloudru.go
index 287c12045..dd597952a 100644
--- a/providers/dns/cloudru/cloudru.go
+++ b/providers/dns/cloudru/cloudru.go
@@ -61,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
}
diff --git a/providers/dns/cloudru/cloudru.toml b/providers/dns/cloudru/cloudru.toml
index a6563a3df..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]
diff --git a/providers/dns/cloudxns/cloudxns.toml b/providers/dns/cloudxns/cloudxns.toml
index e87a741df..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]
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.toml b/providers/dns/conoha/conoha.toml
index 8bd83247e..be90acb0d 100644
--- a/providers/dns/conoha/conoha.toml
+++ b/providers/dns/conoha/conoha.toml
@@ -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]
diff --git a/providers/dns/conohav3/conohav3.toml b/providers/dns/conohav3/conohav3.toml
index 7608e6742..e2c80259d 100644
--- a/providers/dns/conohav3/conohav3.toml
+++ b/providers/dns/conohav3/conohav3.toml
@@ -8,7 +8,7 @@ Example = '''
CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \
CONOHAV3_API_USER_ID=xxxx \
CONOHAV3_API_PASSWORD=yyyy \
-lego --email you@example.com --dns conohav3 -d '*.example.com' -d example.com run
+lego --dns conohav3 -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/constellix/constellix.toml b/providers/dns/constellix/constellix.toml
index c4ae0a194..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]
diff --git a/providers/dns/corenetworks/corenetworks.toml b/providers/dns/corenetworks/corenetworks.toml
index 8546d8723..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]
diff --git a/providers/dns/cpanel/cpanel.toml b/providers/dns/cpanel/cpanel.toml
index faed2abe2..b64adf0cf 100644
--- a/providers/dns/cpanel/cpanel.toml
+++ b/providers/dns/cpanel/cpanel.toml
@@ -10,7 +10,7 @@ Example = '''
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
+lego --dns cpanel -d '*.example.com' -d example.com run
## WHM
@@ -18,7 +18,7 @@ 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
+lego --dns cpanel -d '*.example.com' -d example.com run
'''
[Configuration]
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.toml b/providers/dns/derak/derak.toml
index 45d7e1fcf..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]
diff --git a/providers/dns/desec/desec.toml b/providers/dns/desec/desec.toml
index a79b38cd3..f7e66ae07 100644
--- a/providers/dns/desec/desec.toml
+++ b/providers/dns/desec/desec.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/designate/designate.go b/providers/dns/designate/designate.go
index 47c8ad8f1..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
}
diff --git a/providers/dns/designate/designate.toml b/providers/dns/designate/designate.toml
index 3ea6260a6..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 = '''
diff --git a/providers/dns/digitalocean/digitalocean.toml b/providers/dns/digitalocean/digitalocean.toml
index b30d986f2..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]
diff --git a/providers/dns/directadmin/directadmin.toml b/providers/dns/directadmin/directadmin.toml
index bd1c9316a..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]
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.toml b/providers/dns/dnshomede/dnshomede.toml
index bc52bb6dd..9c3b65277 100644
--- a/providers/dns/dnshomede/dnshomede.toml
+++ b/providers/dns/dnshomede/dnshomede.toml
@@ -6,10 +6,10 @@ 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]
diff --git a/providers/dns/dnsimple/dnsimple.toml b/providers/dns/dnsimple/dnsimple.toml
index dcf999136..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 = '''
diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.toml b/providers/dns/dnsmadeeasy/dnsmadeeasy.toml
index 11a5f85ac..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]
diff --git a/providers/dns/dnspod/dnspod.go b/providers/dns/dnspod/dnspod.go
index c9376b956..52a873c7b 100644
--- a/providers/dns/dnspod/dnspod.go
+++ b/providers/dns/dnspod/dnspod.go
@@ -165,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
diff --git a/providers/dns/dnspod/dnspod.toml b/providers/dns/dnspod/dnspod.toml
index a0bf50e31..162685d76 100644
--- a/providers/dns/dnspod/dnspod.toml
+++ b/providers/dns/dnspod/dnspod.toml
@@ -8,7 +8,7 @@ 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]
diff --git a/providers/dns/dode/dode.toml b/providers/dns/dode/dode.toml
index a96e9ee43..eb629bb3e 100644
--- a/providers/dns/dode/dode.toml
+++ b/providers/dns/dode/dode.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/domeneshop/domeneshop.toml b/providers/dns/domeneshop/domeneshop.toml
index a8d2a1064..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 = '''
diff --git a/providers/dns/dreamhost/dreamhost.toml b/providers/dns/dreamhost/dreamhost.toml
index 4345e9ece..c3a9db360 100644
--- a/providers/dns/dreamhost/dreamhost.toml
+++ b/providers/dns/dreamhost/dreamhost.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/duckdns/duckdns.toml b/providers/dns/duckdns/duckdns.toml
index 9c0b3a6be..6866da57c 100644
--- a/providers/dns/duckdns/duckdns.toml
+++ b/providers/dns/duckdns/duckdns.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/dyn/dyn.toml b/providers/dns/dyn/dyn.toml
index 4b0d3e652..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]
diff --git a/providers/dns/dyndnsfree/dyndnsfree.toml b/providers/dns/dyndnsfree/dyndnsfree.toml
index dd354fb33..e64bb0080 100644
--- a/providers/dns/dyndnsfree/dyndnsfree.toml
+++ b/providers/dns/dyndnsfree/dyndnsfree.toml
@@ -7,7 +7,7 @@ Since = "v4.23.0"
Example = '''
DYNDNSFREE_USERNAME="xxx" \
DYNDNSFREE_PASSWORD="yyy" \
-lego --email you@example.com --dns dyndnsfree -d '*.example.com' -d example.com run
+lego --dns dyndnsfree -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/dynu/dynu.toml b/providers/dns/dynu/dynu.toml
index ba59034dd..ae2367087 100644
--- a/providers/dns/dynu/dynu.toml
+++ b/providers/dns/dynu/dynu.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/easydns/easydns.go b/providers/dns/easydns/easydns.go
index ae0a0c3b8..205063e7b 100644
--- a/providers/dns/easydns/easydns.go
+++ b/providers/dns/easydns/easydns.go
@@ -190,16 +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 71521bbd6..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 = '''
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.toml b/providers/dns/edgedns/edgedns.toml
index d40d5cc03..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 = '''
diff --git a/providers/dns/edgeone/edgeone.go b/providers/dns/edgeone/edgeone.go
index 3402122bb..6931c6715 100644
--- a/providers/dns/edgeone/edgeone.go
+++ b/providers/dns/edgeone/edgeone.go
@@ -26,6 +26,7 @@ const (
EnvSecretKey = envNamespace + "SECRET_KEY"
EnvRegion = envNamespace + "REGION"
EnvSessionToken = envNamespace + "SESSION_TOKEN"
+ EnvZonesMapping = envNamespace + "ZONES_MAPPING"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -40,6 +41,8 @@ type Config struct {
Region string
SessionToken string
+ ZonesMapping map[string]string
+
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
@@ -78,6 +81,14 @@ func NewDNSProvider() (*DNSProvider, error) {
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)
}
@@ -108,10 +119,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
return &DNSProvider{
- config: config,
- client: client,
- recordIDs: map[string]*string{},
- recordIDsMu: sync.Mutex{},
+ config: config,
+ client: client,
+ recordIDs: map[string]*string{},
}, nil
}
@@ -121,7 +131,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
ctx := context.Background()
- zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
+ zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("edgeone: failed to get hosted zone: %w", err)
}
@@ -133,7 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
request := teo.NewCreateDnsRecordRequest()
request.Name = ptr.Pointer(punnyCoded)
- request.ZoneId = zone.ZoneId
+ request.ZoneId = zoneID
request.Type = ptr.Pointer("TXT")
request.Content = ptr.Pointer(info.Value)
request.TTL = ptr.Pointer(int64(d.config.TTL))
@@ -156,7 +166,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
ctx := context.Background()
- zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
+ zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("edgeone: failed to get hosted zone: %w", err)
}
@@ -171,7 +181,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
}
request := teo.NewDeleteDnsRecordsRequest()
- request.ZoneId = zone.ZoneId
+ request.ZoneId = zoneID
request.RecordIds = []*string{recordID}
_, err = teo.DeleteDnsRecordsWithContext(ctx, d.client, request)
@@ -179,6 +189,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("edgeone: delete record failed: %w", err)
}
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/edgeone/edgeone.toml b/providers/dns/edgeone/edgeone.toml
index 120756da6..05b8bc516 100644
--- a/providers/dns/edgeone/edgeone.toml
+++ b/providers/dns/edgeone/edgeone.toml
@@ -7,7 +7,7 @@ Since = "v4.26.0"
Example = '''
EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \
EDGEONE_SECRET_KEY=your-secret-key \
-lego --email you@example.com --dns edgeone -d '*.example.com' -d example.com run
+lego --dns edgeone -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,6 +17,7 @@ lego --email you@example.com --dns edgeone -d '*.example.com' -d example.com run
[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)"
diff --git a/providers/dns/edgeone/edgeone_test.go b/providers/dns/edgeone/edgeone_test.go
index 1c92118dc..7bd4f6f6d 100644
--- a/providers/dns/edgeone/edgeone_test.go
+++ b/providers/dns/edgeone/edgeone_test.go
@@ -9,8 +9,11 @@ import (
const envDomain = envNamespace + "DOMAIN"
-var envTest = tester.NewEnvTest(EnvSecretID, EnvSecretKey).
- WithDomain(envDomain)
+var envTest = tester.NewEnvTest(
+ EnvSecretID,
+ EnvSecretKey,
+ EnvZonesMapping,
+).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
@@ -25,6 +28,14 @@ func TestNewDNSProvider(t *testing.T) {
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{
@@ -49,6 +60,15 @@ func TestNewDNSProvider(t *testing.T) {
},
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 {
diff --git a/providers/dns/edgeone/wrapper.go b/providers/dns/edgeone/wrapper.go
index c3e9d965b..53fae9427 100644
--- a/providers/dns/edgeone/wrapper.go
+++ b/providers/dns/edgeone/wrapper.go
@@ -9,10 +9,22 @@ import (
teo "github.com/go-acme/tencentedgdeone/v20220901"
)
-func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*teo.Zone, error) {
+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 domains []*teo.Zone
+ var zones []*teo.Zone
for {
response, err := teo.DescribeZonesWithContext(ctx, d.client, request)
@@ -20,23 +32,18 @@ func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*teo.Zo
return nil, fmt.Errorf("API call failed: %w", err)
}
- domains = append(domains, response.Response.Zones...)
+ zones = append(zones, response.Response.Zones...)
- if int64(len(domains)) >= ptr.Deref(response.Response.TotalCount) {
+ if int64(len(zones)) >= ptr.Deref(response.Response.TotalCount) {
break
}
- request.Offset = ptr.Pointer(int64(len(domains)))
- }
-
- authZone, err := dns01.FindZoneByFqdn(domain)
- if err != nil {
- return nil, fmt.Errorf("could not find zone: %w", err)
+ request.Offset = ptr.Pointer(int64(len(zones)))
}
var hostedZone *teo.Zone
- for _, zone := range domains {
+ for _, zone := range zones {
unfqdn := dns01.UnFqdn(authZone)
if ptr.Deref(zone.ZoneName) == unfqdn {
hostedZone = zone
@@ -44,8 +51,8 @@ func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*teo.Zo
}
if hostedZone == nil {
- return nil, fmt.Errorf("zone %s not found in dnspod for domain %s", authZone, domain)
+ return nil, fmt.Errorf("zone %s not found for domain %s", authZone, domain)
}
- return hostedZone, nil
+ return hostedZone.ZoneId, nil
}
diff --git a/providers/dns/efficientip/efficientip.toml b/providers/dns/efficientip/efficientip.toml
index 565c9575b..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]
diff --git a/providers/dns/epik/epik.toml b/providers/dns/epik/epik.toml
index 7b4688609..faf453581 100644
--- a/providers/dns/epik/epik.toml
+++ b/providers/dns/epik/epik.toml
@@ -6,7 +6,7 @@ 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]
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 4c8d70b1c..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 = '''
@@ -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/exoscale/exoscale.toml b/providers/dns/exoscale/exoscale.toml
index 82c005d26..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]
diff --git a/providers/dns/f5xc/f5xc.go b/providers/dns/f5xc/f5xc.go
index 6f8a8c493..76a6e0262 100644
--- a/providers/dns/f5xc/f5xc.go
+++ b/providers/dns/f5xc/f5xc.go
@@ -22,6 +22,7 @@ const (
EnvToken = envNamespace + "API_TOKEN"
EnvTenantName = envNamespace + "TENANT_NAME"
+ EnvServer = envNamespace + "SERVER"
EnvGroupName = envNamespace + "GROUP_NAME"
EnvTTL = envNamespace + "TTL"
@@ -34,6 +35,7 @@ const (
type Config struct {
APIToken string
TenantName string
+ Server string
GroupName string
PropagationTimeout time.Duration
@@ -71,6 +73,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config.APIToken = values[EnvToken]
config.TenantName = values[EnvTenantName]
config.GroupName = values[EnvGroupName]
+ config.Server = env.GetOrFile(EnvServer)
return NewDNSProviderConfig(config)
}
@@ -85,7 +88,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("f5xc: missing group name")
}
- client, err := internal.NewClient(config.APIToken, config.TenantName)
+ client, err := internal.NewClient(config.APIToken, config.TenantName, config.Server)
if err != nil {
return nil, fmt.Errorf("f5xc: %w", err)
}
diff --git a/providers/dns/f5xc/f5xc.toml b/providers/dns/f5xc/f5xc.toml
index 7a4cab419..6be604ddd 100644
--- a/providers/dns/f5xc/f5xc.toml
+++ b/providers/dns/f5xc/f5xc.toml
@@ -8,7 +8,7 @@ Example = '''
F5XC_API_TOKEN="xxx" \
F5XC_TENANT_NAME="yyy" \
F5XC_GROUP_NAME="zzz" \
-lego --email you@example.com --dns f5xc -d '*.example.com' -d example.com run
+lego --dns f5xc -d '*.example.com' -d example.com run
'''
[Configuration]
@@ -17,6 +17,7 @@ lego --email you@example.com --dns f5xc -d '*.example.com' -d example.com run
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)"
diff --git a/providers/dns/f5xc/f5xc_test.go b/providers/dns/f5xc/f5xc_test.go
index 98f7484e7..890a4cf09 100644
--- a/providers/dns/f5xc/f5xc_test.go
+++ b/providers/dns/f5xc/f5xc_test.go
@@ -9,7 +9,12 @@ import (
const envDomain = envNamespace + "DOMAIN"
-var envTest = tester.NewEnvTest(EnvToken, EnvTenantName, EnvGroupName).WithDomain(envDomain)
+var envTest = tester.NewEnvTest(
+ EnvToken,
+ EnvTenantName,
+ EnvServer,
+ EnvGroupName,
+).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
diff --git a/providers/dns/f5xc/internal/client.go b/providers/dns/f5xc/internal/client.go
index b0b5d0468..7beab0d03 100644
--- a/providers/dns/f5xc/internal/client.go
+++ b/providers/dns/f5xc/internal/client.go
@@ -14,7 +14,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
-const defaultHost = "console.ves.volterra.io"
+const defaultServer = "console.ves.volterra.io"
const authorizationHeader = "Authorization"
@@ -27,18 +27,14 @@ type Client struct {
}
// NewClient creates a new Client.
-func NewClient(apiToken, tenantName string) (*Client, error) {
+func NewClient(apiToken, tenantName, server string) (*Client, error) {
if apiToken == "" {
return nil, errors.New("credentials missing")
}
- if tenantName == "" {
- return nil, errors.New("missing tenant name")
- }
-
- baseURL, err := url.Parse(fmt.Sprintf("https://%s.%s", tenantName, defaultHost))
+ baseURL, err := createBaseURL(tenantName, server)
if err != nil {
- return nil, fmt.Errorf("parse base URL: %w", err)
+ return nil, err
}
return &Client{
@@ -209,3 +205,20 @@ func parseError(req *http.Request, resp *http.Response) error {
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
index 0357abb16..bb188ef3f 100644
--- a/providers/dns/f5xc/internal/client_test.go
+++ b/providers/dns/f5xc/internal/client_test.go
@@ -14,7 +14,7 @@ import (
func mockBuilder() *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
- client, err := NewClient("secret", "shortname")
+ client, err := NewClient("secret", "shortname", "")
if err != nil {
return nil, err
}
@@ -28,7 +28,7 @@ func mockBuilder() *servermock.Builder[*Client] {
WithAuthorization("APIToken secret"))
}
-func TestClient_Create(t *testing.T) {
+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"),
@@ -62,7 +62,7 @@ func TestClient_Create(t *testing.T) {
assert.Equal(t, expected, result)
}
-func TestClient_Create_error(t *testing.T) {
+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)).
@@ -81,7 +81,7 @@ func TestClient_Create_error(t *testing.T) {
require.Error(t, err)
}
-func TestClient_Get(t *testing.T) {
+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")).
@@ -108,7 +108,7 @@ func TestClient_Get(t *testing.T) {
assert.Equal(t, expected, result)
}
-func TestClient_Get_not_found(t *testing.T) {
+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)).
@@ -120,7 +120,7 @@ func TestClient_Get_not_found(t *testing.T) {
assert.Nil(t, result)
}
-func TestClient_Get_error(t *testing.T) {
+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)).
@@ -130,7 +130,7 @@ func TestClient_Get_error(t *testing.T) {
require.Error(t, err)
}
-func TestClient_Delete(t *testing.T) {
+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")).
@@ -157,7 +157,7 @@ func TestClient_Delete(t *testing.T) {
assert.Equal(t, expected, result)
}
-func TestClient_Delete_error(t *testing.T) {
+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)).
@@ -167,7 +167,7 @@ func TestClient_Delete_error(t *testing.T) {
require.Error(t, err)
}
-func TestClient_Replace(t *testing.T) {
+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"),
@@ -204,7 +204,7 @@ func TestClient_Replace(t *testing.T) {
assert.Equal(t, expected, result)
}
-func TestClient_Replace_error(t *testing.T) {
+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)).
@@ -222,3 +222,70 @@ func TestClient_Replace_error(t *testing.T) {
_, 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/freemyip/freemyip.toml b/providers/dns/freemyip/freemyip.toml
index 4821e2a9c..adbf9e213 100644
--- a/providers/dns/freemyip/freemyip.toml
+++ b/providers/dns/freemyip/freemyip.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/gandi/gandi.toml b/providers/dns/gandi/gandi.toml
index 96d5233be..23d7de5db 100644
--- a/providers/dns/gandi/gandi.toml
+++ b/providers/dns/gandi/gandi.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/gandiv5/gandiv5.toml b/providers/dns/gandiv5/gandiv5.toml
index 246b03524..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]
diff --git a/providers/dns/gandiv5/internal/client.go b/providers/dns/gandiv5/internal/client.go
index 018a05799..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"
@@ -133,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 != "" {
diff --git a/providers/dns/gandiv5/internal/client_test.go b/providers/dns/gandiv5/internal/client_test.go
index 2465566f9..6a4158dcb 100644
--- a/providers/dns/gandiv5/internal/client_test.go
+++ b/providers/dns/gandiv5/internal/client_test.go
@@ -9,23 +9,29 @@ import (
"github.com/stretchr/testify/require"
)
-func mockBuilder() *servermock.Builder[*Client] {
+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("secret", "xxx")
+ client := NewClient(apiKey, pat)
client.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
},
- servermock.CheckHeader().WithJSONHeaders().
- With("X-Api-Key", "secret").
- WithAuthorization("Bearer xxx"),
+ checkHeaders,
)
}
func TestClient_AddTXTRecord(t *testing.T) {
- client := mockBuilder().
+ 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",
@@ -38,7 +44,7 @@ func TestClient_AddTXTRecord(t *testing.T) {
}
func TestClient_DeleteTXTRecord(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("", "secret-pat").
Route("DELETE /domains/example.com/records/foo/TXT",
servermock.ResponseFromFixture("api_response.json")).
Build(t)
diff --git a/providers/dns/gcloud/gcloud.toml b/providers/dns/gcloud/gcloud.toml
index 471e2e9d1..63d22bed3 100644
--- a/providers/dns/gcloud/gcloud.toml
+++ b/providers/dns/gcloud/gcloud.toml
@@ -8,18 +8,18 @@ Example = '''
# Using a service account file
GCE_PROJECT="gc-project-id" \
GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \
-lego --email you@example.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 --email you@example.com --dns gcloud -d '*.example.com' -d example.com run
+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 --email you@example.com --dns gcloud -d '*.example.com' -d example.com run
+lego --dns gcloud -d '*.example.com' -d example.com run
'''
Additional = '''
diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go
index ff317946d..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"
@@ -19,7 +20,6 @@ import (
"github.com/go-acme/lego/v4/platform/wait"
"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/miekg/dns"
- "golang.org/x/net/context"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
gdns "google.golang.org/api/dns/v1"
diff --git a/providers/dns/gcore/gcore.go b/providers/dns/gcore/gcore.go
index 19a548810..9b98f28d4 100644
--- a/providers/dns/gcore/gcore.go
+++ b/providers/dns/gcore/gcore.go
@@ -1,18 +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/clientdebug"
+ "github.com/go-acme/lego/v4/providers/dns/internal/gcore"
)
// Environment variables names.
@@ -27,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),
},
@@ -57,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.
@@ -80,93 +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
- }
-
- client.HTTPClient = clientdebug.Wrap(client.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 986455e80..983c35f8a 100644
--- a/providers/dns/gcore/gcore.toml
+++ b/providers/dns/gcore/gcore.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/gcore/gcore_test.go b/providers/dns/gcore/gcore_test.go
index 88769df21..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"
)
@@ -44,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)
}
@@ -79,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)
}
@@ -115,30 +112,3 @@ func TestLiveCleanUp(t *testing.T) {
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/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.toml b/providers/dns/glesys/glesys.toml
index 1bdd43c2b..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]
diff --git a/providers/dns/godaddy/godaddy.toml b/providers/dns/godaddy/godaddy.toml
index acf0bf404..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 = '''
diff --git a/providers/dns/godaddy/internal/types.go b/providers/dns/godaddy/internal/types.go
index c1e6d6638..3bd5c9560 100644
--- a/providers/dns/godaddy/internal/types.go
+++ b/providers/dns/godaddy/internal/types.go
@@ -26,9 +26,9 @@ type APIError struct {
}
func (a APIError) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
- msg.WriteString(fmt.Sprintf("%s: %s", a.Code, a.Message))
+ _, _ = fmt.Fprintf(msg, "%s: %s", a.Code, a.Message)
for _, field := range a.Fields {
msg.WriteString(" ")
diff --git a/providers/dns/googledomains/googledomains.toml b/providers/dns/googledomains/googledomains.toml
index 1ac7e5e54..52330795d 100644
--- a/providers/dns/googledomains/googledomains.toml
+++ b/providers/dns/googledomains/googledomains.toml
@@ -8,7 +8,7 @@ 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]
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 1b02590d6..bae985b3e 100644
--- a/providers/dns/hetzner/hetzner.go
+++ b/providers/dns/hetzner/hetzner.go
@@ -4,7 +4,6 @@ package hetzner
import (
"errors"
"net/http"
- "os"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -62,10 +61,9 @@ type DNSProvider struct {
}
// NewDNSProvider returns a DNSProvider instance configured for hetzner.
-// Credentials must be passed in the environment variable: HETZNER_API_KEY.
func NewDNSProvider() (*DNSProvider, error) {
- _, foundAPIToken := os.LookupEnv(EnvAPIToken)
- _, foundAPIKey := os.LookupEnv(EnvAPIKey)
+ foundAPIToken := env.GetOrFile(EnvAPIToken) != ""
+ foundAPIKey := env.GetOrFile(EnvAPIKey) != ""
switch {
case foundAPIToken:
diff --git a/providers/dns/hetzner/hetzner.toml b/providers/dns/hetzner/hetzner.toml
index ee1f9a970..40d4cea72 100644
--- a/providers/dns/hetzner/hetzner.toml
+++ b/providers/dns/hetzner/hetzner.toml
@@ -6,7 +6,7 @@ Since = "v3.7.0"
Example = '''
HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns hetzner -d '*.example.com' -d example.com run
+lego --dns hetzner -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go b/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go
index 4fb95eb6f..b31c766ce 100644
--- a/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go
+++ b/providers/dns/hetzner/internal/hetznerv1/hetznerv1.go
@@ -184,7 +184,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
-func (d *DNSProvider) waitAction(ctx context.Context, actionID int) error {
+func (d *DNSProvider) waitAction(ctx context.Context, actionID int64) error {
return wait.Retry(ctx,
func() error {
result, err := d.client.GetAction(ctx, actionID)
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/client.go b/providers/dns/hetzner/internal/hetznerv1/internal/client.go
index 35c3d461b..2f29f642a 100644
--- a/providers/dns/hetzner/internal/hetznerv1/internal/client.go
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/client.go
@@ -85,8 +85,8 @@ func (c *Client) RemoveRRSetRecords(ctx context.Context, zoneIDName, recordType,
// GetAction gets an action.
// https://docs.hetzner.cloud/reference/cloud#actions-get-an-action
-func (c *Client) GetAction(ctx context.Context, id int) (*Action, error) {
- endpoint := c.BaseURL.JoinPath("actions", strconv.Itoa(id))
+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 {
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go b/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go
index fcbc7636f..6fd3d77a7 100644
--- a/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/client_test.go
@@ -49,7 +49,7 @@ func TestClient_AddRRSetRecords(t *testing.T) {
Command: "add_rrset_records",
Status: "running",
Progress: 50,
- Resources: []Resources{{ID: 42, Type: "zone"}},
+ Resources: []Resources{{ID: 590000000000000, Type: "zone"}},
}
assert.Equal(t, expected, result)
@@ -139,11 +139,11 @@ func TestClient_GetAction(t *testing.T) {
require.NoError(t, err)
expected := &Action{
- ID: 42,
+ ID: 590000000000000,
Command: "start_resource",
Status: "running",
Progress: 100,
- Resources: []Resources{{ID: 42, Type: "server"}},
+ Resources: []Resources{{ID: 590000000000000, Type: "server"}},
ErrorInfo: &ErrorInfo{
Code: "action_failed",
Message: "Action failed",
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
index 2341c7e6e..7267b02cb 100644
--- a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json
@@ -8,7 +8,7 @@
"finished": null,
"resources": [
{
- "id": 42,
+ "id": 590000000000000,
"type": "zone"
}
],
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json
index 05f003b1e..19278fc51 100644
--- a/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json
@@ -1,6 +1,6 @@
{
"action": {
- "id": 42,
+ "id": 590000000000000,
"command": "start_resource",
"status": "running",
"started": "2016-01-30T23:55:00+00:00",
@@ -8,7 +8,7 @@
"progress": 100,
"resources": [
{
- "id": 42,
+ "id": 590000000000000,
"type": "server"
}
],
diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/types.go b/providers/dns/hetzner/internal/hetznerv1/internal/types.go
index 08d1684c0..2b38a8a8c 100644
--- a/providers/dns/hetzner/internal/hetznerv1/internal/types.go
+++ b/providers/dns/hetzner/internal/hetznerv1/internal/types.go
@@ -16,20 +16,20 @@ type ErrorInfo struct {
}
func (i *ErrorInfo) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
- msg.WriteString(fmt.Sprintf("%s: %s", i.Code, i.Message))
+ _, _ = fmt.Fprintf(msg, "%s: %s", i.Code, i.Message)
if i.Details.Announcement != "" {
- msg.WriteString(fmt.Sprintf(": %s", i.Details.Announcement))
+ _, _ = fmt.Fprintf(msg, ": %s", i.Details.Announcement)
}
for _, limit := range i.Details.Limits {
- msg.WriteString(fmt.Sprintf("limit: %s", limit.Name))
+ _, _ = fmt.Fprintf(msg, "limit: %s", limit.Name)
}
for _, field := range i.Details.Fields {
- msg.WriteString(fmt.Sprintf("field: %s: %s", field.Name, strings.Join(field.Messages, ", ")))
+ _, _ = fmt.Fprintf(msg, "field: %s: %s", field.Name, strings.Join(field.Messages, ", "))
}
return msg.String()
@@ -79,7 +79,7 @@ type ActionResponse struct {
}
type Action struct {
- ID int `json:"id,omitempty"`
+ ID int64 `json:"id,omitempty"`
Command string `json:"command,omitempty"`
// It can be: `running`, `success`, `error`.
@@ -93,6 +93,6 @@ type Action struct {
}
type Resources struct {
- ID int `json:"id,omitempty"`
+ ID int64 `json:"id,omitempty"`
Type string `json:"type,omitempty"`
}
diff --git a/providers/dns/hostingde/hostingde.go b/providers/dns/hostingde/hostingde.go
index 48c44998f..1e022b630 100644
--- a/providers/dns/hostingde/hostingde.go
+++ b/providers/dns/hostingde/hostingde.go
@@ -2,17 +2,14 @@
package hostingde
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/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/hostingde"
)
@@ -32,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 {
@@ -56,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.
@@ -84,130 +70,27 @@ 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)
}
- client := hostingde.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
+ 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)
- 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)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("hostingde: %w", err)
}
@@ -215,19 +98,8 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
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 569e8a781..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]
diff --git a/providers/dns/hostingde/hostingde_test.go b/providers/dns/hostingde/hostingde_test.go
index 1611cb51b..a92006f81 100644
--- a/providers/dns/hostingde/hostingde_test.go
+++ b/providers/dns/hostingde/hostingde_test.go
@@ -59,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)
}
@@ -102,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)
}
diff --git a/providers/dns/hostinger/hostinger.toml b/providers/dns/hostinger/hostinger.toml
index f49e447ed..a6f152e73 100644
--- a/providers/dns/hostinger/hostinger.toml
+++ b/providers/dns/hostinger/hostinger.toml
@@ -6,7 +6,7 @@ Since = "v4.27.0"
Example = '''
HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns hostinger -d '*.example.com' -d example.com run
+lego --dns hostinger -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/hostinger/internal/types.go b/providers/dns/hostinger/internal/types.go
index 534dfa5e5..c1a02ff8c 100644
--- a/providers/dns/hostinger/internal/types.go
+++ b/providers/dns/hostinger/internal/types.go
@@ -12,12 +12,12 @@ type APIError struct {
}
func (a *APIError) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
- msg.WriteString(fmt.Sprintf("%s: %s", a.CorrelationID, a.Message))
+ _, _ = fmt.Fprintf(msg, "%s: %s", a.CorrelationID, a.Message)
for field, values := range a.Errors {
- msg.WriteString(fmt.Sprintf(": %s: %s", field, strings.Join(values, ", ")))
+ _, _ = fmt.Fprintf(msg, ": %s: %s", field, strings.Join(values, ", "))
}
return msg.String()
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 fac64f054..73346f6cb 100644
--- a/providers/dns/hosttech/hosttech.go
+++ b/providers/dns/hosttech/hosttech.go
@@ -174,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 5d7555499..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]
diff --git a/providers/dns/hosttech/internal/types.go b/providers/dns/hosttech/internal/types.go
index 854fc4883..a4b5b564d 100644
--- a/providers/dns/hosttech/internal/types.go
+++ b/providers/dns/hosttech/internal/types.go
@@ -16,12 +16,12 @@ type APIError struct {
}
func (a APIError) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
- msg.WriteString(fmt.Sprintf("%d: %s", a.StatusCode, a.Message))
+ _, _ = fmt.Fprintf(msg, "%d: %s", a.StatusCode, a.Message)
for k, v := range a.Errors {
- msg.WriteString(fmt.Sprintf(" %s: %v", k, v))
+ _, _ = fmt.Fprintf(msg, " %s: %v", k, v)
}
return msg.String()
diff --git a/providers/dns/httpnet/httpnet.go b/providers/dns/httpnet/httpnet.go
index f18eefd97..4a88f1092 100644
--- a/providers/dns/httpnet/httpnet.go
+++ b/providers/dns/httpnet/httpnet.go
@@ -2,18 +2,14 @@
package httpnet
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/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/hostingde"
)
@@ -30,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 {
@@ -57,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.
@@ -85,131 +72,27 @@ 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)
-
- 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
+ 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)
- 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)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
return fmt.Errorf("httpnet: %w", err)
}
@@ -217,19 +100,8 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
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 204f5bc54..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]
diff --git a/providers/dns/httpnet/httpnet_test.go b/providers/dns/httpnet/httpnet_test.go
index 64a94f80c..ef1d2a1b7 100644
--- a/providers/dns/httpnet/httpnet_test.go
+++ b/providers/dns/httpnet/httpnet_test.go
@@ -59,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)
}
@@ -102,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)
}
diff --git a/providers/dns/httpreq/httpreq.toml b/providers/dns/httpreq/httpreq.toml
index 6c3f8719b..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 = '''
diff --git a/providers/dns/huaweicloud/huaweicloud.go b/providers/dns/huaweicloud/huaweicloud.go
index 5a2773ab2..e47f3e2b5 100644
--- a/providers/dns/huaweicloud/huaweicloud.go
+++ b/providers/dns/huaweicloud/huaweicloud.go
@@ -209,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
}
diff --git a/providers/dns/huaweicloud/huaweicloud.toml b/providers/dns/huaweicloud/huaweicloud.toml
index f7991dfae..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]
diff --git a/providers/dns/hurricane/hurricane.toml b/providers/dns/hurricane/hurricane.toml
index 033c73984..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 = """
diff --git a/providers/dns/hyperone/hyperone.toml b/providers/dns/hyperone/hyperone.toml
index 0f23976c4..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 = '''
diff --git a/providers/dns/ibmcloud/ibmcloud.toml b/providers/dns/ibmcloud/ibmcloud.toml
index 2a6c12f82..01088f09b 100644
--- a/providers/dns/ibmcloud/ibmcloud.toml
+++ b/providers/dns/ibmcloud/ibmcloud.toml
@@ -7,7 +7,7 @@ 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]
diff --git a/providers/dns/iij/iij.toml b/providers/dns/iij/iij.toml
index 8dbf5ba1a..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]
diff --git a/providers/dns/iijdpf/iijdpf.toml b/providers/dns/iijdpf/iijdpf.toml
index 4aaa9ca37..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]
diff --git a/providers/dns/infoblox/infoblox.toml b/providers/dns/infoblox/infoblox.toml
index 3c2632042..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 = '''
diff --git a/providers/dns/infomaniak/infomaniak.toml b/providers/dns/infomaniak/infomaniak.toml
index 283838053..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 = '''
diff --git a/providers/dns/internal/active24/client.go b/providers/dns/internal/active24/internal/client.go
similarity index 99%
rename from providers/dns/internal/active24/client.go
rename to providers/dns/internal/active24/internal/client.go
index 10aaa4666..69e94b367 100644
--- a/providers/dns/internal/active24/client.go
+++ b/providers/dns/internal/active24/internal/client.go
@@ -1,4 +1,4 @@
-package active24
+package internal
import (
"bytes"
diff --git a/providers/dns/internal/active24/client_test.go b/providers/dns/internal/active24/internal/client_test.go
similarity index 99%
rename from providers/dns/internal/active24/client_test.go
rename to providers/dns/internal/active24/internal/client_test.go
index ad2a8126b..f62f78785 100644
--- a/providers/dns/internal/active24/client_test.go
+++ b/providers/dns/internal/active24/internal/client_test.go
@@ -1,4 +1,4 @@
-package active24
+package internal
import (
"net/http"
diff --git a/providers/dns/internal/active24/fixtures/error_403.json b/providers/dns/internal/active24/internal/fixtures/error_403.json
similarity index 100%
rename from providers/dns/internal/active24/fixtures/error_403.json
rename to providers/dns/internal/active24/internal/fixtures/error_403.json
diff --git a/providers/dns/internal/active24/fixtures/error_422.json b/providers/dns/internal/active24/internal/fixtures/error_422.json
similarity index 100%
rename from providers/dns/internal/active24/fixtures/error_422.json
rename to providers/dns/internal/active24/internal/fixtures/error_422.json
diff --git a/providers/dns/internal/active24/fixtures/error_v1.json b/providers/dns/internal/active24/internal/fixtures/error_v1.json
similarity index 100%
rename from providers/dns/internal/active24/fixtures/error_v1.json
rename to providers/dns/internal/active24/internal/fixtures/error_v1.json
diff --git a/providers/dns/internal/active24/fixtures/records.json b/providers/dns/internal/active24/internal/fixtures/records.json
similarity index 100%
rename from providers/dns/internal/active24/fixtures/records.json
rename to providers/dns/internal/active24/internal/fixtures/records.json
diff --git a/providers/dns/internal/active24/fixtures/services.json b/providers/dns/internal/active24/internal/fixtures/services.json
similarity index 100%
rename from providers/dns/internal/active24/fixtures/services.json
rename to providers/dns/internal/active24/internal/fixtures/services.json
diff --git a/providers/dns/internal/active24/types.go b/providers/dns/internal/active24/internal/types.go
similarity index 99%
rename from providers/dns/internal/active24/types.go
rename to providers/dns/internal/active24/internal/types.go
index b9a7ea427..ed8dfc9d3 100644
--- a/providers/dns/internal/active24/types.go
+++ b/providers/dns/internal/active24/internal/types.go
@@ -1,4 +1,4 @@
-package active24
+package internal
import "fmt"
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/client.go b/providers/dns/internal/clientdebug/client.go
index ad2a06405..342577b93 100644
--- a/providers/dns/internal/clientdebug/client.go
+++ b/providers/dns/internal/clientdebug/client.go
@@ -91,6 +91,9 @@ func (d *DumpTransport) RoundTrip(h *http.Request) (*http.Response, error) {
_, _ = fmt.Fprintln(d.writer, d.redact(data))
resp, err := d.rt.RoundTrip(h)
+ if err != nil {
+ return nil, err
+ }
data, _ = httputil.DumpResponse(resp, true)
diff --git a/providers/dns/gcore/internal/client.go b/providers/dns/internal/gcore/internal/client.go
similarity index 92%
rename from providers/dns/gcore/internal/client.go
rename to providers/dns/internal/gcore/internal/client.go
index 638aaf0d7..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,7 +45,7 @@ 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{}
@@ -60,7 +60,7 @@ 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
@@ -75,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 {
@@ -106,14 +106,14 @@ 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)
}
diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/internal/gcore/internal/client_test.go
similarity index 99%
rename from providers/dns/gcore/internal/client_test.go
rename to providers/dns/internal/gcore/internal/client_test.go
index 4a0f83311..7d70c9308 100644
--- a/providers/dns/gcore/internal/client_test.go
+++ b/providers/dns/internal/gcore/internal/client_test.go
@@ -21,7 +21,7 @@ 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.BaseURL, _ = url.Parse(server.URL)
client.HTTPClient = server.Client()
return client, nil
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 94%
rename from providers/dns/internal/hostingde/client.go
rename to providers/dns/internal/hostingde/internal/client.go
index 43354384f..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"
@@ -14,10 +14,7 @@ import (
"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,
diff --git a/providers/dns/internal/hostingde/client_test.go b/providers/dns/internal/hostingde/internal/client_test.go
similarity index 99%
rename from providers/dns/internal/hostingde/client_test.go
rename to providers/dns/internal/hostingde/internal/client_test.go
index 93e0c76e1..d55bbf690 100644
--- a/providers/dns/internal/hostingde/client_test.go
+++ b/providers/dns/internal/hostingde/internal/client_test.go
@@ -1,4 +1,4 @@
-package hostingde
+package internal
import (
"encoding/json"
diff --git a/providers/dns/internal/hostingde/fixtures/zoneConfigsFind-request.json b/providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json
similarity index 100%
rename from providers/dns/internal/hostingde/fixtures/zoneConfigsFind-request.json
rename to providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json
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/fixtures/zoneUpdate-request.json b/providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json
similarity index 100%
rename from providers/dns/internal/hostingde/fixtures/zoneUpdate-request.json
rename to providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json
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 99%
rename from providers/dns/internal/hostingde/types.go
rename to providers/dns/internal/hostingde/internal/types.go
index 86b69ec42..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"
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 98%
rename from providers/dns/ionos/internal/client.go
rename to providers/dns/internal/ionos/internal/client.go
index 935b6bbad..2a556a49b 100644
--- a/providers/dns/ionos/internal/client.go
+++ b/providers/dns/internal/ionos/internal/client.go
@@ -14,7 +14,6 @@ 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.
diff --git a/providers/dns/ionos/internal/client_test.go b/providers/dns/internal/ionos/internal/client_test.go
similarity index 100%
rename from providers/dns/ionos/internal/client_test.go
rename to providers/dns/internal/ionos/internal/client_test.go
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 100%
rename from providers/dns/ionos/internal/types.go
rename to providers/dns/internal/ionos/internal/types.go
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/rimuhosting/client.go b/providers/dns/internal/rimuhosting/internal/client.go
similarity index 94%
rename from providers/dns/internal/rimuhosting/client.go
rename to providers/dns/internal/rimuhosting/internal/client.go
index c46afc544..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},
}
}
diff --git a/providers/dns/internal/rimuhosting/client_test.go b/providers/dns/internal/rimuhosting/internal/client_test.go
similarity index 99%
rename from providers/dns/internal/rimuhosting/client_test.go
rename to providers/dns/internal/rimuhosting/internal/client_test.go
index 6ee9ea3f7..00126dfbe 100644
--- a/providers/dns/internal/rimuhosting/client_test.go
+++ b/providers/dns/internal/rimuhosting/internal/client_test.go
@@ -1,4 +1,4 @@
-package rimuhosting
+package internal
import (
"encoding/xml"
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.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 fe810ebc5..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,
@@ -57,8 +53,8 @@ func (c *Client) GetDomainByName(ctx context.Context, domainName string) (*Domai
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
diff --git a/providers/dns/internal/selectel/client_test.go b/providers/dns/internal/selectel/internal/client_test.go
similarity index 99%
rename from providers/dns/internal/selectel/client_test.go
rename to providers/dns/internal/selectel/internal/client_test.go
index 292f70142..edabe0130 100644
--- a/providers/dns/internal/selectel/client_test.go
+++ b/providers/dns/internal/selectel/internal/client_test.go
@@ -1,4 +1,4 @@
-package selectel
+package internal
import (
"net/http"
diff --git a/providers/dns/internal/selectel/fixtures/add_record-request.json b/providers/dns/internal/selectel/internal/fixtures/add_record-request.json
similarity index 100%
rename from providers/dns/internal/selectel/fixtures/add_record-request.json
rename to providers/dns/internal/selectel/internal/fixtures/add_record-request.json
diff --git a/providers/dns/internal/selectel/fixtures/add_record.json b/providers/dns/internal/selectel/internal/fixtures/add_record.json
similarity index 100%
rename from providers/dns/internal/selectel/fixtures/add_record.json
rename to providers/dns/internal/selectel/internal/fixtures/add_record.json
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 1fdaef71a..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.28.0"
+ ourUserAgent = "goacme-lego/4.32.0"
// ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package.
// values: detach|release
// NOTE: Update this with each tagged release.
- ourUserAgentComment = "release"
+ ourUserAgentComment = "detach"
)
// Get builds and returns the User-Agent string.
diff --git a/providers/dns/westcn/internal/client.go b/providers/dns/internal/westcn/internal/client.go
similarity index 98%
rename from providers/dns/westcn/internal/client.go
rename to providers/dns/internal/westcn/internal/client.go
index bfed159ae..621c7865f 100644
--- a/providers/dns/westcn/internal/client.go
+++ b/providers/dns/internal/westcn/internal/client.go
@@ -30,7 +30,7 @@ type Client struct {
encoder *encoding.Encoder
- baseURL *url.URL
+ BaseURL *url.URL
HTTPClient *http.Client
}
@@ -46,7 +46,7 @@ func NewClient(username, password string) (*Client, error) {
username: username,
password: password,
encoder: simplifiedchinese.GBK.NewEncoder(),
- baseURL: baseURL,
+ BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}, nil
}
@@ -116,7 +116,7 @@ func (c *Client) newRequest(ctx context.Context, p, act string, form url.Values)
return nil, err
}
- endpoint := c.baseURL.JoinPath(p, "/")
+ endpoint := c.BaseURL.JoinPath(p, "/")
query := endpoint.Query()
query.Set("act", act)
diff --git a/providers/dns/westcn/internal/client_test.go b/providers/dns/internal/westcn/internal/client_test.go
similarity index 98%
rename from providers/dns/westcn/internal/client_test.go
rename to providers/dns/internal/westcn/internal/client_test.go
index f7bdac5c0..53fd6ed8f 100644
--- a/providers/dns/westcn/internal/client_test.go
+++ b/providers/dns/internal/westcn/internal/client_test.go
@@ -21,7 +21,7 @@ func mockBuilder() *servermock.Builder[*Client] {
}
client.HTTPClient = server.Client()
- client.baseURL, _ = url.Parse(server.URL)
+ client.BaseURL, _ = url.Parse(server.URL)
return client, nil
},
@@ -69,7 +69,8 @@ func TestClientAddRecord_error(t *testing.T) {
servermock.ResponseFromFixture("error.json").
WithHeader("Content-Type", "application/json", "Charset=gb2312"),
servermock.CheckQueryParameter().Strict().
- With("act", "adddnsrecord")).
+ With("act", "adddnsrecord"),
+ ).
Build(t)
record := Record{
diff --git a/providers/dns/westcn/internal/fixtures/adddnsrecord.json b/providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json
similarity index 100%
rename from providers/dns/westcn/internal/fixtures/adddnsrecord.json
rename to providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json
diff --git a/providers/dns/westcn/internal/fixtures/deldnsrecord.json b/providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json
similarity index 100%
rename from providers/dns/westcn/internal/fixtures/deldnsrecord.json
rename to providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json
diff --git a/providers/dns/westcn/internal/fixtures/error.json b/providers/dns/internal/westcn/internal/fixtures/error.json
similarity index 100%
rename from providers/dns/westcn/internal/fixtures/error.json
rename to providers/dns/internal/westcn/internal/fixtures/error.json
diff --git a/providers/dns/westcn/internal/types.go b/providers/dns/internal/westcn/internal/types.go
similarity index 100%
rename from providers/dns/westcn/internal/types.go
rename to providers/dns/internal/westcn/internal/types.go
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/internetbs.toml b/providers/dns/internetbs/internetbs.toml
index d25418f22..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]
diff --git a/providers/dns/inwx/inwx.go b/providers/dns/inwx/inwx.go
index 794db84b3..0e79d71e0 100644
--- a/providers/dns/inwx/inwx.go
+++ b/providers/dns/inwx/inwx.go
@@ -177,7 +177,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("inwx: %w", err)
}
- var recordID int
+ var recordID string
for _, record := range response.Records {
if record.Content != info.Value {
@@ -189,7 +189,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
break
}
- if recordID == 0 {
+ if recordID == "" {
return errors.New("inwx: TXT record not found")
}
diff --git a/providers/dns/inwx/inwx.toml b/providers/dns/inwx/inwx.toml
index aeab5a242..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]
diff --git a/providers/dns/ionos/ionos.go b/providers/dns/ionos/ionos.go
index a512e8bfd..892370f5d 100644
--- a/providers/dns/ionos/ionos.go
+++ b/providers/dns/ionos/ionos.go
@@ -2,19 +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/internal/clientdebug"
- "github.com/go-acme/lego/v4/providers/dns/ionos/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/ionos"
)
// Environment variables names.
@@ -34,18 +30,12 @@ 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),
+ TTL: env.GetOrDefaultInt(EnvTTL, ionos.MinTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
@@ -56,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.
@@ -80,129 +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
- }
-
- client.HTTPClient = clientdebug.Wrap(client.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 0c905273f..a2c9518fb 100644
--- a/providers/dns/ionos/ionos.toml
+++ b/providers/dns/ionos/ionos.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/ionos/ionos_test.go b/providers/dns/ionos/ionos_test.go
index 7b1f5af11..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 {
@@ -47,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)
}
@@ -92,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)
}
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/ipv64.toml b/providers/dns/ipv64/ipv64.toml
index fba210bdb..aa1720c9e 100644
--- a/providers/dns/ipv64/ipv64.toml
+++ b/providers/dns/ipv64/ipv64.toml
@@ -6,7 +6,7 @@ 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]
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/iwantmyname.toml b/providers/dns/iwantmyname/iwantmyname.toml
index a138dee9e..a82c2b749 100644
--- a/providers/dns/iwantmyname/iwantmyname.toml
+++ b/providers/dns/iwantmyname/iwantmyname.toml
@@ -11,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]
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/joker.toml b/providers/dns/joker/joker.toml
index 35713df18..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 = '''
diff --git a/providers/dns/keyhelp/keyhelp.toml b/providers/dns/keyhelp/keyhelp.toml
index d6f84e34e..e622794ca 100644
--- a/providers/dns/keyhelp/keyhelp.toml
+++ b/providers/dns/keyhelp/keyhelp.toml
@@ -7,7 +7,7 @@ Since = "v4.26.0"
Example = '''
KEYHELP_BASE_URL="https://keyhelp.example.com" \
KEYHELP_API_KEY="xxx" \
-lego --email you@example.com --dns keyhelp -d '*.example.com' -d example.com run
+lego --dns keyhelp -d '*.example.com' -d example.com run
'''
[Configuration]
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 93cdcf7c8..95c39695b 100644
--- a/providers/dns/liara/internal/client.go
+++ b/providers/dns/liara/internal/client.go
@@ -20,17 +20,23 @@ const defaultBaseURL = "https://dns-service.iran.liara.ir"
type Client struct {
baseURL *url.URL
httpClient *http.Client
+
+ teamID string
}
// NewClient creates a new Client.
-func NewClient(hc *http.Client) *Client {
+func NewClient(hc *http.Client, teamID string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
- return &Client{httpClient: hc, baseURL: baseURL}
+ return &Client{
+ httpClient: hc,
+ baseURL: baseURL,
+ teamID: teamID,
+ }
}
// GetRecords gets the records of a domain.
@@ -38,7 +44,7 @@ func NewClient(hc *http.Client) *Client {
func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -73,7 +79,7 @@ func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, e
func (c *Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
- req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -108,7 +114,7 @@ func (c *Client) CreateRecord(ctx context.Context, domainName string, record Rec
func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
- req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
@@ -143,7 +149,7 @@ func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*R
func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) error {
endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
- req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
@@ -162,7 +168,14 @@ func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string)
return nil
}
-func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ if c.teamID != "" {
+ query := endpoint.Query()
+ query.Set("teamID", c.teamID)
+
+ endpoint.RawQuery = query.Encode()
+ }
+
buf := new(bytes.Buffer)
if payload != nil {
diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go
index 57ac7e8b3..b6d007046 100644
--- a/providers/dns/liara/internal/client_test.go
+++ b/providers/dns/liara/internal/client_test.go
@@ -13,10 +13,10 @@ import (
const apiKey = "key"
-func mockBuilder() *servermock.Builder[*Client] {
+func mockBuilder(teamID string) *servermock.Builder[*Client] {
return servermock.NewBuilder[*Client](
func(server *httptest.Server) (*Client, error) {
- client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey))
+ client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey), teamID)
client.baseURL, _ = url.Parse(server.URL)
return client, nil
@@ -26,7 +26,7 @@ func mockBuilder() *servermock.Builder[*Client] {
}
func TestClient_GetRecords(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")).
Build(t)
@@ -50,7 +50,7 @@ func TestClient_GetRecords(t *testing.T) {
}
func TestClient_GetRecord(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")).
Build(t)
@@ -72,7 +72,7 @@ func TestClient_GetRecord(t *testing.T) {
}
func TestClient_CreateRecord(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("POST /api/v1/zones/example.com/dns-records",
servermock.ResponseFromFixture("RecordResponse.json").
WithStatusCode(http.StatusCreated),
@@ -108,8 +108,47 @@ func TestClient_CreateRecord(t *testing.T) {
assert.Equal(t, expected, record)
}
+func TestClient_CreateRecord_withTeamID(t *testing.T) {
+ client := mockBuilder("123").
+ Route("POST /api/v1/zones/example.com/dns-records",
+ servermock.ResponseFromFixture("RecordResponse.json").
+ WithStatusCode(http.StatusCreated),
+ servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`),
+ servermock.CheckQueryParameter().Strict().With("teamID", "123"),
+ ).
+ Build(t)
+
+ data := Record{
+ Type: "string",
+ Name: "string",
+ Contents: []Content{
+ {
+ Text: "string",
+ },
+ },
+ TTL: 3600,
+ }
+
+ record, err := client.CreateRecord(t.Context(), "example.com", data)
+ require.NoError(t, err)
+
+ expected := &Record{
+ ID: "string",
+ Type: "string",
+ Name: "string",
+ Contents: []Content{
+ {
+ Text: "string",
+ },
+ },
+ TTL: 3600,
+ }
+
+ assert.Equal(t, expected, record)
+}
+
func TestClient_DeleteRecord(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.Noop().
WithStatusCode(http.StatusNoContent)).
@@ -120,7 +159,7 @@ func TestClient_DeleteRecord(t *testing.T) {
}
func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.Noop().
WithStatusCode(http.StatusNotFound)).
@@ -131,7 +170,7 @@ func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
}
func TestClient_DeleteRecord_error(t *testing.T) {
- client := mockBuilder().
+ client := mockBuilder("").
Route("DELETE /api/v1/zones/example.com/dns-records/123",
servermock.ResponseFromFixture("error.json").
WithStatusCode(http.StatusUnauthorized)).
diff --git a/providers/dns/liara/liara.go b/providers/dns/liara/liara.go
index b91b004cc..c7e403eed 100644
--- a/providers/dns/liara/liara.go
+++ b/providers/dns/liara/liara.go
@@ -23,6 +23,7 @@ const (
envNamespace = "LIARA_"
EnvAPIKey = envNamespace + "API_KEY"
+ EnvTeamID = envNamespace + "TEAM_ID"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
@@ -39,7 +40,9 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
- APIKey string
+ APIKey string
+ TeamID string
+
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
@@ -77,6 +80,7 @@ func NewDNSProvider() (*DNSProvider, error) {
config := NewDefaultConfig()
config.APIKey = values[EnvAPIKey]
+ config.TeamID = env.GetOrFile(EnvTeamID)
return NewDNSProviderConfig(config)
}
@@ -112,6 +116,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
clientdebug.Wrap(
internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey),
),
+ config.TeamID,
)
return &DNSProvider{
diff --git a/providers/dns/liara/liara.toml b/providers/dns/liara/liara.toml
index 1259999a2..f471de04e 100644
--- a/providers/dns/liara/liara.toml
+++ b/providers/dns/liara/liara.toml
@@ -6,13 +6,14 @@ 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_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)"
diff --git a/providers/dns/limacity/limacity.go b/providers/dns/limacity/limacity.go
index 9e1f58f1a..3291faf66 100644
--- a/providers/dns/limacity/limacity.go
+++ b/providers/dns/limacity/limacity.go
@@ -193,6 +193,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
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
}
diff --git a/providers/dns/limacity/limacity.toml b/providers/dns/limacity/limacity.toml
index b9b9f0018..d236577d0 100644
--- a/providers/dns/limacity/limacity.toml
+++ b/providers/dns/limacity/limacity.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/linode/linode.toml b/providers/dns/linode/linode.toml
index f046d3f9b..9ea30b92b 100644
--- a/providers/dns/linode/linode.toml
+++ b/providers/dns/linode/linode.toml
@@ -7,7 +7,7 @@ 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]
diff --git a/providers/dns/liquidweb/liquidweb.go b/providers/dns/liquidweb/liquidweb.go
index b56968fe3..6e93e2a12 100644
--- a/providers/dns/liquidweb/liquidweb.go
+++ b/providers/dns/liquidweb/liquidweb.go
@@ -62,8 +62,9 @@ func NewDefaultConfig() *Config {
// 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
}
diff --git a/providers/dns/liquidweb/liquidweb.toml b/providers/dns/liquidweb/liquidweb.toml
index 22789f41e..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]
diff --git a/providers/dns/liquidweb/liquidweb_test.go b/providers/dns/liquidweb/liquidweb_test.go
index 26dc5bdc0..a34d19037 100644
--- a/providers/dns/liquidweb/liquidweb_test.go
+++ b/providers/dns/liquidweb/liquidweb_test.go
@@ -27,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",
},
},
@@ -48,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",
},
@@ -148,13 +148,13 @@ func TestNewDNSProviderConfig(t *testing.T) {
func TestDNSProvider_Present(t *testing.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 := mockProvider(t, network.DNSRecord{
- Name: "_acme-challenge.tacoman.com",
+ Name: "_acme-challenge.tacoman.example",
RData: "123d==",
Type: "TXT",
TTL: 300,
@@ -164,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)
}
@@ -181,7 +181,7 @@ func TestDNSProvider(t *testing.T) {
}{
{
desc: "expected successful",
- domain: "tacoman.com",
+ domain: "tacoman.example",
token: "123",
keyAuth: "456",
present: true,
@@ -189,7 +189,7 @@ func TestDNSProvider(t *testing.T) {
},
{
desc: "other successful",
- domain: "banana.com",
+ domain: "banana.example",
token: "123",
keyAuth: "456",
present: true,
@@ -197,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,
@@ -214,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,
diff --git a/providers/dns/liquidweb/servermock_test.go b/providers/dns/liquidweb/servermock_test.go
index f211e7253..4886e17f1 100644
--- a/providers/dns/liquidweb/servermock_test.go
+++ b/providers/dns/liquidweb/servermock_test.go
@@ -26,14 +26,14 @@ func mockProvider(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider {
return servermock.NewBuilder(
func(server *httptest.Server) (*DNSProvider, error) {
config := NewDefaultConfig()
- config.Username = "blars"
- config.Password = "tacoman"
+ config.Username = "user"
+ config.Password = "secret"
config.BaseURL = server.URL
return NewDNSProviderConfig(config)
},
servermock.CheckHeader().
- WithBasicAuth("blars", "tacoman"),
+ WithBasicAuth("user", "secret"),
).
Route("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)).
Route("/v1/Network/DNS/Record/create", mockAPICreate(recs)).
@@ -172,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",
},
},
},
@@ -211,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",
},
},
},
@@ -250,31 +250,31 @@ 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",
},
},
},
diff --git a/providers/dns/loopia/loopia.toml b/providers/dns/loopia/loopia.toml
index 4a127ec55..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 = '''
diff --git a/providers/dns/luadns/luadns.go b/providers/dns/luadns/luadns.go
index 02108ce62..68b9c66b8 100644
--- a/providers/dns/luadns/luadns.go
+++ b/providers/dns/luadns/luadns.go
@@ -104,10 +104,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
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 c80929c21..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]
diff --git a/providers/dns/mailinabox/mailinabox.toml b/providers/dns/mailinabox/mailinabox.toml
index e0072ebdd..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]
diff --git a/providers/dns/manageengine/manageengine.toml b/providers/dns/manageengine/manageengine.toml
index 7708fa74f..43a782841 100644
--- a/providers/dns/manageengine/manageengine.toml
+++ b/providers/dns/manageengine/manageengine.toml
@@ -7,7 +7,7 @@ Since = "v4.21.0"
Example = '''
MANAGEENGINE_CLIENT_ID="xxx" \
MANAGEENGINE_CLIENT_SECRET="yyy" \
-lego --email you@example.com --dns manageengine -d '*.example.com' -d example.com run
+lego --dns manageengine -d '*.example.com' -d example.com run
'''
[Configuration]
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 73%
rename from challenge/dns01/dns_challenge_manual_test.go
rename to providers/dns/manual/manual_test.go
index c183822bb..7badd4b8b 100644
--- a/challenge/dns01/dns_challenge_manual_test.go
+++ b/providers/dns/manual/manual_test.go
@@ -1,22 +1,14 @@
-package dns01
+package manual
import (
"io"
"os"
"testing"
- "github.com/go-acme/lego/v4/platform/tester/dnsmock"
- "github.com/miekg/dns"
"github.com/stretchr/testify/require"
)
func TestDNSProviderManual(t *testing.T) {
- useAsNameserver(t, dnsmock.NewServer().
- Query("_acme-challenge.example.com. CNAME", dnsmock.Noop).
- Query("_acme-challenge.example.com. SOA", dnsmock.Error(dns.RcodeNameError)).
- Query("example.com. SOA", dnsmock.SOA("")).
- Build(t))
-
backupStdin := os.Stdin
defer func() { os.Stdin = backupStdin }()
@@ -52,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 d5d87dc4d..d6e962024 100644
--- a/providers/dns/metaname/metaname.go
+++ b/providers/dns/metaname/metaname.go
@@ -153,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 4a147d043..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]
diff --git a/providers/dns/metaregistrar/metaregistrar.toml b/providers/dns/metaregistrar/metaregistrar.toml
index 952c7ea61..e505e0ce2 100644
--- a/providers/dns/metaregistrar/metaregistrar.toml
+++ b/providers/dns/metaregistrar/metaregistrar.toml
@@ -6,7 +6,7 @@ Since = "v4.23.0"
Example = '''
METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns metaregistrar -d '*.example.com' -d example.com run
+lego --dns metaregistrar -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/mijnhost/mijnhost.toml b/providers/dns/mijnhost/mijnhost.toml
index 00152e132..416fdde53 100644
--- a/providers/dns/mijnhost/mijnhost.toml
+++ b/providers/dns/mijnhost/mijnhost.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/mittwald/internal/types.go b/providers/dns/mittwald/internal/types.go
index ce49cb820..86cdf065c 100644
--- a/providers/dns/mittwald/internal/types.go
+++ b/providers/dns/mittwald/internal/types.go
@@ -61,14 +61,14 @@ type APIError struct {
}
func (a APIError) Error() string {
- var msg strings.Builder
+ msg := new(strings.Builder)
- msg.WriteString(fmt.Sprintf("%s: %s", a.Type, a.Message))
+ _, _ = fmt.Fprintf(msg, "%s: %s", a.Type, a.Message)
if len(a.ValidationErrors) > 0 {
for _, validationError := range a.ValidationErrors {
- msg.WriteString(fmt.Sprintf(" [%s: %s (%s, %s)]",
- validationError.Type, validationError.Message, validationError.Path, validationError.Context.Format))
+ _, _ = fmt.Fprintf(msg, " [%s: %s (%s, %s)]",
+ validationError.Type, validationError.Message, validationError.Path, validationError.Context.Format)
}
}
diff --git a/providers/dns/mittwald/mittwald.go b/providers/dns/mittwald/mittwald.go
index 6292dd787..dcd882482 100644
--- a/providers/dns/mittwald/mittwald.go
+++ b/providers/dns/mittwald/mittwald.go
@@ -170,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
}
diff --git a/providers/dns/mittwald/mittwald.toml b/providers/dns/mittwald/mittwald.toml
index 937b9c172..36a9f6c16 100644
--- a/providers/dns/mittwald/mittwald.toml
+++ b/providers/dns/mittwald/mittwald.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/myaddr/myaddr.toml b/providers/dns/myaddr/myaddr.toml
index 5ff306526..2f5fe6c1f 100644
--- a/providers/dns/myaddr/myaddr.toml
+++ b/providers/dns/myaddr/myaddr.toml
@@ -6,7 +6,7 @@ Since = "v4.22.0"
Example = '''
MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \
-lego --email you@example.com --dns myaddr -d '*.example.com' -d example.com run
+lego --dns myaddr -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/mydnsjp/mydnsjp.toml b/providers/dns/mydnsjp/mydnsjp.toml
index ab842e37f..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]
diff --git a/providers/dns/mythicbeasts/mythicbeasts.toml b/providers/dns/mythicbeasts/mythicbeasts.toml
index 011abba1f..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 = '''
diff --git a/providers/dns/namecheap/namecheap.go b/providers/dns/namecheap/namecheap.go
index cf8520546..54640f8e0 100644
--- a/providers/dns/namecheap/namecheap.go
+++ b/providers/dns/namecheap/namecheap.go
@@ -76,7 +76,8 @@ func NewDefaultConfig() *Config {
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Hour),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second),
HTTPClient: &http.Client{
- Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
+ Transport: defaultTransport(envNamespace),
},
}
}
diff --git a/providers/dns/namecheap/namecheap.toml b/providers/dns/namecheap/namecheap.toml
index 3a5be870c..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]
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 3d1f33af1..04c8b5967 100644
--- a/providers/dns/namedotcom/namedotcom.go
+++ b/providers/dns/namedotcom/namedotcom.go
@@ -116,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)
@@ -127,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,
@@ -148,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)
@@ -156,7 +161,6 @@ 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,
diff --git a/providers/dns/namedotcom/namedotcom.toml b/providers/dns/namedotcom/namedotcom.toml
index e6de796d1..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]
diff --git a/providers/dns/namesilo/namesilo.toml b/providers/dns/namesilo/namesilo.toml
index bab7905bf..113ddb5c5 100644
--- a/providers/dns/namesilo/namesilo.toml
+++ b/providers/dns/namesilo/namesilo.toml
@@ -6,7 +6,7 @@ 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]
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/nearlyfreespeech.toml b/providers/dns/nearlyfreespeech/nearlyfreespeech.toml
index 80d4fd6bc..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]
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/netcup.toml b/providers/dns/netcup/netcup.toml
index 0df09b0df..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]
diff --git a/providers/dns/netlify/netlify.toml b/providers/dns/netlify/netlify.toml
index c5cb670f9..9d3c0f6b5 100644
--- a/providers/dns/netlify/netlify.toml
+++ b/providers/dns/netlify/netlify.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/nicmanager/nicmanager.toml b/providers/dns/nicmanager/nicmanager.toml
index 7fdf296c4..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,7 +24,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
'''
Additional = '''
diff --git a/providers/dns/nicru/nicru.toml b/providers/dns/nicru/nicru.toml
index 6bffe74a5..f955511a2 100644
--- a/providers/dns/nicru/nicru.toml
+++ b/providers/dns/nicru/nicru.toml
@@ -9,7 +9,7 @@ NICRU_USER="" \
NICRU_PASSWORD="" \
NICRU_SERVICE_ID="" \
NICRU_SECRET="" \
-lego --dns nicru --domains "*.example.com" --email you@example.com run
+lego --dns nicru -d '*.example.com' -d example.com run
'''
Additional = '''
diff --git a/providers/dns/nifcloud/nifcloud.toml b/providers/dns/nifcloud/nifcloud.toml
index b692bb9d3..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]
diff --git a/providers/dns/njalla/njalla.toml b/providers/dns/njalla/njalla.toml
index ef1fe158e..ff4750b7d 100644
--- a/providers/dns/njalla/njalla.toml
+++ b/providers/dns/njalla/njalla.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/nodion/nodion.go b/providers/dns/nodion/nodion.go
index e34d7db28..4bc887568 100644
--- a/providers/dns/nodion/nodion.go
+++ b/providers/dns/nodion/nodion.go
@@ -208,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 0888f96c3..c9db46e61 100644
--- a/providers/dns/nodion/nodion.toml
+++ b/providers/dns/nodion/nodion.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/ns1/ns1.toml b/providers/dns/ns1/ns1.toml
index 2a6b10deb..829663bf5 100644
--- a/providers/dns/ns1/ns1.toml
+++ b/providers/dns/ns1/ns1.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/octenium/octenium.go b/providers/dns/octenium/octenium.go
index af469f5ed..6032dcce1 100644
--- a/providers/dns/octenium/octenium.go
+++ b/providers/dns/octenium/octenium.go
@@ -169,6 +169,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
break
}
+ d.domainIDsMu.Lock()
+ delete(d.domainIDs, token)
+ d.domainIDsMu.Unlock()
+
return nil
}
diff --git a/providers/dns/octenium/octenium.toml b/providers/dns/octenium/octenium.toml
index 5084526fd..e3c9d894f 100644
--- a/providers/dns/octenium/octenium.toml
+++ b/providers/dns/octenium/octenium.toml
@@ -6,7 +6,7 @@ Since = "v4.27.0"
Example = '''
OCTENIUM_API_KEY="xxx" \
-lego --email you@example.com --dns octenium -d '*.example.com' -d example.com run
+lego --dns octenium -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/oraclecloud/oraclecloud.toml b/providers/dns/oraclecloud/oraclecloud.toml
index f13cb1e1e..f6155052e 100644
--- a/providers/dns/oraclecloud/oraclecloud.toml
+++ b/providers/dns/oraclecloud/oraclecloud.toml
@@ -13,13 +13,13 @@ OCI_USER_OCID="ocid1.user.oc1..secret" \
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 --email you@example.com --dns oraclecloud -d '*.example.com' -d example.com run
+lego --dns oraclecloud -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/oraclecloud/oraclecloud_test.go b/providers/dns/oraclecloud/oraclecloud_test.go
index c646e90f2..74ee06eac 100644
--- a/providers/dns/oraclecloud/oraclecloud_test.go
+++ b/providers/dns/oraclecloud/oraclecloud_test.go
@@ -61,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",
@@ -383,21 +383,21 @@ 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()
}
diff --git a/providers/dns/otc/otc.toml b/providers/dns/otc/otc.toml
index 91f9f5455..e63077fda 100644
--- a/providers/dns/otc/otc.toml
+++ b/providers/dns/otc/otc.toml
@@ -9,7 +9,7 @@ OTC_DOMAIN_NAME=domain_name \
OTC_USER_NAME=user_name \
OTC_PASSWORD=password \
OTC_PROJECT_NAME=project_name \
-lego --email you@example.com --dns otc -d '*.example.com' -d example.com run
+lego --dns otc -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/ovh/ovh.go b/providers/dns/ovh/ovh.go
index b7e522540..a8d12d819 100644
--- a/providers/dns/ovh/ovh.go
+++ b/providers/dns/ovh/ovh.go
@@ -102,8 +102,9 @@ func (c *Config) hasAppKeyAuth() bool {
// 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
}
diff --git a/providers/dns/ovh/ovh.toml b/providers/dns/ovh/ovh.toml
index 95162185b..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 = '''
diff --git a/providers/dns/pdns/pdns.toml b/providers/dns/pdns/pdns.toml
index 53b5547b9..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 = '''
diff --git a/providers/dns/plesk/plesk.go b/providers/dns/plesk/plesk.go
index b764dff33..5f07dcb50 100644
--- a/providers/dns/plesk/plesk.go
+++ b/providers/dns/plesk/plesk.go
@@ -173,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 5fb4ce073..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]
diff --git a/providers/dns/porkbun/porkbun.go b/providers/dns/porkbun/porkbun.go
index dc9efb013..2f999ebcc 100644
--- a/providers/dns/porkbun/porkbun.go
+++ b/providers/dns/porkbun/porkbun.go
@@ -171,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 d7ed3aedc..9ae036da6 100644
--- a/providers/dns/porkbun/porkbun.toml
+++ b/providers/dns/porkbun/porkbun.toml
@@ -8,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]
diff --git a/providers/dns/rackspace/rackspace.toml b/providers/dns/rackspace/rackspace.toml
index 7ca2c3b7a..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]
diff --git a/providers/dns/rainyun/rainyun.toml b/providers/dns/rainyun/rainyun.toml
index cca16cffe..fe2b3c07d 100644
--- a/providers/dns/rainyun/rainyun.toml
+++ b/providers/dns/rainyun/rainyun.toml
@@ -6,7 +6,7 @@ Since = "v4.21.0"
Example = '''
RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns rainyun -d '*.example.com' -d example.com run
+lego --dns rainyun -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/rcodezero/rcodezero.toml b/providers/dns/rcodezero/rcodezero.toml
index bba5588da..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 = '''
diff --git a/providers/dns/regfish/regfish.toml b/providers/dns/regfish/regfish.toml
index 9869ed96e..fbaacbde4 100644
--- a/providers/dns/regfish/regfish.toml
+++ b/providers/dns/regfish/regfish.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/regru/regru.toml b/providers/dns/regru/regru.toml
index 2ccf3a58f..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]
diff --git a/providers/dns/rfc2136/rfc2136.toml b/providers/dns/rfc2136/rfc2136.toml
index 9243440a4..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]
diff --git a/providers/dns/rimuhosting/rimuhosting.go b/providers/dns/rimuhosting/rimuhosting.go
index 08d7ad413..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"
@@ -11,7 +10,6 @@ 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/rimuhosting"
)
@@ -30,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{
@@ -53,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.
@@ -77,50 +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
- }
-
- 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
+ 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
@@ -128,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 0a4f983e2..c1994e2cc 100644
--- a/providers/dns/rimuhosting/rimuhosting.toml
+++ b/providers/dns/rimuhosting/rimuhosting.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/rimuhosting/rimuhosting_test.go b/providers/dns/rimuhosting/rimuhosting_test.go
index d8b086e25..878ec14da 100644
--- a/providers/dns/rimuhosting/rimuhosting_test.go
+++ b/providers/dns/rimuhosting/rimuhosting_test.go
@@ -46,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)
}
@@ -84,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)
}
diff --git a/providers/dns/route53/route53.toml b/providers/dns/route53/route53.toml
index 9e3b049a6..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 = '''
diff --git a/providers/dns/safedns/internal/client.go b/providers/dns/safedns/internal/client.go
index 51b12e99d..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
diff --git a/providers/dns/safedns/safedns.go b/providers/dns/safedns/safedns.go
index be8ca4fe6..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 (
@@ -75,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")
diff --git a/providers/dns/safedns/safedns.toml b/providers/dns/safedns/safedns.toml
index dcc7bc90e..f387f2535 100644
--- a/providers/dns/safedns/safedns.toml
+++ b/providers/dns/safedns/safedns.toml
@@ -1,12 +1,12 @@
-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]
diff --git a/providers/dns/sakuracloud/sakuracloud.toml b/providers/dns/sakuracloud/sakuracloud.toml
index f754e0c89..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]
diff --git a/providers/dns/scaleway/scaleway.toml b/providers/dns/scaleway/scaleway.toml
index 212cea295..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]
diff --git a/providers/dns/selectel/selectel.go b/providers/dns/selectel/selectel.go
index 804ef04d5..63ddd81ac 100644
--- a/providers/dns/selectel/selectel.go
+++ b/providers/dns/selectel/selectel.go
@@ -4,17 +4,14 @@
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/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/selectel"
)
@@ -31,25 +28,16 @@ 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, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
@@ -60,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.
@@ -84,58 +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
- }
-
- client.HTTPClient = clientdebug.Wrap(client.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)
}
@@ -143,36 +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 f9add7ea9..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]
diff --git a/providers/dns/selectel/selectel_test.go b/providers/dns/selectel/selectel_test.go
index e3c36e226..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"
)
@@ -46,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)
}
@@ -77,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),
},
}
@@ -92,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)
}
diff --git a/providers/dns/selectelv2/selectelv2.go b/providers/dns/selectelv2/selectelv2.go
index 6e3c1f42c..1fcb48583 100644
--- a/providers/dns/selectelv2/selectelv2.go
+++ b/providers/dns/selectelv2/selectelv2.go
@@ -297,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) {
diff --git a/providers/dns/selectelv2/selectelv2.toml b/providers/dns/selectelv2/selectelv2.toml
index fd8dbda9f..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]
diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go
index bb475deea..035cd5363 100644
--- a/providers/dns/selfhostde/selfhostde.go
+++ b/providers/dns/selfhostde/selfhostde.go
@@ -186,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 619f2cae8..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 = """
diff --git a/providers/dns/servercow/servercow.toml b/providers/dns/servercow/servercow.toml
index de9727163..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]
diff --git a/providers/dns/shellrent/shellrent.go b/providers/dns/shellrent/shellrent.go
index 5a3a1f6de..0cd33e19a 100644
--- a/providers/dns/shellrent/shellrent.go
+++ b/providers/dns/shellrent/shellrent.go
@@ -172,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 48a5b9ad9..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]
diff --git a/providers/dns/simply/simply.toml b/providers/dns/simply/simply.toml
index c586e0db5..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]
diff --git a/providers/dns/sonic/sonic.toml b/providers/dns/sonic/sonic.toml
index 921fe4988..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 = '''
diff --git a/providers/dns/spaceship/spaceship.toml b/providers/dns/spaceship/spaceship.toml
index 645abd171..e9abcd408 100644
--- a/providers/dns/spaceship/spaceship.toml
+++ b/providers/dns/spaceship/spaceship.toml
@@ -7,7 +7,7 @@ Since = "v4.22.0"
Example = '''
SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \
SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns spaceship -d '*.example.com' -d example.com run
+lego --dns spaceship -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/stackpath/stackpath.toml b/providers/dns/stackpath/stackpath.toml
index cc14cdfba..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]
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/technitium.toml b/providers/dns/technitium/technitium.toml
index 13b40c304..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 = '''
diff --git a/providers/dns/tencentcloud/tencentcloud.toml b/providers/dns/tencentcloud/tencentcloud.toml
index 7f06d9386..50f4ee9d5 100644
--- a/providers/dns/tencentcloud/tencentcloud.toml
+++ b/providers/dns/tencentcloud/tencentcloud.toml
@@ -7,7 +7,7 @@ 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]
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 d71beea4b..a599566e3 100644
--- a/providers/dns/timewebcloud/timewebcloud.go
+++ b/providers/dns/timewebcloud/timewebcloud.go
@@ -110,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)
diff --git a/providers/dns/timewebcloud/timewebcloud.toml b/providers/dns/timewebcloud/timewebcloud.toml
index 8c20b37b9..c8bde636a 100644
--- a/providers/dns/timewebcloud/timewebcloud.toml
+++ b/providers/dns/timewebcloud/timewebcloud.toml
@@ -6,7 +6,7 @@ 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]
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.toml b/providers/dns/transip/transip.toml
index a894cc3e3..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]
diff --git a/providers/dns/ultradns/ultradns.toml b/providers/dns/ultradns/ultradns.toml
index a403a3dcf..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]
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/variomedia.go b/providers/dns/variomedia/variomedia.go
index 90ac70a05..2d12fd975 100644
--- a/providers/dns/variomedia/variomedia.go
+++ b/providers/dns/variomedia/variomedia.go
@@ -180,6 +180,10 @@ 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
}
diff --git a/providers/dns/variomedia/variomedia.toml b/providers/dns/variomedia/variomedia.toml
index fe6f2797a..8390d1922 100644
--- a/providers/dns/variomedia/variomedia.toml
+++ b/providers/dns/variomedia/variomedia.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/vercel/vercel.toml b/providers/dns/vercel/vercel.toml
index 2545b9c48..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]
diff --git a/providers/dns/versio/versio.toml b/providers/dns/versio/versio.toml
index 33f7125c8..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 = '''
diff --git a/providers/dns/vinyldns/vinyldns.toml b/providers/dns/vinyldns/vinyldns.toml
index 5789d10ab..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 = '''
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/vkcloud.toml b/providers/dns/vkcloud/vkcloud.toml
index 366039694..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 = '''
diff --git a/providers/dns/volcengine/volcengine.go b/providers/dns/volcengine/volcengine.go
index 9a5886e6d..765d38adb 100644
--- a/providers/dns/volcengine/volcengine.go
+++ b/providers/dns/volcengine/volcengine.go
@@ -171,6 +171,10 @@ 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
}
diff --git a/providers/dns/volcengine/volcengine.toml b/providers/dns/volcengine/volcengine.toml
index cb7c219f7..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]
diff --git a/providers/dns/vscale/vscale.go b/providers/dns/vscale/vscale.go
index 01fae946d..a159db307 100644
--- a/providers/dns/vscale/vscale.go
+++ b/providers/dns/vscale/vscale.go
@@ -4,17 +4,14 @@
package vscale
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/platform/config/env"
- "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
"github.com/go-acme/lego/v4/providers/dns/internal/selectel"
)
@@ -31,25 +28,18 @@ 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, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
@@ -60,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.
@@ -84,58 +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
- }
-
- client.HTTPClient = clientdebug.Wrap(client.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)
}
@@ -143,36 +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 78b6c99e5..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]
diff --git a/providers/dns/vscale/vscale_test.go b/providers/dns/vscale/vscale_test.go
index f3bc15531..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"
)
@@ -46,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)
}
@@ -77,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),
},
}
@@ -92,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)
}
diff --git a/providers/dns/vultr/vultr.go b/providers/dns/vultr/vultr.go
index 2064cee19..f97a321c1 100644
--- a/providers/dns/vultr/vultr.go
+++ b/providers/dns/vultr/vultr.go
@@ -107,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 + `"`,
diff --git a/providers/dns/vultr/vultr.toml b/providers/dns/vultr/vultr.toml
index 7d31bd52b..78e878bea 100644
--- a/providers/dns/vultr/vultr.toml
+++ b/providers/dns/vultr/vultr.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/webnames/webnames.toml b/providers/dns/webnames/webnames.toml
index 04dea25c5..b038deaf5 100644
--- a/providers/dns/webnames/webnames.toml
+++ b/providers/dns/webnames/webnames.toml
@@ -7,7 +7,7 @@ Since = "v4.15.0"
Example = '''
WEBNAMESRU_API_KEY=xxxxxx \
-lego --email you@example.com --dns webnamesru -d '*.example.com' -d example.com run
+lego --dns webnamesru -d '*.example.com' -d example.com run
'''
Additional = '''
diff --git a/providers/dns/webnamesca/webnamesca.toml b/providers/dns/webnamesca/webnamesca.toml
index c7d30751b..ab68a04a0 100644
--- a/providers/dns/webnamesca/webnamesca.toml
+++ b/providers/dns/webnamesca/webnamesca.toml
@@ -7,7 +7,7 @@ Since = "v4.28.0"
Example = '''
WEBNAMESCA_API_USER="xxx" \
WEBNAMESCA_API_KEY="yyy" \
-lego --email you@example.com --dns webnamesca -d '*.example.com' -d example.com run
+lego --dns webnamesca -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/websupport/websupport.go b/providers/dns/websupport/websupport.go
index 7f93653c9..4187ba32b 100644
--- a/providers/dns/websupport/websupport.go
+++ b/providers/dns/websupport/websupport.go
@@ -2,17 +2,15 @@
package websupport
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/active24"
- "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
)
const baseAPIDomain = "websupport.sk"
@@ -31,15 +29,7 @@ const (
)
// 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
-}
+type Config = active24.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
@@ -55,8 +45,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
- config *Config
- client *active24.Client
+ prv challenge.ProviderTimeout
}
// NewDNSProvider returns a DNSProvider instance configured for Websupport.
@@ -80,83 +69,29 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("websupport: the configuration of the DNS provider is nil")
}
- client, err := active24.NewClient(baseAPIDomain, 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
- }
-
- client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
-
- return &DNSProvider{
- config: config,
- client: client,
- }, nil
+ return &DNSProvider{prv: provider}, 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("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)
}
- serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
- if err != nil {
- return fmt.Errorf("websupport: find service ID: %w", err)
- }
-
- record := active24.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("websupport: 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)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("websupport: could not find zone for domain %q: %w", domain, err)
- }
-
- serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
- if err != nil {
- return fmt.Errorf("websupport: find service ID: %w", err)
- }
-
- recordID, err := d.findRecordID(ctx, strconv.Itoa(serviceID), info)
- if err != nil {
- return fmt.Errorf("websupport: find record ID: %w", err)
- }
-
- err = d.client.DeleteRecord(ctx, strconv.Itoa(serviceID), strconv.Itoa(recordID))
- if err != nil {
- return fmt.Errorf("websupport: delete record %w", err)
+ return fmt.Errorf("websupport: %w", err)
}
return nil
@@ -165,58 +100,5 @@ func (d *DNSProvider) CleanUp(domain, token, 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 (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 := active24.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")
+ return d.prv.Timeout()
}
diff --git a/providers/dns/websupport/websupport.toml b/providers/dns/websupport/websupport.toml
index 1f34b431b..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]
diff --git a/providers/dns/websupport/websupport_test.go b/providers/dns/websupport/websupport_test.go
index c7b8572b5..196c9bab8 100644
--- a/providers/dns/websupport/websupport_test.go
+++ b/providers/dns/websupport/websupport_test.go
@@ -60,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)
}
@@ -110,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)
}
diff --git a/providers/dns/wedos/wedos.toml b/providers/dns/wedos/wedos.toml
index 2ed351a2d..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]
diff --git a/providers/dns/westcn/westcn.go b/providers/dns/westcn/westcn.go
index c641f661d..1906f9737 100644
--- a/providers/dns/westcn/westcn.go
+++ b/providers/dns/westcn/westcn.go
@@ -2,18 +2,14 @@
package westcn
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/internal/clientdebug"
- "github.com/go-acme/lego/v4/providers/dns/westcn/internal"
+ "github.com/go-acme/lego/v4/providers/dns/internal/westcn"
)
// Environment variables names.
@@ -29,18 +25,12 @@ const (
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 struct {
- Username string
- Password string
-
- PropagationTimeout time.Duration
- PollingInterval time.Duration
- TTL int
- HTTPClient *http.Client
-}
+type Config = westcn.Config
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
@@ -56,11 +46,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 West.cn/西部数码.
@@ -83,91 +69,36 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, errors.New("westcn: the configuration of the DNS provider is nil")
}
- client, err := internal.NewClient(config.Username, config.Password)
+ provider, err := westcn.NewDNSProviderConfig(config, defaultBaseURL)
if err != nil {
return nil, fmt.Errorf("westcn: %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
+ 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("westcn: 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("westcn: %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("westcn: 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)
+ err := d.prv.CleanUp(domain, token, keyAuth)
if err != nil {
- return fmt.Errorf("westcn: could not find zone for domain %q: %w", domain, err)
+ return fmt.Errorf("westcn: %w", err)
}
- // gets the record's unique ID
- d.recordIDsMu.Lock()
- recordID, ok := d.recordIDs[token]
- d.recordIDsMu.Unlock()
-
- if !ok {
- return fmt.Errorf("westcn: 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("westcn: 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
+ return d.prv.Timeout()
}
diff --git a/providers/dns/westcn/westcn.toml b/providers/dns/westcn/westcn.toml
index acf3a4e49..1b0cb0a7a 100644
--- a/providers/dns/westcn/westcn.toml
+++ b/providers/dns/westcn/westcn.toml
@@ -7,7 +7,7 @@ Since = "v4.21.0"
Example = '''
WESTCN_USERNAME="xxx" \
WESTCN_PASSWORD="yyy" \
-lego --email you@example.com --dns westcn -d '*.example.com' -d example.com run
+lego --dns westcn -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/westcn/westcn_test.go b/providers/dns/westcn/westcn_test.go
index 36827fd06..a546d518e 100644
--- a/providers/dns/westcn/westcn_test.go
+++ b/providers/dns/westcn/westcn_test.go
@@ -60,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)
}
@@ -108,8 +107,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)
}
diff --git a/providers/dns/yandex/yandex.toml b/providers/dns/yandex/yandex.toml
index 78da1444d..a36df069e 100644
--- a/providers/dns/yandex/yandex.toml
+++ b/providers/dns/yandex/yandex.toml
@@ -7,7 +7,7 @@ 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]
diff --git a/providers/dns/yandex360/yandex360.toml b/providers/dns/yandex360/yandex360.toml
index 69ea02a28..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]
diff --git a/providers/dns/yandexcloud/yandexcloud.toml b/providers/dns/yandexcloud/yandexcloud.toml
index a4bf3ebbb..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 = '''
diff --git a/providers/dns/zoneedit/zoneedit.toml b/providers/dns/zoneedit/zoneedit.toml
index d3c547c23..cdc53b33a 100644
--- a/providers/dns/zoneedit/zoneedit.toml
+++ b/providers/dns/zoneedit/zoneedit.toml
@@ -7,7 +7,7 @@ Since = "v4.25.0"
Example = '''
ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \
ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \
-lego --email you@example.com --dns zoneedit -d '*.example.com' -d example.com run
+lego --dns zoneedit -d '*.example.com' -d example.com run
'''
[Configuration]
diff --git a/providers/dns/zoneee/zoneee.toml b/providers/dns/zoneee/zoneee.toml
index 75ccabbda..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]
diff --git a/providers/dns/zonomi/zonomi.go b/providers/dns/zonomi/zonomi.go
index e6eae08de..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"
@@ -11,7 +10,6 @@ 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/rimuhosting"
)
@@ -27,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{
@@ -53,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.
@@ -77,50 +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
- }
-
- 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
+ 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
@@ -128,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 a5577999a..b91bcaac6 100644
--- a/providers/dns/zonomi/zonomi.toml
+++ b/providers/dns/zonomi/zonomi.toml
@@ -6,7 +6,7 @@ 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]
diff --git a/providers/dns/zonomi/zonomi_test.go b/providers/dns/zonomi/zonomi_test.go
index 0583f4a1c..2e13e937e 100644
--- a/providers/dns/zonomi/zonomi_test.go
+++ b/providers/dns/zonomi/zonomi_test.go
@@ -46,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)
}
@@ -84,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)
}
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index 32de816a3..9c4bc9e61 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -6,12 +6,14 @@ 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"
@@ -24,6 +26,7 @@ import (
"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"
@@ -34,16 +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"
@@ -56,10 +63,13 @@ import (
"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"
@@ -68,12 +78,15 @@ import (
"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"
@@ -88,10 +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"
@@ -101,6 +119,7 @@ import (
"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"
@@ -111,7 +130,9 @@ import (
"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"
@@ -147,16 +168,20 @@ import (
"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"
@@ -177,18 +202,22 @@ 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":
@@ -213,6 +242,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return bindman.NewDNSProvider()
case "bluecat":
return bluecat.NewDNSProvider()
+ case "bluecatv2":
+ return bluecatv2.NewDNSProvider()
case "bookmyname":
return bookmyname.NewDNSProvider()
case "brandit":
@@ -233,6 +264,8 @@ 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":
@@ -243,6 +276,10 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
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":
@@ -253,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":
@@ -277,6 +316,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return dynu.NewDNSProvider()
case "easydns":
return easydns.NewDNSProvider()
+ case "edgecenter":
+ return edgecenter.NewDNSProvider()
case "edgedns", "fastdns":
return edgedns.NewDNSProvider()
case "edgeone":
@@ -285,6 +326,10 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
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":
@@ -301,18 +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":
@@ -341,14 +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":
@@ -367,6 +428,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return mailinabox.NewDNSProvider()
case "manageengine":
return manageengine.NewDNSProvider()
+ case "manual":
+ return manual.NewDNSProvider()
case "metaname":
return metaname.NewDNSProvider()
case "metaregistrar":
@@ -387,8 +450,12 @@ 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":
@@ -459,16 +526,22 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
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":
@@ -479,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":